1use crate::agent::truncation::safe_head;
2use serde_json::Value;
3use std::collections::HashSet;
4use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9const DEFAULT_MAX_ENTRIES: usize = 10;
10const MAX_ENTRIES_CAP: usize = 25;
11const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
12
13pub async fn inspect_host(args: &Value) -> Result<String, String> {
14 let mut topic = args
15 .get("topic")
16 .and_then(|v| v.as_str())
17 .unwrap_or("summary")
18 .to_string();
19 let max_entries = parse_max_entries(args);
20 let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
21
22 if (topic == "processes" || topic == "network" || topic == "summary")
24 && (filter.contains("ad")
25 || filter.contains("sid")
26 || filter.contains("administrator")
27 || filter.contains("domain"))
28 {
29 topic = "ad_user".to_string();
30 }
31
32 let result = match topic.as_str() {
33 "summary" => inspect_summary(max_entries),
34 "toolchains" => inspect_toolchains(),
35 "path" => inspect_path(max_entries),
36 "env_doctor" => inspect_env_doctor(max_entries),
37 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
38 "network" => inspect_network(max_entries),
39 "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
40 inspect_lan_discovery(max_entries)
41 }
42 "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
43 inspect_audio(max_entries)
44 }
45 "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
46 inspect_bluetooth(max_entries)
47 }
48 "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
49 "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
50 inspect_sign_in(max_entries)
51 }
52 "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
53 inspect_installer_health(max_entries)
54 }
55 "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
56 inspect_onedrive(max_entries)
57 }
58 "browser_health" | "browser" | "webview2" | "default_browser" => {
59 inspect_browser_health(max_entries)
60 }
61 "identity_auth"
62 | "office_auth"
63 | "m365_auth"
64 | "microsoft_365_auth"
65 | "auth_broker" => inspect_identity_auth(max_entries),
66 "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
67 "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
68 "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
69 inspect_windows_backup(max_entries)
70 }
71 "search_index" | "windows_search" | "indexing" | "search" => {
72 inspect_search_index(max_entries)
73 }
74 "services" => inspect_services(parse_name_filter(args), max_entries),
75 "processes" => inspect_processes(parse_name_filter(args), max_entries),
76 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
77 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
78 "disk" => {
79 let path = resolve_optional_path(args)?;
80 inspect_disk(path, max_entries).await
81 }
82 "ports" => inspect_ports(parse_port_filter(args), max_entries),
83 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
84 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
85 "health_report" | "system_health" => inspect_health_report(),
86 "storage" => inspect_storage(max_entries),
87 "storage_deep" | "disk_deep" | "where_is_space" => inspect_storage_deep(),
88 "hardware" => inspect_hardware(),
89 "updates" | "windows_update" => inspect_updates(),
90 "security" | "antivirus" | "defender" => inspect_security(),
91 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
92 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
93 "battery" => inspect_battery(),
94 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
95 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
96 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
97 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
98 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
99 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
100 "vpn" => inspect_vpn(),
101 "public_ip" | "external_ip" | "myip" => inspect_public_ip().await,
102 "ssl_cert" | "tls_cert" | "cert_audit" | "website_cert" => {
103 let host = args.get("host").and_then(|v| v.as_str()).unwrap_or("google.com");
104 inspect_ssl_cert(host)
105 }
106 "proxy" | "proxy_settings" => inspect_proxy(),
107 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
108 "traceroute" | "tracert" | "trace_route" | "trace" => {
109 let host = args
110 .get("host")
111 .and_then(|v| v.as_str())
112 .unwrap_or("8.8.8.8")
113 .to_string();
114 inspect_traceroute(&host, max_entries)
115 }
116 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
117 "arp" | "arp_table" => inspect_arp(),
118 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
119 "os_config" | "system_config" => inspect_os_config(),
120 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
121 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
122 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
123 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
124 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
125 inspect_docker_filesystems(max_entries)
126 }
127 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
128 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
129 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
130 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
131 "git_config" | "git_global" => inspect_git_config(),
132 "databases" | "database" | "db_services" | "db" => inspect_databases(),
133 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
134 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
135 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
136 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
137 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
138 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
139 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
140 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
141 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
142 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
143 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
144 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
145 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
146 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
147 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
148 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
149 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
150 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
151 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
152 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
153 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
154 "data_audit" | "csv_audit" | "file_audit" => {
155 let path = resolve_optional_path(args)?;
156 inspect_data_audit(path, max_entries).await
157 }
158 "repo_doctor" => {
159 let path = resolve_optional_path(args)?;
160 inspect_repo_doctor(path, max_entries)
161 }
162 "directory" => {
163 let raw_path = args
164 .get("path")
165 .and_then(|v| v.as_str())
166 .ok_or_else(|| {
167 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
168 .to_string()
169 })?;
170 let resolved = resolve_path(raw_path)?;
171 inspect_directory("Directory", resolved, max_entries).await
172 }
173 "disk_benchmark" | "stress_test" | "io_intensity" => {
174 let path = resolve_optional_path(args)?;
175 inspect_disk_benchmark(path).await
176 }
177 "permissions" | "acl" | "access_control" => {
178 let path = resolve_optional_path(args)?;
179 inspect_permissions(path, max_entries)
180 }
181 "login_history" | "logon_history" | "user_logins" => {
182 inspect_login_history(max_entries)
183 }
184 "share_access" | "unc_access" | "remote_share" => {
185 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
186 inspect_share_access(path)
187 }
188 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
189 "thermal" | "throttling" | "overheating" => inspect_thermal(),
190 "activation" | "license_status" | "slmgr" => inspect_activation(),
191 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
192 "ad_user" | "ad" | "domain_user" => {
193 let identity = parse_name_filter(args).unwrap_or_default();
194 inspect_ad_user(&identity)
195 }
196 "dns_lookup" | "dig" | "nslookup" => {
197 let name = parse_name_filter(args).unwrap_or_default();
198 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
199 inspect_dns_lookup(&name, record_type)
200 }
201 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
202 "ip_config" | "ip_detail" => inspect_ip_config(),
203 "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
204 "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
205 "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
206 "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
207 "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
208 "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
209 "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
210 "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
211 "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
212 "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
213 let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
214 let pt_port = args.get("port").and_then(|v| v.as_u64()).and_then(|p| u16::try_from(p).ok());
215 inspect_port_test(pt_host.as_deref(), pt_port)
216 }
217 "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
218 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
219 "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
220 inspect_display_config(max_entries)
221 }
222 "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
223 inspect_ntp()
224 }
225 "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
226 inspect_cpu_power()
227 }
228 "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
229 inspect_credentials(max_entries)
230 }
231 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
232 inspect_tpm()
233 }
234 "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
235 inspect_latency()
236 }
237 "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
238 inspect_network_adapter()
239 }
240 "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
241 let event_id = args.get("event_id").and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok());
242 let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
243 let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
244 let hours = args.get("hours").and_then(|v| v.as_u64()).and_then(|h| u32::try_from(h).ok()).unwrap_or(24u32);
245 let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
246 inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
247 }
248 "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
249 let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
250 inspect_app_crashes(process_filter.as_deref(), max_entries)
251 }
252 "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
253 inspect_mdm_enrollment()
254 }
255 "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
256 inspect_storage_spaces()
257 }
258 "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
259 inspect_defender_quarantine(max_entries)
260 }
261 "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
262 inspect_domain_health()
263 }
264 "service_dependencies" | "svc_deps" | "service_deps" => {
265 inspect_service_dependencies(max_entries)
266 }
267 "wmi_health" | "wmi_repository" | "wmi_status" => {
268 inspect_wmi_health()
269 }
270 "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
271 inspect_local_security_policy()
272 }
273 "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
274 inspect_usb_history(max_entries)
275 }
276 "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
277 inspect_print_spooler()
278 }
279 other => Err(format!(
280 "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, public_ip, ssl_cert, data_audit, network_profile, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, app_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, docker_filesystems, wsl, wsl_filesystems, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, domain_health, device_health, drivers, peripherals, sessions, permissions, login_history, share_access, registry_audit, thermal, activation, patch_history, ad_user, dns_lookup, hyperv, ip_config, overclocker, event_query, mdm_enrollment, storage_spaces, defender_quarantine, service_dependencies, wmi_health, local_security_policy, usb_history, print_spooler.",
281 other
282 )),
283
284 };
285
286 result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
287}
288
289fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
290 let Some(scope) = admin_sensitive_topic_scope(topic) else {
291 return body;
292 };
293 let lower = body.to_lowercase();
294 let privilege_limited = lower.contains("access denied")
295 || lower.contains("administrator privilege is required")
296 || lower.contains("administrator privileges required")
297 || lower.contains("requires administrator")
298 || lower.contains("requires elevation")
299 || lower.contains("non-admin session")
300 || lower.contains("could not be fully determined from this session");
301 if !privilege_limited || lower.contains("=== elevation note ===") {
302 return body;
303 }
304
305 let mut annotated = body;
306 annotated.push_str("\n=== Elevation note ===\n");
307 annotated.push_str("- Hematite should stay non-admin by default.\n");
308 annotated.push_str(
309 "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
310 );
311 let _ = writeln!(
312 annotated,
313 "- Rerun Hematite as Administrator only if you need a definitive {scope} answer."
314 );
315 annotated
316}
317
318fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
319 match topic {
320 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
321 Some("TPM / Secure Boot / firmware")
322 }
323 "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
324 "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
325 "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
326 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
327 "windows_features" | "optional_features" | "installed_features" | "features" => {
328 Some("Windows Features")
329 }
330 "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
331 _ => None,
332 }
333}
334
335#[cfg(test)]
336mod privilege_hint_tests {
337 use super::annotate_privilege_limited_output;
338
339 #[test]
340 fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
341 let body = "Host inspection: network\nError: Access denied.\n".to_string();
342 let annotated = annotate_privilege_limited_output("network", body.clone());
343 assert_eq!(annotated, body);
344 }
345
346 #[test]
347 fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
348 let body = "Host inspection: tpm\n\n=== Findings ===\n- Finding: TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility.\n".to_string();
349 let annotated = annotate_privilege_limited_output("tpm", body);
350 assert!(annotated.contains("=== Elevation note ==="));
351 assert!(annotated.contains("stay non-admin by default"));
352 assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
353 }
354}
355
356#[cfg(test)]
357mod event_query_tests {
358 use super::is_event_query_no_results_message;
359
360 #[cfg(target_os = "windows")]
361 #[test]
362 fn treats_windows_no_results_message_as_empty_query() {
363 assert!(is_event_query_no_results_message(
364 "No events were found that match the specified selection criteria."
365 ));
366 }
367
368 #[cfg(target_os = "windows")]
369 #[test]
370 fn does_not_treat_real_errors_as_empty_query() {
371 assert!(!is_event_query_no_results_message("Access is denied."));
372 }
373}
374
375fn parse_max_entries(args: &Value) -> usize {
376 args.get("max_entries")
377 .and_then(|v| v.as_u64())
378 .map(|n| n as usize)
379 .unwrap_or(DEFAULT_MAX_ENTRIES)
380 .clamp(1, MAX_ENTRIES_CAP)
381}
382
383fn parse_port_filter(args: &Value) -> Option<u16> {
384 args.get("port")
385 .and_then(|v| v.as_u64())
386 .and_then(|n| u16::try_from(n).ok())
387}
388
389fn parse_name_filter(args: &Value) -> Option<String> {
390 args.get("name")
391 .and_then(|v| v.as_str())
392 .map(str::trim)
393 .filter(|value| !value.is_empty())
394 .map(|value| value.to_string())
395}
396
397fn parse_lookback_hours(args: &Value) -> Option<u32> {
398 args.get("lookback_hours")
399 .and_then(|v| v.as_u64())
400 .map(|n| n as u32)
401}
402
403fn parse_issue_text(args: &Value) -> Option<String> {
404 args.get("issue")
405 .and_then(|v| v.as_str())
406 .map(str::trim)
407 .filter(|value| !value.is_empty())
408 .map(|value| value.to_string())
409}
410
411fn ps_escape_single_quoted(s: &str) -> String {
414 s.replace('\'', "''")
415}
416
417fn validate_dns_record_type(record_type: &str) -> &str {
419 match record_type.to_uppercase().as_str() {
420 "A" | "AAAA" | "MX" | "TXT" | "SRV" | "CNAME" | "NS" | "PTR" | "SOA" | "CAA" | "NAPTR"
421 | "DS" | "DNSKEY" | "ANY" => record_type,
422 _ => "A",
423 }
424}
425
426#[cfg(target_os = "windows")]
427fn is_event_query_no_results_message(message: &str) -> bool {
428 let lower = message.to_ascii_lowercase();
429 lower.contains("no events were found")
430 || lower.contains("no events match the specified selection criteria")
431}
432
433fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
434 match args.get("path").and_then(|v| v.as_str()) {
435 Some(raw_path) => resolve_path(raw_path),
436 None => {
437 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
438 }
439 }
440}
441
442fn inspect_summary(max_entries: usize) -> Result<String, String> {
443 let current_dir =
444 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
445 let workspace_root = crate::tools::file_ops::workspace_root();
446 let workspace_mode = workspace_mode_label(&workspace_root);
447 let path_stats = analyze_path_env();
448 let toolchains = collect_toolchains();
449
450 let mut out = String::from("Host inspection: summary\n\n");
451 let _ = writeln!(out, "- OS: {}", std::env::consts::OS);
452 let _ = writeln!(out, "- Current directory: {}", current_dir.display());
453 let _ = writeln!(out, "- Workspace root: {}", workspace_root.display());
454 let _ = writeln!(out, "- Workspace mode: {}", workspace_mode);
455 let _ = writeln!(out, "- Preferred shell: {}", preferred_shell_label());
456 let _ = writeln!(
457 out,
458 "- PATH entries: {} total, {} unique, {} duplicates, {} missing",
459 path_stats.total_entries,
460 path_stats.unique_entries,
461 path_stats.duplicate_entries.len(),
462 path_stats.missing_entries.len()
463 );
464
465 if toolchains.found.is_empty() {
466 out.push_str(
467 "- Toolchains found: none of the common developer tools were detected on PATH\n",
468 );
469 } else {
470 out.push_str("- Toolchains found:\n");
471 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
472 let _ = writeln!(out, " - {}: {}", label, version);
473 }
474 if toolchains.found.len() > max_entries.min(8) {
475 let _ = writeln!(
476 out,
477 " - ... {} more found tools omitted",
478 toolchains.found.len() - max_entries.min(8)
479 );
480 }
481 }
482
483 if !toolchains.missing.is_empty() {
484 let _ = writeln!(
485 out,
486 "- Common tools not detected on PATH: {}",
487 toolchains.missing.join(", ")
488 );
489 }
490
491 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
492 match path {
493 Some(path) if path.exists() => match count_top_level_items(&path) {
494 Ok(count) => {
495 let _ = writeln!(
496 out,
497 "- {}: {} top-level items at {}",
498 label,
499 count,
500 path.display()
501 );
502 }
503 Err(e) => {
504 let _ = writeln!(
505 out,
506 "- {}: exists at {} but could not inspect ({})",
507 label,
508 path.display(),
509 e
510 );
511 }
512 },
513 Some(path) => {
514 let _ = writeln!(
515 out,
516 "- {}: expected at {} but not found",
517 label,
518 path.display()
519 );
520 }
521 None => {
522 let _ = writeln!(out, "- {}: location unavailable on this host", label);
523 }
524 }
525 }
526
527 Ok(out.trim_end().to_string())
528}
529
530fn inspect_toolchains() -> Result<String, String> {
531 let report = collect_toolchains();
532 let mut out = String::from("Host inspection: toolchains\n\n");
533
534 if report.found.is_empty() {
535 out.push_str("- No common developer tools were detected on PATH.");
536 } else {
537 out.push_str("Detected developer tools:\n");
538 for (label, version) in report.found {
539 let _ = writeln!(out, "- {}: {}", label, version);
540 }
541 }
542
543 if !report.missing.is_empty() {
544 out.push_str("\nNot detected on PATH:\n");
545 for label in report.missing {
546 let _ = writeln!(out, "- {}", label);
547 }
548 }
549
550 Ok(out.trim_end().to_string())
551}
552
553fn inspect_path(max_entries: usize) -> Result<String, String> {
554 let path_stats = analyze_path_env();
555 let mut out = String::from("Host inspection: PATH\n\n");
556 let _ = writeln!(out, "- Total entries: {}", path_stats.total_entries);
557 let _ = writeln!(out, "- Unique entries: {}", path_stats.unique_entries);
558 let _ = writeln!(
559 out,
560 "- Duplicate entries: {}",
561 path_stats.duplicate_entries.len()
562 );
563 let _ = writeln!(out, "- Missing paths: {}", path_stats.missing_entries.len());
564
565 out.push_str("\nPATH entries:\n");
566 for entry in path_stats.entries.iter().take(max_entries) {
567 let _ = writeln!(out, "- {}", entry);
568 }
569 if path_stats.entries.len() > max_entries {
570 let _ = writeln!(
571 out,
572 "- ... {} more entries omitted",
573 path_stats.entries.len() - max_entries
574 );
575 }
576
577 if !path_stats.duplicate_entries.is_empty() {
578 out.push_str("\nDuplicate entries:\n");
579 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
580 let _ = writeln!(out, "- {}", entry);
581 }
582 if path_stats.duplicate_entries.len() > max_entries {
583 let _ = writeln!(
584 out,
585 "- ... {} more duplicates omitted",
586 path_stats.duplicate_entries.len() - max_entries
587 );
588 }
589 }
590
591 if !path_stats.missing_entries.is_empty() {
592 out.push_str("\nMissing directories:\n");
593 for entry in path_stats.missing_entries.iter().take(max_entries) {
594 let _ = writeln!(out, "- {}", entry);
595 }
596 if path_stats.missing_entries.len() > max_entries {
597 let _ = writeln!(
598 out,
599 "- ... {} more missing entries omitted",
600 path_stats.missing_entries.len() - max_entries
601 );
602 }
603 }
604
605 Ok(out.trim_end().to_string())
606}
607
608fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
609 let path_stats = analyze_path_env();
610 let toolchains = collect_toolchains();
611 let package_managers = collect_package_managers();
612 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
613
614 let mut out = String::from("Host inspection: env_doctor\n\n");
615 let _ = writeln!(
616 out,
617 "- PATH health: {} duplicates, {} missing entries",
618 path_stats.duplicate_entries.len(),
619 path_stats.missing_entries.len()
620 );
621 let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
622 let _ = writeln!(
623 out,
624 "- Package managers found: {}",
625 package_managers.found.len()
626 );
627
628 if !package_managers.found.is_empty() {
629 out.push_str("\nPackage managers:\n");
630 for (label, version) in package_managers.found.iter().take(max_entries) {
631 let _ = writeln!(out, "- {}: {}", label, version);
632 }
633 if package_managers.found.len() > max_entries {
634 let _ = writeln!(
635 out,
636 "- ... {} more package managers omitted",
637 package_managers.found.len() - max_entries
638 );
639 }
640 }
641
642 if !path_stats.duplicate_entries.is_empty() {
643 out.push_str("\nDuplicate PATH entries:\n");
644 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
645 let _ = writeln!(out, "- {}", entry);
646 }
647 if path_stats.duplicate_entries.len() > max_entries.min(5) {
648 let _ = writeln!(
649 out,
650 "- ... {} more duplicate entries omitted",
651 path_stats.duplicate_entries.len() - max_entries.min(5)
652 );
653 }
654 }
655
656 if !path_stats.missing_entries.is_empty() {
657 out.push_str("\nMissing PATH entries:\n");
658 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
659 let _ = writeln!(out, "- {}", entry);
660 }
661 if path_stats.missing_entries.len() > max_entries.min(5) {
662 let _ = writeln!(
663 out,
664 "- ... {} more missing entries omitted",
665 path_stats.missing_entries.len() - max_entries.min(5)
666 );
667 }
668 }
669
670 if !findings.is_empty() {
671 out.push_str("\nFindings:\n");
672 for finding in findings.iter().take(max_entries.max(5)) {
673 let _ = writeln!(out, "- {}", finding);
674 }
675 if findings.len() > max_entries.max(5) {
676 let _ = writeln!(
677 out,
678 "- ... {} more findings omitted",
679 findings.len() - max_entries.max(5)
680 );
681 }
682 } else {
683 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
684 }
685
686 out.push_str(
687 "\nGuidance:\n- This report already includes the PATH and package-manager health details. Do not call `inspect_host(path)` next unless the user explicitly asks for the raw PATH list.",
688 );
689
690 Ok(out.trim_end().to_string())
691}
692
693#[derive(Clone, Copy, Debug, Eq, PartialEq)]
694enum FixPlanKind {
695 EnvPath,
696 PortConflict,
697 LmStudio,
698 DriverInstall,
699 GroupPolicy,
700 FirewallRule,
701 SshKey,
702 WslSetup,
703 ServiceConfig,
704 WindowsActivation,
705 RegistryEdit,
706 ScheduledTaskCreate,
707 DiskCleanup,
708 DnsResolution,
709 Generic,
710}
711
712async fn inspect_fix_plan(
713 issue: Option<String>,
714 port_filter: Option<u16>,
715 max_entries: usize,
716) -> Result<String, String> {
717 let issue = issue.unwrap_or_else(|| {
718 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
719 .to_string()
720 });
721 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
722 match plan_kind {
723 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
724 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
725 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
726 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
727 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
728 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
729 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
730 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
731 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
732 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
733 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
734 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
735 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
736 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
737 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
738 }
739}
740
741fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
742 let lower = issue.to_ascii_lowercase();
743 if lower.contains("firewall rule")
746 || lower.contains("inbound rule")
747 || lower.contains("outbound rule")
748 || (lower.contains("firewall")
749 && (lower.contains("allow")
750 || lower.contains("block")
751 || lower.contains("create")
752 || lower.contains("open")))
753 {
754 FixPlanKind::FirewallRule
755 } else if port_filter.is_some()
756 || lower.contains("port ")
757 || lower.contains("address already in use")
758 || lower.contains("already in use")
759 || lower.contains("what owns port")
760 || lower.contains("listening on port")
761 {
762 FixPlanKind::PortConflict
763 } else if lower.contains("lm studio")
764 || lower.contains("localhost:1234")
765 || lower.contains("/v1/models")
766 || lower.contains("no coding model loaded")
767 || lower.contains("embedding model")
768 || lower.contains("server on port 1234")
769 || lower.contains("runtime refresh")
770 {
771 FixPlanKind::LmStudio
772 } else if lower.contains("driver")
773 || lower.contains("gpu driver")
774 || lower.contains("nvidia driver")
775 || lower.contains("amd driver")
776 || lower.contains("install driver")
777 || lower.contains("update driver")
778 {
779 FixPlanKind::DriverInstall
780 } else if lower.contains("group policy")
781 || lower.contains("gpedit")
782 || lower.contains("local policy")
783 || lower.contains("secpol")
784 || lower.contains("administrative template")
785 {
786 FixPlanKind::GroupPolicy
787 } else if lower.contains("ssh key")
788 || lower.contains("ssh-keygen")
789 || lower.contains("generate ssh")
790 || lower.contains("authorized_keys")
791 || lower.contains("id_rsa")
792 || lower.contains("id_ed25519")
793 {
794 FixPlanKind::SshKey
795 } else if lower.contains("wsl")
796 || lower.contains("windows subsystem for linux")
797 || lower.contains("install ubuntu")
798 || lower.contains("install linux on windows")
799 || lower.contains("wsl2")
800 {
801 FixPlanKind::WslSetup
802 } else if lower.contains("service")
803 && (lower.contains("start ")
804 || lower.contains("stop ")
805 || lower.contains("restart ")
806 || lower.contains("enable ")
807 || lower.contains("disable ")
808 || lower.contains("configure service"))
809 {
810 FixPlanKind::ServiceConfig
811 } else if lower.contains("activate windows")
812 || lower.contains("windows activation")
813 || lower.contains("product key")
814 || lower.contains("kms")
815 || lower.contains("not activated")
816 {
817 FixPlanKind::WindowsActivation
818 } else if lower.contains("registry")
819 || lower.contains("regedit")
820 || lower.contains("hklm")
821 || lower.contains("hkcu")
822 || lower.contains("reg add")
823 || lower.contains("reg delete")
824 || lower.contains("registry key")
825 {
826 FixPlanKind::RegistryEdit
827 } else if lower.contains("scheduled task")
828 || lower.contains("task scheduler")
829 || lower.contains("schtasks")
830 || lower.contains("create task")
831 || lower.contains("run on startup")
832 || lower.contains("run on schedule")
833 || lower.contains("cron")
834 {
835 FixPlanKind::ScheduledTaskCreate
836 } else if lower.contains("disk cleanup")
837 || lower.contains("free up disk")
838 || lower.contains("free up space")
839 || lower.contains("clear cache")
840 || lower.contains("disk full")
841 || lower.contains("low disk space")
842 || lower.contains("reclaim space")
843 {
844 FixPlanKind::DiskCleanup
845 } else if lower.contains("cargo")
846 || lower.contains("rustc")
847 || lower.contains("path")
848 || lower.contains("package manager")
849 || lower.contains("package managers")
850 || lower.contains("toolchain")
851 || lower.contains("winget")
852 || lower.contains("choco")
853 || lower.contains("scoop")
854 || lower.contains("python")
855 || lower.contains("node")
856 {
857 FixPlanKind::EnvPath
858 } else if lower.contains("dns ")
859 || lower.contains("nameserver")
860 || lower.contains("cannot resolve")
861 || lower.contains("nslookup")
862 || lower.contains("flushdns")
863 {
864 FixPlanKind::DnsResolution
865 } else {
866 FixPlanKind::Generic
867 }
868}
869
870fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
871 let path_stats = analyze_path_env();
872 let toolchains = collect_toolchains();
873 let package_managers = collect_package_managers();
874 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
875 let found_tools = toolchains
876 .found
877 .iter()
878 .map(|(label, _)| label.as_str())
879 .collect::<HashSet<_>>();
880 let found_managers = package_managers
881 .found
882 .iter()
883 .map(|(label, _)| label.as_str())
884 .collect::<HashSet<_>>();
885
886 let mut out = String::from("Host inspection: fix_plan\n\n");
887 let _ = writeln!(out, "- Requested issue: {}", issue);
888 out.push_str("- Fix-plan type: environment/path\n");
889 let _ = writeln!(
890 out,
891 "- PATH health: {} duplicates, {} missing entries",
892 path_stats.duplicate_entries.len(),
893 path_stats.missing_entries.len()
894 );
895 let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
896 let _ = writeln!(
897 out,
898 "- Package managers found: {}",
899 package_managers.found.len()
900 );
901
902 out.push_str("\nLikely causes:\n");
903 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
904 out.push_str(
905 "- Rust is present but Cargo is not. The most common cause is a missing Rustup bin path such as `%USERPROFILE%\\.cargo\\bin` on Windows or `$HOME/.cargo/bin` on Unix.\n",
906 );
907 }
908 if path_stats.duplicate_entries.is_empty()
909 && path_stats.missing_entries.is_empty()
910 && !findings.is_empty()
911 {
912 for finding in findings.iter().take(max_entries.max(4)) {
913 let _ = writeln!(out, "- {}", finding);
914 }
915 } else {
916 if !path_stats.duplicate_entries.is_empty() {
917 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
918 }
919 if !path_stats.missing_entries.is_empty() {
920 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
921 }
922 }
923 if found_tools.contains("node")
924 && !found_managers.contains("npm")
925 && !found_managers.contains("pnpm")
926 {
927 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
928 }
929 if found_tools.contains("python")
930 && !found_managers.contains("pip")
931 && !found_managers.contains("uv")
932 && !found_managers.contains("pipx")
933 {
934 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
935 }
936
937 out.push_str("\nFix plan:\n");
938 out.push_str("- Verify the command resolution first with `where cargo`, `where rustc`, `where python`, or `Get-Command cargo` so you know whether the tool is missing or just hidden behind PATH drift.\n");
939 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
940 out.push_str("- Add the Rustup bin directory to your user PATH, then restart the terminal. On Windows that is usually `%USERPROFILE%\\.cargo\\bin`.\n");
941 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
942 out.push_str("- If Rust is not installed at all, install Rustup first, then reopen the terminal. On Windows the clean path is `winget install Rustlang.Rustup`.\n");
943 }
944 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
945 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
946 }
947 if found_tools.contains("node")
948 && !found_managers.contains("npm")
949 && !found_managers.contains("pnpm")
950 {
951 out.push_str("- Repair the Node install or reinstall Node so `npm` is restored. If you prefer `pnpm`, install it after Node is healthy.\n");
952 }
953 if found_tools.contains("python")
954 && !found_managers.contains("pip")
955 && !found_managers.contains("uv")
956 && !found_managers.contains("pipx")
957 {
958 out.push_str("- Repair Python or install a Python package manager explicitly. `py -m ensurepip --upgrade` is the least-invasive first check on Windows.\n");
959 }
960
961 if !path_stats.duplicate_entries.is_empty() {
962 out.push_str("\nExample duplicate PATH rows:\n");
963 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
964 let _ = writeln!(out, "- {}", entry);
965 }
966 }
967 if !path_stats.missing_entries.is_empty() {
968 out.push_str("\nExample missing PATH rows:\n");
969 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
970 let _ = writeln!(out, "- {}", entry);
971 }
972 }
973
974 out.push_str(
975 "\nWhy this works:\n- PATH problems are usually resolution problems, not mysterious tool failures. Verify the executable path, repair the install only when needed, then restart the shell so the environment is rebuilt cleanly.",
976 );
977 Ok(out.trim_end().to_string())
978}
979
980fn inspect_port_fix_plan(
981 issue: &str,
982 port_filter: Option<u16>,
983 max_entries: usize,
984) -> Result<String, String> {
985 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
986 let listeners = collect_listening_ports().unwrap_or_default();
987 let mut matching = listeners;
988 if let Some(port) = requested_port {
989 matching.retain(|entry| entry.port == port);
990 }
991 let processes = collect_processes().unwrap_or_default();
992
993 let mut out = String::from("Host inspection: fix_plan\n\n");
994 let _ = writeln!(out, "- Requested issue: {}", issue);
995 out.push_str("- Fix-plan type: port_conflict\n");
996 if let Some(port) = requested_port {
997 let _ = writeln!(out, "- Requested port: {}", port);
998 } else {
999 out.push_str("- Requested port: not parsed from the issue text\n");
1000 }
1001 let _ = writeln!(out, "- Matching listeners found: {}", matching.len());
1002
1003 if !matching.is_empty() {
1004 out.push_str("\nCurrent listeners:\n");
1005 for entry in matching.iter().take(max_entries.min(5)) {
1006 let process_name = entry
1007 .pid
1008 .as_deref()
1009 .and_then(|pid| pid.parse::<u32>().ok())
1010 .and_then(|pid| {
1011 processes
1012 .iter()
1013 .find(|process| process.pid == pid)
1014 .map(|process| process.name.as_str())
1015 })
1016 .unwrap_or("unknown");
1017 let pid = entry.pid.as_deref().unwrap_or("unknown");
1018 let _ = writeln!(
1019 out,
1020 "- {} {} ({}) pid {} process {}",
1021 entry.protocol, entry.local, entry.state, pid, process_name
1022 );
1023 }
1024 }
1025
1026 out.push_str("\nFix plan:\n");
1027 out.push_str("- Identify whether the existing listener is expected. If it is your dev server, reuse it or change your app config instead of killing it blindly.\n");
1028 if !matching.is_empty() {
1029 out.push_str("- If the listener is stale, stop the owning process by PID or close the parent app cleanly. On Windows, `taskkill /PID <pid> /F` is the blunt option, but closing the app normally is safer.\n");
1030 } else {
1031 out.push_str("- Re-run a listener check right before changing anything. Port conflicts can disappear if a stale dev process exits between checks.\n");
1032 }
1033 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
1034 out.push_str("- If the port keeps getting reclaimed after you kill it, inspect startup services or background tools rather than repeating `taskkill` loops.\n");
1035 out.push_str(
1036 "\nWhy this works:\n- Port conflicts are ownership problems. Once you know which PID owns the listener, the clean fix is either stop that owner or move your app to a different port.",
1037 );
1038 Ok(out.trim_end().to_string())
1039}
1040
1041async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
1042 let config = crate::agent::config::load_config();
1043 let configured_api = config
1044 .api_url
1045 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
1046 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
1047 let reachability = probe_http_endpoint(&models_url).await;
1048 let embed_model = detect_loaded_embed_model(&configured_api).await;
1049
1050 let mut out = String::from("Host inspection: fix_plan\n\n");
1051 let _ = writeln!(out, "- Requested issue: {}", issue);
1052 out.push_str("- Fix-plan type: lm_studio\n");
1053 let _ = writeln!(out, "- Configured API URL: {}", configured_api);
1054 let _ = writeln!(out, "- Probe URL: {}", models_url);
1055 match &reachability {
1056 EndpointProbe::Reachable(status) => {
1057 let _ = writeln!(out, "- Endpoint reachable: yes (HTTP {})", status);
1058 }
1059 EndpointProbe::Unreachable(detail) => {
1060 let _ = writeln!(out, "- Endpoint reachable: no ({})", detail);
1061 }
1062 }
1063 let _ = writeln!(
1064 out,
1065 "- Embedding model loaded: {}",
1066 embed_model.as_deref().unwrap_or("none detected")
1067 );
1068
1069 out.push_str("\nFix plan:\n");
1070 match reachability {
1071 EndpointProbe::Reachable(_) => {
1072 out.push_str("- LM Studio is reachable, so the first fix step is model state, not networking. Check whether a chat model is actually loaded and whether the local server is still serving the model you expect.\n");
1073 }
1074 EndpointProbe::Unreachable(_) => {
1075 out.push_str("- Start LM Studio and make sure the local server is running on the configured endpoint. Hematite defaults to `http://localhost:1234/v1` unless `.hematite/settings.json` overrides `api_url`.\n");
1076 }
1077 }
1078 out.push_str("- If Hematite is pointed at the wrong endpoint, fix `api_url` in `.hematite/settings.json` and restart or run `/runtime-refresh`.\n");
1079 out.push_str("- If chat works but semantic search does not, load an embedding model as a second resident local model. Hematite expects a `nomic-embed` or similar embedding model there.\n");
1080 out.push_str("- If LM Studio keeps responding with no model loaded, load the coding model first, then start the server again before blaming Hematite.\n");
1081 out.push_str("- If the server is up but turns still fail, narrow the prompt or refresh the runtime profile so Hematite picks up the live model and context budget.\n");
1082 if let Some(model) = embed_model {
1083 let _ = writeln!(out,
1084 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.",
1085 model
1086 );
1087 }
1088 if max_entries > 0 {
1089 out.push_str(
1090 "\nWhy this works:\n- LM Studio failures usually collapse into three buckets: wrong endpoint, server not running, or models not loaded. Confirm the endpoint first, then fix model state instead of guessing.",
1091 );
1092 }
1093 Ok(out.trim_end().to_string())
1094}
1095
1096fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1097 #[cfg(target_os = "windows")]
1099 let gpu_info = {
1100 let out = Command::new("powershell")
1101 .args([
1102 "-NoProfile",
1103 "-NonInteractive",
1104 "-Command",
1105 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1106 ])
1107 .output()
1108 .ok()
1109 .and_then(|o| String::from_utf8(o.stdout).ok())
1110 .unwrap_or_default();
1111 out.trim().to_string()
1112 };
1113 #[cfg(not(target_os = "windows"))]
1114 let gpu_info = String::from("(GPU detection not available on this platform)");
1115
1116 let mut out = String::from("Host inspection: fix_plan\n\n");
1117 let _ = writeln!(out, "- Requested issue: {}", issue);
1118 out.push_str("- Fix-plan type: driver_install\n");
1119 if !gpu_info.is_empty() {
1120 let _ = write!(out, "\nDetected GPU(s):\n{}\n", gpu_info);
1121 }
1122 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1123 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1124 out.push_str(
1125 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1126 );
1127 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1128 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1129 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1130 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
1131 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1132 out.push_str("5. Run the downloaded installer. Choose 'Express Install' (keeps settings) or 'Custom / Clean Install' (wipes old driver state — recommended if fixing corruption).\n");
1133 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1134 out.push_str("\nVerification:\n");
1135 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1136 out.push_str("- The DriverVersion should match what you installed.\n");
1137 out.push_str("\nWhy this works:\nManufacturer installers handle INF signing, kernel-mode driver registration, and WDDM version negotiation automatically. Manual Device Manager updates often miss supporting components.");
1138 Ok(out.trim_end().to_string())
1139}
1140
1141fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1142 #[cfg(target_os = "windows")]
1144 let edition = {
1145 Command::new("powershell")
1146 .args([
1147 "-NoProfile",
1148 "-NonInteractive",
1149 "-Command",
1150 "(Get-CimInstance Win32_OperatingSystem).Caption",
1151 ])
1152 .output()
1153 .ok()
1154 .and_then(|o| String::from_utf8(o.stdout).ok())
1155 .unwrap_or_default()
1156 .trim()
1157 .to_string()
1158 };
1159 #[cfg(not(target_os = "windows"))]
1160 let edition = String::from("(Windows edition detection not available)");
1161
1162 let is_home = edition.to_lowercase().contains("home");
1163
1164 let mut out = String::from("Host inspection: fix_plan\n\n");
1165 let _ = writeln!(out, "- Requested issue: {}", issue);
1166 out.push_str("- Fix-plan type: group_policy\n");
1167 let _ = writeln!(
1168 out,
1169 "- Windows edition detected: {}",
1170 if edition.is_empty() {
1171 "unknown".to_string()
1172 } else {
1173 edition.clone()
1174 }
1175 );
1176
1177 if is_home {
1178 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1179 out.push_str("Options on Home edition:\n");
1180 out.push_str("1. Use the Registry Editor (regedit) as an alternative — most Group Policy settings map to registry keys under HKLM\\SOFTWARE\\Policies or HKCU\\SOFTWARE\\Policies.\n");
1181 out.push_str(
1182 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1183 );
1184 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1185 } else {
1186 out.push_str("\nFix plan — Editing Local Group Policy:\n");
1187 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1188 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1189 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1190 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1191 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1192 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
1193 }
1194 out.push_str("\nVerification:\n");
1195 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1196 out.push_str(
1197 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1198 );
1199 out.push_str("\nWhy this works:\nGroup Policy writes settings to well-known registry paths that Windows reads at logon and on policy refresh cycles. gpupdate /force triggers an immediate refresh without requiring a restart.");
1200 Ok(out.trim_end().to_string())
1201}
1202
1203fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1204 #[cfg(target_os = "windows")]
1205 let profile_state = {
1206 Command::new("powershell")
1207 .args([
1208 "-NoProfile",
1209 "-NonInteractive",
1210 "-Command",
1211 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1212 ])
1213 .output()
1214 .ok()
1215 .and_then(|o| String::from_utf8(o.stdout).ok())
1216 .unwrap_or_default()
1217 .trim()
1218 .to_string()
1219 };
1220 #[cfg(not(target_os = "windows"))]
1221 let profile_state = String::new();
1222
1223 let mut out = String::from("Host inspection: fix_plan\n\n");
1224 let _ = writeln!(out, "- Requested issue: {}", issue);
1225 out.push_str("- Fix-plan type: firewall_rule\n");
1226 if !profile_state.is_empty() {
1227 let _ = write!(out, "\nFirewall profile state:\n{}\n", profile_state);
1228 }
1229 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1230 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1231 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1232 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1233 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1234 out.push_str("\nTo ALLOW an application through the firewall:\n");
1235 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1236 out.push_str("\nTo REMOVE a rule you created:\n");
1237 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1238 out.push_str("\nTo see existing custom rules:\n");
1239 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1240 out.push_str("\nVerification:\n");
1241 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1242 out.push_str("\nWhy this works:\nNew-NetFirewallRule writes directly to the Windows Filtering Platform (WFP) rule store — the same engine used by the Firewall GUI, but scriptable and reproducible.");
1243 Ok(out.trim_end().to_string())
1244}
1245
1246fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1247 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1248 let ssh_dir = home.join(".ssh");
1249 let has_ssh_dir = ssh_dir.exists();
1250 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1251 let has_rsa = ssh_dir.join("id_rsa").exists();
1252 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1253
1254 let mut out = String::from("Host inspection: fix_plan\n\n");
1255 let _ = writeln!(out, "- Requested issue: {}", issue);
1256 out.push_str("- Fix-plan type: ssh_key\n");
1257 let _ = writeln!(out, "- ~/.ssh directory exists: {}", has_ssh_dir);
1258 let _ = writeln!(out, "- id_ed25519 key found: {}", has_ed25519);
1259 let _ = writeln!(out, "- id_rsa key found: {}", has_rsa);
1260 let _ = writeln!(out, "- authorized_keys found: {}", has_authorized_keys);
1261
1262 if has_ed25519 {
1263 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1264 }
1265
1266 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1267 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1268 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1269 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1270 out.push_str(
1271 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1272 );
1273 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1274 out.push_str("3. Start the SSH agent and add your key:\n");
1275 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1276 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1277 out.push_str(" Start-Service ssh-agent\n");
1278 out.push_str(" # Then add the key (normal PowerShell):\n");
1279 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1280 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1281 out.push_str(" # Print your public key:\n");
1282 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1283 out.push_str(" # On the target server, append it:\n");
1284 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1285 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1286 out.push_str("5. Test the connection:\n");
1287 out.push_str(" ssh user@server-address\n");
1288 out.push_str("\nFor GitHub/GitLab:\n");
1289 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1290 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1291 out.push_str("- Test: ssh -T git@github.com\n");
1292 out.push_str("\nWhy this works:\nEd25519 keys use elliptic-curve cryptography — shorter than RSA, harder to brute-force, and supported by all modern SSH servers. The agent caches the decrypted key so you only enter the passphrase once per session.");
1293 Ok(out.trim_end().to_string())
1294}
1295
1296fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1297 #[cfg(target_os = "windows")]
1298 let wsl_status = {
1299 let out = Command::new("wsl")
1300 .args(["--status"])
1301 .output()
1302 .ok()
1303 .map(|o| {
1304 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1305 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1306 format!("{}{}", stdout, stderr)
1307 })
1308 .unwrap_or_default();
1309 out.trim().to_string()
1310 };
1311 #[cfg(not(target_os = "windows"))]
1312 let wsl_status = String::new();
1313
1314 let wsl_installed =
1315 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1316
1317 let mut out = String::from("Host inspection: fix_plan\n\n");
1318 let _ = writeln!(out, "- Requested issue: {}", issue);
1319 out.push_str("- Fix-plan type: wsl_setup\n");
1320 let _ = writeln!(out, "- WSL already installed: {}", wsl_installed);
1321 if !wsl_status.is_empty() {
1322 let _ = write!(out, "- WSL status:\n{}\n", wsl_status);
1323 }
1324
1325 if wsl_installed {
1326 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1327 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1328 out.push_str(" Available distros: wsl --list --online\n");
1329 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1330 out.push_str("3. Create your Linux username and password when prompted.\n");
1331 } else {
1332 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1333 out.push_str("1. Open PowerShell as Administrator.\n");
1334 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1335 out.push_str(" wsl --install\n");
1336 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1337 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1338 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1339 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1340 out.push_str(" wsl --set-default-version 2\n");
1341 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1342 out.push_str(" wsl --install -d Debian\n");
1343 out.push_str(" wsl --list --online # to see all available distros\n");
1344 }
1345 out.push_str("\nVerification:\n");
1346 out.push_str("- Run: wsl --list --verbose\n");
1347 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1348 out.push_str("\nWhy this works:\nWSL2 runs a real Linux kernel inside a lightweight Hyper-V VM. The `wsl --install` command handles all the Windows feature enablement, kernel download, and distro bootstrapping automatically.");
1349 Ok(out.trim_end().to_string())
1350}
1351
1352fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1353 let lower = issue.to_ascii_lowercase();
1354 let service_hint = if lower.contains("ssh") {
1356 Some("sshd")
1357 } else if lower.contains("mysql") {
1358 Some("MySQL80")
1359 } else if lower.contains("postgres") || lower.contains("postgresql") {
1360 Some("postgresql")
1361 } else if lower.contains("redis") {
1362 Some("Redis")
1363 } else if lower.contains("nginx") {
1364 Some("nginx")
1365 } else if lower.contains("apache") {
1366 Some("Apache2.4")
1367 } else {
1368 None
1369 };
1370
1371 #[cfg(target_os = "windows")]
1372 let service_state = if let Some(svc) = service_hint {
1373 Command::new("powershell")
1374 .args([
1375 "-NoProfile",
1376 "-NonInteractive",
1377 "-Command",
1378 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1379 ])
1380 .output()
1381 .ok()
1382 .and_then(|o| String::from_utf8(o.stdout).ok())
1383 .unwrap_or_default()
1384 .trim()
1385 .to_string()
1386 } else {
1387 String::new()
1388 };
1389 #[cfg(not(target_os = "windows"))]
1390 let service_state = String::new();
1391
1392 let mut out = String::from("Host inspection: fix_plan\n\n");
1393 let _ = writeln!(out, "- Requested issue: {}", issue);
1394 out.push_str("- Fix-plan type: service_config\n");
1395 if let Some(svc) = service_hint {
1396 let _ = writeln!(out, "- Service detected in request: {}", svc);
1397 }
1398 if !service_state.is_empty() {
1399 let _ = writeln!(out, "- Current state: {}", service_state);
1400 }
1401
1402 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1403 out.push_str("\nStart a service:\n");
1404 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1405 out.push_str("\nStop a service:\n");
1406 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1407 out.push_str("\nRestart a service:\n");
1408 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1409 out.push_str("\nEnable a service to start automatically:\n");
1410 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1411 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1412 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1413 out.push_str("\nFind the exact service name:\n");
1414 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1415 out.push_str("\nVerification:\n");
1416 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1417 if let Some(svc) = service_hint {
1418 let _ = write!(
1419 out,
1420 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1421 svc, svc
1422 );
1423 }
1424 out.push_str("\nWhy this works:\nPowerShell's service cmdlets talk directly to the Windows Service Control Manager (SCM) — the same authority that manages auto-start, stop, and dependency resolution for all registered Windows services.");
1425 Ok(out.trim_end().to_string())
1426}
1427
1428fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1429 #[cfg(target_os = "windows")]
1430 let activation_status = {
1431 Command::new("powershell")
1432 .args([
1433 "-NoProfile",
1434 "-NonInteractive",
1435 "-Command",
1436 "Get-CimInstance SoftwareLicensingProduct -Filter \"Name like 'Windows%'\" | Where-Object { $_.PartialProductKey } | Select-Object Name,LicenseStatus | ForEach-Object { \"Product: $($_.Name) | Status: $(if ($_.LicenseStatus -eq 1) { 'LICENSED' } else { 'NOT LICENSED (code ' + $_.LicenseStatus + ')' })\" }",
1437 ])
1438 .output()
1439 .ok()
1440 .and_then(|o| String::from_utf8(o.stdout).ok())
1441 .unwrap_or_default()
1442 .trim()
1443 .to_string()
1444 };
1445 #[cfg(not(target_os = "windows"))]
1446 let activation_status = String::new();
1447
1448 let activation_lower = activation_status.to_lowercase();
1449 let is_licensed =
1450 activation_lower.contains("licensed") && !activation_lower.contains("not licensed");
1451
1452 let mut out = String::from("Host inspection: fix_plan\n\n");
1453 let _ = writeln!(out, "- Requested issue: {}", issue);
1454 out.push_str("- Fix-plan type: windows_activation\n");
1455 if !activation_status.is_empty() {
1456 let _ = write!(out, "- Current activation state:\n{}\n", activation_status);
1457 }
1458
1459 if is_licensed {
1460 out.push_str(
1461 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1462 );
1463 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1464 out.push_str(" (Forces an online activation attempt)\n");
1465 out.push_str("2. Check activation details: slmgr /dli\n");
1466 } else {
1467 out.push_str("\nFix plan — Activating Windows:\n");
1468 out.push_str("1. Check your current status first:\n");
1469 out.push_str(" slmgr /dli (basic info)\n");
1470 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1471 out.push_str("\n2. If you have a retail product key:\n");
1472 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1473 out.push_str(" slmgr /ato (activate online)\n");
1474 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1475 out.push_str(" - Go to Settings → System → Activation\n");
1476 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1477 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1478 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1479 out.push_str(" - Contact your IT department for the KMS server address\n");
1480 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1481 out.push_str(" - Activate: slmgr /ato\n");
1482 }
1483 out.push_str("\nVerification:\n");
1484 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1485 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1486 out.push_str("\nWhy this works:\nslmgr.vbs is the Software License Manager — Microsoft's official command-line tool for all Windows license operations. It talks directly to the Software Protection Platform service.");
1487 Ok(out.trim_end().to_string())
1488}
1489
1490fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1491 let mut out = String::from("Host inspection: fix_plan\n\n");
1492 let _ = writeln!(out, "- Requested issue: {}", issue);
1493 out.push_str("- Fix-plan type: registry_edit\n");
1494 out.push_str(
1495 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1496 );
1497 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1498 out.push_str("\n1. Back up before you touch anything:\n");
1499 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1500 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1501 out.push_str(" # Or export the whole registry (takes a while):\n");
1502 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1503 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1504 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1505 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1506 out.push_str(
1507 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1508 );
1509 out.push_str("\n4. Create a new key:\n");
1510 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1511 out.push_str("\n5. Delete a value:\n");
1512 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1513 out.push_str("\n6. Restore from backup if something breaks:\n");
1514 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1515 out.push_str("\nCommon registry hives:\n");
1516 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1517 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1518 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1519 out.push_str("\nVerification:\n");
1520 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1521 out.push_str("\nWhy this works:\nPowerShell's registry provider (HKLM:, HKCU:) is the safest scripted way to edit the registry — it validates paths and types, unlike raw reg.exe which accepts anything silently.");
1522 Ok(out.trim_end().to_string())
1523}
1524
1525fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1526 let mut out = String::from("Host inspection: fix_plan\n\n");
1527 let _ = writeln!(out, "- Requested issue: {}", issue);
1528 out.push_str("- Fix-plan type: scheduled_task_create\n");
1529 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1530 out.push_str("\nExample: Run a script at 9 AM every day\n");
1531 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1532 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1533 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1534 out.push_str("\nExample: Run at Windows startup\n");
1535 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1536 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1537 out.push_str("\nExample: Run at user logon\n");
1538 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1539 out.push_str(
1540 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1541 );
1542 out.push_str("\nExample: Run every 30 minutes\n");
1543 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1544 out.push_str("\nView all tasks:\n");
1545 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1546 out.push_str("\nDelete a task:\n");
1547 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1548 out.push_str("\nRun a task immediately:\n");
1549 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1550 out.push_str("\nVerification:\n");
1551 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1552 out.push_str("\nWhy this works:\nPowerShell's ScheduledTask cmdlets use the Task Scheduler COM interface — the same engine as the Task Scheduler GUI (taskschd.msc). Tasks persist in the Windows Task Scheduler database across reboots.");
1553 Ok(out.trim_end().to_string())
1554}
1555
1556fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1557 #[cfg(target_os = "windows")]
1558 let disk_info = {
1559 Command::new("powershell")
1560 .args([
1561 "-NoProfile",
1562 "-NonInteractive",
1563 "-Command",
1564 "Get-PSDrive -PSProvider FileSystem | Select-Object Name,@{N='Used_GB';E={[Math]::Round($_.Used/1GB,1)}},@{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}} | Where-Object { $_.Used_GB -gt 0 } | ForEach-Object { \"Drive $($_.Name): Used $($_.Used_GB) GB, Free $($_.Free_GB) GB\" }",
1565 ])
1566 .output()
1567 .ok()
1568 .and_then(|o| String::from_utf8(o.stdout).ok())
1569 .unwrap_or_default()
1570 .trim()
1571 .to_string()
1572 };
1573 #[cfg(not(target_os = "windows"))]
1574 let disk_info = String::new();
1575
1576 let mut out = String::from("Host inspection: fix_plan\n\n");
1577 let _ = writeln!(out, "- Requested issue: {}", issue);
1578 out.push_str("- Fix-plan type: disk_cleanup\n");
1579 if !disk_info.is_empty() {
1580 let _ = write!(out, "\nCurrent drive usage:\n{}\n", disk_info);
1581 }
1582 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1583 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1584 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1585 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1586 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1587 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1588 out.push_str(" Stop-Service wuauserv\n");
1589 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1590 out.push_str(" Start-Service wuauserv\n");
1591 out.push_str("\n3. Clear Windows Temp folder:\n");
1592 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1593 out.push_str(
1594 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1595 );
1596 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1597 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1598 out.push_str(" - npm cache: npm cache clean --force\n");
1599 out.push_str(" - pip cache: pip cache purge\n");
1600 out.push_str(
1601 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1602 );
1603 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1604 out.push_str("\n5. Check for large files:\n");
1605 out.push_str(" Get-ChildItem C:\\ -Recurse -ErrorAction SilentlyContinue | Sort-Object Length -Descending | Select-Object -First 20 FullName,@{N='MB';E={[Math]::Round($_.Length/1MB,1)}}\n");
1606 out.push_str("\nVerification:\n");
1607 out.push_str(
1608 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1609 );
1610 out.push_str("\nWhy this works:\nWindows accumulates update packages, temp files, and developer build artifacts over months. Targeting those specific locations gives the most space back with the least risk of breaking anything.");
1611 Ok(out.trim_end().to_string())
1612}
1613
1614fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1615 let mut out = String::from("Host inspection: fix_plan\n\n");
1616 let _ = writeln!(out, "- Requested issue: {}", issue);
1617 out.push_str("- Fix-plan type: generic\n");
1618 out.push_str(
1619 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1620 Structured lanes available:\n\
1621 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1622 - Port conflict (address already in use, what owns port)\n\
1623 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1624 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1625 - Group Policy (gpedit, local policy, administrative template)\n\
1626 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1627 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1628 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1629 - Service config (start/stop/restart/enable/disable a service)\n\
1630 - Windows activation (product key, not activated, kms)\n\
1631 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1632 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1633 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1634 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1635 );
1636 Ok(out.trim_end().to_string())
1637}
1638
1639fn inspect_resource_load() -> Result<String, String> {
1640 #[cfg(target_os = "windows")]
1641 {
1642 let output = Command::new("powershell")
1643 .args([
1644 "-NoProfile",
1645 "-Command",
1646 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1647 ])
1648 .output()
1649 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1650
1651 let text = String::from_utf8_lossy(&output.stdout);
1652 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1653
1654 let cpu_load = lines
1655 .next()
1656 .and_then(|l| l.parse::<u32>().ok())
1657 .unwrap_or(0);
1658 let mem_json: String = lines.collect();
1659 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1660
1661 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1662 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1663 let used_kb = total_kb.saturating_sub(free_kb);
1664 let mem_percent = (used_kb * 100).checked_div(total_kb).unwrap_or(0);
1665
1666 let mut out = String::from("Host inspection: resource_load\n\n");
1667 out.push_str("**System Performance Summary:**\n");
1668 let _ = writeln!(out, "- CPU Load: {}%", cpu_load);
1669 let _ = writeln!(
1670 out,
1671 "- Memory Usage: {} / {} ({}%)",
1672 human_bytes(used_kb * 1024),
1673 human_bytes(total_kb * 1024),
1674 mem_percent
1675 );
1676
1677 if cpu_load > 85 {
1678 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1679 }
1680 if mem_percent > 90 {
1681 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1682 }
1683
1684 Ok(out)
1685 }
1686 #[cfg(not(target_os = "windows"))]
1687 {
1688 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1689 }
1690}
1691
1692#[derive(Debug)]
1693enum EndpointProbe {
1694 Reachable(u16),
1695 Unreachable(String),
1696}
1697
1698async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1699 let client = match reqwest::Client::builder()
1700 .timeout(std::time::Duration::from_secs(3))
1701 .build()
1702 {
1703 Ok(client) => client,
1704 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1705 };
1706
1707 match client.get(url).send().await {
1708 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1709 Err(err) => EndpointProbe::Unreachable(err.to_string()),
1710 }
1711}
1712
1713async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1714 if configured_api.contains("11434") {
1715 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1716 let url = format!("{}/api/ps", base);
1717 let client = reqwest::Client::builder()
1718 .timeout(std::time::Duration::from_secs(3))
1719 .build()
1720 .ok()?;
1721 let response = client.get(url).send().await.ok()?;
1722 let body = response.json::<serde_json::Value>().await.ok()?;
1723 let entries = body["models"].as_array()?;
1724 for entry in entries {
1725 let name = entry["name"]
1726 .as_str()
1727 .or_else(|| entry["model"].as_str())
1728 .unwrap_or_default();
1729 let lower = name.to_ascii_lowercase();
1730 if lower.contains("embed")
1731 || lower.contains("embedding")
1732 || lower.contains("minilm")
1733 || lower.contains("bge")
1734 || lower.contains("e5")
1735 {
1736 return Some(name.to_string());
1737 }
1738 }
1739 return None;
1740 }
1741
1742 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1743 let url = format!("{}/api/v0/models", base);
1744 let client = reqwest::Client::builder()
1745 .timeout(std::time::Duration::from_secs(3))
1746 .build()
1747 .ok()?;
1748
1749 #[derive(serde::Deserialize)]
1750 struct ModelList {
1751 data: Vec<ModelEntry>,
1752 }
1753 #[derive(serde::Deserialize)]
1754 struct ModelEntry {
1755 id: String,
1756 #[serde(rename = "type", default)]
1757 model_type: String,
1758 #[serde(default)]
1759 state: String,
1760 }
1761
1762 let response = client.get(url).send().await.ok()?;
1763 let models = response.json::<ModelList>().await.ok()?;
1764 models
1765 .data
1766 .into_iter()
1767 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1768 .map(|model| model.id)
1769}
1770
1771fn first_port_in_text(text: &str) -> Option<u16> {
1772 text.split(|c: char| !c.is_ascii_digit())
1773 .find(|fragment| !fragment.is_empty())
1774 .and_then(|fragment| fragment.parse::<u16>().ok())
1775}
1776
1777fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1778 let mut processes = collect_processes()?;
1779 if let Some(filter) = name_filter.as_deref() {
1780 let lowered = filter.to_ascii_lowercase();
1781 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1782 }
1783 processes.sort_by(|a, b| {
1784 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1785 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1786 b_cpu
1787 .partial_cmp(&a_cpu)
1788 .unwrap_or(std::cmp::Ordering::Equal)
1789 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1790 .then_with(|| a.name.cmp(&b.name))
1791 .then_with(|| a.pid.cmp(&b.pid))
1792 });
1793
1794 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1795
1796 let mut out = String::from("Host inspection: processes\n\n");
1797 if let Some(filter) = name_filter.as_deref() {
1798 let _ = writeln!(out, "- Filter name: {}", filter);
1799 }
1800 let _ = writeln!(out, "- Processes found: {}", processes.len());
1801 let _ = writeln!(
1802 out,
1803 "- Total reported working set: {}",
1804 human_bytes(total_memory)
1805 );
1806
1807 if processes.is_empty() {
1808 out.push_str("\nNo running processes matched.");
1809 return Ok(out);
1810 }
1811
1812 out.push_str("\nTop processes by resource usage:\n");
1813 for entry in processes.iter().take(max_entries) {
1814 let cpu_str = entry
1815 .cpu_percent
1816 .map(|p| format!(" [CPU: {:.1}%]", p))
1817 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1818 .unwrap_or_default();
1819 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1820 format!(" [I/O R:{}/W:{}]", r, w)
1821 } else {
1822 " [I/O unknown]".to_string()
1823 };
1824 let _ = writeln!(
1825 out,
1826 "- {} (pid {}) - {}{}{}{}",
1827 entry.name,
1828 entry.pid,
1829 human_bytes(entry.memory_bytes),
1830 cpu_str,
1831 io_str,
1832 entry
1833 .detail
1834 .as_deref()
1835 .map(|detail| format!(" [{}]", detail))
1836 .unwrap_or_default()
1837 );
1838 }
1839 if processes.len() > max_entries {
1840 let _ = writeln!(
1841 out,
1842 "- ... {} more processes omitted",
1843 processes.len() - max_entries
1844 );
1845 }
1846
1847 Ok(out.trim_end().to_string())
1848}
1849
1850fn inspect_network(max_entries: usize) -> Result<String, String> {
1851 let adapters = collect_network_adapters()?;
1852 let active_count = adapters
1853 .iter()
1854 .filter(|adapter| adapter.is_active())
1855 .count();
1856 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1857
1858 let mut out = String::from("Host inspection: network\n\n");
1859 let _ = writeln!(out, "- Adapters found: {}", adapters.len());
1860 let _ = writeln!(out, "- Active adapters: {}", active_count);
1861 let _ = writeln!(
1862 out,
1863 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind",
1864 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1865 );
1866
1867 if adapters.is_empty() {
1868 out.push_str("\nNo adapter details were detected.");
1869 return Ok(out);
1870 }
1871
1872 out.push_str("\nAdapter summary:\n");
1873 for adapter in adapters.iter().take(max_entries) {
1874 let status = if adapter.is_active() {
1875 "active"
1876 } else if adapter.disconnected {
1877 "disconnected"
1878 } else {
1879 "idle"
1880 };
1881 let mut details = vec![status.to_string()];
1882 if !adapter.ipv4.is_empty() {
1883 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1884 }
1885 if !adapter.ipv6.is_empty() {
1886 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1887 }
1888 if !adapter.gateways.is_empty() {
1889 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1890 }
1891 if !adapter.dns_servers.is_empty() {
1892 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1893 }
1894 let _ = writeln!(out, "- {} - {}", adapter.name, details.join(" | "));
1895 }
1896 if adapters.len() > max_entries {
1897 let _ = writeln!(
1898 out,
1899 "- ... {} more adapters omitted",
1900 adapters.len() - max_entries
1901 );
1902 }
1903
1904 Ok(out.trim_end().to_string())
1905}
1906
1907fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1908 let mut out = String::from("Host inspection: lan_discovery\n\n");
1909
1910 #[cfg(target_os = "windows")]
1911 {
1912 let n = max_entries.clamp(5, 20);
1913 let adapters = collect_network_adapters()?;
1914 let services = collect_services().unwrap_or_default();
1915 let active_adapters: Vec<&NetworkAdapter> = adapters
1916 .iter()
1917 .filter(|adapter| adapter.is_active())
1918 .collect();
1919 let gateways: Vec<String> = active_adapters
1920 .iter()
1921 .flat_map(|adapter| adapter.gateways.clone())
1922 .collect::<HashSet<_>>()
1923 .into_iter()
1924 .collect();
1925
1926 let neighbor_script = r#"
1927$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1928 Where-Object {
1929 $_.IPAddress -notlike '127.*' -and
1930 $_.IPAddress -notlike '169.254*' -and
1931 $_.State -notin @('Unreachable','Invalid')
1932 } |
1933 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1934$neighbors | ConvertTo-Json -Compress
1935"#;
1936 let neighbor_text = Command::new("powershell")
1937 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1938 .output()
1939 .ok()
1940 .and_then(|o| String::from_utf8(o.stdout).ok())
1941 .unwrap_or_default();
1942 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1943 .into_iter()
1944 .take(n)
1945 .collect();
1946
1947 let listener_script = r#"
1948Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1949 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1950 Select-Object LocalAddress, LocalPort, OwningProcess |
1951 ForEach-Object {
1952 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1953 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1954 }
1955"#;
1956 let listener_text = Command::new("powershell")
1957 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1958 .output()
1959 .ok()
1960 .and_then(|o| String::from_utf8(o.stdout).ok())
1961 .unwrap_or_default();
1962 let listeners: Vec<(String, u16, String, String)> = listener_text
1963 .lines()
1964 .filter_map(|line| {
1965 let mut it = line.trim().splitn(4, '|');
1966 let a = it.next()?.to_string();
1967 let b = it.next()?.parse::<u16>().ok()?;
1968 let c = it.next()?.to_string();
1969 let d = it.next()?.to_string();
1970 Some((a, b, c, d))
1971 })
1972 .take(n)
1973 .collect();
1974
1975 let smb_mapping_script = r#"
1976Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1977 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1978"#;
1979 let smb_mappings: Vec<String> = Command::new("powershell")
1980 .args([
1981 "-NoProfile",
1982 "-NonInteractive",
1983 "-Command",
1984 smb_mapping_script,
1985 ])
1986 .output()
1987 .ok()
1988 .and_then(|o| String::from_utf8(o.stdout).ok())
1989 .unwrap_or_default()
1990 .lines()
1991 .take(n)
1992 .map(|line| line.trim().to_string())
1993 .filter(|line| !line.is_empty())
1994 .collect();
1995
1996 let smb_connections_script = r#"
1997Get-SmbConnection -ErrorAction SilentlyContinue |
1998 Select-Object ServerName, ShareName, NumOpens |
1999 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
2000"#;
2001 let smb_connections: Vec<String> = Command::new("powershell")
2002 .args([
2003 "-NoProfile",
2004 "-NonInteractive",
2005 "-Command",
2006 smb_connections_script,
2007 ])
2008 .output()
2009 .ok()
2010 .and_then(|o| String::from_utf8(o.stdout).ok())
2011 .unwrap_or_default()
2012 .lines()
2013 .take(n)
2014 .map(|line| line.trim().to_string())
2015 .filter(|line| !line.is_empty())
2016 .collect();
2017
2018 let discovery_service_names = [
2019 "FDResPub",
2020 "fdPHost",
2021 "SSDPSRV",
2022 "upnphost",
2023 "LanmanServer",
2024 "LanmanWorkstation",
2025 "lmhosts",
2026 ];
2027 let discovery_services: Vec<&ServiceEntry> = services
2028 .iter()
2029 .filter(|entry| {
2030 discovery_service_names
2031 .iter()
2032 .any(|name| entry.name.eq_ignore_ascii_case(name))
2033 })
2034 .collect();
2035
2036 let mut findings = Vec::with_capacity(4);
2037 if active_adapters.is_empty() {
2038 findings.push(AuditFinding {
2039 finding: "No active LAN adapters were detected.".to_string(),
2040 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2041 fix: "Bring up Wi-Fi or Ethernet first, then rerun LAN discovery. If the adapter should be up already, inspect `network` or `connectivity` next.".to_string(),
2042 });
2043 }
2044
2045 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2046 .iter()
2047 .copied()
2048 .filter(|entry| {
2049 !entry.status.eq_ignore_ascii_case("running")
2050 && !entry.status.eq_ignore_ascii_case("active")
2051 })
2052 .collect();
2053 if !stopped_discovery_services.is_empty() {
2054 let names = {
2055 let mut s = String::new();
2056 for (i, entry) in stopped_discovery_services.iter().enumerate() {
2057 if i > 0 {
2058 s.push_str(", ");
2059 }
2060 s.push_str(&entry.name);
2061 }
2062 s
2063 };
2064 findings.push(AuditFinding {
2065 finding: format!("Discovery-related services are not running: {names}"),
2066 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2067 fix: "Start the relevant services and set their startup type appropriately. `FDResPub` and `fdPHost` matter for neighborhood visibility; `SSDPSRV` and `upnphost` matter for UPnP.".to_string(),
2068 });
2069 }
2070
2071 if listeners.is_empty() {
2072 findings.push(AuditFinding {
2073 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2074 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2075 fix: "If auto-discovery is expected, confirm the related services are running and check whether local firewall policy is suppressing these discovery ports.".to_string(),
2076 });
2077 }
2078
2079 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2080 findings.push(AuditFinding {
2081 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2082 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2083 fix: "Check whether the target device is on the same subnet/VLAN, whether discovery is enabled on both sides, and whether the local firewall is allowing discovery protocols.".to_string(),
2084 });
2085 }
2086
2087 out.push_str("=== Findings ===\n");
2088 if findings.is_empty() {
2089 out.push_str(
2090 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2091 );
2092 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2093 out.push_str(" Fix: If one device still cannot be seen, test the specific host/share/printer path next to separate name resolution from service reachability.\n");
2094 } else {
2095 for finding in &findings {
2096 let _ = writeln!(out, "- Finding: {}", finding.finding);
2097 let _ = writeln!(out, " Impact: {}", finding.impact);
2098 let _ = writeln!(out, " Fix: {}", finding.fix);
2099 }
2100 }
2101
2102 out.push_str("\n=== Active adapter and gateway summary ===\n");
2103 if active_adapters.is_empty() {
2104 out.push_str("- No active adapters detected.\n");
2105 } else {
2106 for adapter in active_adapters.iter().take(n) {
2107 let ipv4 = if adapter.ipv4.is_empty() {
2108 "no IPv4".to_string()
2109 } else {
2110 adapter.ipv4.join(", ")
2111 };
2112 let gateway = if adapter.gateways.is_empty() {
2113 "no gateway".to_string()
2114 } else {
2115 adapter.gateways.join(", ")
2116 };
2117 let _ = writeln!(
2118 out,
2119 "- {} | IPv4: {} | Gateway: {}",
2120 adapter.name, ipv4, gateway
2121 );
2122 }
2123 }
2124
2125 out.push_str("\n=== Neighborhood evidence ===\n");
2126 let _ = writeln!(out, "- Gateway count: {}", gateways.len());
2127 let _ = writeln!(out, "- Neighbor entries observed: {}", neighbors.len());
2128 if neighbors.is_empty() {
2129 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2130 } else {
2131 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2132 let _ = writeln!(
2133 out,
2134 "- {} on {} | MAC: {} | State: {}",
2135 ip, iface, mac, state
2136 );
2137 }
2138 }
2139
2140 out.push_str("\n=== Discovery services ===\n");
2141 if discovery_services.is_empty() {
2142 out.push_str("- Discovery service status unavailable.\n");
2143 } else {
2144 for entry in discovery_services.iter().take(n) {
2145 let startup = entry.startup.as_deref().unwrap_or("unknown");
2146 let _ = writeln!(
2147 out,
2148 "- {} | Status: {} | Startup: {}",
2149 entry.name, entry.status, startup
2150 );
2151 }
2152 }
2153
2154 out.push_str("\n=== Discovery listener surface ===\n");
2155 if listeners.is_empty() {
2156 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2157 } else {
2158 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2159 let label = match *port {
2160 137 => "NetBIOS Name Service",
2161 138 => "NetBIOS Datagram",
2162 1900 => "SSDP/UPnP",
2163 5353 => "mDNS",
2164 5355 => "LLMNR",
2165 _ => "Discovery",
2166 };
2167 let proc_label = if proc_name.is_empty() {
2168 "unknown".to_string()
2169 } else {
2170 proc_name.clone()
2171 };
2172 let _ = writeln!(
2173 out,
2174 "- {}:{} | {} | PID {} ({})",
2175 addr, port, label, pid, proc_label
2176 );
2177 }
2178 }
2179
2180 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2181 if smb_mappings.is_empty() && smb_connections.is_empty() {
2182 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2183 } else {
2184 if !smb_mappings.is_empty() {
2185 out.push_str("- Mapped drives:\n");
2186 for mapping in smb_mappings.iter().take(n) {
2187 let mut it = mapping.splitn(3, '|');
2188 if let (Some(a), Some(b)) = (it.next(), it.next()) {
2189 let _ = writeln!(out, " - {} -> {}", a, b);
2190 }
2191 }
2192 }
2193 if !smb_connections.is_empty() {
2194 out.push_str("- Active SMB connections:\n");
2195 for connection in smb_connections.iter().take(n) {
2196 let mut it = connection.splitn(4, '|');
2197 if let (Some(a), Some(b), Some(c)) = (it.next(), it.next(), it.next()) {
2198 let _ = writeln!(out, " - {}\\{} | Opens: {}", a, b, c);
2199 }
2200 }
2201 }
2202 }
2203 }
2204
2205 #[cfg(not(target_os = "windows"))]
2206 {
2207 let n = max_entries.clamp(5, 20);
2208 let adapters = collect_network_adapters()?;
2209 let arp_output = Command::new("ip")
2210 .args(["neigh"])
2211 .output()
2212 .ok()
2213 .and_then(|o| String::from_utf8(o.stdout).ok())
2214 .unwrap_or_default();
2215 let neighbors: Vec<&str> = arp_output
2216 .lines()
2217 .filter(|line| !line.trim().is_empty())
2218 .take(n)
2219 .collect();
2220
2221 out.push_str("=== Findings ===\n");
2222 if adapters.iter().any(|adapter| adapter.is_active()) {
2223 out.push_str(
2224 "- Finding: LAN discovery support is partially available on this platform.\n",
2225 );
2226 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2227 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2228 } else {
2229 out.push_str("- Finding: No active LAN adapters were detected.\n");
2230 out.push_str(
2231 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2232 );
2233 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2234 }
2235
2236 out.push_str("\n=== Active adapter and gateway summary ===\n");
2237 if adapters.is_empty() {
2238 out.push_str("- No adapters detected.\n");
2239 } else {
2240 for adapter in adapters.iter().take(n) {
2241 let ipv4 = if adapter.ipv4.is_empty() {
2242 "no IPv4".to_string()
2243 } else {
2244 adapter.ipv4.join(", ")
2245 };
2246 let gateway = if adapter.gateways.is_empty() {
2247 "no gateway".to_string()
2248 } else {
2249 adapter.gateways.join(", ")
2250 };
2251 let _ = write!(
2252 out,
2253 "- {} | IPv4: {} | Gateway: {}\n",
2254 adapter.name, ipv4, gateway
2255 );
2256 }
2257 }
2258
2259 out.push_str("\n=== Neighborhood evidence ===\n");
2260 if neighbors.is_empty() {
2261 out.push_str("- No neighbor entries detected.\n");
2262 } else {
2263 for line in neighbors {
2264 let _ = write!(out, "- {}\n", line.trim());
2265 }
2266 }
2267 }
2268
2269 Ok(out.trim_end().to_string())
2270}
2271
2272fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2273 let mut services = collect_services()?;
2274 if let Some(filter) = name_filter.as_deref() {
2275 let lowered = filter.to_ascii_lowercase();
2276 services.retain(|entry| {
2277 entry.name.to_ascii_lowercase().contains(&lowered)
2278 || entry
2279 .display_name
2280 .as_deref()
2281 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2282 .unwrap_or(false)
2283 });
2284 }
2285
2286 services.sort_by(|a, b| {
2287 let a_running =
2288 a.status.eq_ignore_ascii_case("running") || a.status.eq_ignore_ascii_case("active");
2289 let b_running =
2290 b.status.eq_ignore_ascii_case("running") || b.status.eq_ignore_ascii_case("active");
2291 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2292 });
2293
2294 let running = services
2295 .iter()
2296 .filter(|entry| {
2297 entry.status.eq_ignore_ascii_case("running")
2298 || entry.status.eq_ignore_ascii_case("active")
2299 })
2300 .count();
2301 let failed = services
2302 .iter()
2303 .filter(|entry| {
2304 entry.status.eq_ignore_ascii_case("failed")
2305 || entry.status.eq_ignore_ascii_case("error")
2306 || entry.status.eq_ignore_ascii_case("stopped")
2307 })
2308 .count();
2309
2310 let mut out = String::from("Host inspection: services\n\n");
2311 if let Some(filter) = name_filter.as_deref() {
2312 let _ = writeln!(out, "- Filter name: {}", filter);
2313 }
2314 let _ = writeln!(out, "- Services found: {}", services.len());
2315 let _ = writeln!(out, "- Running/active: {}", running);
2316 let _ = writeln!(out, "- Failed/stopped: {}", failed);
2317
2318 if services.is_empty() {
2319 out.push_str("\nNo services matched.");
2320 return Ok(out);
2321 }
2322
2323 let per_section = (max_entries / 2).max(5);
2325
2326 let running_services: Vec<_> = services
2327 .iter()
2328 .filter(|e| {
2329 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2330 })
2331 .collect();
2332 let stopped_services: Vec<_> = services
2333 .iter()
2334 .filter(|e| {
2335 e.status.eq_ignore_ascii_case("stopped")
2336 || e.status.eq_ignore_ascii_case("failed")
2337 || e.status.eq_ignore_ascii_case("error")
2338 })
2339 .collect();
2340
2341 let fmt_entry = |entry: &&ServiceEntry| {
2342 let startup = entry
2343 .startup
2344 .as_deref()
2345 .map(|v| format!(" | startup {}", v))
2346 .unwrap_or_default();
2347 let logon = entry
2348 .start_name
2349 .as_deref()
2350 .map(|v| format!(" | LogOn: {}", v))
2351 .unwrap_or_default();
2352 let display = entry
2353 .display_name
2354 .as_deref()
2355 .filter(|v| *v != entry.name)
2356 .map(|v| format!(" [{}]", v))
2357 .unwrap_or_default();
2358 format!(
2359 "- {}{} - {}{}{}\n",
2360 entry.name, display, entry.status, startup, logon
2361 )
2362 };
2363
2364 let _ = write!(
2365 out,
2366 "\nRunning services ({} total, showing up to {}):\n",
2367 running_services.len(),
2368 per_section
2369 );
2370 for entry in running_services.iter().take(per_section) {
2371 out.push_str(&fmt_entry(entry));
2372 }
2373 if running_services.len() > per_section {
2374 let _ = writeln!(
2375 out,
2376 "- ... {} more running services omitted",
2377 running_services.len() - per_section
2378 );
2379 }
2380
2381 let _ = write!(
2382 out,
2383 "\nStopped/failed services ({} total, showing up to {}):\n",
2384 stopped_services.len(),
2385 per_section
2386 );
2387 for entry in stopped_services.iter().take(per_section) {
2388 out.push_str(&fmt_entry(entry));
2389 }
2390 if stopped_services.len() > per_section {
2391 let _ = writeln!(
2392 out,
2393 "- ... {} more stopped services omitted",
2394 stopped_services.len() - per_section
2395 );
2396 }
2397
2398 Ok(out.trim_end().to_string())
2399}
2400
2401async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2402 inspect_directory("Disk", path, max_entries).await
2403}
2404
2405fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2406 let mut listeners = collect_listening_ports()?;
2407 if let Some(port) = port_filter {
2408 listeners.retain(|entry| entry.port == port);
2409 }
2410 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2411
2412 let mut out = String::from("Host inspection: ports\n\n");
2413 if let Some(port) = port_filter {
2414 let _ = writeln!(out, "- Filter port: {}", port);
2415 }
2416 let _ = writeln!(out, "- Listening endpoints found: {}", listeners.len());
2417
2418 if listeners.is_empty() {
2419 out.push_str("\nNo listening endpoints matched.");
2420 return Ok(out);
2421 }
2422
2423 out.push_str("\nListening endpoints:\n");
2424 for entry in listeners.iter().take(max_entries) {
2425 let pid_str = entry
2426 .pid
2427 .as_deref()
2428 .map(|p| format!(" pid {}", p))
2429 .unwrap_or_default();
2430 let name_str = entry
2431 .process_name
2432 .as_deref()
2433 .map(|n| format!(" [{}]", n))
2434 .unwrap_or_default();
2435 let _ = writeln!(
2436 out,
2437 "- {} {} ({}){}{}",
2438 entry.protocol, entry.local, entry.state, pid_str, name_str
2439 );
2440 }
2441 if listeners.len() > max_entries {
2442 let _ = writeln!(
2443 out,
2444 "- ... {} more listening endpoints omitted",
2445 listeners.len() - max_entries
2446 );
2447 }
2448
2449 Ok(out.trim_end().to_string())
2450}
2451
2452fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2453 if !path.exists() {
2454 return Err(format!("Path does not exist: {}", path.display()));
2455 }
2456 if !path.is_dir() {
2457 return Err(format!("Path is not a directory: {}", path.display()));
2458 }
2459
2460 let markers = collect_project_markers(&path);
2461 let hematite_state = collect_hematite_state(&path);
2462 let git_state = inspect_git_state(&path);
2463 let release_state = inspect_release_artifacts(&path);
2464
2465 let mut out = String::from("Host inspection: repo_doctor\n\n");
2466 let _ = writeln!(out, "- Path: {}", path.display());
2467 let _ = writeln!(out, "- Workspace mode: {}", workspace_mode_for_path(&path));
2468
2469 if markers.is_empty() {
2470 out.push_str("- Project markers: none of Cargo.toml, package.json, pyproject.toml, go.mod, justfile, Makefile, or .git were found at this path\n");
2471 } else {
2472 out.push_str("- Project markers:\n");
2473 for marker in markers.iter().take(max_entries) {
2474 let _ = writeln!(out, " - {}", marker);
2475 }
2476 }
2477
2478 match git_state {
2479 Some(git) => {
2480 let _ = writeln!(out, "- Git root: {}", git.root.display());
2481 let _ = writeln!(out, "- Git branch: {}", git.branch);
2482 let _ = writeln!(out, "- Git status: {}", git.status_label());
2483 }
2484 None => out.push_str("- Git: not inside a detected work tree\n"),
2485 }
2486
2487 let _ = writeln!(
2488 out,
2489 "- Hematite docs/imports/reports: {}/{}/{}",
2490 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2491 );
2492 if hematite_state.workspace_profile {
2493 out.push_str("- Workspace profile: present\n");
2494 } else {
2495 out.push_str("- Workspace profile: absent\n");
2496 }
2497
2498 if let Some(release) = release_state {
2499 let _ = writeln!(out, "- Cargo version: {}", release.version);
2500 let _ = writeln!(
2501 out,
2502 "- Windows artifacts for current version: {}/{}/{}",
2503 bool_label(release.portable_dir),
2504 bool_label(release.portable_zip),
2505 bool_label(release.setup_exe)
2506 );
2507 }
2508
2509 Ok(out.trim_end().to_string())
2510}
2511
2512async fn inspect_known_directory(
2513 label: &str,
2514 path: Option<PathBuf>,
2515 max_entries: usize,
2516) -> Result<String, String> {
2517 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2518 inspect_directory(label, path, max_entries).await
2519}
2520
2521async fn inspect_directory(
2522 label: &str,
2523 path: PathBuf,
2524 max_entries: usize,
2525) -> Result<String, String> {
2526 let label = label.to_string();
2527 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2528 .await
2529 .map_err(|e| format!("inspect_host task failed: {e}"))?
2530}
2531
2532fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2533 if !path.exists() {
2534 return Err(format!("Path does not exist: {}", path.display()));
2535 }
2536 if !path.is_dir() {
2537 return Err(format!("Path is not a directory: {}", path.display()));
2538 }
2539
2540 let mut top_level_entries = Vec::new();
2541 for entry in fs::read_dir(path)
2542 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2543 {
2544 match entry {
2545 Ok(entry) => top_level_entries.push(entry),
2546 Err(_) => continue,
2547 }
2548 }
2549 top_level_entries.sort_by_key(|entry| entry.file_name());
2550
2551 let top_level_count = top_level_entries.len();
2552 let mut sample_names = Vec::with_capacity(max_entries.min(top_level_count));
2553 let mut largest_entries = Vec::with_capacity(top_level_count);
2554 let mut aggregate = PathAggregate::default();
2555 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2556
2557 for entry in top_level_entries {
2558 let name = entry.file_name().to_string_lossy().to_string();
2559 if sample_names.len() < max_entries {
2560 sample_names.push(name.clone());
2561 }
2562 let kind = match entry.file_type() {
2563 Ok(ft) if ft.is_dir() => "dir",
2564 Ok(ft) if ft.is_symlink() => "symlink",
2565 _ => "file",
2566 };
2567 let stats = measure_path(&entry.path(), &mut budget);
2568 aggregate.merge(&stats);
2569 largest_entries.push(LargestEntry {
2570 name,
2571 kind,
2572 bytes: stats.total_bytes,
2573 });
2574 }
2575
2576 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2577
2578 let mut out = format!("Directory inspection: {}\n\n", label);
2579 let _ = writeln!(out, "- Path: {}", path.display());
2580 let _ = writeln!(out, "- Top-level items: {}", top_level_count);
2581 let _ = writeln!(out, "- Recursive files: {}", aggregate.file_count);
2582 let _ = writeln!(out, "- Recursive directories: {}", aggregate.dir_count);
2583 let _ = writeln!(
2584 out,
2585 "- Total size: {}{}",
2586 human_bytes(aggregate.total_bytes),
2587 if aggregate.partial {
2588 " (partial scan)"
2589 } else {
2590 ""
2591 }
2592 );
2593 if aggregate.skipped_entries > 0 {
2594 let _ = writeln!(
2595 out,
2596 "- Skipped entries: {} (permissions, symlinks, or scan budget)",
2597 aggregate.skipped_entries
2598 );
2599 }
2600
2601 if !largest_entries.is_empty() {
2602 out.push_str("\nLargest top-level entries:\n");
2603 for entry in largest_entries.iter().take(max_entries) {
2604 let _ = writeln!(
2605 out,
2606 "- {} [{}] - {}",
2607 entry.name,
2608 entry.kind,
2609 human_bytes(entry.bytes)
2610 );
2611 }
2612 }
2613
2614 if !sample_names.is_empty() {
2615 out.push_str("\nSample names:\n");
2616 for name in sample_names {
2617 let _ = writeln!(out, "- {}", name);
2618 }
2619 }
2620
2621 Ok(out.trim_end().to_string())
2622}
2623
2624fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2625 let trimmed = raw.trim();
2626 if trimmed.is_empty() {
2627 return Err("Path must not be empty.".to_string());
2628 }
2629
2630 if let Some(rest) = trimmed
2631 .strip_prefix("~/")
2632 .or_else(|| trimmed.strip_prefix("~\\"))
2633 {
2634 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2635 return Ok(home.join(rest));
2636 }
2637
2638 let path = PathBuf::from(trimmed);
2639 if path.is_absolute() {
2640 Ok(path)
2641 } else {
2642 let cwd =
2643 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2644 let full_path = cwd.join(&path);
2645
2646 if !full_path.exists()
2649 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2650 {
2651 if let Some(home) = home::home_dir() {
2652 let home_path = home.join(trimmed);
2653 if home_path.exists() {
2654 return Ok(home_path);
2655 }
2656 }
2657 }
2658
2659 Ok(full_path)
2660 }
2661}
2662
2663fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2664 workspace_mode_for_path(workspace_root)
2665}
2666
2667fn workspace_mode_for_path(path: &Path) -> &'static str {
2668 if is_project_marker_path(path) {
2669 "project"
2670 } else if path.join(".hematite").join("docs").exists()
2671 || path.join(".hematite").join("imports").exists()
2672 || path.join(".hematite").join("reports").exists()
2673 {
2674 "docs-only"
2675 } else {
2676 "general directory"
2677 }
2678}
2679
2680fn is_project_marker_path(path: &Path) -> bool {
2681 [
2682 "Cargo.toml",
2683 "package.json",
2684 "pyproject.toml",
2685 "go.mod",
2686 "composer.json",
2687 "requirements.txt",
2688 "Makefile",
2689 "justfile",
2690 ]
2691 .iter()
2692 .any(|name| path.join(name).exists())
2693 || path.join(".git").exists()
2694}
2695
2696fn preferred_shell_label() -> &'static str {
2697 #[cfg(target_os = "windows")]
2698 {
2699 "PowerShell"
2700 }
2701 #[cfg(not(target_os = "windows"))]
2702 {
2703 "sh"
2704 }
2705}
2706
2707fn desktop_dir() -> Option<PathBuf> {
2708 home::home_dir().map(|home| home.join("Desktop"))
2709}
2710
2711fn downloads_dir() -> Option<PathBuf> {
2712 home::home_dir().map(|home| home.join("Downloads"))
2713}
2714
2715fn count_top_level_items(path: &Path) -> Result<usize, String> {
2716 let mut count = 0usize;
2717 for entry in
2718 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2719 {
2720 if entry.is_ok() {
2721 count += 1;
2722 }
2723 }
2724 Ok(count)
2725}
2726
2727#[derive(Default)]
2728struct PathAggregate {
2729 total_bytes: u64,
2730 file_count: u64,
2731 dir_count: u64,
2732 skipped_entries: u64,
2733 partial: bool,
2734}
2735
2736impl PathAggregate {
2737 fn merge(&mut self, other: &PathAggregate) {
2738 self.total_bytes += other.total_bytes;
2739 self.file_count += other.file_count;
2740 self.dir_count += other.dir_count;
2741 self.skipped_entries += other.skipped_entries;
2742 self.partial |= other.partial;
2743 }
2744}
2745
2746struct LargestEntry {
2747 name: String,
2748 kind: &'static str,
2749 bytes: u64,
2750}
2751
2752fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2753 if *budget == 0 {
2754 return PathAggregate {
2755 partial: true,
2756 skipped_entries: 1,
2757 ..PathAggregate::default()
2758 };
2759 }
2760 *budget -= 1;
2761
2762 let metadata = match fs::symlink_metadata(path) {
2763 Ok(metadata) => metadata,
2764 Err(_) => {
2765 return PathAggregate {
2766 skipped_entries: 1,
2767 ..PathAggregate::default()
2768 }
2769 }
2770 };
2771
2772 let file_type = metadata.file_type();
2773 if file_type.is_symlink() {
2774 return PathAggregate {
2775 skipped_entries: 1,
2776 ..PathAggregate::default()
2777 };
2778 }
2779
2780 if metadata.is_file() {
2781 return PathAggregate {
2782 total_bytes: metadata.len(),
2783 file_count: 1,
2784 ..PathAggregate::default()
2785 };
2786 }
2787
2788 if !metadata.is_dir() {
2789 return PathAggregate::default();
2790 }
2791
2792 let mut aggregate = PathAggregate {
2793 dir_count: 1,
2794 ..PathAggregate::default()
2795 };
2796
2797 let read_dir = match fs::read_dir(path) {
2798 Ok(read_dir) => read_dir,
2799 Err(_) => {
2800 aggregate.skipped_entries += 1;
2801 return aggregate;
2802 }
2803 };
2804
2805 for child in read_dir {
2806 match child {
2807 Ok(child) => {
2808 let child_stats = measure_path(&child.path(), budget);
2809 aggregate.merge(&child_stats);
2810 }
2811 Err(_) => aggregate.skipped_entries += 1,
2812 }
2813 }
2814
2815 aggregate
2816}
2817
2818struct PathAnalysis {
2819 total_entries: usize,
2820 unique_entries: usize,
2821 entries: Vec<String>,
2822 duplicate_entries: Vec<String>,
2823 missing_entries: Vec<String>,
2824}
2825
2826fn analyze_path_env() -> PathAnalysis {
2827 let mut entries = Vec::new();
2828 let mut duplicate_entries = Vec::new();
2829 let mut missing_entries = Vec::new();
2830 let mut seen = HashSet::new();
2831
2832 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2833 for path in std::env::split_paths(&raw_path) {
2834 let display = path.display().to_string();
2835 if display.trim().is_empty() {
2836 continue;
2837 }
2838
2839 let normalized = normalize_path_entry(&display);
2840 if !seen.insert(normalized) {
2841 duplicate_entries.push(display.clone());
2842 }
2843 if !path.exists() {
2844 missing_entries.push(display.clone());
2845 }
2846 entries.push(display);
2847 }
2848
2849 let total_entries = entries.len();
2850 let unique_entries = seen.len();
2851
2852 PathAnalysis {
2853 total_entries,
2854 unique_entries,
2855 entries,
2856 duplicate_entries,
2857 missing_entries,
2858 }
2859}
2860
2861fn normalize_path_entry(value: &str) -> String {
2862 #[cfg(target_os = "windows")]
2863 {
2864 value
2865 .replace('/', "\\")
2866 .trim_end_matches(['\\', '/'])
2867 .to_ascii_lowercase()
2868 }
2869 #[cfg(not(target_os = "windows"))]
2870 {
2871 value.trim_end_matches('/').to_string()
2872 }
2873}
2874
2875struct ToolchainReport {
2876 found: Vec<(String, String)>,
2877 missing: Vec<String>,
2878}
2879
2880struct PackageManagerReport {
2881 found: Vec<(String, String)>,
2882}
2883
2884#[derive(Debug, Clone)]
2885struct ProcessEntry {
2886 name: String,
2887 pid: u32,
2888 memory_bytes: u64,
2889 cpu_seconds: Option<f64>,
2890 cpu_percent: Option<f64>,
2891 read_ops: Option<u64>,
2892 write_ops: Option<u64>,
2893 detail: Option<String>,
2894}
2895
2896#[derive(Debug, Clone)]
2897struct ServiceEntry {
2898 name: String,
2899 status: String,
2900 startup: Option<String>,
2901 display_name: Option<String>,
2902 start_name: Option<String>,
2903}
2904
2905#[derive(Debug, Clone, Default)]
2906struct NetworkAdapter {
2907 name: String,
2908 ipv4: Vec<String>,
2909 ipv6: Vec<String>,
2910 gateways: Vec<String>,
2911 dns_servers: Vec<String>,
2912 disconnected: bool,
2913}
2914
2915impl NetworkAdapter {
2916 fn is_active(&self) -> bool {
2917 !self.disconnected
2918 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2919 }
2920}
2921
2922#[derive(Debug, Clone, Copy, Default)]
2923struct ListenerExposureSummary {
2924 loopback_only: usize,
2925 wildcard_public: usize,
2926 specific_bind: usize,
2927}
2928
2929#[derive(Debug, Clone)]
2930struct ListeningPort {
2931 protocol: String,
2932 local: String,
2933 port: u16,
2934 state: String,
2935 pid: Option<String>,
2936 process_name: Option<String>,
2937}
2938
2939fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2940 #[cfg(target_os = "windows")]
2941 {
2942 collect_windows_listening_ports()
2943 }
2944 #[cfg(not(target_os = "windows"))]
2945 {
2946 collect_unix_listening_ports()
2947 }
2948}
2949
2950fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2951 #[cfg(target_os = "windows")]
2952 {
2953 collect_windows_network_adapters()
2954 }
2955 #[cfg(not(target_os = "windows"))]
2956 {
2957 collect_unix_network_adapters()
2958 }
2959}
2960
2961fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2962 #[cfg(target_os = "windows")]
2963 {
2964 collect_windows_services()
2965 }
2966 #[cfg(not(target_os = "windows"))]
2967 {
2968 collect_unix_services()
2969 }
2970}
2971
2972#[cfg(target_os = "windows")]
2973fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2974 let output = Command::new("netstat")
2975 .args(["-ano", "-p", "tcp"])
2976 .output()
2977 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2978 if !output.status.success() {
2979 return Err("netstat returned a non-success status.".to_string());
2980 }
2981
2982 let text = String::from_utf8_lossy(&output.stdout);
2983 let mut listeners = Vec::new();
2984 for line in text.lines() {
2985 let trimmed = line.trim();
2986 if !trimmed.starts_with("TCP") {
2987 continue;
2988 }
2989 let mut it = trimmed.split_whitespace();
2990 if let (Some(proto), Some(local), Some(_), Some(state), Some(pid)) =
2991 (it.next(), it.next(), it.next(), it.next(), it.next())
2992 {
2993 if state != "LISTENING" {
2994 continue;
2995 }
2996 let Some(port) = extract_port_from_socket(local) else {
2997 continue;
2998 };
2999 listeners.push(ListeningPort {
3000 protocol: proto.to_string(),
3001 local: local.to_string(),
3002 port,
3003 state: state.to_string(),
3004 pid: Some(pid.to_string()),
3005 process_name: None,
3006 });
3007 }
3008 }
3009
3010 let unique_pids: Vec<String> = listeners
3013 .iter()
3014 .filter_map(|l| l.pid.clone())
3015 .collect::<HashSet<_>>()
3016 .into_iter()
3017 .collect();
3018
3019 if !unique_pids.is_empty() {
3020 let pid_list = unique_pids.join(",");
3021 let ps_cmd = format!(
3022 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
3023 pid_list
3024 );
3025 if let Ok(ps_out) = Command::new("powershell")
3026 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
3027 .output()
3028 {
3029 let mut pid_map = std::collections::HashMap::<String, String>::new();
3030 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
3031 for line in ps_text.lines() {
3032 let mut it = line.split_whitespace();
3033 if let (Some(a), Some(b)) = (it.next(), it.next()) {
3034 pid_map.insert(a.to_string(), b.to_string());
3035 }
3036 }
3037 for listener in &mut listeners {
3038 if let Some(pid) = &listener.pid {
3039 listener.process_name = pid_map.get(pid).cloned();
3040 }
3041 }
3042 }
3043 }
3044
3045 Ok(listeners)
3046}
3047
3048#[cfg(not(target_os = "windows"))]
3049fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3050 let output = Command::new("ss")
3051 .args(["-ltn"])
3052 .output()
3053 .map_err(|e| format!("Failed to run ss: {e}"))?;
3054 if !output.status.success() {
3055 return Err("ss returned a non-success status.".to_string());
3056 }
3057
3058 let text = String::from_utf8_lossy(&output.stdout);
3059 let mut listeners = Vec::new();
3060 for line in text.lines().skip(1) {
3061 let mut it = line.split_whitespace();
3062 if let (Some(state), Some(_), Some(_), Some(local)) =
3063 (it.next(), it.next(), it.next(), it.next())
3064 {
3065 let Some(port) = extract_port_from_socket(local) else {
3066 continue;
3067 };
3068 listeners.push(ListeningPort {
3069 protocol: "tcp".to_string(),
3070 local: local.to_string(),
3071 port,
3072 state: state.to_string(),
3073 pid: None,
3074 process_name: None,
3075 });
3076 }
3077 }
3078
3079 Ok(listeners)
3080}
3081
3082fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3083 #[cfg(target_os = "windows")]
3084 {
3085 collect_windows_processes()
3086 }
3087 #[cfg(not(target_os = "windows"))]
3088 {
3089 collect_unix_processes()
3090 }
3091}
3092
3093#[cfg(target_os = "windows")]
3094fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3095 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3096 let output = Command::new("powershell")
3097 .args(["-NoProfile", "-Command", command])
3098 .output()
3099 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3100 if !output.status.success() {
3101 return Err("PowerShell service inspection returned a non-success status.".to_string());
3102 }
3103
3104 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3105}
3106
3107#[cfg(not(target_os = "windows"))]
3108fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3109 let status_output = Command::new("systemctl")
3110 .args([
3111 "list-units",
3112 "--type=service",
3113 "--all",
3114 "--no-pager",
3115 "--no-legend",
3116 "--plain",
3117 ])
3118 .output()
3119 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3120 if !status_output.status.success() {
3121 return Err("systemctl list-units returned a non-success status.".to_string());
3122 }
3123
3124 let startup_output = Command::new("systemctl")
3125 .args([
3126 "list-unit-files",
3127 "--type=service",
3128 "--no-legend",
3129 "--no-pager",
3130 "--plain",
3131 ])
3132 .output()
3133 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3134 if !startup_output.status.success() {
3135 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3136 }
3137
3138 Ok(parse_unix_services(
3139 &String::from_utf8_lossy(&status_output.stdout),
3140 &String::from_utf8_lossy(&startup_output.stdout),
3141 ))
3142}
3143
3144#[cfg(target_os = "windows")]
3145fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3146 let output = Command::new("ipconfig")
3147 .args(["/all"])
3148 .output()
3149 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3150 if !output.status.success() {
3151 return Err("ipconfig returned a non-success status.".to_string());
3152 }
3153
3154 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3155 &output.stdout,
3156 )))
3157}
3158
3159#[cfg(not(target_os = "windows"))]
3160fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3161 let addr_output = Command::new("ip")
3162 .args(["-o", "addr", "show", "up"])
3163 .output()
3164 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3165 if !addr_output.status.success() {
3166 return Err("ip addr returned a non-success status.".to_string());
3167 }
3168
3169 let route_output = Command::new("ip")
3170 .args(["route", "show", "default"])
3171 .output()
3172 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3173 if !route_output.status.success() {
3174 return Err("ip route returned a non-success status.".to_string());
3175 }
3176
3177 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3178 apply_unix_default_routes(
3179 &mut adapters,
3180 &String::from_utf8_lossy(&route_output.stdout),
3181 );
3182 apply_unix_dns_servers(&mut adapters);
3183 Ok(adapters)
3184}
3185
3186#[cfg(target_os = "windows")]
3187fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3188 let script = r#"
3190 $s1 = Get-Process | Select-Object Id, CPU
3191 Start-Sleep -Milliseconds 250
3192 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3193 $s2 | ForEach-Object {
3194 $p2 = $_
3195 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3196 $pct = 0.0
3197 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3198 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3199 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3200 # Standard Task Manager style is (delta / interval) * 100.
3201 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3202 }
3203 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3204 }
3205 "#;
3206
3207 let output = Command::new("powershell")
3208 .args(["-NoProfile", "-Command", script])
3209 .output()
3210 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3211
3212 let text = String::from_utf8_lossy(&output.stdout);
3213 let mut out = Vec::new();
3214 let mut parts: Vec<&str> = Vec::with_capacity(8);
3215 for line in text.lines() {
3216 parts.clear();
3217 parts.extend(line.trim().split('|'));
3218 if parts.len() < 5 {
3219 continue;
3220 }
3221 let mut entry = ProcessEntry {
3222 name: "unknown".to_string(),
3223 pid: 0,
3224 memory_bytes: 0,
3225 cpu_seconds: None,
3226 cpu_percent: None,
3227 read_ops: None,
3228 write_ops: None,
3229 detail: None,
3230 };
3231 for p in &parts {
3232 if let Some((k, v)) = p.split_once(':') {
3233 match k {
3234 "PID" => entry.pid = v.parse().unwrap_or(0),
3235 "NAME" => entry.name = v.to_string(),
3236 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3237 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3238 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3239 "READ" => entry.read_ops = v.parse().ok(),
3240 "WRITE" => entry.write_ops = v.parse().ok(),
3241 _ => {}
3242 }
3243 }
3244 }
3245 out.push(entry);
3246 }
3247 Ok(out)
3248}
3249
3250#[cfg(not(target_os = "windows"))]
3251fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3252 let output = Command::new("ps")
3253 .args(["-eo", "pid=,rss=,comm="])
3254 .output()
3255 .map_err(|e| format!("Failed to run ps: {e}"))?;
3256 if !output.status.success() {
3257 return Err("ps returned a non-success status.".to_string());
3258 }
3259
3260 let text = String::from_utf8_lossy(&output.stdout);
3261 let mut processes = Vec::new();
3262 for line in text.lines() {
3263 let mut it = line.split_whitespace();
3264 let Some(pid_str) = it.next() else {
3265 continue;
3266 };
3267 let Some(rss_str) = it.next() else {
3268 continue;
3269 };
3270 let Some(first_word) = it.next() else {
3271 continue;
3272 };
3273 let Ok(pid) = pid_str.parse::<u32>() else {
3274 continue;
3275 };
3276 let Ok(rss_kib) = rss_str.parse::<u64>() else {
3277 continue;
3278 };
3279 let mut name = first_word.to_string();
3280 for w in it {
3281 name.push(' ');
3282 name.push_str(w);
3283 }
3284 processes.push(ProcessEntry {
3285 name,
3286 pid,
3287 memory_bytes: rss_kib * 1024,
3288 cpu_seconds: None,
3289 cpu_percent: None,
3290 read_ops: None,
3291 write_ops: None,
3292 detail: None,
3293 });
3294 }
3295
3296 Ok(processes)
3297}
3298
3299fn extract_port_from_socket(value: &str) -> Option<u16> {
3300 let cleaned = value.trim().trim_matches(['[', ']']);
3301 let port_str = cleaned.rsplit(':').next()?;
3302 port_str.parse::<u16>().ok()
3303}
3304
3305fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3306 let mut summary = ListenerExposureSummary::default();
3307 for entry in listeners {
3308 let local = entry.local.to_ascii_lowercase();
3309 if is_loopback_listener(&local) {
3310 summary.loopback_only += 1;
3311 } else if is_wildcard_listener(&local) {
3312 summary.wildcard_public += 1;
3313 } else {
3314 summary.specific_bind += 1;
3315 }
3316 }
3317 summary
3318}
3319
3320fn is_loopback_listener(local: &str) -> bool {
3321 local.starts_with("127.")
3322 || local.starts_with("[::1]")
3323 || local.starts_with("::1")
3324 || local.starts_with("localhost:")
3325}
3326
3327fn is_wildcard_listener(local: &str) -> bool {
3328 local.starts_with("0.0.0.0:")
3329 || local.starts_with("[::]:")
3330 || local.starts_with(":::")
3331 || local == "*:*"
3332}
3333
3334struct GitState {
3335 root: PathBuf,
3336 branch: String,
3337 dirty_entries: usize,
3338}
3339
3340impl GitState {
3341 fn status_label(&self) -> String {
3342 if self.dirty_entries == 0 {
3343 "clean".to_string()
3344 } else {
3345 format!("dirty ({} changed path(s))", self.dirty_entries)
3346 }
3347 }
3348}
3349
3350fn inspect_git_state(path: &Path) -> Option<GitState> {
3351 let root = capture_first_line(
3352 "git",
3353 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3354 )?;
3355 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3356 .unwrap_or_else(|| "detached".to_string());
3357 let output = Command::new("git")
3358 .args(["-C", path.to_str()?, "status", "--short"])
3359 .output()
3360 .ok()?;
3361 if !output.status.success() {
3362 return None;
3363 }
3364 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3365 Some(GitState {
3366 root: PathBuf::from(root),
3367 branch,
3368 dirty_entries,
3369 })
3370}
3371
3372struct HematiteState {
3373 docs_count: usize,
3374 import_count: usize,
3375 report_count: usize,
3376 workspace_profile: bool,
3377}
3378
3379fn collect_hematite_state(path: &Path) -> HematiteState {
3380 let root = path.join(".hematite");
3381 HematiteState {
3382 docs_count: count_entries_if_exists(&root.join("docs")),
3383 import_count: count_entries_if_exists(&root.join("imports")),
3384 report_count: count_entries_if_exists(&root.join("reports")),
3385 workspace_profile: root.join("workspace_profile.json").exists(),
3386 }
3387}
3388
3389fn count_entries_if_exists(path: &Path) -> usize {
3390 if !path.exists() || !path.is_dir() {
3391 return 0;
3392 }
3393 fs::read_dir(path)
3394 .ok()
3395 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3396 .unwrap_or(0)
3397}
3398
3399fn collect_project_markers(path: &Path) -> Vec<String> {
3400 [
3401 "Cargo.toml",
3402 "package.json",
3403 "pyproject.toml",
3404 "go.mod",
3405 "justfile",
3406 "Makefile",
3407 ".git",
3408 ]
3409 .iter()
3410 .filter(|&name| path.join(name).exists())
3411 .map(|name| (*name).to_string())
3412 .collect()
3413}
3414
3415struct ReleaseArtifactState {
3416 version: String,
3417 portable_dir: bool,
3418 portable_zip: bool,
3419 setup_exe: bool,
3420}
3421
3422fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3423 let cargo_toml = path.join("Cargo.toml");
3424 if !cargo_toml.exists() {
3425 return None;
3426 }
3427 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3428 let version = [regex_line_capture(
3429 &cargo_text,
3430 r#"(?m)^version\s*=\s*"([^"]+)""#,
3431 )?]
3432 .concat();
3433 let dist_windows = path.join("dist").join("windows");
3434 let prefix = format!("Hematite-{}", version);
3435 Some(ReleaseArtifactState {
3436 version,
3437 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3438 portable_zip: dist_windows
3439 .join(format!("{}-portable.zip", prefix))
3440 .exists(),
3441 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3442 })
3443}
3444
3445fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3446 let regex = regex::Regex::new(pattern).ok()?;
3447 let captures = regex.captures(text)?;
3448 captures.get(1).map(|m| m.as_str().to_string())
3449}
3450
3451fn bool_label(value: bool) -> &'static str {
3452 if value {
3453 "yes"
3454 } else {
3455 "no"
3456 }
3457}
3458
3459fn collect_toolchains() -> ToolchainReport {
3460 let config = crate::agent::config::load_config();
3461 let mut python_probes = Vec::with_capacity(5);
3462 if let Some(ref path) = config.python_path {
3463 python_probes.push(CommandProbe::new(path, &["--version"]));
3464 };
3465
3466 python_probes.extend([
3467 CommandProbe::new("python3", &["--version"]),
3468 CommandProbe::new("python", &["--version"]),
3469 CommandProbe::new("py", &["-3", "--version"]),
3470 CommandProbe::new("py", &["--version"]),
3471 ]);
3472
3473 let checks = [
3474 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3475 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3476 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3477 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3478 ToolCheck::new(
3479 "npm",
3480 &[
3481 CommandProbe::new("npm", &["--version"]),
3482 CommandProbe::new("npm.cmd", &["--version"]),
3483 ],
3484 ),
3485 ToolCheck::new(
3486 "pnpm",
3487 &[
3488 CommandProbe::new("pnpm", &["--version"]),
3489 CommandProbe::new("pnpm.cmd", &["--version"]),
3490 ],
3491 ),
3492 ToolCheck::new("python", &python_probes),
3493 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3494 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3495 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3496 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3497 ];
3498
3499 let mut found = Vec::with_capacity(checks.len());
3500 let mut missing = Vec::with_capacity(checks.len());
3501
3502 for check in checks {
3503 match check.detect() {
3504 Some(version) => found.push((check.label.to_string(), version)),
3505 None => missing.push(check.label.to_string()),
3506 }
3507 }
3508
3509 ToolchainReport { found, missing }
3510}
3511
3512fn collect_package_managers() -> PackageManagerReport {
3513 let config = crate::agent::config::load_config();
3514 let mut pip_probes = Vec::with_capacity(6);
3515 if let Some(ref path) = config.python_path {
3516 pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3517 }
3518 pip_probes.extend([
3519 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3520 CommandProbe::new("python", &["-m", "pip", "--version"]),
3521 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3522 CommandProbe::new("py", &["-m", "pip", "--version"]),
3523 CommandProbe::new("pip", &["--version"]),
3524 ]);
3525
3526 let checks = [
3527 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3528 ToolCheck::new(
3529 "npm",
3530 &[
3531 CommandProbe::new("npm", &["--version"]),
3532 CommandProbe::new("npm.cmd", &["--version"]),
3533 ],
3534 ),
3535 ToolCheck::new(
3536 "pnpm",
3537 &[
3538 CommandProbe::new("pnpm", &["--version"]),
3539 CommandProbe::new("pnpm.cmd", &["--version"]),
3540 ],
3541 ),
3542 ToolCheck::new("pip", &pip_probes),
3543 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3544 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3545 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3546 ToolCheck::new(
3547 "choco",
3548 &[
3549 CommandProbe::new("choco", &["--version"]),
3550 CommandProbe::new("choco.exe", &["--version"]),
3551 ],
3552 ),
3553 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3554 ];
3555
3556 let mut found = Vec::with_capacity(checks.len());
3557 for check in checks {
3558 if let Some(version) = check.detect() {
3559 found.push((check.label.to_string(), version))
3560 }
3561 }
3562
3563 PackageManagerReport { found }
3564}
3565
3566#[derive(Clone)]
3567struct ToolCheck {
3568 label: &'static str,
3569 probes: Vec<CommandProbe>,
3570}
3571
3572impl ToolCheck {
3573 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3574 Self {
3575 label,
3576 probes: probes.to_vec(),
3577 }
3578 }
3579
3580 fn detect(&self) -> Option<String> {
3581 for probe in &self.probes {
3582 if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3583 return Some(output);
3584 }
3585 }
3586 None
3587 }
3588}
3589
3590#[derive(Clone)]
3591struct CommandProbe {
3592 program: String,
3593 args: Vec<String>,
3594}
3595
3596impl CommandProbe {
3597 fn new(program: &str, args: &[&str]) -> Self {
3598 Self {
3599 program: program.to_string(),
3600 args: args.iter().map(|s| s.to_string()).collect(),
3601 }
3602 }
3603}
3604
3605fn build_env_doctor_findings(
3606 toolchains: &ToolchainReport,
3607 package_managers: &PackageManagerReport,
3608 path_stats: &PathAnalysis,
3609) -> Vec<String> {
3610 let found_tools = toolchains
3611 .found
3612 .iter()
3613 .map(|(label, _)| label.as_str())
3614 .collect::<HashSet<_>>();
3615 let found_managers = package_managers
3616 .found
3617 .iter()
3618 .map(|(label, _)| label.as_str())
3619 .collect::<HashSet<_>>();
3620
3621 let mut findings = Vec::with_capacity(4);
3622
3623 if !path_stats.duplicate_entries.is_empty() {
3624 findings.push(format!(
3625 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3626 path_stats.duplicate_entries.len()
3627 ));
3628 }
3629 if !path_stats.missing_entries.is_empty() {
3630 findings.push(format!(
3631 "PATH contains {} entries that do not exist on disk.",
3632 path_stats.missing_entries.len()
3633 ));
3634 }
3635 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3636 findings.push(
3637 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3638 .to_string(),
3639 );
3640 }
3641 if found_tools.contains("node")
3642 && !found_managers.contains("npm")
3643 && !found_managers.contains("pnpm")
3644 {
3645 findings.push(
3646 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3647 .to_string(),
3648 );
3649 }
3650 if found_tools.contains("python")
3651 && !found_managers.contains("pip")
3652 && !found_managers.contains("uv")
3653 && !found_managers.contains("pipx")
3654 {
3655 findings.push(
3656 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3657 .to_string(),
3658 );
3659 }
3660 let windows_manager_count = ["winget", "choco", "scoop"]
3661 .iter()
3662 .filter(|label| found_managers.contains(**label))
3663 .count();
3664 if windows_manager_count > 1 {
3665 findings.push(
3666 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3667 .to_string(),
3668 );
3669 }
3670 if findings.is_empty() && !found_managers.is_empty() {
3671 findings.push(
3672 "Core package-manager coverage looks healthy for a normal developer workstation."
3673 .to_string(),
3674 );
3675 }
3676
3677 findings
3678}
3679
3680fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3681 let output = std::process::Command::new(program)
3682 .args(args.iter().map(|s| s.as_ref()))
3683 .output()
3684 .ok()?;
3685 if !output.status.success() {
3686 return None;
3687 }
3688
3689 let stdout = if output.stdout.is_empty() {
3690 String::from_utf8_lossy(&output.stderr).into_owned()
3691 } else {
3692 String::from_utf8_lossy(&output.stdout).into_owned()
3693 };
3694
3695 stdout
3696 .lines()
3697 .map(str::trim)
3698 .find(|line| !line.is_empty())
3699 .map(|line| line.to_string())
3700}
3701
3702fn human_bytes(bytes: u64) -> String {
3703 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3704 let mut value = bytes as f64;
3705 let mut unit_index = 0usize;
3706
3707 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3708 value /= 1024.0;
3709 unit_index += 1;
3710 }
3711
3712 if unit_index == 0 {
3713 format!("{} {}", bytes, UNITS[unit_index])
3714 } else {
3715 format!("{value:.1} {}", UNITS[unit_index])
3716 }
3717}
3718
3719#[cfg(target_os = "windows")]
3720fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3721 let mut adapters = Vec::new();
3722 let mut current: Option<NetworkAdapter> = None;
3723 let mut pending_dns = false;
3724
3725 for raw_line in text.lines() {
3726 let line = raw_line.trim_end();
3727 let trimmed = line.trim();
3728 if trimmed.is_empty() {
3729 pending_dns = false;
3730 continue;
3731 }
3732
3733 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3734 if let Some(adapter) = current.take() {
3735 adapters.push(adapter);
3736 }
3737 current = Some(NetworkAdapter {
3738 name: trimmed.trim_end_matches(':').to_string(),
3739 ..NetworkAdapter::default()
3740 });
3741 pending_dns = false;
3742 continue;
3743 }
3744
3745 let Some(adapter) = current.as_mut() else {
3746 continue;
3747 };
3748
3749 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3750 adapter.disconnected = true;
3751 }
3752
3753 if let Some(value) = value_after_colon(trimmed) {
3754 let normalized = normalize_ipconfig_value(value);
3755 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3756 adapter.ipv4.push(normalized);
3757 pending_dns = false;
3758 } else if trimmed.starts_with("IPv6 Address")
3759 || trimmed.starts_with("Temporary IPv6 Address")
3760 || trimmed.starts_with("Link-local IPv6 Address")
3761 {
3762 if !normalized.is_empty() {
3763 adapter.ipv6.push(normalized);
3764 }
3765 pending_dns = false;
3766 } else if trimmed.starts_with("Default Gateway") {
3767 if !normalized.is_empty() {
3768 adapter.gateways.push(normalized);
3769 }
3770 pending_dns = false;
3771 } else if trimmed.starts_with("DNS Servers") {
3772 if !normalized.is_empty() {
3773 adapter.dns_servers.push(normalized);
3774 }
3775 pending_dns = true;
3776 } else {
3777 pending_dns = false;
3778 }
3779 } else if pending_dns {
3780 let normalized = normalize_ipconfig_value(trimmed);
3781 if !normalized.is_empty() {
3782 adapter.dns_servers.push(normalized);
3783 }
3784 }
3785 }
3786
3787 if let Some(adapter) = current.take() {
3788 adapters.push(adapter);
3789 }
3790
3791 for adapter in &mut adapters {
3792 dedup_vec(&mut adapter.ipv4);
3793 dedup_vec(&mut adapter.ipv6);
3794 dedup_vec(&mut adapter.gateways);
3795 dedup_vec(&mut adapter.dns_servers);
3796 }
3797
3798 adapters
3799}
3800
3801#[cfg(not(target_os = "windows"))]
3802fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3803 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3804
3805 for line in text.lines() {
3806 let mut it = line.split_whitespace();
3807 let (Some(_), Some(iface), Some(family), Some(addr_full)) =
3808 (it.next(), it.next(), it.next(), it.next())
3809 else {
3810 continue;
3811 };
3812 let name = iface.trim_end_matches(':').to_string();
3813 let addr = addr_full.split('/').next().unwrap_or("").to_string();
3814 let entry = adapters
3815 .entry(name.clone())
3816 .or_insert_with(|| NetworkAdapter {
3817 name,
3818 ..NetworkAdapter::default()
3819 });
3820 match family {
3821 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3822 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3823 _ => {}
3824 }
3825 }
3826
3827 adapters.into_values().collect()
3828}
3829
3830#[cfg(not(target_os = "windows"))]
3831fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3832 for line in text.lines() {
3833 let cols: Vec<&str> = line.split_whitespace().collect();
3834 if cols.len() < 5 {
3835 continue;
3836 }
3837 let gateway = cols
3838 .windows(2)
3839 .find(|pair| pair[0] == "via")
3840 .map(|pair| pair[1].to_string());
3841 let dev = cols
3842 .windows(2)
3843 .find(|pair| pair[0] == "dev")
3844 .map(|pair| pair[1]);
3845 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3846 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3847 adapter.gateways.push(gateway);
3848 }
3849 }
3850 }
3851
3852 for adapter in adapters {
3853 dedup_vec(&mut adapter.gateways);
3854 }
3855}
3856
3857#[cfg(not(target_os = "windows"))]
3858fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3859 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3860 return;
3861 };
3862 let mut dns_servers = text
3863 .lines()
3864 .filter_map(|line| line.strip_prefix("nameserver "))
3865 .map(str::trim)
3866 .filter(|value| !value.is_empty())
3867 .map(|value| value.to_string())
3868 .collect::<Vec<_>>();
3869 dedup_vec(&mut dns_servers);
3870 if dns_servers.is_empty() {
3871 return;
3872 }
3873 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3874 adapter.dns_servers = dns_servers.clone();
3875 }
3876}
3877
3878#[cfg(target_os = "windows")]
3879fn value_after_colon(line: &str) -> Option<&str> {
3880 line.split_once(':').map(|(_, value)| value.trim())
3881}
3882
3883#[cfg(target_os = "windows")]
3884fn normalize_ipconfig_value(value: &str) -> String {
3885 value
3886 .trim()
3887 .trim_end_matches("(Preferred)")
3888 .trim_end_matches("(Deprecated)")
3889 .trim()
3890 .trim_matches(['(', ')'])
3891 .trim()
3892 .to_string()
3893}
3894
3895#[cfg(target_os = "windows")]
3896fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3897 let mac_upper = mac.to_ascii_uppercase();
3898 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3899 return true;
3900 }
3901
3902 ip == "255.255.255.255"
3903 || ip.starts_with("224.")
3904 || ip.starts_with("225.")
3905 || ip.starts_with("226.")
3906 || ip.starts_with("227.")
3907 || ip.starts_with("228.")
3908 || ip.starts_with("229.")
3909 || ip.starts_with("230.")
3910 || ip.starts_with("231.")
3911 || ip.starts_with("232.")
3912 || ip.starts_with("233.")
3913 || ip.starts_with("234.")
3914 || ip.starts_with("235.")
3915 || ip.starts_with("236.")
3916 || ip.starts_with("237.")
3917 || ip.starts_with("238.")
3918 || ip.starts_with("239.")
3919}
3920
3921fn dedup_vec(values: &mut Vec<String>) {
3922 let mut seen = HashSet::new();
3923 values.retain(|value| seen.insert(value.clone()));
3924}
3925
3926#[cfg(target_os = "windows")]
3927fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3928 let trimmed = text.trim();
3929 if trimmed.is_empty() {
3930 return Vec::new();
3931 }
3932
3933 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3934 return Vec::new();
3935 };
3936 let entries = match value {
3937 Value::Array(items) => items,
3938 other => vec![other],
3939 };
3940
3941 let mut neighbors = Vec::with_capacity(entries.len());
3942 for entry in entries {
3943 let ip = entry
3944 .get("IPAddress")
3945 .and_then(|v| v.as_str())
3946 .unwrap_or("")
3947 .to_string();
3948 if ip.is_empty() {
3949 continue;
3950 }
3951 let mac = entry
3952 .get("LinkLayerAddress")
3953 .and_then(|v| v.as_str())
3954 .unwrap_or("unknown")
3955 .to_string();
3956 let state = entry
3957 .get("State")
3958 .and_then(|v| v.as_str())
3959 .unwrap_or("unknown")
3960 .to_string();
3961 let iface = entry
3962 .get("InterfaceAlias")
3963 .and_then(|v| v.as_str())
3964 .unwrap_or("unknown")
3965 .to_string();
3966 if is_noise_lan_neighbor(&ip, &mac) {
3967 continue;
3968 }
3969 neighbors.push((ip, mac, state, iface));
3970 }
3971
3972 neighbors
3973}
3974
3975#[cfg(target_os = "windows")]
3976fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3977 let trimmed = text.trim();
3978 if trimmed.is_empty() {
3979 return Ok(Vec::new());
3980 }
3981
3982 let value: Value = serde_json::from_str(trimmed)
3983 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3984 let entries = match value {
3985 Value::Array(items) => items,
3986 other => vec![other],
3987 };
3988
3989 let mut services = Vec::with_capacity(entries.len());
3990 for entry in entries {
3991 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3992 continue;
3993 };
3994 services.push(ServiceEntry {
3995 name: name.to_string(),
3996 status: entry
3997 .get("State")
3998 .and_then(|v| v.as_str())
3999 .unwrap_or("unknown")
4000 .to_string(),
4001 startup: entry
4002 .get("StartMode")
4003 .and_then(|v| v.as_str())
4004 .map(|v| v.to_string()),
4005 display_name: entry
4006 .get("DisplayName")
4007 .and_then(|v| v.as_str())
4008 .map(|v| v.to_string()),
4009 start_name: entry
4010 .get("StartName")
4011 .and_then(|v| v.as_str())
4012 .map(|v| v.to_string()),
4013 });
4014 }
4015
4016 Ok(services)
4017}
4018
4019#[cfg(target_os = "windows")]
4020fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
4021 match node.cloned() {
4022 Some(Value::Array(items)) => items,
4023 Some(other) => vec![other],
4024 None => Vec::new(),
4025 }
4026}
4027
4028#[cfg(target_os = "windows")]
4029fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
4030 windows_json_entries(node)
4031 .into_iter()
4032 .filter_map(|entry| {
4033 let name = entry
4034 .get("FriendlyName")
4035 .and_then(|v| v.as_str())
4036 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
4037 .unwrap_or("")
4038 .trim()
4039 .to_string();
4040 if name.is_empty() {
4041 return None;
4042 }
4043 Some(WindowsPnpDevice {
4044 name,
4045 status: entry
4046 .get("Status")
4047 .and_then(|v| v.as_str())
4048 .unwrap_or("Unknown")
4049 .trim()
4050 .to_string(),
4051 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
4052 entry
4053 .get("Problem")
4054 .and_then(|v| v.as_i64())
4055 .map(|v| v as u64)
4056 }),
4057 class_name: entry
4058 .get("Class")
4059 .and_then(|v| v.as_str())
4060 .map(|v| v.trim().to_string()),
4061 instance_id: entry
4062 .get("InstanceId")
4063 .and_then(|v| v.as_str())
4064 .map(|v| v.trim().to_string()),
4065 })
4066 })
4067 .collect()
4068}
4069
4070#[cfg(target_os = "windows")]
4071fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4072 windows_json_entries(node)
4073 .into_iter()
4074 .filter_map(|entry| {
4075 let name = entry
4076 .get("Name")
4077 .and_then(|v| v.as_str())
4078 .unwrap_or("")
4079 .trim()
4080 .to_string();
4081 if name.is_empty() {
4082 return None;
4083 }
4084 Some(WindowsSoundDevice {
4085 name,
4086 status: entry
4087 .get("Status")
4088 .and_then(|v| v.as_str())
4089 .unwrap_or("Unknown")
4090 .trim()
4091 .to_string(),
4092 manufacturer: entry
4093 .get("Manufacturer")
4094 .and_then(|v| v.as_str())
4095 .map(|v| v.trim().to_string()),
4096 })
4097 })
4098 .collect()
4099}
4100
4101#[cfg(target_os = "windows")]
4102fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4103 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4104 || device.problem.unwrap_or(0) != 0
4105}
4106
4107#[cfg(target_os = "windows")]
4108fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4109 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4110}
4111
4112#[cfg(target_os = "windows")]
4113fn is_microphone_like_name(name: &str) -> bool {
4114 let lower = name.to_ascii_lowercase();
4115 lower.contains("microphone")
4116 || lower.contains("mic")
4117 || lower.contains("input")
4118 || lower.contains("array")
4119 || lower.contains("capture")
4120 || lower.contains("record")
4121}
4122
4123#[cfg(target_os = "windows")]
4124fn is_bluetooth_like_name(name: &str) -> bool {
4125 let lower = name.to_ascii_lowercase();
4126 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4127}
4128
4129#[cfg(target_os = "windows")]
4130fn service_is_running(service: &ServiceEntry) -> bool {
4131 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4132}
4133
4134#[cfg(not(target_os = "windows"))]
4135fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4136 let mut startup_modes = std::collections::HashMap::<String, String>::new();
4137 for line in startup_text.lines() {
4138 let mut it = line.split_whitespace();
4139 if let (Some(name), Some(mode)) = (it.next(), it.next()) {
4140 startup_modes.insert(name.to_string(), mode.to_string());
4141 }
4142 }
4143
4144 let mut services = Vec::new();
4145 for line in status_text.lines() {
4146 let mut it = line.split_whitespace();
4147 let Some(unit) = it.next() else {
4148 continue;
4149 };
4150 let Some(load) = it.next() else {
4151 continue;
4152 };
4153 let Some(active) = it.next() else {
4154 continue;
4155 };
4156 let Some(sub) = it.next() else {
4157 continue;
4158 };
4159 let description = {
4160 let mut desc = String::new();
4161 for (i, w) in it.enumerate() {
4162 if i > 0 {
4163 desc.push(' ');
4164 }
4165 desc.push_str(w);
4166 }
4167 if desc.is_empty() {
4168 None
4169 } else {
4170 Some(desc)
4171 }
4172 };
4173 services.push(ServiceEntry {
4174 name: unit.to_string(),
4175 status: format!("{}/{}", active, sub),
4176 startup: startup_modes
4177 .get(unit)
4178 .cloned()
4179 .or_else(|| Some(load.to_string())),
4180 display_name: description,
4181 start_name: None,
4182 });
4183 }
4184
4185 services
4186}
4187
4188fn inspect_health_report() -> Result<String, String> {
4194 let mut needs_fix: Vec<String> = Vec::with_capacity(8);
4195 let mut watch: Vec<String> = Vec::with_capacity(8);
4196 let mut good: Vec<String> = Vec::with_capacity(8);
4197 let mut tips: Vec<String> = Vec::with_capacity(8);
4198
4199 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4200 health_check_memory(&mut watch, &mut good);
4201 health_check_network(&mut needs_fix, &mut watch, &mut good);
4202 health_check_pending_reboot(&mut watch, &mut good);
4203 health_check_services(&mut needs_fix, &mut watch, &mut good);
4204 health_check_thermal(&mut watch, &mut good);
4205 health_check_tools(&mut watch, &mut good, &mut tips);
4206 health_check_recent_errors(&mut watch, &mut tips);
4207
4208 let overall = if !needs_fix.is_empty() {
4209 "ACTION REQUIRED"
4210 } else if !watch.is_empty() {
4211 "WORTH A LOOK"
4212 } else {
4213 "ALL GOOD"
4214 };
4215
4216 let mut out = format!("System Health Report — {overall}\n\n");
4217
4218 if !needs_fix.is_empty() {
4219 out.push_str("Needs fixing:\n");
4220 for item in &needs_fix {
4221 let _ = writeln!(out, " [!] {item}");
4222 }
4223 out.push('\n');
4224 }
4225 if !watch.is_empty() {
4226 out.push_str("Worth watching:\n");
4227 for item in &watch {
4228 let _ = writeln!(out, " [-] {item}");
4229 }
4230 out.push('\n');
4231 }
4232 if !good.is_empty() {
4233 out.push_str("Looking good:\n");
4234 for item in &good {
4235 let _ = writeln!(out, " [+] {item}");
4236 }
4237 out.push('\n');
4238 }
4239 if !tips.is_empty() {
4240 out.push_str("To dig deeper:\n");
4241 for tip in &tips {
4242 let _ = writeln!(out, " {tip}");
4243 }
4244 }
4245
4246 Ok(out.trim_end().to_string())
4247}
4248
4249fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4250 #[cfg(target_os = "windows")]
4251 {
4252 let script = r#"try {
4253 $d = Get-PSDrive C -ErrorAction Stop
4254 "$($d.Free)|$($d.Used)"
4255} catch { "ERR" }"#;
4256 if let Ok(out) = Command::new("powershell")
4257 .args(["-NoProfile", "-Command", script])
4258 .output()
4259 {
4260 let text = String::from_utf8_lossy(&out.stdout);
4261 let text = text.trim();
4262 if !text.starts_with("ERR") {
4263 let mut it = text.splitn(3, '|');
4264 if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4265 let free_bytes: u64 = p0.trim().parse().unwrap_or(0);
4266 let used_bytes: u64 = p1.trim().parse().unwrap_or(0);
4267 let total = free_bytes + used_bytes;
4268 let free_gb = free_bytes / 1_073_741_824;
4269 let pct_free = if total > 0 {
4270 (free_bytes as f64 / total as f64 * 100.0) as u64
4271 } else {
4272 0
4273 };
4274 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4275 if free_gb < 5 {
4276 needs_fix.push(format!(
4277 "{msg} — very low. Free up space or your system may slow down or stop working."
4278 ));
4279 } else if free_gb < 15 {
4280 watch.push(format!("{msg} — getting low, consider cleaning up."));
4281 } else {
4282 good.push(msg);
4283 }
4284 return;
4285 }
4286 }
4287 }
4288 watch.push("Disk: could not read free space from C: drive.".to_string());
4289 }
4290
4291 #[cfg(not(target_os = "windows"))]
4292 {
4293 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4294 let text = String::from_utf8_lossy(&out.stdout);
4295 for line in text.lines().skip(1) {
4296 let mut it = line.split_whitespace();
4297 if let (Some(_), Some(_), Some(_), Some(avail_raw), Some(use_pct_raw)) =
4298 (it.next(), it.next(), it.next(), it.next(), it.next())
4299 {
4300 let avail_str = avail_raw.trim_end_matches('G');
4301 let use_pct = use_pct_raw.trim_end_matches('%');
4302 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4303 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4304 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4305 if avail_gb < 5 {
4306 needs_fix.push(format!(
4307 "{msg} — very low. Free up space to prevent system issues."
4308 ));
4309 } else if avail_gb < 15 {
4310 watch.push(format!("{msg} — getting low."));
4311 } else {
4312 good.push(msg);
4313 }
4314 return;
4315 }
4316 }
4317 }
4318 watch.push("Disk: could not determine free space.".to_string());
4319 }
4320}
4321
4322fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4323 #[cfg(target_os = "windows")]
4324 {
4325 let script = r#"try {
4326 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4327 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4328} catch { "ERR" }"#;
4329 if let Ok(out) = Command::new("powershell")
4330 .args(["-NoProfile", "-Command", script])
4331 .output()
4332 {
4333 let text = String::from_utf8_lossy(&out.stdout);
4334 let text = text.trim();
4335 if !text.starts_with("ERR") {
4336 let mut it = text.splitn(3, '|');
4337 if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4338 let free_kb: u64 = p0.trim().parse().unwrap_or(0);
4339 let total_kb: u64 = p1.trim().parse().unwrap_or(0);
4340 if total_kb > 0 {
4341 let free_gb = free_kb / 1_048_576;
4342 let total_gb = total_kb / 1_048_576;
4343 let free_pct = free_kb * 100 / total_kb;
4344 let msg = format!(
4345 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4346 );
4347 if free_pct < 10 {
4348 watch.push(format!(
4349 "{msg} — very low. Close unused apps to free up memory."
4350 ));
4351 } else if free_pct < 25 {
4352 watch.push(format!("{msg} — running a bit low."));
4353 } else {
4354 good.push(msg);
4355 }
4356 }
4357 }
4358 }
4359 }
4360 }
4361
4362 #[cfg(not(target_os = "windows"))]
4363 {
4364 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4365 let mut total_kb = 0u64;
4366 let mut avail_kb = 0u64;
4367 for line in content.lines() {
4368 if line.starts_with("MemTotal:") {
4369 total_kb = line
4370 .split_whitespace()
4371 .nth(1)
4372 .and_then(|v| v.parse().ok())
4373 .unwrap_or(0);
4374 } else if line.starts_with("MemAvailable:") {
4375 avail_kb = line
4376 .split_whitespace()
4377 .nth(1)
4378 .and_then(|v| v.parse().ok())
4379 .unwrap_or(0);
4380 }
4381 }
4382 if total_kb > 0 {
4383 let free_gb = avail_kb / 1_048_576;
4384 let total_gb = total_kb / 1_048_576;
4385 let free_pct = avail_kb * 100 / total_kb;
4386 let msg =
4387 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4388 if free_pct < 10 {
4389 watch.push(format!("{msg} — very low. Close unused apps."));
4390 } else if free_pct < 25 {
4391 watch.push(format!("{msg} — running a bit low."));
4392 } else {
4393 good.push(msg);
4394 }
4395 }
4396 }
4397 }
4398}
4399
4400fn probe_tool(cmd: &str, arg: &str) -> bool {
4404 if Command::new(cmd)
4405 .arg(arg)
4406 .stdout(std::process::Stdio::null())
4407 .stderr(std::process::Stdio::null())
4408 .status()
4409 .map(|s| s.success())
4410 .unwrap_or(false)
4411 {
4412 return true;
4413 }
4414 #[cfg(windows)]
4416 {
4417 let home = std::env::var("USERPROFILE").unwrap_or_default();
4418 let fallback: Option<String> = match cmd {
4419 "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4420 "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4421 "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4422 _ => None,
4423 };
4424 if let Some(path) = fallback {
4425 return Command::new(&path)
4426 .arg(arg)
4427 .stdout(std::process::Stdio::null())
4428 .stderr(std::process::Stdio::null())
4429 .status()
4430 .map(|s| s.success())
4431 .unwrap_or(false);
4432 }
4433 }
4434 false
4435}
4436
4437fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4438 let tool_checks: &[(&str, &str, &str)] = &[
4439 ("git", "--version", "Git"),
4440 ("cargo", "--version", "Rust / Cargo"),
4441 ("node", "--version", "Node.js"),
4442 ("python", "--version", "Python"),
4443 ("python3", "--version", "Python 3"),
4444 ("npm", "--version", "npm"),
4445 ];
4446
4447 let mut found: Vec<String> = Vec::with_capacity(tool_checks.len());
4448 let mut missing: Vec<String> = Vec::with_capacity(tool_checks.len());
4449 let mut python_found = false;
4450
4451 for (cmd, arg, label) in tool_checks {
4452 if cmd.starts_with("python") && python_found {
4453 continue;
4454 }
4455 let ok = probe_tool(cmd, arg);
4456 if ok {
4457 found.push((*label).to_string());
4458 if cmd.starts_with("python") {
4459 python_found = true;
4460 }
4461 } else if !cmd.starts_with("python") || !python_found {
4462 missing.push((*label).to_string());
4463 }
4464 }
4465
4466 if !found.is_empty() {
4467 good.push(format!("Dev tools found: {}", found.join(", ")));
4468 }
4469 if !missing.is_empty() {
4470 watch.push(format!(
4471 "Not installed (or not on PATH): {} — only matters if you need them",
4472 missing.join(", ")
4473 ));
4474 tips.push(
4475 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4476 .to_string(),
4477 );
4478 }
4479}
4480
4481fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4482 #[cfg(target_os = "windows")]
4483 {
4484 let script = r#"try {
4485 $cutoff = (Get-Date).AddHours(-24)
4486 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4487 $count
4488} catch { "0" }"#;
4489 if let Ok(out) = Command::new("powershell")
4490 .args(["-NoProfile", "-Command", script])
4491 .output()
4492 {
4493 let text = String::from_utf8_lossy(&out.stdout);
4494 let count: u64 = text.trim().parse().unwrap_or(0);
4495 if count > 0 {
4496 watch.push(format!(
4497 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4498 if count == 1 { "" } else { "s" }
4499 ));
4500 tips.push(
4501 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4502 .to_string(),
4503 );
4504 }
4505 }
4506 }
4507
4508 #[cfg(not(target_os = "windows"))]
4509 {
4510 if let Ok(out) = Command::new("journalctl")
4511 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4512 .output()
4513 {
4514 let text = String::from_utf8_lossy(&out.stdout);
4515 if !text.trim().is_empty() {
4516 watch.push("Critical/error entries found in the system journal.".to_string());
4517 tips.push(
4518 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4519 );
4520 }
4521 }
4522 }
4523}
4524
4525fn health_check_network(
4526 needs_fix: &mut Vec<String>,
4527 watch: &mut Vec<String>,
4528 good: &mut Vec<String>,
4529) {
4530 #[cfg(target_os = "windows")]
4531 {
4532 let script = r#"try {
4534 $ping = New-Object System.Net.NetworkInformation.Ping
4535 $r = $ping.Send("1.1.1.1", 2000)
4536 if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4537} catch { "FAIL" }"#;
4538 if let Ok(out) = Command::new("powershell")
4539 .args(["-NoProfile", "-Command", script])
4540 .output()
4541 {
4542 let text = String::from_utf8_lossy(&out.stdout);
4543 let text = text.trim();
4544 if text.starts_with("OK") {
4545 let latency = text.split('|').nth(1).unwrap_or("?");
4546 let latency_ms: u64 = latency.parse().unwrap_or(0);
4547 let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4548 if latency_ms > 300 {
4549 watch.push(format!("{msg} — high latency, may indicate network issue."));
4550 } else {
4551 good.push(msg);
4552 }
4553 } else {
4554 needs_fix.push(
4555 "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4556 Check adapter, gateway, or DNS."
4557 .to_string(),
4558 );
4559 }
4560 return;
4561 }
4562 watch.push("Network: could not run connectivity check.".to_string());
4563 }
4564
4565 #[cfg(not(target_os = "windows"))]
4566 {
4567 let _ = watch;
4568 let ok = Command::new("ping")
4569 .args(["-c", "1", "-W", "2", "1.1.1.1"])
4570 .stdout(std::process::Stdio::null())
4571 .stderr(std::process::Stdio::null())
4572 .status()
4573 .map(|s| s.success())
4574 .unwrap_or(false);
4575 if ok {
4576 good.push("Internet connectivity: reachable.".to_string());
4577 } else {
4578 needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4579 }
4580 }
4581}
4582
4583fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4584 #[cfg(target_os = "windows")]
4585 {
4586 let script = r#"try {
4587 $pending = $false
4588 $reasons = @()
4589 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4590 $pending = $true; $reasons += 'CBS/component update'
4591 }
4592 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4593 $pending = $true; $reasons += 'Windows Update'
4594 }
4595 $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4596 if ($pfr -and $pfr.PendingFileRenameOperations) {
4597 $pending = $true; $reasons += 'file rename ops'
4598 }
4599 if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4600} catch { "OK" }"#;
4601 if let Ok(out) = Command::new("powershell")
4602 .args(["-NoProfile", "-Command", script])
4603 .output()
4604 {
4605 let text = String::from_utf8_lossy(&out.stdout);
4606 let text = text.trim();
4607 if text.starts_with("PENDING") {
4608 let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4609 watch.push(format!(
4610 "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4611 ));
4612 } else {
4613 good.push("No pending reboot.".to_string());
4614 }
4615 }
4616 }
4617
4618 #[cfg(not(target_os = "windows"))]
4619 {
4620 if std::path::Path::new("/var/run/reboot-required").exists() {
4622 watch.push(
4623 "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4624 );
4625 } else {
4626 good.push("No pending reboot.".to_string());
4627 }
4628 }
4629}
4630
4631fn health_check_services(
4632 needs_fix: &mut Vec<String>,
4633 watch: &mut Vec<String>,
4634 good: &mut Vec<String>,
4635) {
4636 #[cfg(not(target_os = "windows"))]
4637 let _ = (&needs_fix, &good);
4638 #[cfg(target_os = "windows")]
4639 let _ = &watch;
4640
4641 #[cfg(target_os = "windows")]
4642 {
4643 let script = r#"try {
4645 $names = @('EventLog','WinDefend','Dnscache')
4646 $stopped = @()
4647 foreach ($n in $names) {
4648 $s = Get-Service $n -ErrorAction SilentlyContinue
4649 if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4650 }
4651 if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4652} catch { "OK" }"#;
4653 if let Ok(out) = Command::new("powershell")
4654 .args(["-NoProfile", "-Command", script])
4655 .output()
4656 {
4657 let text = String::from_utf8_lossy(&out.stdout);
4658 let text = text.trim();
4659 if text.starts_with("STOPPED") {
4660 let names = text.split('|').nth(1).unwrap_or("unknown");
4661 needs_fix.push(format!(
4662 "Critical service(s) not running: {names} — these should always be active."
4663 ));
4664 } else {
4665 good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4666 }
4667 }
4668 }
4669
4670 #[cfg(not(target_os = "windows"))]
4671 {
4672 if let Ok(out) = Command::new("systemctl")
4674 .args(["--failed", "--no-legend", "--plain"])
4675 .output()
4676 {
4677 let text = String::from_utf8_lossy(&out.stdout);
4678 let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4679 if !failed.is_empty() {
4680 watch.push(format!(
4681 "{} failed systemd unit(s): {}",
4682 failed.len(),
4683 failed.join(", ")
4684 ));
4685 } else {
4686 good.push("No failed systemd units.".to_string());
4687 }
4688 }
4689 }
4690}
4691
4692fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4693 #[cfg(target_os = "windows")]
4694 {
4695 let script = r#"try {
4697 $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4698 $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4699 $max = ($temps | Measure-Object -Maximum).Maximum
4700 "$max"
4701} catch { "NA" }"#;
4702 if let Ok(out) = Command::new("powershell")
4703 .args(["-NoProfile", "-Command", script])
4704 .output()
4705 {
4706 let text = String::from_utf8_lossy(&out.stdout);
4707 let text = text.trim();
4708 if text != "NA" && !text.is_empty() {
4709 if let Ok(temp) = text.parse::<f64>() {
4710 let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4711 if temp >= 90.0 {
4712 watch.push(format!("{msg} — very high, check cooling and airflow."));
4713 } else if temp >= 75.0 {
4714 watch.push(format!(
4715 "{msg} — elevated under load, monitor for throttling."
4716 ));
4717 } else {
4718 good.push(format!("{msg} — normal."));
4719 }
4720 }
4721 }
4722 }
4724 }
4725
4726 #[cfg(not(target_os = "windows"))]
4727 {
4728 let paths = [
4730 "/sys/class/thermal/thermal_zone0/temp",
4731 "/sys/class/hwmon/hwmon0/temp1_input",
4732 ];
4733 for path in &paths {
4734 if let Ok(content) = std::fs::read_to_string(path) {
4735 if let Ok(raw) = content.trim().parse::<u64>() {
4736 let temp_c = raw / 1000;
4737 let msg = format!("CPU thermal: {temp_c}°C");
4738 if temp_c >= 90 {
4739 watch.push(format!("{msg} — very high, check cooling."));
4740 } else if temp_c >= 75 {
4741 watch.push(format!("{msg} — elevated under load."));
4742 } else {
4743 good.push(format!("{msg} — normal."));
4744 }
4745 return;
4746 }
4747 }
4748 }
4749 }
4750}
4751
4752fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4755 let mut out = String::from("Host inspection: log_check\n\n");
4756
4757 #[cfg(target_os = "windows")]
4758 {
4759 let hours = lookback_hours.unwrap_or(24);
4761 let _ = write!(
4762 out,
4763 "Checking System/Application logs from the last {} hours...\n\n",
4764 hours
4765 );
4766
4767 let n = max_entries.clamp(1, 50);
4768 let script = format!(
4769 r#"try {{
4770 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4771 if (-not $events) {{ "NO_EVENTS"; exit }}
4772 $events | Select-Object -First {n} | ForEach-Object {{
4773 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4774 $line
4775 }}
4776}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4777 hours = hours,
4778 n = n
4779 );
4780 let output = Command::new("powershell")
4781 .args(["-NoProfile", "-Command", &script])
4782 .output()
4783 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4784
4785 let raw = String::from_utf8_lossy(&output.stdout);
4786 let text = raw.trim();
4787
4788 if text.is_empty() || text == "NO_EVENTS" {
4789 out.push_str("No critical or error events found in Application/System logs.\n");
4790 return Ok(out.trim_end().to_string());
4791 }
4792 if text.starts_with("ERROR:") {
4793 let _ = writeln!(out, "Warning: event log query returned: {text}");
4794 return Ok(out.trim_end().to_string());
4795 }
4796
4797 let mut count = 0usize;
4798 for line in text.lines() {
4799 let mut it = line.splitn(4, '|');
4800 if let (Some(time), Some(level), Some(source), Some(msg)) =
4801 (it.next(), it.next(), it.next(), it.next())
4802 {
4803 let _ = writeln!(out, "[{time}] [{level}] {source}: {msg}");
4804 count += 1;
4805 }
4806 }
4807 let _ = write!(
4808 out,
4809 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4810 );
4811 }
4812
4813 #[cfg(not(target_os = "windows"))]
4814 {
4815 let _ = lookback_hours;
4816 let n = max_entries.clamp(1, 50).to_string();
4818 let output = Command::new("journalctl")
4819 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4820 .output();
4821
4822 match output {
4823 Ok(o) if o.status.success() => {
4824 let text = String::from_utf8_lossy(&o.stdout);
4825 let trimmed = text.trim();
4826 if trimmed.is_empty() || trimmed.contains("No entries") {
4827 out.push_str("No critical or error entries found in the system journal.\n");
4828 } else {
4829 out.push_str(trimmed);
4830 out.push('\n');
4831 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4832 }
4833 }
4834 _ => {
4835 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4837 let mut found = false;
4838 for log_path in &log_paths {
4839 if let Ok(content) = std::fs::read_to_string(log_path) {
4840 let lines: Vec<&str> = content.lines().collect();
4841 let mut tail: Vec<&str> = lines
4842 .iter()
4843 .rev()
4844 .filter(|l| {
4845 let l_lower = l.to_ascii_lowercase();
4846 l_lower.contains("error") || l_lower.contains("crit")
4847 })
4848 .take(max_entries)
4849 .copied()
4850 .collect::<Vec<_>>();
4851 tail.reverse();
4852 if !tail.is_empty() {
4853 let _ = write!(out, "Source: {log_path}\n");
4854 for l in &tail {
4855 out.push_str(l);
4856 out.push('\n');
4857 }
4858 found = true;
4859 break;
4860 }
4861 }
4862 }
4863 if !found {
4864 out.push_str(
4865 "journalctl not found and no readable syslog detected on this system.\n",
4866 );
4867 }
4868 }
4869 }
4870 }
4871
4872 Ok(out.trim_end().to_string())
4873}
4874
4875fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4878 let mut out = String::from("Host inspection: startup_items\n\n");
4879
4880 #[cfg(target_os = "windows")]
4881 {
4882 let script = r#"
4884$hives = @(
4885 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4886 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4887 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4888)
4889foreach ($h in $hives) {
4890 try {
4891 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4892 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4893 "$($h.Hive)|$($_.Name)|$($_.Value)"
4894 }
4895 } catch {}
4896}
4897"#;
4898 let output = Command::new("powershell")
4899 .args(["-NoProfile", "-Command", script])
4900 .output()
4901 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4902
4903 let raw = String::from_utf8_lossy(&output.stdout);
4904 let text = raw.trim();
4905
4906 let entries: Vec<(String, String, String)> = text
4907 .lines()
4908 .filter_map(|l| {
4909 let mut it = l.splitn(3, '|');
4910 match (it.next(), it.next(), it.next()) {
4911 (Some(a), Some(b), Some(c)) => {
4912 Some((a.to_string(), b.to_string(), c.to_string()))
4913 }
4914 _ => None,
4915 }
4916 })
4917 .take(max_entries)
4918 .collect();
4919
4920 if entries.is_empty() {
4921 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4922 } else {
4923 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4924 let mut last_hive = String::new();
4925 for (hive, name, value) in &entries {
4926 if *hive != last_hive {
4927 let _ = writeln!(out, "[{}]", hive);
4928 last_hive = hive.clone();
4929 }
4930 let display = if value.len() > 100 {
4932 format!("{}…", safe_head(value, 100))
4933 } else {
4934 value.clone()
4935 };
4936 let _ = writeln!(out, " {name}: {display}");
4937 }
4938 let _ = write!(out, "\nTotal startup entries: {}\n", entries.len());
4939 }
4940
4941 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4943 if let Ok(unified_out) = Command::new("powershell")
4944 .args(["-NoProfile", "-Command", unified_script])
4945 .output()
4946 {
4947 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4948 let trimmed = unified_text.trim();
4949 if !trimmed.is_empty() {
4950 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4951 out.push_str(trimmed);
4952 out.push('\n');
4953 }
4954 }
4955 }
4956
4957 #[cfg(not(target_os = "windows"))]
4958 {
4959 let output = Command::new("systemctl")
4961 .args([
4962 "list-unit-files",
4963 "--type=service",
4964 "--state=enabled",
4965 "--no-legend",
4966 "--no-pager",
4967 "--plain",
4968 ])
4969 .output();
4970
4971 match output {
4972 Ok(o) if o.status.success() => {
4973 let text = String::from_utf8_lossy(&o.stdout);
4974 let services: Vec<&str> = text
4975 .lines()
4976 .filter(|l| !l.trim().is_empty())
4977 .take(max_entries)
4978 .collect();
4979 if services.is_empty() {
4980 out.push_str("No enabled systemd services found.\n");
4981 } else {
4982 out.push_str("Enabled systemd services (run at boot):\n\n");
4983 for s in &services {
4984 let _ = write!(out, " {s}\n");
4985 }
4986 let _ = write!(out, "\nShowing {} of enabled services.\n", services.len());
4987 }
4988 }
4989 _ => {
4990 out.push_str(
4991 "systemctl not found on this system. Cannot enumerate startup services.\n",
4992 );
4993 }
4994 }
4995
4996 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4998 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4999 let reboot_entries: Vec<&str> = cron_text
5000 .lines()
5001 .filter(|l| l.trim_start().starts_with("@reboot"))
5002 .collect();
5003 if !reboot_entries.is_empty() {
5004 out.push_str("\nCron @reboot entries:\n");
5005 for e in reboot_entries {
5006 let _ = write!(out, " {e}\n");
5007 }
5008 }
5009 }
5010 }
5011
5012 Ok(out.trim_end().to_string())
5013}
5014
5015fn inspect_os_config() -> Result<String, String> {
5016 let mut out = String::from("Host inspection: OS Configuration\n\n");
5017
5018 #[cfg(target_os = "windows")]
5019 {
5020 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
5022 let power_str = String::from_utf8_lossy(&power_out.stdout);
5023 out.push_str("=== Power Plan ===\n");
5024 out.push_str(power_str.trim());
5025 out.push_str("\n\n");
5026 }
5027
5028 let fw_script =
5030 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
5031 if let Ok(fw_out) = Command::new("powershell")
5032 .args(["-NoProfile", "-Command", fw_script])
5033 .output()
5034 {
5035 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
5036 out.push_str("=== Firewall Profiles ===\n");
5037 out.push_str(fw_str.trim());
5038 out.push_str("\n\n");
5039 }
5040
5041 let uptime_script =
5043 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
5044 if let Ok(uptime_out) = Command::new("powershell")
5045 .args(["-NoProfile", "-Command", uptime_script])
5046 .output()
5047 {
5048 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5049 out.push_str("=== System Uptime (Last Boot) ===\n");
5050 out.push_str(uptime_str.trim());
5051 out.push_str("\n\n");
5052 }
5053 }
5054
5055 #[cfg(not(target_os = "windows"))]
5056 {
5057 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
5059 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5060 out.push_str("=== System Uptime ===\n");
5061 out.push_str(uptime_str.trim());
5062 out.push_str("\n\n");
5063 }
5064
5065 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
5067 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
5068 if !ufw_str.trim().is_empty() {
5069 out.push_str("=== Firewall (UFW) ===\n");
5070 out.push_str(ufw_str.trim());
5071 out.push_str("\n\n");
5072 }
5073 }
5074 }
5075 Ok(out.trim_end().to_string())
5076}
5077
5078pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5079 let action = args
5080 .get("action")
5081 .and_then(|v| v.as_str())
5082 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5083
5084 let target = args
5085 .get("target")
5086 .and_then(|v| v.as_str())
5087 .unwrap_or("")
5088 .trim();
5089
5090 if target.is_empty() && action != "clear_temp" {
5091 return Err("Missing required argument: 'target' for this action".to_string());
5092 }
5093
5094 match action {
5095 "install_package" => {
5096 #[cfg(target_os = "windows")]
5097 {
5098 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5099 match Command::new("powershell")
5100 .args(["-NoProfile", "-Command", &cmd])
5101 .output()
5102 {
5103 Ok(out) => Ok(format!(
5104 "Executed remediation (winget install):\n{}",
5105 String::from_utf8_lossy(&out.stdout)
5106 )),
5107 Err(e) => Err(format!("Failed to run winget: {}", e)),
5108 }
5109 }
5110 #[cfg(not(target_os = "windows"))]
5111 {
5112 Err(
5113 "install_package via wrapper is only supported on Windows currently (winget)"
5114 .to_string(),
5115 )
5116 }
5117 }
5118 "restart_service" => {
5119 #[cfg(target_os = "windows")]
5120 {
5121 let cmd = format!("Restart-Service -Name {} -Force", target);
5122 match Command::new("powershell")
5123 .args(["-NoProfile", "-Command", &cmd])
5124 .output()
5125 {
5126 Ok(out) => {
5127 let err_str = String::from_utf8_lossy(&out.stderr);
5128 if !err_str.is_empty() {
5129 return Err(format!("Error restarting service:\n{}", err_str));
5130 }
5131 Ok(format!("Successfully restarted service: {}", target))
5132 }
5133 Err(e) => Err(format!("Failed to restart service: {}", e)),
5134 }
5135 }
5136 #[cfg(not(target_os = "windows"))]
5137 {
5138 Err(
5139 "restart_service via wrapper is only supported on Windows currently"
5140 .to_string(),
5141 )
5142 }
5143 }
5144 "clear_temp" => {
5145 #[cfg(target_os = "windows")]
5146 {
5147 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5148 match Command::new("powershell")
5149 .args(["-NoProfile", "-Command", cmd])
5150 .output()
5151 {
5152 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5153 Err(e) => Err(format!("Failed to clear temp: {}", e)),
5154 }
5155 }
5156 #[cfg(not(target_os = "windows"))]
5157 {
5158 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5159 }
5160 }
5161 other => Err(format!("Unknown remediation action: {}", other)),
5162 }
5163}
5164
5165fn inspect_storage(max_entries: usize) -> Result<String, String> {
5168 let mut out = String::from("Host inspection: storage\n\n");
5169 let _ = max_entries; out.push_str("Drives:\n");
5173
5174 #[cfg(target_os = "windows")]
5175 {
5176 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5177 $free = $_.Free
5178 $used = $_.Used
5179 if ($free -eq $null) { $free = 0 }
5180 if ($used -eq $null) { $used = 0 }
5181 $total = $free + $used
5182 "$($_.Name)|$free|$used|$total"
5183}"#;
5184 match Command::new("powershell")
5185 .args(["-NoProfile", "-Command", script])
5186 .output()
5187 {
5188 Ok(o) => {
5189 let text = String::from_utf8_lossy(&o.stdout);
5190 let mut drive_count = 0usize;
5191 for line in text.lines() {
5192 let mut it = line.trim().splitn(5, '|');
5193 if let (Some(name), Some(p1), _, Some(p3)) =
5194 (it.next(), it.next(), it.next(), it.next())
5195 {
5196 let free: u64 = p1.parse().unwrap_or(0);
5197 let total: u64 = p3.parse().unwrap_or(0);
5198 if total == 0 {
5199 continue;
5200 }
5201 let free_gb = free / 1_073_741_824;
5202 let total_gb = total / 1_073_741_824;
5203 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5204 let bar_len = 20usize;
5205 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5206 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5207 let warn = if free_gb < 5 {
5208 " [!] CRITICALLY LOW"
5209 } else if free_gb < 15 {
5210 " [-] LOW"
5211 } else {
5212 ""
5213 };
5214 let _ = writeln!(out,
5215 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}"
5216 );
5217 drive_count += 1;
5218 }
5219 }
5220 if drive_count == 0 {
5221 out.push_str(" (could not enumerate drives)\n");
5222 }
5223 }
5224 Err(e) => {
5225 let _ = writeln!(out, " (drive scan failed: {e})");
5226 }
5227 }
5228
5229 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5231 match Command::new("powershell")
5232 .args(["-NoProfile", "-Command", latency_script])
5233 .output()
5234 {
5235 Ok(o) => {
5236 out.push_str("\nReal-time Disk Intensity:\n");
5237 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5238 if !text.is_empty() {
5239 let _ = writeln!(out, " Average Disk Queue Length: {text}");
5240 if let Ok(q) = text.parse::<f64>() {
5241 if q > 2.0 {
5242 out.push_str(
5243 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5244 );
5245 } else {
5246 out.push_str(" [~] Disk latency is within healthy bounds.\n");
5247 }
5248 }
5249 } else {
5250 out.push_str(" Average Disk Queue Length: unavailable\n");
5251 }
5252 }
5253 Err(_) => {
5254 out.push_str("\nReal-time Disk Intensity:\n");
5255 out.push_str(" Average Disk Queue Length: unavailable\n");
5256 }
5257 }
5258 }
5259
5260 #[cfg(not(target_os = "windows"))]
5261 {
5262 match Command::new("df")
5263 .args(["-h", "--output=target,size,avail,pcent"])
5264 .output()
5265 {
5266 Ok(o) => {
5267 let text = String::from_utf8_lossy(&o.stdout);
5268 let mut count = 0usize;
5269 for line in text.lines().skip(1) {
5270 let mut it = line.split_whitespace();
5271 if let (Some(fs), Some(size), Some(avail), Some(used)) =
5272 (it.next(), it.next(), it.next(), it.next())
5273 {
5274 if !fs.starts_with("tmpfs") {
5275 let _ = write!(
5276 out,
5277 " {} size: {} avail: {} used: {}\n",
5278 fs, size, avail, used
5279 );
5280 count += 1;
5281 if count >= max_entries {
5282 break;
5283 }
5284 }
5285 }
5286 }
5287 }
5288 Err(e) => {
5289 let _ = write!(out, " (df failed: {e})\n");
5290 }
5291 }
5292 }
5293
5294 out.push_str("\nLarge developer cache directories (if present):\n");
5296
5297 #[cfg(target_os = "windows")]
5298 {
5299 let home = std::env::var("USERPROFILE").unwrap_or_default();
5300 let check_dirs: &[(&str, &str)] = &[
5301 ("Temp", r"AppData\Local\Temp"),
5302 ("npm cache", r"AppData\Roaming\npm-cache"),
5303 ("Cargo registry", r".cargo\registry"),
5304 ("Cargo git", r".cargo\git"),
5305 ("pip cache", r"AppData\Local\pip\cache"),
5306 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5307 (".rustup toolchains", r".rustup\toolchains"),
5308 ("node_modules (home)", r"node_modules"),
5309 ];
5310
5311 let mut found_any = false;
5312 for (label, rel) in check_dirs {
5313 let full = format!(r"{}\{}", home, rel);
5314 let path = std::path::Path::new(&full);
5315 if path.exists() {
5316 let size_script = format!(
5318 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5319 full.replace('\'', "''")
5320 );
5321 let size_mb = Command::new("powershell")
5322 .args(["-NoProfile", "-Command", &size_script])
5323 .output()
5324 .ok()
5325 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5326 .unwrap_or_else(|| "?".to_string());
5327 let _ = writeln!(out, " {label}: {size_mb} MB ({full})");
5328 found_any = true;
5329 }
5330 }
5331 if !found_any {
5332 out.push_str(" (none of the common cache directories found)\n");
5333 }
5334
5335 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5336 }
5337
5338 #[cfg(not(target_os = "windows"))]
5339 {
5340 let home = std::env::var("HOME").unwrap_or_default();
5341 let check_dirs: &[(&str, &str)] = &[
5342 ("npm cache", ".npm"),
5343 ("Cargo registry", ".cargo/registry"),
5344 ("pip cache", ".cache/pip"),
5345 (".rustup toolchains", ".rustup/toolchains"),
5346 ("Yarn cache", ".cache/yarn"),
5347 ];
5348 let mut found_any = false;
5349 for (label, rel) in check_dirs {
5350 let full = format!("{}/{}", home, rel);
5351 if std::path::Path::new(&full).exists() {
5352 let size = Command::new("du")
5353 .args(["-sh", &full])
5354 .output()
5355 .ok()
5356 .map(|o| {
5357 let s = String::from_utf8_lossy(&o.stdout);
5358 s.split_whitespace().next().unwrap_or("?").to_string()
5359 })
5360 .unwrap_or_else(|| "?".to_string());
5361 let _ = write!(out, " {label}: {size} ({full})\n");
5362 found_any = true;
5363 }
5364 }
5365 if !found_any {
5366 out.push_str(" (none of the common cache directories found)\n");
5367 }
5368 }
5369
5370 Ok(out.trim_end().to_string())
5371}
5372
5373fn inspect_storage_deep() -> Result<String, String> {
5376 let mut out = String::from("Host inspection: storage_deep\n\n");
5377 out.push_str(
5378 "Deep storage analysis — scanning drives, top directories, and dev artifact caches.\n\n",
5379 );
5380
5381 #[cfg(target_os = "windows")]
5382 {
5383 let script = r#"
5384# Fast FSO folder sizer
5385function sz($p) {
5386 try { [long](New-Object -ComObject Scripting.FileSystemObject).GetFolder($p).Size }
5387 catch { -1L }
5388}
5389
5390# ── Section 1: Drive overview ──
5391$drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue |
5392 Where-Object { $_.Used -ne $null -and ($_.Used + $_.Free) -gt 0 }
5393foreach ($d in $drives) {
5394 $total = $d.Used + $d.Free
5395 $pct = [math]::Round($d.Used / $total * 100, 1)
5396 "DRIVE|$($d.Root)|$($d.Used)|$($d.Free)|$total|$pct"
5397}
5398"---END_DRIVES---"
5399
5400# ── Section 2: Known large user paths ──
5401$H = $env:USERPROFILE
5402$knownPaths = @(
5403 @{ l="Downloads"; p="$H\Downloads"; hint="Review — delete old files or move to external drive" },
5404 @{ l="Videos"; p="$H\Videos"; hint="Review — move to external drive or NAS" },
5405 @{ l="Documents"; p="$H\Documents"; hint="Review for large files" },
5406 @{ l="Pictures"; p="$H\Pictures"; hint="Review for duplicates" },
5407 @{ l="Desktop"; p="$H\Desktop"; hint="Move large files off desktop" },
5408 @{ l="User Temp"; p="$H\AppData\Local\Temp"; hint="Safe to delete — clear with: cleanmgr /sageset:1" },
5409 @{ l="Windows Temp"; p="C:\Windows\Temp"; hint="Safe to delete (close apps first)" },
5410 @{ l="WU Download Cache"; p="C:\Windows\SoftwareDistribution\Download"; hint="Safe after reboot: net stop wuauserv, delete contents, net start wuauserv" },
5411 @{ l="IE/Edge Cache"; p="$H\AppData\Local\Microsoft\Windows\INetCache"; hint="Clear in browser settings" },
5412 @{ l="Chrome Cache"; p="$H\AppData\Local\Google\Chrome\User Data\Default\Cache"; hint="Clear in Chrome: chrome://settings/clearBrowserData" },
5413 @{ l="Edge Cache"; p="$H\AppData\Local\Microsoft\Edge\User Data\Default\Cache"; hint="Clear in Edge: edge://settings/clearBrowserData" },
5414 @{ l="Teams Cache (classic)"; p="$H\AppData\Roaming\Microsoft\Teams"; hint="Safe to clear — close Teams first, delete contents of Cache/ and blob_storage/" },
5415 @{ l="Teams Cache (new)"; p="$H\AppData\Local\Packages\MSTeams_8wekyb3d8bbwe\LocalCache"; hint="Close new Teams, then clear LocalCache" },
5416 @{ l="Cargo Registry"; p="$H\.cargo\registry"; hint="cargo cache --autoclean OR delete registry\src\* to reclaim" },
5417 @{ l="Cargo Git"; p="$H\.cargo\git"; hint="cargo cache --autoclean" },
5418 @{ l="Rustup Toolchains"; p="$H\.rustup\toolchains"; hint="rustup toolchain remove <old-version> to prune" },
5419 @{ l="npm Cache"; p="$H\AppData\Roaming\npm-cache"; hint="npm cache clean --force" },
5420 @{ l="Yarn Cache"; p="$H\AppData\Local\Yarn\Cache"; hint="yarn cache clean" },
5421 @{ l="pip Cache"; p="$H\AppData\Local\pip\cache"; hint="pip cache purge" },
5422 @{ l="Gradle Cache"; p="$H\.gradle\caches"; hint="gradle cleanBuildCache OR delete .gradle\caches" },
5423 @{ l="Maven Repository"; p="$H\.m2\repository"; hint="mvn dependency:purge-local-repository" },
5424 @{ l="NuGet Packages"; p="$H\.nuget\packages"; hint="dotnet nuget locals all --clear" },
5425 @{ l="Docker Desktop Disk Image"; p="$H\AppData\Local\Docker\wsl\data"; hint="docker system prune -a to reclaim unused images/containers" },
5426 @{ l="WSL ext4 VHD"; p="$H\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu_79rhkp1fndgsc\LocalState"; hint="Compact with: wsl --shutdown; Optimize-VHD" }
5427)
5428
5429foreach ($item in $knownPaths) {
5430 if (Test-Path $item.p -ErrorAction SilentlyContinue) {
5431 $bytes = sz $item.p
5432 if ($bytes -gt 1MB) {
5433 "PATH|$($item.l)|$($item.p)|$bytes|$($item.hint)"
5434 }
5435 }
5436}
5437"---END_PATHS---"
5438
5439# ── Section 3: Dev artifact discovery ──
5440$devRoots = @(
5441 "$env:USERPROFILE\source", "$env:USERPROFILE\repos", "$env:USERPROFILE\projects",
5442 "$env:USERPROFILE\dev", "$env:USERPROFILE\code", "$env:USERPROFILE\Desktop",
5443 "$env:USERPROFILE\Documents", "C:\source", "C:\repos", "C:\projects", "C:\dev", "C:\code"
5444) | Where-Object { Test-Path $_ -ErrorAction SilentlyContinue }
5445
5446$artDefs = @(
5447 @{ filter="node_modules"; label="node_modules (JS/TS)"; fix="Delete folder then run: npm install" },
5448 @{ filter="target"; label="target/ (Rust build)"; fix="cargo clean (rebuilds on next cargo build)" },
5449 @{ filter=".venv"; label=".venv (Python venv)"; fix="Delete and recreate: python -m venv .venv" },
5450 @{ filter="venv"; label="venv (Python venv)"; fix="Delete and recreate: python -m venv venv" },
5451 @{ filter="dist"; label="dist/ (build output)"; fix="Delete — regenerated by your build tool" },
5452 @{ filter=".next"; label=".next (Next.js cache)"; fix="Delete — regenerated by next build" },
5453 @{ filter=".nuxt"; label=".nuxt (Nuxt cache)"; fix="Delete — regenerated by nuxt build" }
5454)
5455
5456foreach ($root in $devRoots) {
5457 foreach ($art in $artDefs) {
5458 $dirs = Get-ChildItem -Path $root -Recurse -Directory -Depth 5 `
5459 -Filter $art.filter -ErrorAction SilentlyContinue |
5460 Select-Object -First 40
5461 foreach ($dir in $dirs) {
5462 $bytes = sz $dir.FullName
5463 if ($bytes -gt 5MB) {
5464 "ARTIFACT|$($art.label)|$($dir.FullName)|$bytes|$($art.fix)"
5465 }
5466 }
5467 }
5468}
5469"---END_ARTIFACTS---"
5470"#;
5471
5472 match Command::new("powershell")
5473 .args(["-NoProfile", "-Command", script])
5474 .output()
5475 {
5476 Ok(o) => {
5477 let raw = String::from_utf8_lossy(&o.stdout);
5478 let sections: Vec<&str> = raw.split("---END_").collect();
5479
5480 let mut drive_lines: Vec<String> = Vec::new();
5482 if let Some(sec) = sections.first() {
5483 for line in sec.lines() {
5484 if !line.starts_with("DRIVE|") {
5485 continue;
5486 }
5487 let mut it = line.splitn(7, '|');
5488 it.next(); if let (Some(root), Some(used_s), Some(free_s), Some(total_s), Some(_pct)) =
5490 (it.next(), it.next(), it.next(), it.next(), it.next())
5491 {
5492 let used: u64 = used_s.trim().parse().unwrap_or(0);
5493 let free: u64 = free_s.trim().parse().unwrap_or(0);
5494 let total: u64 = total_s.trim().parse().unwrap_or(0);
5495 if total == 0 {
5496 continue;
5497 }
5498 let pct = (used as f64 / total as f64 * 100.0) as u64;
5499 let bar_len = 28usize;
5500 let filled = (pct as usize * bar_len / 100).min(bar_len);
5501 let bar = "#".repeat(filled) + &".".repeat(bar_len - filled);
5502 let warn = if free < 5_368_709_120 {
5503 " [!] CRITICALLY LOW"
5504 } else if free < 16_106_127_360 {
5505 " [-] LOW"
5506 } else {
5507 ""
5508 };
5509 drive_lines.push(format!(
5510 " {root} [{bar}] {pct}% used — {} free of {}{}",
5511 human_bytes(free),
5512 human_bytes(total),
5513 warn
5514 ));
5515 }
5516 }
5517 }
5518
5519 struct PathEntry {
5521 label: String,
5522 bytes: u64,
5523 path: String,
5524 hint: String,
5525 }
5526 let mut path_entries: Vec<PathEntry> = Vec::new();
5527 if let Some(sec) = sections.get(1) {
5528 for line in sec.lines() {
5529 if !line.starts_with("PATH|") {
5530 continue;
5531 }
5532 let mut it = line.splitn(6, '|');
5533 it.next();
5534 if let (Some(label), Some(path), Some(bytes_s), Some(hint)) =
5535 (it.next(), it.next(), it.next(), it.next())
5536 {
5537 let bytes: u64 = bytes_s.trim().parse().unwrap_or(0);
5538 if bytes == 0 {
5539 continue;
5540 }
5541 path_entries.push(PathEntry {
5542 label: label.to_string(),
5543 bytes,
5544 path: path.to_string(),
5545 hint: hint.to_string(),
5546 });
5547 }
5548 }
5549 path_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes));
5550 }
5551
5552 struct ArtEntry {
5554 label: String,
5555 path: String,
5556 bytes: u64,
5557 fix: String,
5558 }
5559 let mut art_entries: Vec<ArtEntry> = Vec::new();
5560 if let Some(sec) = sections.get(2) {
5561 for line in sec.lines() {
5562 if !line.starts_with("ARTIFACT|") {
5563 continue;
5564 }
5565 let mut it = line.splitn(6, '|');
5566 it.next();
5567 if let (Some(label), Some(path), Some(bytes_s), Some(fix)) =
5568 (it.next(), it.next(), it.next(), it.next())
5569 {
5570 let bytes: u64 = bytes_s.trim().parse().unwrap_or(0);
5571 art_entries.push(ArtEntry {
5572 label: label.to_string(),
5573 path: path.to_string(),
5574 bytes,
5575 fix: fix.to_string(),
5576 });
5577 }
5578 }
5579 art_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes));
5580 }
5581
5582 out.push_str("Drives:\n");
5584 if drive_lines.is_empty() {
5585 out.push_str(" (could not enumerate drives)\n");
5586 } else {
5587 for line in &drive_lines {
5588 out.push_str(line);
5589 out.push('\n');
5590 }
5591 }
5592
5593 out.push_str("\nTop space consumers:\n");
5595 if path_entries.is_empty() {
5596 out.push_str(" (no large directories found in known locations)\n");
5597 } else {
5598 for e in path_entries.iter().take(20) {
5599 let _ = writeln!(
5600 out,
5601 " {:>8} {} ({})",
5602 human_bytes(e.bytes),
5603 e.label,
5604 e.path
5605 );
5606 }
5607 }
5608
5609 if !art_entries.is_empty() {
5611 let total_artifact_bytes: u64 = art_entries.iter().map(|a| a.bytes).sum();
5612 let _ = writeln!(
5613 out,
5614 "\nDev artifact caches found ({} total across {} directories):",
5615 human_bytes(total_artifact_bytes),
5616 art_entries.len()
5617 );
5618 for e in art_entries.iter().take(30) {
5619 let _ = writeln!(
5620 out,
5621 " {:>8} [{}] {}\n Fix: {}",
5622 human_bytes(e.bytes),
5623 e.label,
5624 e.path,
5625 e.fix
5626 );
5627 }
5628 } else {
5629 out.push_str(
5630 "\nDev artifact caches: none found in common project locations.\n",
5631 );
5632 }
5633
5634 out.push_str("\nFindings:\n");
5636 let mut findings: Vec<String> = Vec::new();
5637
5638 for line in &drive_lines {
5640 if line.contains("[!] CRITICALLY LOW") {
5641 let drive_name = line.trim().chars().take(3).collect::<String>();
5642 findings.push(format!(
5643 " [ACTION] Drive {} is critically low on space — immediate cleanup required.", drive_name
5644 ));
5645 } else if line.contains("[-] LOW") {
5646 let drive_name = line.trim().chars().take(3).collect::<String>();
5647 findings.push(format!(
5648 " [INVESTIGATE] Drive {} is low on space — review and clean up soon.",
5649 drive_name
5650 ));
5651 }
5652 }
5653
5654 if let Some(e) = path_entries
5656 .iter()
5657 .find(|e| e.label.contains("Temp") || e.label.contains("Cache"))
5658 {
5659 if e.bytes > 1_073_741_824 {
5660 findings.push(format!(
5661 " [ACTION] {} is using {} — {}",
5662 e.label,
5663 human_bytes(e.bytes),
5664 e.hint
5665 ));
5666 }
5667 }
5668
5669 if let Some(e) = path_entries
5671 .iter()
5672 .find(|e| e.label.contains("Teams Cache"))
5673 {
5674 if e.bytes > 536_870_912 {
5675 findings.push(format!(
5676 " [ACTION] {} using {} — {}",
5677 e.label,
5678 human_bytes(e.bytes),
5679 e.hint
5680 ));
5681 }
5682 }
5683
5684 if !art_entries.is_empty() {
5686 let total: u64 = art_entries.iter().map(|a| a.bytes).sum();
5687 if total > 1_073_741_824 {
5688 findings.push(format!(
5689 " [ACTION] Dev build artifacts total {} across {} directories — safe to delete and rebuild.",
5690 human_bytes(total), art_entries.len()
5691 ));
5692 }
5693 }
5694
5695 if let Some(e) = path_entries.iter().find(|e| e.label == "Downloads") {
5697 if e.bytes > 5_368_709_120 {
5698 findings.push(format!(
5699 " [MONITOR] Downloads folder is {} — review for large files to archive or delete.",
5700 human_bytes(e.bytes)
5701 ));
5702 }
5703 }
5704
5705 if findings.is_empty() {
5706 out.push_str(
5707 " Storage usage looks healthy — no major space issues detected.\n",
5708 );
5709 } else {
5710 for f in &findings {
5711 out.push_str(f);
5712 out.push('\n');
5713 }
5714 }
5715
5716 out.push_str("\nTip: ask the AI 'where did my disk space go?' or 'help me clean up my C drive' for a guided cleanup plan.\n");
5717 }
5718 Err(e) => {
5719 let _ = writeln!(out, "(storage deep scan failed: {e})");
5720 }
5721 }
5722 }
5723
5724 #[cfg(not(target_os = "windows"))]
5725 {
5726 let home = std::env::var("HOME").unwrap_or_default();
5727 out.push_str("Drives:\n");
5729 if let Ok(o) = Command::new("df")
5730 .args(["-h", "--output=target,size,avail,pcent"])
5731 .output()
5732 {
5733 for line in String::from_utf8_lossy(&o.stdout).lines().skip(1) {
5734 let mut it = line.split_whitespace();
5735 if let (Some(fs), Some(sz), Some(av), Some(pct)) =
5736 (it.next(), it.next(), it.next(), it.next())
5737 {
5738 if !fs.starts_with("tmpfs") {
5739 let _ = writeln!(out, " {fs} size: {sz} avail: {av} used: {pct}");
5740 }
5741 }
5742 }
5743 }
5744
5745 out.push_str("\nTop space consumers:\n");
5747 let check: &[(&str, &str, &str)] = &[
5748 ("Downloads", "Downloads", "review for large files"),
5749 ("npm Cache", ".npm", "npm cache clean --force"),
5750 (
5751 "Cargo Registry",
5752 ".cargo/registry",
5753 "cargo cache --autoclean",
5754 ),
5755 ("Cargo Git", ".cargo/git", "cargo cache --autoclean"),
5756 ("pip Cache", ".cache/pip", "pip cache purge"),
5757 (
5758 "Rustup",
5759 ".rustup/toolchains",
5760 "rustup toolchain remove <old>",
5761 ),
5762 ("Gradle", ".gradle/caches", "gradle cleanBuildCache"),
5763 (
5764 "Maven",
5765 ".m2/repository",
5766 "mvn dependency:purge-local-repository",
5767 ),
5768 ];
5769 for (label, rel, hint) in check {
5770 let full = format!("{home}/{rel}");
5771 if std::path::Path::new(&full).exists() {
5772 if let Ok(o) = Command::new("du").args(["-sh", &full]).output() {
5773 let sz = String::from_utf8_lossy(&o.stdout);
5774 let sz = sz.split_whitespace().next().unwrap_or("?");
5775 let _ = writeln!(out, " {sz:>8} {label} ({full}) — {hint}");
5776 }
5777 }
5778 }
5779
5780 out.push_str("\nDev artifact caches:\n");
5782 for pattern in &["node_modules", "target", ".venv", "venv", "dist", ".next"] {
5783 if let Ok(o) = Command::new("find")
5784 .args([&home, "-name", pattern, "-maxdepth", "7", "-type", "d"])
5785 .output()
5786 {
5787 for dir in String::from_utf8_lossy(&o.stdout).lines().take(20) {
5788 if let Ok(o2) = Command::new("du").args(["-sh", dir]).output() {
5789 let sz = String::from_utf8_lossy(&o2.stdout);
5790 let sz = sz.split_whitespace().next().unwrap_or("?");
5791 let _ = writeln!(out, " {sz:>8} [{pattern}] {dir}");
5792 }
5793 }
5794 }
5795 }
5796 }
5797
5798 Ok(out.trim_end().to_string())
5799}
5800
5801fn inspect_hardware() -> Result<String, String> {
5804 let mut out = String::from("Host inspection: hardware\n\n");
5805
5806 #[cfg(target_os = "windows")]
5807 {
5808 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5810 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5811} | Select-Object -First 1"#;
5812 if let Ok(o) = Command::new("powershell")
5813 .args(["-NoProfile", "-Command", cpu_script])
5814 .output()
5815 {
5816 let text = String::from_utf8_lossy(&o.stdout);
5817 let text = text.trim();
5818 let mut it = text.splitn(5, '|');
5819 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5820 (it.next(), it.next(), it.next(), it.next())
5821 {
5822 let _ = write!(
5823 out,
5824 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
5825 p0,
5826 p1,
5827 p2,
5828 p3.parse::<f32>().unwrap_or(0.0)
5829 );
5830 } else {
5831 let _ = write!(out, "CPU: {text}\n\n");
5832 }
5833 }
5834
5835 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5837$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5838$speed = ($sticks | Select-Object -First 1).Speed
5839"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5840 if let Ok(o) = Command::new("powershell")
5841 .args(["-NoProfile", "-Command", ram_script])
5842 .output()
5843 {
5844 let text = String::from_utf8_lossy(&o.stdout);
5845 let _ = write!(out, "RAM: {}\n\n", text.trim().trim_matches('"'));
5846 }
5847
5848 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5850 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5851}"#;
5852 if let Ok(o) = Command::new("powershell")
5853 .args(["-NoProfile", "-Command", gpu_script])
5854 .output()
5855 {
5856 let text = String::from_utf8_lossy(&o.stdout);
5857 let lines: Vec<&str> = text.lines().collect();
5858 if !lines.is_empty() {
5859 out.push_str("GPU(s):\n");
5860 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5861 let mut it = line.trim().splitn(4, '|');
5862 if let (Some(p0), Some(p1), Some(p2)) = (it.next(), it.next(), it.next()) {
5863 let res = if p2 == "x" || p2.starts_with('0') {
5864 String::new()
5865 } else {
5866 format!(" — {}@display", p2)
5867 };
5868 let _ = write!(out, " {}\n Driver: {}{}\n", p0, p1, res);
5869 } else {
5870 let _ = writeln!(out, " {}", line.trim());
5871 }
5872 }
5873 out.push('\n');
5874 }
5875 }
5876
5877 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5879$bios = Get-CimInstance Win32_BIOS
5880$cs = Get-CimInstance Win32_ComputerSystem
5881$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5882$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5883"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5884 if let Ok(o) = Command::new("powershell")
5885 .args(["-NoProfile", "-Command", mb_script])
5886 .output()
5887 {
5888 let text = String::from_utf8_lossy(&o.stdout);
5889 let text = text.trim().trim_matches('"');
5890 let mut it = text.splitn(5, '|');
5891 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5892 (it.next(), it.next(), it.next(), it.next())
5893 {
5894 let _ = write!(
5895 out,
5896 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5897 p0.trim(),
5898 p1.trim(),
5899 p2.trim(),
5900 p3.trim()
5901 );
5902 }
5903 }
5904
5905 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5907 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5908}"#;
5909 if let Ok(o) = Command::new("powershell")
5910 .args(["-NoProfile", "-Command", disp_script])
5911 .output()
5912 {
5913 let text = String::from_utf8_lossy(&o.stdout);
5914 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5915 if !lines.is_empty() {
5916 out.push_str("Display(s):\n");
5917 for line in &lines {
5918 let mut it = line.trim().splitn(3, '|');
5919 if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
5920 let _ = writeln!(out, " {} — {}", p0.trim(), p1);
5921 }
5922 }
5923 }
5924 }
5925 }
5926
5927 #[cfg(not(target_os = "windows"))]
5928 {
5929 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5931 let model = content
5932 .lines()
5933 .find(|l| l.starts_with("model name"))
5934 .and_then(|l| l.split(':').nth(1))
5935 .map(str::trim)
5936 .unwrap_or("unknown");
5937 let cores = content
5938 .lines()
5939 .filter(|l| l.starts_with("processor"))
5940 .count();
5941 let _ = write!(out, "CPU: {model}\n {cores} logical processors\n\n");
5942 }
5943
5944 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5946 let total_kb: u64 = content
5947 .lines()
5948 .find(|l| l.starts_with("MemTotal:"))
5949 .and_then(|l| l.split_whitespace().nth(1))
5950 .and_then(|v| v.parse().ok())
5951 .unwrap_or(0);
5952 let total_gb = total_kb / 1_048_576;
5953 let _ = write!(out, "RAM: {total_gb} GB total\n\n");
5954 }
5955
5956 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5958 let text = String::from_utf8_lossy(&o.stdout);
5959 let gpu_lines: Vec<&str> = text
5960 .lines()
5961 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5962 .collect();
5963 if !gpu_lines.is_empty() {
5964 out.push_str("GPU(s):\n");
5965 for l in gpu_lines {
5966 let _ = write!(out, " {l}\n");
5967 }
5968 out.push('\n');
5969 }
5970 }
5971
5972 if let Ok(o) = Command::new("dmidecode")
5974 .args(["-t", "baseboard", "-t", "bios"])
5975 .output()
5976 {
5977 let text = String::from_utf8_lossy(&o.stdout);
5978 out.push_str("Motherboard/BIOS:\n");
5979 for line in text
5980 .lines()
5981 .filter(|l| {
5982 l.contains("Manufacturer:")
5983 || l.contains("Product Name:")
5984 || l.contains("Version:")
5985 })
5986 .take(6)
5987 {
5988 let _ = write!(out, " {}\n", line.trim());
5989 }
5990 }
5991 }
5992
5993 Ok(out.trim_end().to_string())
5994}
5995
5996fn inspect_updates() -> Result<String, String> {
5999 let mut out = String::from("Host inspection: updates\n\n");
6000
6001 #[cfg(target_os = "windows")]
6002 {
6003 let script = r#"
6005try {
6006 $sess = New-Object -ComObject Microsoft.Update.Session
6007 $searcher = $sess.CreateUpdateSearcher()
6008 $count = $searcher.GetTotalHistoryCount()
6009 if ($count -gt 0) {
6010 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
6011 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
6012 } else { "NONE|LAST_INSTALL" }
6013} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
6014"#;
6015 if let Ok(o) = Command::new("powershell")
6016 .args(["-NoProfile", "-Command", script])
6017 .output()
6018 {
6019 let raw = String::from_utf8_lossy(&o.stdout);
6020 let text = raw.trim();
6021 if text.starts_with("ERROR:") {
6022 out.push_str("Last update install: (unable to query)\n");
6023 } else if text.contains("NONE") {
6024 out.push_str("Last update install: No update history found\n");
6025 } else {
6026 let date = text.replace("|LAST_INSTALL", "");
6027 let _ = writeln!(out, "Last update install: {date}");
6028 }
6029 }
6030
6031 let pending_script = r#"
6033try {
6034 $sess = New-Object -ComObject Microsoft.Update.Session
6035 $searcher = $sess.CreateUpdateSearcher()
6036 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
6037 $results.Updates.Count.ToString() + "|PENDING"
6038} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
6039"#;
6040 if let Ok(o) = Command::new("powershell")
6041 .args(["-NoProfile", "-Command", pending_script])
6042 .output()
6043 {
6044 let raw = String::from_utf8_lossy(&o.stdout);
6045 let text = raw.trim();
6046 if text.starts_with("ERROR:") {
6047 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
6048 } else {
6049 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
6050 if count == 0 {
6051 out.push_str("Pending updates: Up to date — no updates waiting\n");
6052 } else if count > 0 {
6053 let _ = writeln!(out, "Pending updates: {count} update(s) available");
6054 out.push_str(
6055 " → Open Windows Update (Settings > Windows Update) to install\n",
6056 );
6057 }
6058 }
6059 }
6060
6061 let svc_script = r#"
6063$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
6064if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
6065"#;
6066 if let Ok(o) = Command::new("powershell")
6067 .args(["-NoProfile", "-Command", svc_script])
6068 .output()
6069 {
6070 let raw = String::from_utf8_lossy(&o.stdout);
6071 let status = raw.trim();
6072 let _ = writeln!(out, "Windows Update service: {status}");
6073 }
6074 }
6075
6076 #[cfg(not(target_os = "windows"))]
6077 {
6078 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
6079 let mut found = false;
6080 if let Ok(o) = apt_out {
6081 let text = String::from_utf8_lossy(&o.stdout);
6082 let lines: Vec<&str> = text
6083 .lines()
6084 .filter(|l| l.contains('/') && !l.contains("Listing"))
6085 .collect();
6086 if !lines.is_empty() {
6087 let _ = write!(out, "{} package(s) can be upgraded (apt)\n", lines.len());
6088 out.push_str(" → Run: sudo apt upgrade\n");
6089 found = true;
6090 }
6091 }
6092 if !found {
6093 if let Ok(o) = Command::new("dnf")
6094 .args(["check-update", "--quiet"])
6095 .output()
6096 {
6097 let text = String::from_utf8_lossy(&o.stdout);
6098 let count = text
6099 .lines()
6100 .filter(|l| !l.is_empty() && !l.starts_with('!'))
6101 .count();
6102 if count > 0 {
6103 let _ = write!(out, "{count} package(s) can be upgraded (dnf)\n");
6104 out.push_str(" → Run: sudo dnf upgrade\n");
6105 } else {
6106 out.push_str("System is up to date.\n");
6107 }
6108 } else {
6109 out.push_str("Could not query package manager for updates.\n");
6110 }
6111 }
6112 }
6113
6114 Ok(out.trim_end().to_string())
6115}
6116
6117fn inspect_security() -> Result<String, String> {
6120 let mut out = String::from("Host inspection: security\n\n");
6121
6122 #[cfg(target_os = "windows")]
6123 {
6124 let defender_script = r#"
6126try {
6127 $status = Get-MpComputerStatus -ErrorAction Stop
6128 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
6129} catch { "ERROR:" + $_.Exception.Message }
6130"#;
6131 if let Ok(o) = Command::new("powershell")
6132 .args(["-NoProfile", "-Command", defender_script])
6133 .output()
6134 {
6135 let raw = String::from_utf8_lossy(&o.stdout);
6136 let text = raw.trim();
6137 if text.starts_with("ERROR:") {
6138 let _ = writeln!(out, "Windows Defender: unable to query — {text}");
6139 } else {
6140 let get = |key: &str| -> String {
6141 text.split('|')
6142 .find(|s| s.starts_with(key))
6143 .and_then(|s| s.split_once(':').map(|x| x.1))
6144 .unwrap_or("unknown")
6145 .to_string()
6146 };
6147 let rtp = get("RTP");
6148 let last_scan = {
6149 text.split('|')
6151 .find(|s| s.starts_with("SCAN:"))
6152 .and_then(|s| s.get(5..))
6153 .unwrap_or("unknown")
6154 .to_string()
6155 };
6156 let def_ver = get("VER");
6157 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
6158
6159 let rtp_label = if rtp == "True" {
6160 "ENABLED"
6161 } else {
6162 "DISABLED [!]"
6163 };
6164 let _ = writeln!(out, "Windows Defender real-time protection: {rtp_label}");
6165 let _ = writeln!(out, "Last quick scan: {last_scan}");
6166 let _ = writeln!(out, "Signature version: {def_ver}");
6167 if age_days >= 0 {
6168 let freshness = if age_days == 0 {
6169 "up to date".to_string()
6170 } else if age_days <= 3 {
6171 format!("{age_days} day(s) old — OK")
6172 } else if age_days <= 7 {
6173 format!("{age_days} day(s) old — consider updating")
6174 } else {
6175 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
6176 };
6177 let _ = writeln!(out, "Signature age: {freshness}");
6178 }
6179 if rtp != "True" {
6180 out.push_str(
6181 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
6182 );
6183 out.push_str(
6184 " → Open Windows Security > Virus & threat protection to re-enable.\n",
6185 );
6186 }
6187 }
6188 }
6189
6190 out.push('\n');
6191
6192 let fw_script = r#"
6194try {
6195 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
6196} catch { "ERROR:" + $_.Exception.Message }
6197"#;
6198 if let Ok(o) = Command::new("powershell")
6199 .args(["-NoProfile", "-Command", fw_script])
6200 .output()
6201 {
6202 let raw = String::from_utf8_lossy(&o.stdout);
6203 let text = raw.trim();
6204 if !text.starts_with("ERROR:") && !text.is_empty() {
6205 out.push_str("Windows Firewall:\n");
6206 for line in text.lines() {
6207 if let Some((name, enabled)) = line.split_once(':') {
6208 let state = if enabled.trim() == "True" {
6209 "ON"
6210 } else {
6211 "OFF [!]"
6212 };
6213 let _ = writeln!(out, " {name}: {state}");
6214 }
6215 }
6216 out.push('\n');
6217 }
6218 }
6219
6220 let act_script = r#"
6222try {
6223 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
6224 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
6225} catch { "UNKNOWN" }
6226"#;
6227 if let Ok(o) = Command::new("powershell")
6228 .args(["-NoProfile", "-Command", act_script])
6229 .output()
6230 {
6231 let raw = String::from_utf8_lossy(&o.stdout);
6232 match raw.trim() {
6233 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
6234 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
6235 _ => out.push_str("Windows activation: Unable to determine\n"),
6236 }
6237 }
6238
6239 let uac_script = r#"
6241$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
6242if ($val -eq 1) { "ON" } else { "OFF" }
6243"#;
6244 if let Ok(o) = Command::new("powershell")
6245 .args(["-NoProfile", "-Command", uac_script])
6246 .output()
6247 {
6248 let raw = String::from_utf8_lossy(&o.stdout);
6249 let state = raw.trim();
6250 let label = if state == "ON" {
6251 "Enabled"
6252 } else {
6253 "DISABLED [!] — recommended to re-enable via secpol.msc"
6254 };
6255 let _ = writeln!(out, "UAC (User Account Control): {label}");
6256 }
6257 }
6258
6259 #[cfg(not(target_os = "windows"))]
6260 {
6261 if let Ok(o) = Command::new("ufw").arg("status").output() {
6262 let text = String::from_utf8_lossy(&o.stdout);
6263 let _ = write!(out, "UFW: {}\n", text.lines().next().unwrap_or("unknown"));
6264 }
6265 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
6266 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
6267 let _ = write!(out, "{line}\n");
6268 }
6269 }
6270 }
6271
6272 Ok(out.trim_end().to_string())
6273}
6274
6275fn inspect_pending_reboot() -> Result<String, String> {
6278 let mut out = String::from("Host inspection: pending_reboot\n\n");
6279
6280 #[cfg(target_os = "windows")]
6281 {
6282 let script = r#"
6283$reasons = @()
6284if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
6285 $reasons += "Windows Update requires a restart"
6286}
6287if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
6288 $reasons += "Windows component install/update requires a restart"
6289}
6290$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
6291if ($pfro -and $pfro.PendingFileRenameOperations) {
6292 $reasons += "Pending file rename operations (driver or system file replacement)"
6293}
6294if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
6295"#;
6296 let output = Command::new("powershell")
6297 .args(["-NoProfile", "-Command", script])
6298 .output()
6299 .map_err(|e| format!("pending_reboot: {e}"))?;
6300
6301 let raw = String::from_utf8_lossy(&output.stdout);
6302 let text = raw.trim();
6303
6304 if text == "NO_REBOOT_NEEDED" {
6305 out.push_str("No restart required — system is up to date and stable.\n");
6306 } else if text.is_empty() {
6307 out.push_str("Could not determine reboot status.\n");
6308 } else {
6309 out.push_str("[!] A system restart is pending:\n\n");
6310 for reason in text.split("|REASON|") {
6311 let _ = writeln!(out, " • {}", reason.trim());
6312 }
6313 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
6314 }
6315 }
6316
6317 #[cfg(not(target_os = "windows"))]
6318 {
6319 if std::path::Path::new("/var/run/reboot-required").exists() {
6320 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
6321 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
6322 out.push_str("Packages requiring restart:\n");
6323 for p in pkgs.lines().take(10) {
6324 let _ = write!(out, " • {p}\n");
6325 }
6326 }
6327 } else {
6328 out.push_str("No restart required.\n");
6329 }
6330 }
6331
6332 Ok(out.trim_end().to_string())
6333}
6334
6335fn inspect_disk_health() -> Result<String, String> {
6338 let mut out = String::from("Host inspection: disk_health\n\n");
6339
6340 #[cfg(target_os = "windows")]
6341 {
6342 let script = r#"
6343try {
6344 $disks = Get-PhysicalDisk -ErrorAction Stop
6345 foreach ($d in $disks) {
6346 $size_gb = [math]::Round($d.Size / 1GB, 0)
6347 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
6348 }
6349} catch { "ERROR:" + $_.Exception.Message }
6350"#;
6351 let output = Command::new("powershell")
6352 .args(["-NoProfile", "-Command", script])
6353 .output()
6354 .map_err(|e| format!("disk_health: {e}"))?;
6355
6356 let raw = String::from_utf8_lossy(&output.stdout);
6357 let text = raw.trim();
6358
6359 if text.starts_with("ERROR:") {
6360 let _ = writeln!(out, "Unable to query disk health: {text}");
6361 out.push_str("This may require running as administrator.\n");
6362 } else if text.is_empty() {
6363 out.push_str("No physical disks found.\n");
6364 } else {
6365 out.push_str("Physical Drive Health:\n\n");
6366 for line in text.lines() {
6367 let mut it = line.splitn(5, '|');
6368 if let (Some(name), Some(media), Some(size), Some(health)) =
6369 (it.next(), it.next(), it.next(), it.next())
6370 {
6371 let op_status = it.next().unwrap_or("");
6372 let health_label = match health.trim() {
6373 "Healthy" => "OK",
6374 "Warning" => "[!] WARNING",
6375 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
6376 other => other,
6377 };
6378 let _ = writeln!(out, " {name}");
6379 let _ = writeln!(out, " Type: {media} | Size: {size}");
6380 let _ = writeln!(out, " Health: {health_label}");
6381 if !op_status.is_empty() {
6382 let _ = writeln!(out, " Status: {op_status}");
6383 }
6384 out.push('\n');
6385 }
6386 }
6387 }
6388
6389 let smart_script = r#"
6391try {
6392 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
6393 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
6394} catch { "" }
6395"#;
6396 if let Ok(o) = Command::new("powershell")
6397 .args(["-NoProfile", "-Command", smart_script])
6398 .output()
6399 {
6400 let raw2 = String::from_utf8_lossy(&o.stdout);
6401 let text2 = raw2.trim();
6402 if !text2.is_empty() {
6403 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
6404 if failures.is_empty() {
6405 out.push_str("SMART failure prediction: No failures predicted\n");
6406 } else {
6407 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
6408 for f in failures {
6409 let name = f.split('|').next().unwrap_or(f);
6410 let _ = writeln!(out, " • {name}");
6411 }
6412 out.push_str(
6413 "\nBack up your data immediately and replace the failing drive.\n",
6414 );
6415 }
6416 }
6417 }
6418 }
6419
6420 #[cfg(not(target_os = "windows"))]
6421 {
6422 if let Ok(o) = Command::new("lsblk")
6423 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
6424 .output()
6425 {
6426 let text = String::from_utf8_lossy(&o.stdout);
6427 out.push_str("Block devices:\n");
6428 out.push_str(text.trim());
6429 out.push('\n');
6430 }
6431 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
6432 let devices = String::from_utf8_lossy(&scan.stdout);
6433 for dev_line in devices.lines().take(4) {
6434 let dev = dev_line.split_whitespace().next().unwrap_or("");
6435 if dev.is_empty() {
6436 continue;
6437 }
6438 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
6439 let health = String::from_utf8_lossy(&o.stdout);
6440 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
6441 {
6442 let _ = write!(out, "{dev}: {}\n", line.trim());
6443 }
6444 }
6445 }
6446 } else {
6447 out.push_str("(install smartmontools for SMART health data)\n");
6448 }
6449 }
6450
6451 Ok(out.trim_end().to_string())
6452}
6453
6454fn inspect_battery() -> Result<String, String> {
6457 let mut out = String::from("Host inspection: battery\n\n");
6458
6459 #[cfg(target_os = "windows")]
6460 {
6461 let script = r#"
6462try {
6463 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
6464 if (-not $bats) { "NO_BATTERY"; exit }
6465
6466 # Modern Battery Health (Cycle count + Capacity health)
6467 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
6468 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
6469 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
6470
6471 foreach ($b in $bats) {
6472 $state = switch ($b.BatteryStatus) {
6473 1 { "Discharging" }
6474 2 { "AC Power (Fully Charged)" }
6475 3 { "AC Power (Charging)" }
6476 default { "Status $($b.BatteryStatus)" }
6477 }
6478
6479 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
6480 $health = if ($static -and $full) {
6481 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
6482 } else { "unknown" }
6483
6484 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
6485 }
6486} catch { "ERROR:" + $_.Exception.Message }
6487"#;
6488 let output = Command::new("powershell")
6489 .args(["-NoProfile", "-Command", script])
6490 .output()
6491 .map_err(|e| format!("battery: {e}"))?;
6492
6493 let raw = String::from_utf8_lossy(&output.stdout);
6494 let text = raw.trim();
6495
6496 if text == "NO_BATTERY" {
6497 out.push_str("No battery detected — desktop or AC-only system.\n");
6498 return Ok(out.trim_end().to_string());
6499 }
6500 if text.starts_with("ERROR:") {
6501 let _ = writeln!(out, "Unable to query battery: {text}");
6502 return Ok(out.trim_end().to_string());
6503 }
6504
6505 for line in text.lines() {
6506 let mut it = line.splitn(6, '|');
6507 if let (Some(name), Some(p1), Some(state), Some(cycles), Some(health)) =
6508 (it.next(), it.next(), it.next(), it.next(), it.next())
6509 {
6510 let charge: i64 = p1.parse().unwrap_or(-1);
6511
6512 let _ = writeln!(out, "Battery: {name}");
6513 if charge >= 0 {
6514 let bar_filled = (charge as usize * 20) / 100;
6515 let _ = writeln!(
6516 out,
6517 " Charge: [{}{}] {}%",
6518 "#".repeat(bar_filled),
6519 ".".repeat(20 - bar_filled),
6520 charge
6521 );
6522 }
6523 let _ = writeln!(out, " Status: {state}");
6524 let _ = writeln!(out, " Cycles: {cycles}");
6525 let _ = write!(out, " Health: {health}% (Actual vs Design Capacity)\n\n");
6526 }
6527 }
6528 }
6529
6530 #[cfg(not(target_os = "windows"))]
6531 {
6532 let power_path = std::path::Path::new("/sys/class/power_supply");
6533 let mut found = false;
6534 if power_path.exists() {
6535 if let Ok(entries) = std::fs::read_dir(power_path) {
6536 for entry in entries.flatten() {
6537 let p = entry.path();
6538 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6539 if t.trim() == "Battery" {
6540 found = true;
6541 let name = p
6542 .file_name()
6543 .unwrap_or_default()
6544 .to_string_lossy()
6545 .to_string();
6546 let _ = write!(out, "Battery: {name}\n");
6547 let read = |f: &str| {
6548 std::fs::read_to_string(p.join(f))
6549 .ok()
6550 .map(|s| s.trim().to_string())
6551 };
6552 if let Some(cap) = read("capacity") {
6553 let _ = write!(out, " Charge: {cap}%\n");
6554 }
6555 if let Some(status) = read("status") {
6556 let _ = write!(out, " Status: {status}\n");
6557 }
6558 if let (Some(full), Some(design)) =
6559 (read("energy_full"), read("energy_full_design"))
6560 {
6561 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6562 {
6563 if d > 0.0 {
6564 let _ = write!(
6565 out,
6566 " Wear level: {:.1}% of design capacity\n",
6567 (f / d) * 100.0
6568 );
6569 }
6570 }
6571 }
6572 }
6573 }
6574 }
6575 }
6576 }
6577 if !found {
6578 out.push_str("No battery found.\n");
6579 }
6580 }
6581
6582 Ok(out.trim_end().to_string())
6583}
6584
6585fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6588 let mut out = String::from("Host inspection: recent_crashes\n\n");
6589 let n = max_entries.clamp(1, 30);
6590
6591 #[cfg(target_os = "windows")]
6592 {
6593 let bsod_script = format!(
6595 r#"
6596try {{
6597 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6598 if ($events) {{
6599 $events | ForEach-Object {{
6600 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6601 }}
6602 }} else {{ "NO_BSOD" }}
6603}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6604 );
6605
6606 if let Ok(o) = Command::new("powershell")
6607 .args(["-NoProfile", "-Command", &bsod_script])
6608 .output()
6609 {
6610 let raw = String::from_utf8_lossy(&o.stdout);
6611 let text = raw.trim();
6612 if text == "NO_BSOD" {
6613 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6614 } else if text.starts_with("ERROR:") {
6615 out.push_str("System crashes: unable to query\n");
6616 } else {
6617 out.push_str("System crashes / unexpected shutdowns:\n");
6618 for line in text.lines() {
6619 let mut it = line.splitn(3, '|');
6620 if let (Some(time), Some(id), Some(msg)) = (it.next(), it.next(), it.next()) {
6621 let label = if id == "41" {
6622 "Unexpected shutdown"
6623 } else {
6624 "BSOD (BugCheck)"
6625 };
6626 let _ = writeln!(out, " [{time}] {label}: {msg}");
6627 }
6628 }
6629 out.push('\n');
6630 }
6631 }
6632
6633 let app_script = format!(
6635 r#"
6636try {{
6637 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6638 if ($crashes) {{
6639 $crashes | ForEach-Object {{
6640 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6641 }}
6642 }} else {{ "NO_CRASHES" }}
6643}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6644 );
6645
6646 if let Ok(o) = Command::new("powershell")
6647 .args(["-NoProfile", "-Command", &app_script])
6648 .output()
6649 {
6650 let raw = String::from_utf8_lossy(&o.stdout);
6651 let text = raw.trim();
6652 if text == "NO_CRASHES" {
6653 out.push_str("Application crashes: None in recent history\n");
6654 } else if text.starts_with("ERROR_APP:") {
6655 out.push_str("Application crashes: unable to query\n");
6656 } else {
6657 out.push_str("Application crashes:\n");
6658 for line in text.lines().take(n) {
6659 let mut it = line.splitn(2, '|');
6660 if let (Some(a), Some(b)) = (it.next(), it.next()) {
6661 let _ = writeln!(out, " [{}] {}", a, b);
6662 }
6663 }
6664 }
6665 }
6666 }
6667
6668 #[cfg(not(target_os = "windows"))]
6669 {
6670 let n_str = n.to_string();
6671 if let Ok(o) = Command::new("journalctl")
6672 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6673 .output()
6674 {
6675 let text = String::from_utf8_lossy(&o.stdout);
6676 let trimmed = text.trim();
6677 if trimmed.is_empty() || trimmed.contains("No entries") {
6678 out.push_str("No kernel panics or critical crashes found.\n");
6679 } else {
6680 out.push_str("Kernel critical events:\n");
6681 out.push_str(trimmed);
6682 out.push('\n');
6683 }
6684 }
6685 if let Ok(o) = Command::new("coredumpctl")
6686 .args(["list", "--no-pager"])
6687 .output()
6688 {
6689 let text = String::from_utf8_lossy(&o.stdout);
6690 let count = text
6691 .lines()
6692 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6693 .count();
6694 if count > 0 {
6695 let _ = write!(
6696 out,
6697 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
6698 );
6699 }
6700 }
6701 }
6702
6703 Ok(out.trim_end().to_string())
6704}
6705
6706fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6709 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6710 let n = max_entries.clamp(1, 30);
6711
6712 #[cfg(target_os = "windows")]
6713 {
6714 let script = format!(
6715 r#"
6716try {{
6717 $tasks = Get-ScheduledTask -ErrorAction Stop |
6718 Where-Object {{ $_.State -ne 'Disabled' }} |
6719 ForEach-Object {{
6720 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6721 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6722 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6723 }} else {{ "never" }}
6724 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6725 $exec = ($_.Actions | Select-Object -First 1).Execute
6726 if (-not $exec) {{ $exec = "(no exec)" }}
6727 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6728 }}
6729 $tasks | Select-Object -First {n}
6730}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6731 );
6732
6733 let output = Command::new("powershell")
6734 .args(["-NoProfile", "-Command", &script])
6735 .output()
6736 .map_err(|e| format!("scheduled_tasks: {e}"))?;
6737
6738 let raw = String::from_utf8_lossy(&output.stdout);
6739 let text = raw.trim();
6740
6741 if text.starts_with("ERROR:") {
6742 let _ = writeln!(out, "Unable to query scheduled tasks: {text}");
6743 } else if text.is_empty() {
6744 out.push_str("No active scheduled tasks found.\n");
6745 } else {
6746 let _ = write!(out, "Active scheduled tasks (up to {n}):\n\n");
6747 for line in text.lines() {
6748 let mut it = line.splitn(6, '|');
6749 if let (Some(name), Some(path), Some(state), Some(last), Some(res)) =
6750 (it.next(), it.next(), it.next(), it.next(), it.next())
6751 {
6752 let exec = it.next().unwrap_or("").trim();
6753 let display_path = path.trim_matches('\\');
6754 let display_path = if display_path.is_empty() {
6755 "Root"
6756 } else {
6757 display_path
6758 };
6759 let _ = writeln!(out, " {name} [{display_path}]");
6760 let _ = writeln!(out, " State: {state} | Last run: {last} | Result: {res}");
6761 if !exec.is_empty() && exec != "(no exec)" {
6762 let short = if exec.len() > 80 {
6763 safe_head(exec, 80)
6764 } else {
6765 exec
6766 };
6767 let _ = writeln!(out, " Runs: {short}");
6768 }
6769 }
6770 }
6771 }
6772 }
6773
6774 #[cfg(not(target_os = "windows"))]
6775 {
6776 if let Ok(o) = Command::new("systemctl")
6777 .args(["list-timers", "--no-pager", "--all"])
6778 .output()
6779 {
6780 let text = String::from_utf8_lossy(&o.stdout);
6781 out.push_str("Systemd timers:\n");
6782 for l in text
6783 .lines()
6784 .filter(|l| {
6785 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6786 })
6787 .take(n)
6788 {
6789 let _ = write!(out, " {l}\n");
6790 }
6791 out.push('\n');
6792 }
6793 if let Ok(o) = Command::new("crontab").arg("-l").output() {
6794 let text = String::from_utf8_lossy(&o.stdout);
6795 let jobs: Vec<&str> = text
6796 .lines()
6797 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6798 .collect();
6799 if !jobs.is_empty() {
6800 out.push_str("User crontab:\n");
6801 for j in jobs.iter().take(n) {
6802 let _ = write!(out, " {j}\n");
6803 }
6804 }
6805 }
6806 }
6807
6808 Ok(out.trim_end().to_string())
6809}
6810
6811fn inspect_dev_conflicts() -> Result<String, String> {
6814 let mut out = String::from("Host inspection: dev_conflicts\n\n");
6815 let mut conflicts: Vec<String> = Vec::with_capacity(4);
6816 let mut notes: Vec<String> = Vec::with_capacity(4);
6817
6818 {
6820 let node_ver = Command::new("node")
6821 .arg("--version")
6822 .output()
6823 .ok()
6824 .and_then(|o| String::from_utf8(o.stdout).ok())
6825 .map(|s| s.trim().to_string());
6826 let nvm_active = Command::new("nvm")
6827 .arg("current")
6828 .output()
6829 .ok()
6830 .and_then(|o| String::from_utf8(o.stdout).ok())
6831 .map(|s| s.trim().to_string())
6832 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6833 let fnm_active = Command::new("fnm")
6834 .arg("current")
6835 .output()
6836 .ok()
6837 .and_then(|o| String::from_utf8(o.stdout).ok())
6838 .map(|s| s.trim().to_string())
6839 .filter(|s| !s.is_empty() && !s.contains("none"));
6840 let volta_active = Command::new("volta")
6841 .args(["which", "node"])
6842 .output()
6843 .ok()
6844 .and_then(|o| String::from_utf8(o.stdout).ok())
6845 .map(|s| s.trim().to_string())
6846 .filter(|s| !s.is_empty());
6847
6848 out.push_str("Node.js:\n");
6849 if let Some(ref v) = node_ver {
6850 let _ = writeln!(out, " Active: {v}");
6851 } else {
6852 out.push_str(" Not installed\n");
6853 }
6854 let managers: Vec<&str> = [
6855 nvm_active.as_deref(),
6856 fnm_active.as_deref(),
6857 volta_active.as_deref(),
6858 ]
6859 .iter()
6860 .filter_map(|x| *x)
6861 .collect();
6862 if managers.len() > 1 {
6863 conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6864 } else if !managers.is_empty() {
6865 let _ = writeln!(out, " Version manager: {}", managers[0]);
6866 }
6867 out.push('\n');
6868 }
6869
6870 {
6872 let py3 = Command::new("python3")
6873 .arg("--version")
6874 .output()
6875 .ok()
6876 .and_then(|o| {
6877 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6878 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6879 let v = if stdout.is_empty() { stderr } else { stdout };
6880 if v.is_empty() {
6881 None
6882 } else {
6883 Some(v)
6884 }
6885 });
6886 let py = Command::new("python")
6887 .arg("--version")
6888 .output()
6889 .ok()
6890 .and_then(|o| {
6891 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6892 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6893 let v = if stdout.is_empty() { stderr } else { stdout };
6894 if v.is_empty() {
6895 None
6896 } else {
6897 Some(v)
6898 }
6899 });
6900 let pyenv = Command::new("pyenv")
6901 .arg("version")
6902 .output()
6903 .ok()
6904 .and_then(|o| String::from_utf8(o.stdout).ok())
6905 .map(|s| s.trim().to_string())
6906 .filter(|s| !s.is_empty());
6907 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6908
6909 out.push_str("Python:\n");
6910 match (&py3, &py) {
6911 (Some(v3), Some(v)) if v3 != v => {
6912 let _ = write!(out, " python3: {v3}\n python: {v}\n");
6913 if v.contains("2.") {
6914 conflicts.push(
6915 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6916 );
6917 } else {
6918 notes.push(
6919 "python and python3 resolve to different minor versions.".to_string(),
6920 );
6921 }
6922 }
6923 (Some(v3), None) => {
6924 let _ = writeln!(out, " python3: {v3}");
6925 }
6926 (None, Some(v)) => {
6927 let _ = writeln!(out, " python: {v}");
6928 }
6929 (Some(v3), Some(_)) => {
6930 let _ = writeln!(out, " {v3}");
6931 }
6932 (None, None) => out.push_str(" Not installed\n"),
6933 }
6934 if let Some(ref pe) = pyenv {
6935 let _ = writeln!(out, " pyenv: {pe}");
6936 }
6937 if let Some(env) = conda_env {
6938 if env == "base" {
6939 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6940 } else {
6941 let _ = writeln!(out, " conda env: {env}");
6942 }
6943 }
6944 out.push('\n');
6945 }
6946
6947 {
6949 let toolchain = Command::new("rustup")
6950 .args(["show", "active-toolchain"])
6951 .output()
6952 .ok()
6953 .and_then(|o| String::from_utf8(o.stdout).ok())
6954 .map(|s| s.trim().to_string())
6955 .filter(|s| !s.is_empty());
6956 let cargo_ver = Command::new("cargo")
6957 .arg("--version")
6958 .output()
6959 .ok()
6960 .and_then(|o| String::from_utf8(o.stdout).ok())
6961 .map(|s| s.trim().to_string());
6962 let rustc_ver = Command::new("rustc")
6963 .arg("--version")
6964 .output()
6965 .ok()
6966 .and_then(|o| String::from_utf8(o.stdout).ok())
6967 .map(|s| s.trim().to_string());
6968
6969 out.push_str("Rust:\n");
6970 if let Some(ref t) = toolchain {
6971 let _ = writeln!(out, " Active toolchain: {t}");
6972 }
6973 if let Some(ref c) = cargo_ver {
6974 let _ = writeln!(out, " {c}");
6975 }
6976 if let Some(ref r) = rustc_ver {
6977 let _ = writeln!(out, " {r}");
6978 }
6979 if cargo_ver.is_none() && rustc_ver.is_none() {
6980 out.push_str(" Not installed\n");
6981 }
6982
6983 #[cfg(not(target_os = "windows"))]
6985 if let Ok(o) = Command::new("which").arg("rustc").output() {
6986 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6987 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6988 conflicts.push(format!(
6989 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6990 ));
6991 }
6992 }
6993 out.push('\n');
6994 }
6995
6996 {
6998 let git_ver = Command::new("git")
6999 .arg("--version")
7000 .output()
7001 .ok()
7002 .and_then(|o| String::from_utf8(o.stdout).ok())
7003 .map(|s| s.trim().to_string());
7004 out.push_str("Git:\n");
7005 if let Some(ref v) = git_ver {
7006 let _ = writeln!(out, " {v}");
7007 let email = Command::new("git")
7008 .args(["config", "--global", "user.email"])
7009 .output()
7010 .ok()
7011 .and_then(|o| String::from_utf8(o.stdout).ok())
7012 .map(|s| s.trim().to_string());
7013 if let Some(ref e) = email {
7014 if e.is_empty() {
7015 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
7016 } else {
7017 let _ = writeln!(out, " user.email: {e}");
7018 }
7019 }
7020 let gpg_sign = Command::new("git")
7021 .args(["config", "--global", "commit.gpgsign"])
7022 .output()
7023 .ok()
7024 .and_then(|o| String::from_utf8(o.stdout).ok())
7025 .map(|s| s.trim().to_string());
7026 if gpg_sign.as_deref() == Some("true") {
7027 let key = Command::new("git")
7028 .args(["config", "--global", "user.signingkey"])
7029 .output()
7030 .ok()
7031 .and_then(|o| String::from_utf8(o.stdout).ok())
7032 .map(|s| s.trim().to_string());
7033 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
7034 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
7035 }
7036 }
7037 } else {
7038 out.push_str(" Not installed\n");
7039 }
7040 out.push('\n');
7041 }
7042
7043 {
7045 let path_env = std::env::var("PATH").unwrap_or_default();
7046 let sep = if cfg!(windows) { ';' } else { ':' };
7047 let mut seen = HashSet::new();
7048 let mut dupes: Vec<String> = Vec::new();
7049 for p in path_env.split(sep) {
7050 let norm = p.trim().to_lowercase();
7051 if !norm.is_empty() && !seen.insert(norm) {
7052 dupes.push(p.to_string());
7053 }
7054 }
7055 if !dupes.is_empty() {
7056 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
7057 notes.push(format!(
7058 "Duplicate PATH entries: {} {}",
7059 shown.join(", "),
7060 if dupes.len() > 3 {
7061 format!("+{} more", dupes.len() - 3)
7062 } else {
7063 String::new()
7064 }
7065 ));
7066 }
7067 }
7068
7069 if conflicts.is_empty() && notes.is_empty() {
7071 out.push_str("No conflicts detected — dev environment looks clean.\n");
7072 } else {
7073 if !conflicts.is_empty() {
7074 out.push_str("CONFLICTS:\n");
7075 for c in &conflicts {
7076 let _ = writeln!(out, " [!] {c}");
7077 }
7078 out.push('\n');
7079 }
7080 if !notes.is_empty() {
7081 out.push_str("NOTES:\n");
7082 for n in ¬es {
7083 let _ = writeln!(out, " [-] {n}");
7084 }
7085 }
7086 }
7087
7088 Ok(out.trim_end().to_string())
7089}
7090
7091async fn inspect_public_ip() -> Result<String, String> {
7094 let mut out = String::from("Host inspection: public_ip\n\n");
7095
7096 let client = reqwest::Client::builder()
7097 .timeout(std::time::Duration::from_secs(5))
7098 .build()
7099 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
7100
7101 match client.get("https://api.ipify.org?format=json").send().await {
7102 Ok(resp) => {
7103 if let Ok(json) = resp.json::<serde_json::Value>().await {
7104 let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
7105 let _ = writeln!(out, "Public IP: {}", ip);
7106
7107 if let Ok(geo_resp) = client
7109 .get(format!("http://ip-api.com/json/{}", ip))
7110 .send()
7111 .await
7112 {
7113 if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
7114 if let (Some(city), Some(region), Some(country), Some(isp)) = (
7115 geo_json.get("city").and_then(|v| v.as_str()),
7116 geo_json.get("regionName").and_then(|v| v.as_str()),
7117 geo_json.get("country").and_then(|v| v.as_str()),
7118 geo_json.get("isp").and_then(|v| v.as_str()),
7119 ) {
7120 let _ = writeln!(out, "Location: {}, {} ({})", city, region, country);
7121 let _ = writeln!(out, "ISP: {}", isp);
7122 }
7123 }
7124 }
7125 } else {
7126 out.push_str("Error: Failed to parse public IP response.\n");
7127 }
7128 }
7129 Err(e) => {
7130 let _ = writeln!(
7131 out,
7132 "Error: Failed to fetch public IP ({}). Check internet connectivity.",
7133 e
7134 );
7135 }
7136 }
7137
7138 Ok(out)
7139}
7140
7141fn inspect_ssl_cert(host: &str) -> Result<String, String> {
7142 let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
7143
7144 #[cfg(target_os = "windows")]
7145 {
7146 use std::process::Command;
7147 let escaped_host = ps_escape_single_quoted(host);
7148 let script = format!(
7149 r#"$domain = '{escaped_host}'
7150try {{
7151 $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
7152 $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
7153 $sslStream.AuthenticateAsClient($domain)
7154 $cert = $sslStream.RemoteCertificate
7155 $tcpClient.Close()
7156 if ($cert) {{
7157 $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
7158 $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
7159 }} else {{
7160 "null"
7161 }}
7162}} catch {{
7163 "ERROR:" + $_.Exception.Message
7164}}"#
7165 );
7166
7167 let ps_out = Command::new("powershell")
7168 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
7169 .output()
7170 .map_err(|e| format!("powershell launch failed: {e}"))?;
7171
7172 let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
7173 if text.starts_with("ERROR:") {
7174 let _ = writeln!(out, "Error: {}", text.trim_start_matches("ERROR:"));
7175 } else if text == "null" || text.is_empty() {
7176 out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
7177 } else if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
7178 if let Some(obj) = json.as_object() {
7179 for (k, v) in obj {
7180 let val_str = v.as_str().unwrap_or("");
7181 let _ = writeln!(out, "{:<12}: {}", k, val_str);
7182 }
7183
7184 if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
7185 if not_after_raw.starts_with("/Date(") {
7186 let ts = not_after_raw
7187 .trim_start_matches("/Date(")
7188 .trim_end_matches(")/")
7189 .parse::<i64>()
7190 .unwrap_or(0);
7191 let expiry =
7192 chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
7193 let now = chrono::Utc::now();
7194 let days_left = expiry.signed_duration_since(now).num_days();
7195 if days_left < 0 {
7196 out.push_str("\nSTATUS: [!!] EXPIRED\n");
7197 } else if days_left < 30 {
7198 let _ = write!(
7199 out,
7200 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
7201 days_left
7202 );
7203 } else {
7204 let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
7205 }
7206 } else if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw) {
7207 let now = chrono::Utc::now();
7208 let days_left = expiry.signed_duration_since(now).num_days();
7209 if days_left < 0 {
7210 out.push_str("\nSTATUS: [!!] EXPIRED\n");
7211 } else if days_left < 30 {
7212 let _ = write!(
7213 out,
7214 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
7215 days_left
7216 );
7217 } else {
7218 let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
7219 }
7220 }
7221 }
7222 }
7223 } else {
7224 let _ = writeln!(out, "Raw Output: {}", text);
7225 }
7226 }
7227
7228 #[cfg(not(target_os = "windows"))]
7229 {
7230 out.push_str(
7231 "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
7232 );
7233 }
7234
7235 Ok(out)
7236}
7237
7238async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
7239 let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
7240
7241 if !path.exists() {
7242 return Err(format!("File not found: {:?}", path));
7243 }
7244 if !path.is_file() {
7245 return Err(format!("Not a file: {:?}", path));
7246 }
7247
7248 let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
7249 let _ = writeln!(
7250 out,
7251 "File Size: {} bytes ({:.2} MB)",
7252 file_size,
7253 file_size as f64 / 1_048_576.0
7254 );
7255
7256 let ext = path
7257 .extension()
7258 .and_then(|s| s.to_str())
7259 .unwrap_or("")
7260 .to_lowercase();
7261 let _ = write!(out, "Format: {}\n\n", ext.to_uppercase());
7262
7263 match ext.as_str() {
7264 "csv" | "tsv" | "txt" | "log" => {
7265 let content = std::fs::read_to_string(&path)
7266 .map_err(|e| format!("Failed to read file: {}", e))?;
7267 let lines: Vec<&str> = content.lines().collect();
7268 let _ = writeln!(out, "Row Count: {} (total lines)", lines.len());
7269
7270 if let Some(header) = lines.first() {
7271 out.push_str("Columns (Guessed from header):\n");
7272 let delimiter = if ext == "tsv" {
7273 "\t"
7274 } else if header.contains(',') {
7275 ","
7276 } else {
7277 " "
7278 };
7279 for (i, col) in header.split(delimiter).map(|s| s.trim()).enumerate() {
7280 let _ = writeln!(out, " {}. {}", i + 1, col);
7281 }
7282 }
7283
7284 out.push_str("\nSample Data (First 5 rows):\n");
7285 for line in lines.iter().take(6) {
7286 let _ = writeln!(out, " {}", line);
7287 }
7288 }
7289 "json" => {
7290 let content = std::fs::read_to_string(&path)
7291 .map_err(|e| format!("Failed to read file: {}", e))?;
7292 if let Ok(json) = serde_json::from_str::<Value>(&content) {
7293 if let Some(arr) = json.as_array() {
7294 let _ = writeln!(out, "Record Count: {}", arr.len());
7295 if let Some(first) = arr.first() {
7296 if let Some(obj) = first.as_object() {
7297 out.push_str("Fields (from first record):\n");
7298 for k in obj.keys() {
7299 let _ = writeln!(out, " - {}", k);
7300 }
7301 }
7302 }
7303 out.push_str("\nSample Record:\n");
7304 out.push_str(&serde_json::to_string_pretty(&arr.first()).unwrap_or_default());
7305 } else if let Some(obj) = json.as_object() {
7306 out.push_str("Top-level Keys:\n");
7307 for k in obj.keys() {
7308 let _ = writeln!(out, " - {}", k);
7309 }
7310 }
7311 } else {
7312 out.push_str("Error: Failed to parse as JSON.\n");
7313 }
7314 }
7315 "db" | "sqlite" | "sqlite3" => {
7316 out.push_str("SQLite Database detected.\n");
7317 out.push_str("Use `query_data` to execute SQL against this database.\n");
7318 }
7319 _ => {
7320 out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
7321 let content = std::fs::read_to_string(&path)
7322 .map_err(|e| format!("Failed to read file: {}", e))?;
7323 for line in content.lines().take(10) {
7324 let _ = writeln!(out, " {}", line);
7325 }
7326 }
7327 }
7328
7329 Ok(out)
7330}
7331
7332fn inspect_connectivity() -> Result<String, String> {
7333 let mut out = String::from("Host inspection: connectivity\n\n");
7334
7335 #[cfg(target_os = "windows")]
7336 {
7337 let inet_script = r#"
7338try {
7339 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
7340 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
7341} catch { "ERROR:" + $_.Exception.Message }
7342"#;
7343 if let Ok(o) = Command::new("powershell")
7344 .args(["-NoProfile", "-Command", inet_script])
7345 .output()
7346 {
7347 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7348 match text.as_str() {
7349 "REACHABLE" => out.push_str("Internet: reachable\n"),
7350 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
7351 _ => {
7352 let _ = writeln!(
7353 out,
7354 "Internet: {}",
7355 text.trim_start_matches("ERROR:").trim()
7356 );
7357 }
7358 }
7359 }
7360
7361 let dns_script = r#"
7362try {
7363 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
7364 "DNS:ok"
7365} catch { "DNS:fail:" + $_.Exception.Message }
7366"#;
7367 if let Ok(o) = Command::new("powershell")
7368 .args(["-NoProfile", "-Command", dns_script])
7369 .output()
7370 {
7371 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7372 if text == "DNS:ok" {
7373 out.push_str("DNS: resolving correctly\n");
7374 } else {
7375 let detail = text.trim_start_matches("DNS:fail:").trim();
7376 let _ = writeln!(out, "DNS: failed — {}", detail);
7377 }
7378 }
7379
7380 let gw_script = r#"
7381(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
7382"#;
7383 if let Ok(o) = Command::new("powershell")
7384 .args(["-NoProfile", "-Command", gw_script])
7385 .output()
7386 {
7387 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
7388 if !gw.is_empty() && gw != "0.0.0.0" {
7389 let _ = writeln!(out, "Default gateway: {}", gw);
7390 }
7391 }
7392 }
7393
7394 #[cfg(not(target_os = "windows"))]
7395 {
7396 let reachable = Command::new("ping")
7397 .args(["-c", "1", "-W", "2", "8.8.8.8"])
7398 .output()
7399 .map(|o| o.status.success())
7400 .unwrap_or(false);
7401 out.push_str(if reachable {
7402 "Internet: reachable\n"
7403 } else {
7404 "Internet: unreachable\n"
7405 });
7406 let dns_ok = Command::new("getent")
7407 .args(["hosts", "dns.google"])
7408 .output()
7409 .map(|o| o.status.success())
7410 .unwrap_or(false);
7411 out.push_str(if dns_ok {
7412 "DNS: resolving correctly\n"
7413 } else {
7414 "DNS: failed\n"
7415 });
7416 if let Ok(o) = Command::new("ip")
7417 .args(["route", "show", "default"])
7418 .output()
7419 {
7420 let text = String::from_utf8_lossy(&o.stdout);
7421 if let Some(line) = text.lines().next() {
7422 let _ = write!(out, "Default gateway: {}\n", line.trim());
7423 }
7424 }
7425 }
7426
7427 Ok(out.trim_end().to_string())
7428}
7429
7430fn inspect_wifi() -> Result<String, String> {
7433 let mut out = String::from("Host inspection: wifi\n\n");
7434
7435 #[cfg(target_os = "windows")]
7436 {
7437 let output = Command::new("netsh")
7438 .args(["wlan", "show", "interfaces"])
7439 .output()
7440 .map_err(|e| format!("wifi: {e}"))?;
7441 let text = String::from_utf8_lossy(&output.stdout).into_owned();
7442
7443 if text.contains("There is no wireless interface") || text.trim().is_empty() {
7444 out.push_str("No wireless interface detected on this machine.\n");
7445 return Ok(out.trim_end().to_string());
7446 }
7447
7448 let fields = [
7449 ("SSID", "SSID"),
7450 ("State", "State"),
7451 ("Signal", "Signal"),
7452 ("Radio type", "Radio type"),
7453 ("Channel", "Channel"),
7454 ("Receive rate (Mbps)", "Download speed (Mbps)"),
7455 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
7456 ("Authentication", "Authentication"),
7457 ("Network type", "Network type"),
7458 ];
7459
7460 let mut any = false;
7461 for line in text.lines() {
7462 let trimmed = line.trim();
7463 for (key, label) in &fields {
7464 if trimmed.starts_with(key) && trimmed.contains(':') {
7465 let val = trimmed.split_once(':').map(|x| x.1).unwrap_or("").trim();
7466 if !val.is_empty() {
7467 let _ = writeln!(out, " {label}: {val}");
7468 any = true;
7469 }
7470 }
7471 }
7472 }
7473 if !any {
7474 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
7475 }
7476 }
7477
7478 #[cfg(not(target_os = "windows"))]
7479 {
7480 if let Ok(o) = Command::new("nmcli")
7481 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
7482 .output()
7483 {
7484 let text = String::from_utf8_lossy(&o.stdout).into_owned();
7485 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
7486 if lines.is_empty() {
7487 out.push_str("No Wi-Fi devices found.\n");
7488 } else {
7489 for l in lines {
7490 let _ = write!(out, " {l}\n");
7491 }
7492 }
7493 } else if let Ok(o) = Command::new("iwconfig").output() {
7494 let text = String::from_utf8_lossy(&o.stdout).into_owned();
7495 if !text.trim().is_empty() {
7496 out.push_str(text.trim());
7497 out.push('\n');
7498 }
7499 } else {
7500 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7501 }
7502 }
7503
7504 Ok(out.trim_end().to_string())
7505}
7506
7507fn inspect_connections(max_entries: usize) -> Result<String, String> {
7510 let mut out = String::from("Host inspection: connections\n\n");
7511 let n = max_entries.clamp(1, 25);
7512
7513 #[cfg(target_os = "windows")]
7514 {
7515 let script = format!(
7516 r#"
7517try {{
7518 $procs = @{{}}
7519 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7520 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7521 Sort-Object OwningProcess
7522 "TOTAL:" + $all.Count
7523 $all | Select-Object -First {n} | ForEach-Object {{
7524 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7525 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7526 }}
7527}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7528 );
7529
7530 let output = Command::new("powershell")
7531 .args(["-NoProfile", "-Command", &script])
7532 .output()
7533 .map_err(|e| format!("connections: {e}"))?;
7534
7535 let raw = String::from_utf8_lossy(&output.stdout);
7536 let text = raw.trim();
7537
7538 if text.starts_with("ERROR:") {
7539 let _ = writeln!(out, "Unable to query connections: {text}");
7540 } else {
7541 let mut total = 0usize;
7542 let mut rows = Vec::new();
7543 for line in text.lines() {
7544 if let Some(rest) = line.strip_prefix("TOTAL:") {
7545 total = rest.trim().parse().unwrap_or(0);
7546 } else {
7547 rows.push(line);
7548 }
7549 }
7550 let _ = write!(out, "Established TCP connections: {total}\n\n");
7551 for row in &rows {
7552 let mut it = row.splitn(4, '|');
7553 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
7554 (it.next(), it.next(), it.next(), it.next())
7555 {
7556 let _ = writeln!(out, " {:<15} (pid {:<5}) | {} → {}", p0, p1, p2, p3);
7557 }
7558 }
7559 if total > n {
7560 let _ = write!(
7561 out,
7562 "\n ... {} more connections not shown\n",
7563 total.saturating_sub(n)
7564 );
7565 }
7566 }
7567 }
7568
7569 #[cfg(not(target_os = "windows"))]
7570 {
7571 if let Ok(o) = Command::new("ss")
7572 .args(["-tnp", "state", "established"])
7573 .output()
7574 {
7575 let text = String::from_utf8_lossy(&o.stdout);
7576 let lines: Vec<&str> = text
7577 .lines()
7578 .skip(1)
7579 .filter(|l| !l.trim().is_empty())
7580 .collect();
7581 let _ = write!(out, "Established TCP connections: {}\n\n", lines.len());
7582 for line in lines.iter().take(n) {
7583 let _ = write!(out, " {}\n", line.trim());
7584 }
7585 if lines.len() > n {
7586 let _ = write!(out, "\n ... {} more not shown\n", lines.len() - n);
7587 }
7588 } else {
7589 out.push_str("ss not available — install iproute2\n");
7590 }
7591 }
7592
7593 Ok(out.trim_end().to_string())
7594}
7595
7596fn inspect_vpn() -> Result<String, String> {
7599 let mut out = String::from("Host inspection: vpn\n\n");
7600
7601 #[cfg(target_os = "windows")]
7602 {
7603 let script = r#"
7604try {
7605 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7606 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7607 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7608 }
7609 if ($vpn) {
7610 foreach ($a in $vpn) {
7611 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7612 }
7613 } else { "NONE" }
7614} catch { "ERROR:" + $_.Exception.Message }
7615"#;
7616 let output = Command::new("powershell")
7617 .args(["-NoProfile", "-Command", script])
7618 .output()
7619 .map_err(|e| format!("vpn: {e}"))?;
7620
7621 let raw = String::from_utf8_lossy(&output.stdout);
7622 let text = raw.trim();
7623
7624 if text == "NONE" {
7625 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7626 } else if text.starts_with("ERROR:") {
7627 let _ = writeln!(out, "Unable to query adapters: {text}");
7628 } else {
7629 out.push_str("VPN adapters:\n\n");
7630 for line in text.lines() {
7631 let mut it = line.splitn(4, '|');
7632 if let (Some(name), Some(desc), Some(status)) = (it.next(), it.next(), it.next()) {
7633 let media = it.next().unwrap_or("unknown");
7634 let label = if status.trim() == "Up" {
7635 "CONNECTED"
7636 } else {
7637 "disconnected"
7638 };
7639 let _ =
7640 write!(out,
7641 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
7642 );
7643 }
7644 }
7645 }
7646
7647 let ras_script = r#"
7649try {
7650 $c = Get-VpnConnection -ErrorAction Stop
7651 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7652 else { "NO_RAS" }
7653} catch { "NO_RAS" }
7654"#;
7655 if let Ok(o) = Command::new("powershell")
7656 .args(["-NoProfile", "-Command", ras_script])
7657 .output()
7658 {
7659 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7660 if t != "NO_RAS" && !t.is_empty() {
7661 out.push_str("Windows VPN connections:\n");
7662 for line in t.lines() {
7663 let mut it = line.splitn(3, '|');
7664 if let (Some(name), Some(status)) = (it.next(), it.next()) {
7665 let server = it.next().unwrap_or("");
7666 let _ = writeln!(out, " {name} → {server} [{status}]");
7667 }
7668 }
7669 }
7670 }
7671 }
7672
7673 #[cfg(not(target_os = "windows"))]
7674 {
7675 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7676 let text = String::from_utf8_lossy(&o.stdout);
7677 let vpn_ifaces: Vec<&str> = text
7678 .lines()
7679 .filter(|l| {
7680 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7681 })
7682 .collect();
7683 if vpn_ifaces.is_empty() {
7684 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7685 } else {
7686 let _ = write!(out, "VPN-like interfaces ({}):\n", vpn_ifaces.len());
7687 for l in vpn_ifaces {
7688 let _ = write!(out, " {}\n", l.trim());
7689 }
7690 }
7691 }
7692 }
7693
7694 Ok(out.trim_end().to_string())
7695}
7696
7697fn inspect_proxy() -> Result<String, String> {
7700 let mut out = String::from("Host inspection: proxy\n\n");
7701
7702 #[cfg(target_os = "windows")]
7703 {
7704 let script = r#"
7705$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7706if ($ie) {
7707 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7708} else { "NONE" }
7709"#;
7710 if let Ok(o) = Command::new("powershell")
7711 .args(["-NoProfile", "-Command", script])
7712 .output()
7713 {
7714 let raw = String::from_utf8_lossy(&o.stdout);
7715 let text = raw.trim();
7716 if text != "NONE" && !text.is_empty() {
7717 let get = |key: &str| -> &str {
7718 text.split('|')
7719 .find(|s| s.starts_with(key))
7720 .and_then(|s| s.split_once(':').map(|x| x.1))
7721 .unwrap_or("")
7722 };
7723 let enabled = get("ENABLE");
7724 let server = get("SERVER");
7725 let overrides = get("OVERRIDE");
7726 out.push_str("WinINET / IE proxy:\n");
7727 let _ = writeln!(
7728 out,
7729 " Enabled: {}",
7730 if enabled == "1" { "yes" } else { "no" }
7731 );
7732 if !server.is_empty() && server != "None" {
7733 let _ = writeln!(out, " Proxy server: {server}");
7734 }
7735 if !overrides.is_empty() && overrides != "None" {
7736 let _ = writeln!(out, " Bypass list: {overrides}");
7737 }
7738 out.push('\n');
7739 }
7740 }
7741
7742 if let Ok(o) = Command::new("netsh")
7743 .args(["winhttp", "show", "proxy"])
7744 .output()
7745 {
7746 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7747 out.push_str("WinHTTP proxy:\n");
7748 for line in text.lines() {
7749 let l = line.trim();
7750 if !l.is_empty() {
7751 let _ = writeln!(out, " {l}");
7752 }
7753 }
7754 out.push('\n');
7755 }
7756
7757 let mut env_found = false;
7758 for var in &[
7759 "http_proxy",
7760 "https_proxy",
7761 "HTTP_PROXY",
7762 "HTTPS_PROXY",
7763 "no_proxy",
7764 "NO_PROXY",
7765 ] {
7766 if let Ok(val) = std::env::var(var) {
7767 if !env_found {
7768 out.push_str("Environment proxy variables:\n");
7769 env_found = true;
7770 }
7771 let _ = writeln!(out, " {var}: {val}");
7772 }
7773 }
7774 if !env_found {
7775 out.push_str("No proxy environment variables set.\n");
7776 }
7777 }
7778
7779 #[cfg(not(target_os = "windows"))]
7780 {
7781 let mut found = false;
7782 for var in &[
7783 "http_proxy",
7784 "https_proxy",
7785 "HTTP_PROXY",
7786 "HTTPS_PROXY",
7787 "no_proxy",
7788 "NO_PROXY",
7789 "ALL_PROXY",
7790 "all_proxy",
7791 ] {
7792 if let Ok(val) = std::env::var(var) {
7793 if !found {
7794 out.push_str("Proxy environment variables:\n");
7795 found = true;
7796 }
7797 let _ = write!(out, " {var}: {val}\n");
7798 }
7799 }
7800 if !found {
7801 out.push_str("No proxy environment variables set.\n");
7802 }
7803 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7804 let proxy_lines: Vec<&str> = content
7805 .lines()
7806 .filter(|l| l.to_lowercase().contains("proxy"))
7807 .collect();
7808 if !proxy_lines.is_empty() {
7809 out.push_str("\nSystem proxy (/etc/environment):\n");
7810 for l in proxy_lines {
7811 let _ = write!(out, " {l}\n");
7812 }
7813 }
7814 }
7815 }
7816
7817 Ok(out.trim_end().to_string())
7818}
7819
7820fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7823 let mut out = String::from("Host inspection: firewall_rules\n\n");
7824 let n = max_entries.clamp(1, 20);
7825
7826 #[cfg(target_os = "windows")]
7827 {
7828 let script = format!(
7829 r#"
7830try {{
7831 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7832 Where-Object {{
7833 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7834 $_.Owner -eq $null
7835 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7836 "TOTAL:" + $rules.Count
7837 $rules | ForEach-Object {{
7838 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7839 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7840 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7841 }}
7842}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7843 );
7844
7845 let output = Command::new("powershell")
7846 .args(["-NoProfile", "-Command", &script])
7847 .output()
7848 .map_err(|e| format!("firewall_rules: {e}"))?;
7849
7850 let raw = String::from_utf8_lossy(&output.stdout);
7851 let text = raw.trim();
7852
7853 if text.starts_with("ERROR:") {
7854 let _ = writeln!(
7855 out,
7856 "Unable to query firewall rules: {}",
7857 text.trim_start_matches("ERROR:").trim()
7858 );
7859 out.push_str("This query may require running as administrator.\n");
7860 } else if text.is_empty() {
7861 out.push_str("No non-default enabled firewall rules found.\n");
7862 } else {
7863 let mut total = 0usize;
7864 for line in text.lines() {
7865 if let Some(rest) = line.strip_prefix("TOTAL:") {
7866 total = rest.trim().parse().unwrap_or(0);
7867 let _ = write!(out, "Non-default enabled rules (showing up to {n}):\n\n");
7868 } else {
7869 let mut it = line.splitn(4, '|');
7870 if let (Some(name), Some(dir), Some(action)) = (it.next(), it.next(), it.next())
7871 {
7872 let profile = it.next().unwrap_or("Any");
7873 let icon = if action == "Block" { "[!]" } else { " " };
7874 let _ = writeln!(
7875 out,
7876 " {icon} [{dir}] {action}: {name} (profile: {profile})"
7877 );
7878 }
7879 }
7880 }
7881 if total == 0 {
7882 out.push_str("No non-default enabled rules found.\n");
7883 }
7884 }
7885 }
7886
7887 #[cfg(not(target_os = "windows"))]
7888 {
7889 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7890 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7891 if !text.is_empty() {
7892 out.push_str(&text);
7893 out.push('\n');
7894 }
7895 } else if let Ok(o) = Command::new("iptables")
7896 .args(["-L", "-n", "--line-numbers"])
7897 .output()
7898 {
7899 let text = String::from_utf8_lossy(&o.stdout);
7900 for l in text.lines().take(n * 2) {
7901 let _ = write!(out, " {l}\n");
7902 }
7903 } else {
7904 out.push_str("ufw and iptables not available or insufficient permissions.\n");
7905 }
7906 }
7907
7908 Ok(out.trim_end().to_string())
7909}
7910
7911fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7914 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7915 let hops = max_entries.clamp(5, 30);
7916
7917 #[cfg(target_os = "windows")]
7918 {
7919 let output = Command::new("tracert")
7920 .args(["-d", "-h", &hops.to_string(), host])
7921 .output()
7922 .map_err(|e| format!("tracert: {e}"))?;
7923 let raw = String::from_utf8_lossy(&output.stdout);
7924 let mut hop_count = 0usize;
7925 for line in raw.lines() {
7926 let trimmed = line.trim();
7927 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7928 hop_count += 1;
7929 let _ = writeln!(out, " {trimmed}");
7930 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7931 let _ = writeln!(out, "{trimmed}");
7932 }
7933 }
7934 if hop_count == 0 {
7935 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7936 }
7937 }
7938
7939 #[cfg(not(target_os = "windows"))]
7940 {
7941 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7942 || std::path::Path::new("/usr/sbin/traceroute").exists()
7943 {
7944 "traceroute"
7945 } else {
7946 "tracepath"
7947 };
7948 let output = Command::new(cmd)
7949 .args(["-m", &hops.to_string(), "-n", host])
7950 .output()
7951 .map_err(|e| format!("{cmd}: {e}"))?;
7952 let raw = String::from_utf8_lossy(&output.stdout);
7953 let mut hop_count = 0usize;
7954 for line in raw.lines().take(hops + 2) {
7955 let trimmed = line.trim();
7956 if !trimmed.is_empty() {
7957 hop_count += 1;
7958 let _ = write!(out, " {trimmed}\n");
7959 }
7960 }
7961 if hop_count == 0 {
7962 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7963 }
7964 }
7965
7966 Ok(out.trim_end().to_string())
7967}
7968
7969fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7972 let mut out = String::from("Host inspection: dns_cache\n\n");
7973 let n = max_entries.clamp(10, 100);
7974
7975 #[cfg(target_os = "windows")]
7976 {
7977 let output = Command::new("powershell")
7978 .args([
7979 "-NoProfile",
7980 "-Command",
7981 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7982 ])
7983 .output()
7984 .map_err(|e| format!("dns_cache: {e}"))?;
7985
7986 let raw = String::from_utf8_lossy(&output.stdout);
7987 let lines: Vec<&str> = raw.lines().skip(1).collect();
7988 let total = lines.len();
7989
7990 if total == 0 {
7991 out.push_str("DNS cache is empty or could not be read.\n");
7992 } else {
7993 let _ = write!(out, "DNS cache entries (showing up to {n} of {total}):\n\n");
7994 let mut shown = 0usize;
7995 for line in lines.iter().take(n) {
7996 let mut it = line.splitn(4, ',');
7997 if let (Some(e), Some(rt), Some(d)) = (it.next(), it.next(), it.next()) {
7998 let entry = e.trim_matches('"');
7999 let rtype = rt.trim_matches('"');
8000 let data = d.trim_matches('"');
8001 let ttl = it.next().map(|s| s.trim_matches('"')).unwrap_or("?");
8002 let _ = writeln!(out, " {entry:<45} {rtype:<6} {data} (TTL {ttl}s)");
8003 shown += 1;
8004 }
8005 }
8006 if total > shown {
8007 let _ = write!(out, "\n ... and {} more entries\n", total - shown);
8008 }
8009 }
8010 }
8011
8012 #[cfg(not(target_os = "windows"))]
8013 {
8014 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
8015 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8016 if !text.is_empty() {
8017 out.push_str("systemd-resolved statistics:\n");
8018 for line in text.lines().take(n) {
8019 let _ = write!(out, " {line}\n");
8020 }
8021 out.push('\n');
8022 }
8023 }
8024 if let Ok(o) = Command::new("dscacheutil")
8025 .args(["-cachedump", "-entries", "Host"])
8026 .output()
8027 {
8028 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8029 if !text.is_empty() {
8030 out.push_str("DNS cache (macOS dscacheutil):\n");
8031 for line in text.lines().take(n) {
8032 let _ = write!(out, " {line}\n");
8033 }
8034 } else {
8035 out.push_str("DNS cache is empty or not accessible on this platform.\n");
8036 }
8037 } else {
8038 out.push_str(
8039 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
8040 );
8041 }
8042 }
8043
8044 Ok(out.trim_end().to_string())
8045}
8046
8047fn inspect_arp() -> Result<String, String> {
8050 let mut out = String::from("Host inspection: arp\n\n");
8051
8052 #[cfg(target_os = "windows")]
8053 {
8054 let output = Command::new("arp")
8055 .args(["-a"])
8056 .output()
8057 .map_err(|e| format!("arp: {e}"))?;
8058 let raw = String::from_utf8_lossy(&output.stdout);
8059 let mut count = 0usize;
8060 for line in raw.lines() {
8061 let t = line.trim();
8062 if t.is_empty() {
8063 continue;
8064 }
8065 let _ = writeln!(out, " {t}");
8066 if t.contains("dynamic") || t.contains("static") {
8067 count += 1;
8068 }
8069 }
8070 let _ = write!(out, "\nTotal entries: {count}\n");
8071 }
8072
8073 #[cfg(not(target_os = "windows"))]
8074 {
8075 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
8076 let raw = String::from_utf8_lossy(&o.stdout);
8077 let mut count = 0usize;
8078 for line in raw.lines() {
8079 let t = line.trim();
8080 if !t.is_empty() {
8081 let _ = write!(out, " {t}\n");
8082 count += 1;
8083 }
8084 }
8085 let _ = write!(out, "\nTotal entries: {}\n", count.saturating_sub(1));
8086 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
8087 let raw = String::from_utf8_lossy(&o.stdout);
8088 let mut count = 0usize;
8089 for line in raw.lines() {
8090 let t = line.trim();
8091 if !t.is_empty() {
8092 let _ = write!(out, " {t}\n");
8093 count += 1;
8094 }
8095 }
8096 let _ = write!(out, "\nTotal entries: {count}\n");
8097 } else {
8098 out.push_str("arp and ip neigh not available.\n");
8099 }
8100 }
8101
8102 Ok(out.trim_end().to_string())
8103}
8104
8105fn inspect_route_table(max_entries: usize) -> Result<String, String> {
8108 let mut out = String::from("Host inspection: route_table\n\n");
8109 let n = max_entries.clamp(10, 50);
8110
8111 #[cfg(target_os = "windows")]
8112 {
8113 let script = r#"
8114try {
8115 $routes = Get-NetRoute -ErrorAction Stop |
8116 Where-Object { $_.RouteMetric -lt 9000 } |
8117 Sort-Object RouteMetric |
8118 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
8119 "TOTAL:" + $routes.Count
8120 $routes | ForEach-Object {
8121 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
8122 }
8123} catch { "ERROR:" + $_.Exception.Message }
8124"#;
8125 let output = Command::new("powershell")
8126 .args(["-NoProfile", "-Command", script])
8127 .output()
8128 .map_err(|e| format!("route_table: {e}"))?;
8129 let raw = String::from_utf8_lossy(&output.stdout);
8130 let text = raw.trim();
8131
8132 if text.starts_with("ERROR:") {
8133 let _ = writeln!(
8134 out,
8135 "Unable to read route table: {}",
8136 text.trim_start_matches("ERROR:").trim()
8137 );
8138 } else {
8139 let mut shown = 0usize;
8140 for line in text.lines() {
8141 if let Some(rest) = line.strip_prefix("TOTAL:") {
8142 let total: usize = rest.trim().parse().unwrap_or(0);
8143 let _ = write!(
8144 out,
8145 "Routing table (showing up to {n} of {total} routes):\n\n"
8146 );
8147 let _ = writeln!(
8148 out,
8149 " {:<22} {:<18} {:>8} Interface",
8150 "Destination", "Next Hop", "Metric"
8151 );
8152 let _ = writeln!(out, " {}", "-".repeat(70));
8153 } else if shown < n {
8154 let mut it = line.splitn(4, '|');
8155 if let (Some(dest), Some(p1), Some(metric), Some(iface)) =
8156 (it.next(), it.next(), it.next(), it.next())
8157 {
8158 let hop = if p1.is_empty() || p1 == "0.0.0.0" || p1 == "::" {
8159 "on-link"
8160 } else {
8161 p1
8162 };
8163 let _ = writeln!(out, " {dest:<22} {hop:<18} {metric:>8} {iface}");
8164 shown += 1;
8165 }
8166 }
8167 }
8168 }
8169 }
8170
8171 #[cfg(not(target_os = "windows"))]
8172 {
8173 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
8174 let raw = String::from_utf8_lossy(&o.stdout);
8175 let lines: Vec<&str> = raw.lines().collect();
8176 let total = lines.len();
8177 let _ = write!(
8178 out,
8179 "Routing table (showing up to {n} of {total} routes):\n\n"
8180 );
8181 for line in lines.iter().take(n) {
8182 let _ = write!(out, " {line}\n");
8183 }
8184 if total > n {
8185 let _ = write!(out, "\n ... and {} more routes\n", total - n);
8186 }
8187 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
8188 let raw = String::from_utf8_lossy(&o.stdout);
8189 for line in raw.lines().take(n) {
8190 let _ = write!(out, " {line}\n");
8191 }
8192 } else {
8193 out.push_str("ip route and netstat not available.\n");
8194 }
8195 }
8196
8197 Ok(out.trim_end().to_string())
8198}
8199
8200fn inspect_env(max_entries: usize) -> Result<String, String> {
8203 let mut out = String::from("Host inspection: env\n\n");
8204 let n = max_entries.clamp(10, 50);
8205
8206 fn looks_like_secret(name: &str) -> bool {
8207 let n = name.to_uppercase();
8208 n.contains("KEY")
8209 || n.contains("SECRET")
8210 || n.contains("TOKEN")
8211 || n.contains("PASSWORD")
8212 || n.contains("PASSWD")
8213 || n.contains("CREDENTIAL")
8214 || n.contains("AUTH")
8215 || n.contains("CERT")
8216 || n.contains("PRIVATE")
8217 }
8218
8219 let known_dev_vars: &[&str] = &[
8220 "CARGO_HOME",
8221 "RUSTUP_HOME",
8222 "GOPATH",
8223 "GOROOT",
8224 "GOBIN",
8225 "JAVA_HOME",
8226 "ANDROID_HOME",
8227 "ANDROID_SDK_ROOT",
8228 "PYTHONPATH",
8229 "PYTHONHOME",
8230 "VIRTUAL_ENV",
8231 "CONDA_DEFAULT_ENV",
8232 "CONDA_PREFIX",
8233 "NODE_PATH",
8234 "NVM_DIR",
8235 "NVM_BIN",
8236 "PNPM_HOME",
8237 "DENO_INSTALL",
8238 "DENO_DIR",
8239 "DOTNET_ROOT",
8240 "NUGET_PACKAGES",
8241 "CMAKE_HOME",
8242 "VCPKG_ROOT",
8243 "AWS_PROFILE",
8244 "AWS_REGION",
8245 "AWS_DEFAULT_REGION",
8246 "GCP_PROJECT",
8247 "GOOGLE_CLOUD_PROJECT",
8248 "GOOGLE_APPLICATION_CREDENTIALS",
8249 "AZURE_SUBSCRIPTION_ID",
8250 "DATABASE_URL",
8251 "REDIS_URL",
8252 "MONGO_URI",
8253 "EDITOR",
8254 "VISUAL",
8255 "SHELL",
8256 "TERM",
8257 "XDG_CONFIG_HOME",
8258 "XDG_DATA_HOME",
8259 "XDG_CACHE_HOME",
8260 "HOME",
8261 "USERPROFILE",
8262 "APPDATA",
8263 "LOCALAPPDATA",
8264 "TEMP",
8265 "TMP",
8266 "COMPUTERNAME",
8267 "USERNAME",
8268 "USERDOMAIN",
8269 "PROCESSOR_ARCHITECTURE",
8270 "NUMBER_OF_PROCESSORS",
8271 "OS",
8272 "HOMEDRIVE",
8273 "HOMEPATH",
8274 "HTTP_PROXY",
8275 "HTTPS_PROXY",
8276 "NO_PROXY",
8277 "ALL_PROXY",
8278 "http_proxy",
8279 "https_proxy",
8280 "no_proxy",
8281 "DOCKER_HOST",
8282 "DOCKER_BUILDKIT",
8283 "COMPOSE_PROJECT_NAME",
8284 "KUBECONFIG",
8285 "KUBE_CONTEXT",
8286 "CI",
8287 "GITHUB_ACTIONS",
8288 "GITLAB_CI",
8289 "LMSTUDIO_HOME",
8290 "HEMATITE_URL",
8291 ];
8292
8293 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
8294 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
8295 let total = all_vars.len();
8296
8297 let mut dev_found: Vec<String> = Vec::new();
8298 let mut secret_found: Vec<String> = Vec::new();
8299
8300 for (k, v) in &all_vars {
8301 if k == "PATH" {
8302 continue;
8303 }
8304 if looks_like_secret(k) {
8305 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
8306 } else {
8307 let k_upper = k.to_uppercase();
8308 let is_known = known_dev_vars
8309 .iter()
8310 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
8311 if is_known {
8312 let display = if v.len() > 120 {
8313 format!("{k} = {}…", safe_head(v, 117))
8314 } else {
8315 format!("{k} = {v}")
8316 };
8317 dev_found.push(display);
8318 }
8319 }
8320 }
8321
8322 let _ = write!(out, "Total environment variables: {total}\n\n");
8323
8324 if let Ok(p) = std::env::var("PATH") {
8325 let sep = if cfg!(target_os = "windows") {
8326 ';'
8327 } else {
8328 ':'
8329 };
8330 let count = p.split(sep).count();
8331 let _ = write!(
8332 out,
8333 "PATH: {count} entries (use topic=path for full audit)\n\n"
8334 );
8335 }
8336
8337 if !secret_found.is_empty() {
8338 let _ = writeln!(
8339 out,
8340 "=== Secret/credential variables ({} detected, values hidden) ===",
8341 secret_found.len()
8342 );
8343 for s in secret_found.iter().take(n) {
8344 let _ = writeln!(out, " {s}");
8345 }
8346 out.push('\n');
8347 }
8348
8349 if !dev_found.is_empty() {
8350 let _ = writeln!(
8351 out,
8352 "=== Developer & tool variables ({}) ===",
8353 dev_found.len()
8354 );
8355 for d in dev_found.iter().take(n) {
8356 let _ = writeln!(out, " {d}");
8357 }
8358 out.push('\n');
8359 }
8360
8361 let other_count = all_vars
8362 .iter()
8363 .filter(|(k, _)| {
8364 k != "PATH"
8365 && !looks_like_secret(k)
8366 && !known_dev_vars
8367 .iter()
8368 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
8369 })
8370 .count();
8371 if other_count > 0 {
8372 let _ = writeln!(
8373 out,
8374 "Other variables: {other_count} (use 'env' in shell to see all)"
8375 );
8376 }
8377
8378 Ok(out.trim_end().to_string())
8379}
8380
8381fn inspect_hosts_file() -> Result<String, String> {
8384 let mut out = String::from("Host inspection: hosts_file\n\n");
8385
8386 let hosts_path = if cfg!(target_os = "windows") {
8387 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
8388 } else {
8389 std::path::PathBuf::from("/etc/hosts")
8390 };
8391
8392 let _ = write!(out, "Path: {}\n\n", hosts_path.display());
8393
8394 match fs::read_to_string(&hosts_path) {
8395 Ok(content) => {
8396 let mut active_entries: Vec<String> = Vec::new();
8397 let mut comment_lines = 0usize;
8398 let mut blank_lines = 0usize;
8399
8400 for line in content.lines() {
8401 let t = line.trim();
8402 if t.is_empty() {
8403 blank_lines += 1;
8404 } else if t.starts_with('#') {
8405 comment_lines += 1;
8406 } else {
8407 active_entries.push(line.to_string());
8408 }
8409 }
8410
8411 let _ = write!(
8412 out,
8413 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
8414 active_entries.len(),
8415 comment_lines,
8416 blank_lines
8417 );
8418
8419 if active_entries.is_empty() {
8420 out.push_str(
8421 "No active host entries (file contains only comments/blanks — standard default state).\n",
8422 );
8423 } else {
8424 out.push_str("=== Active entries ===\n");
8425 for entry in &active_entries {
8426 let _ = writeln!(out, " {entry}");
8427 }
8428 out.push('\n');
8429
8430 let custom: Vec<&String> = active_entries
8431 .iter()
8432 .filter(|e| {
8433 let t = e.trim_start();
8434 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
8435 })
8436 .collect();
8437 if !custom.is_empty() {
8438 let _ = writeln!(out, "[!] Custom (non-loopback) entries: {}", custom.len());
8439 for e in &custom {
8440 let _ = writeln!(out, " {e}");
8441 }
8442 } else {
8443 out.push_str("All active entries are standard loopback or block entries.\n");
8444 }
8445 }
8446
8447 out.push_str("\n=== Full file ===\n");
8448 for line in content.lines() {
8449 let _ = writeln!(out, " {line}");
8450 }
8451 }
8452 Err(e) => {
8453 let _ = writeln!(out, "Could not read hosts file: {e}");
8454 if cfg!(target_os = "windows") {
8455 out.push_str(
8456 "On Windows, run Hematite as Administrator if permission is denied.\n",
8457 );
8458 }
8459 }
8460 }
8461
8462 Ok(out.trim_end().to_string())
8463}
8464
8465struct AuditFinding {
8468 finding: String,
8469 impact: String,
8470 fix: String,
8471}
8472
8473#[cfg(target_os = "windows")]
8474#[derive(Debug, Clone)]
8475struct WindowsPnpDevice {
8476 name: String,
8477 status: String,
8478 problem: Option<u64>,
8479 class_name: Option<String>,
8480 instance_id: Option<String>,
8481}
8482
8483#[cfg(target_os = "windows")]
8484#[derive(Debug, Clone)]
8485struct WindowsSoundDevice {
8486 name: String,
8487 status: String,
8488 manufacturer: Option<String>,
8489}
8490
8491struct DockerMountAudit {
8492 mount_type: String,
8493 source: Option<String>,
8494 destination: String,
8495 name: Option<String>,
8496 read_write: Option<bool>,
8497 driver: Option<String>,
8498 exists_on_host: Option<bool>,
8499}
8500
8501struct DockerContainerAudit {
8502 name: String,
8503 image: String,
8504 status: String,
8505 mounts: Vec<DockerMountAudit>,
8506}
8507
8508struct DockerVolumeAudit {
8509 name: String,
8510 driver: String,
8511 mountpoint: Option<String>,
8512 scope: Option<String>,
8513}
8514
8515#[cfg(target_os = "windows")]
8516struct WslDistroAudit {
8517 name: String,
8518 state: String,
8519 version: String,
8520}
8521
8522#[cfg(target_os = "windows")]
8523struct WslRootUsage {
8524 total_kb: u64,
8525 used_kb: u64,
8526 avail_kb: u64,
8527 use_percent: String,
8528 mnt_c_present: Option<bool>,
8529}
8530
8531fn docker_engine_version() -> Result<String, String> {
8532 let version_output = Command::new("docker")
8533 .args(["version", "--format", "{{.Server.Version}}"])
8534 .output();
8535
8536 match version_output {
8537 Err(_) => Err(
8538 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8539 ),
8540 Ok(o) if !o.status.success() => {
8541 let stderr = String::from_utf8_lossy(&o.stderr);
8542 if stderr.contains("cannot connect")
8543 || stderr.contains("Is the docker daemon running")
8544 || stderr.contains("pipe")
8545 || stderr.contains("socket")
8546 {
8547 Err(
8548 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8549 )
8550 } else {
8551 Err(format!("Docker: error - {}", stderr.trim()))
8552 }
8553 }
8554 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8555 }
8556}
8557
8558fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8559 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8560 return Vec::new();
8561 };
8562 let Value::Array(entries) = value else {
8563 return Vec::new();
8564 };
8565
8566 let mut mounts = Vec::with_capacity(entries.len());
8567 for entry in entries {
8568 let mount_type = entry
8569 .get("Type")
8570 .and_then(|v| v.as_str())
8571 .unwrap_or("unknown")
8572 .to_string();
8573 let source = entry
8574 .get("Source")
8575 .and_then(|v| v.as_str())
8576 .map(|v| v.to_string());
8577 let destination = entry
8578 .get("Destination")
8579 .and_then(|v| v.as_str())
8580 .unwrap_or("?")
8581 .to_string();
8582 let name = entry
8583 .get("Name")
8584 .and_then(|v| v.as_str())
8585 .map(|v| v.to_string());
8586 let read_write = entry.get("RW").and_then(|v| v.as_bool());
8587 let driver = entry
8588 .get("Driver")
8589 .and_then(|v| v.as_str())
8590 .map(|v| v.to_string());
8591 let exists_on_host = if mount_type == "bind" {
8592 source.as_deref().map(|path| Path::new(path).exists())
8593 } else {
8594 None
8595 };
8596 mounts.push(DockerMountAudit {
8597 mount_type,
8598 source,
8599 destination,
8600 name,
8601 read_write,
8602 driver,
8603 exists_on_host,
8604 });
8605 }
8606
8607 mounts
8608}
8609
8610fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8611 let mut audit = DockerVolumeAudit {
8612 name: name.to_string(),
8613 driver: "unknown".to_string(),
8614 mountpoint: None,
8615 scope: None,
8616 };
8617
8618 if let Ok(output) = Command::new("docker")
8619 .args(["volume", "inspect", name, "--format", "{{json .}}"])
8620 .output()
8621 {
8622 if output.status.success() {
8623 if let Ok(value) =
8624 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8625 {
8626 audit.driver = value
8627 .get("Driver")
8628 .and_then(|v| v.as_str())
8629 .unwrap_or("unknown")
8630 .to_string();
8631 audit.mountpoint = value
8632 .get("Mountpoint")
8633 .and_then(|v| v.as_str())
8634 .map(|v| v.to_string());
8635 audit.scope = value
8636 .get("Scope")
8637 .and_then(|v| v.as_str())
8638 .map(|v| v.to_string());
8639 }
8640 }
8641 }
8642
8643 audit
8644}
8645
8646#[cfg(target_os = "windows")]
8647fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8648 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8649 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8650 let path = local_app_data
8651 .join("Docker")
8652 .join("wsl")
8653 .join("disk")
8654 .join(file_name);
8655 if let Ok(metadata) = fs::metadata(&path) {
8656 return Some((path, metadata.len()));
8657 }
8658 }
8659 None
8660}
8661
8662#[cfg(target_os = "windows")]
8663fn clean_wsl_text(raw: &[u8]) -> String {
8664 String::from_utf8_lossy(raw)
8665 .chars()
8666 .filter(|c| *c != '\0')
8667 .collect()
8668}
8669
8670#[cfg(target_os = "windows")]
8671fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8672 let mut distros = Vec::new();
8673 for line in raw.lines() {
8674 let trimmed = line.trim();
8675 if trimmed.is_empty()
8676 || trimmed.to_uppercase().starts_with("NAME")
8677 || trimmed.starts_with("---")
8678 {
8679 continue;
8680 }
8681 let normalized = trimmed.trim_start_matches('*').trim();
8682 let cols: Vec<&str> = normalized.split_whitespace().collect();
8683 if cols.len() < 3 {
8684 continue;
8685 }
8686 let version = cols[cols.len() - 1].to_string();
8687 let state = cols[cols.len() - 2].to_string();
8688 let name = cols[..cols.len() - 2].join(" ");
8689 if !name.is_empty() {
8690 distros.push(WslDistroAudit {
8691 name,
8692 state,
8693 version,
8694 });
8695 }
8696 }
8697 distros
8698}
8699
8700#[cfg(target_os = "windows")]
8701fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8702 let output = Command::new("wsl")
8703 .args([
8704 "-d",
8705 distro_name,
8706 "--",
8707 "sh",
8708 "-lc",
8709 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8710 ])
8711 .output()
8712 .ok()?;
8713 if !output.status.success() {
8714 return None;
8715 }
8716
8717 let text = clean_wsl_text(&output.stdout);
8718 let mut total_kb = 0;
8719 let mut used_kb = 0;
8720 let mut avail_kb = 0;
8721 let mut use_percent = String::from("unknown");
8722 let mut mnt_c_present = None;
8723
8724 for line in text.lines() {
8725 let trimmed = line.trim();
8726 if trimmed.starts_with("__MNTC__:") {
8727 mnt_c_present = Some(trimmed.ends_with("ok"));
8728 continue;
8729 }
8730 let mut it = trimmed.split_whitespace();
8731 if let (Some(_), Some(total), Some(used), Some(avail), Some(pct), Some(_)) = (
8732 it.next(),
8733 it.next(),
8734 it.next(),
8735 it.next(),
8736 it.next(),
8737 it.next(),
8738 ) {
8739 total_kb = total.parse::<u64>().unwrap_or(0);
8740 used_kb = used.parse::<u64>().unwrap_or(0);
8741 avail_kb = avail.parse::<u64>().unwrap_or(0);
8742 use_percent = pct.to_string();
8743 }
8744 }
8745
8746 Some(WslRootUsage {
8747 total_kb,
8748 used_kb,
8749 avail_kb,
8750 use_percent,
8751 mnt_c_present,
8752 })
8753}
8754
8755#[cfg(target_os = "windows")]
8756fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8757 let mut vhds = Vec::new();
8758 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8759 return vhds;
8760 };
8761 let packages_dir = local_app_data.join("Packages");
8762 let Ok(entries) = fs::read_dir(packages_dir) else {
8763 return vhds;
8764 };
8765
8766 for entry in entries.flatten() {
8767 let path = entry.path().join("LocalState").join("ext4.vhdx");
8768 if let Ok(metadata) = fs::metadata(&path) {
8769 vhds.push((path, metadata.len()));
8770 }
8771 }
8772 vhds.sort_by_key(|b| std::cmp::Reverse(b.1));
8773 vhds
8774}
8775
8776fn inspect_docker(max_entries: usize) -> Result<String, String> {
8777 let mut out = String::from("Host inspection: docker\n\n");
8778 let n = max_entries.clamp(5, 25);
8779
8780 let version_output = Command::new("docker")
8781 .args(["version", "--format", "{{.Server.Version}}"])
8782 .output();
8783
8784 match version_output {
8785 Err(_) => {
8786 out.push_str("Docker: not found on PATH.\n");
8787 out.push_str(
8788 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8789 );
8790 return Ok(out.trim_end().to_string());
8791 }
8792 Ok(o) if !o.status.success() => {
8793 let stderr = String::from_utf8_lossy(&o.stderr);
8794 if stderr.contains("cannot connect")
8795 || stderr.contains("Is the docker daemon running")
8796 || stderr.contains("pipe")
8797 || stderr.contains("socket")
8798 {
8799 out.push_str("Docker: installed but daemon is NOT running.\n");
8800 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8801 } else {
8802 let _ = writeln!(out, "Docker: error — {}", stderr.trim());
8803 }
8804 return Ok(out.trim_end().to_string());
8805 }
8806 Ok(o) => {
8807 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8808 let _ = writeln!(out, "Docker Engine: {version}");
8809 }
8810 }
8811
8812 if let Ok(o) = Command::new("docker")
8813 .args([
8814 "info",
8815 "--format",
8816 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8817 ])
8818 .output()
8819 {
8820 let info = String::from_utf8_lossy(&o.stdout);
8821 for line in info.lines() {
8822 let t = line.trim();
8823 if !t.is_empty() {
8824 let _ = writeln!(out, " {t}");
8825 }
8826 }
8827 out.push('\n');
8828 }
8829
8830 if let Ok(o) = Command::new("docker")
8831 .args([
8832 "ps",
8833 "--format",
8834 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8835 ])
8836 .output()
8837 {
8838 let raw = String::from_utf8_lossy(&o.stdout);
8839 let lines: Vec<&str> = raw.lines().collect();
8840 if lines.len() <= 1 {
8841 out.push_str("Running containers: none\n\n");
8842 } else {
8843 let _ = writeln!(
8844 out,
8845 "=== Running containers ({}) ===",
8846 lines.len().saturating_sub(1)
8847 );
8848 for line in lines.iter().take(n + 1) {
8849 let _ = writeln!(out, " {line}");
8850 }
8851 if lines.len() > n + 1 {
8852 let _ = writeln!(out, " ... and {} more", lines.len() - n - 1);
8853 }
8854 out.push('\n');
8855 }
8856 }
8857
8858 if let Ok(o) = Command::new("docker")
8859 .args([
8860 "images",
8861 "--format",
8862 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8863 ])
8864 .output()
8865 {
8866 let raw = String::from_utf8_lossy(&o.stdout);
8867 let lines: Vec<&str> = raw.lines().collect();
8868 if lines.len() > 1 {
8869 let _ = writeln!(
8870 out,
8871 "=== Local images ({}) ===",
8872 lines.len().saturating_sub(1)
8873 );
8874 for line in lines.iter().take(n + 1) {
8875 let _ = writeln!(out, " {line}");
8876 }
8877 if lines.len() > n + 1 {
8878 let _ = writeln!(out, " ... and {} more", lines.len() - n - 1);
8879 }
8880 out.push('\n');
8881 }
8882 }
8883
8884 if let Ok(o) = Command::new("docker")
8885 .args([
8886 "compose",
8887 "ls",
8888 "--format",
8889 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8890 ])
8891 .output()
8892 {
8893 let raw = String::from_utf8_lossy(&o.stdout);
8894 let lines: Vec<&str> = raw.lines().collect();
8895 if lines.len() > 1 {
8896 let _ = writeln!(
8897 out,
8898 "=== Compose projects ({}) ===",
8899 lines.len().saturating_sub(1)
8900 );
8901 for line in lines.iter().take(n + 1) {
8902 let _ = writeln!(out, " {line}");
8903 }
8904 out.push('\n');
8905 }
8906 }
8907
8908 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8909 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8910 if !ctx.is_empty() {
8911 let _ = writeln!(out, "Active context: {ctx}");
8912 }
8913 }
8914
8915 Ok(out.trim_end().to_string())
8916}
8917
8918fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8921 let mut out = String::from("Host inspection: docker_filesystems\n\n");
8922 let n = max_entries.clamp(3, 12);
8923
8924 match docker_engine_version() {
8925 Ok(version) => {
8926 let _ = writeln!(out, "Docker Engine: {version}");
8927 }
8928 Err(message) => {
8929 out.push_str(&message);
8930 return Ok(out.trim_end().to_string());
8931 }
8932 }
8933
8934 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8935 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8936 if !ctx.is_empty() {
8937 let _ = writeln!(out, "Active context: {ctx}");
8938 }
8939 }
8940 out.push('\n');
8941
8942 let mut containers = Vec::with_capacity(n);
8943 if let Ok(o) = Command::new("docker")
8944 .args([
8945 "ps",
8946 "-a",
8947 "--format",
8948 "{{.Names}}\t{{.Image}}\t{{.Status}}",
8949 ])
8950 .output()
8951 {
8952 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8953 let mut it = line.splitn(3, '\t');
8954 let (Some(name_raw), Some(image_raw), Some(status_raw)) =
8955 (it.next(), it.next(), it.next())
8956 else {
8957 continue;
8958 };
8959 let name = name_raw.trim().to_string();
8960 if name.is_empty() {
8961 continue;
8962 }
8963 let inspect_output = Command::new("docker")
8964 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8965 .output();
8966 let mounts = match inspect_output {
8967 Ok(result) if result.status.success() => {
8968 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8969 }
8970 _ => Vec::new(),
8971 };
8972 containers.push(DockerContainerAudit {
8973 name,
8974 image: image_raw.trim().to_string(),
8975 status: status_raw.trim().to_string(),
8976 mounts,
8977 });
8978 }
8979 }
8980
8981 let mut volumes = Vec::with_capacity(n);
8982 if let Ok(o) = Command::new("docker")
8983 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8984 .output()
8985 {
8986 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8987 let mut it = line.split('\t');
8988 let Some(name) = it.next().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8989 continue;
8990 };
8991 let driver_hint = it.next().map(|v| v.trim()).filter(|v| !v.is_empty());
8992 let mut audit = inspect_docker_volume(name);
8993 if audit.driver == "unknown" {
8994 audit.driver = driver_hint.unwrap_or("unknown").to_string();
8995 }
8996 volumes.push(audit);
8997 }
8998 }
8999
9000 let mut findings = Vec::with_capacity(4);
9001 for container in &containers {
9002 for mount in &container.mounts {
9003 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
9004 let source = mount.source.as_deref().unwrap_or("<unknown>");
9005 findings.push(AuditFinding {
9006 finding: format!(
9007 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
9008 container.name, source, mount.destination
9009 ),
9010 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
9011 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
9012 });
9013 }
9014 }
9015 }
9016
9017 #[cfg(target_os = "windows")]
9018 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
9019 if size_bytes >= 20 * 1024 * 1024 * 1024 {
9020 findings.push(AuditFinding {
9021 finding: format!(
9022 "Docker Desktop disk image is large: {} at {}",
9023 human_bytes(size_bytes),
9024 path.display()
9025 ),
9026 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
9027 fix: "Review `docker system df`, prune unused images, containers, and volumes if safe, then compact the Docker Desktop disk with your normal maintenance workflow.".to_string(),
9028 });
9029 }
9030 }
9031
9032 out.push_str("=== Findings ===\n");
9033 if findings.is_empty() {
9034 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
9035 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
9036 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
9037 } else {
9038 for finding in &findings {
9039 let _ = writeln!(out, "- Finding: {}", finding.finding);
9040 let _ = writeln!(out, " Impact: {}", finding.impact);
9041 let _ = writeln!(out, " Fix: {}", finding.fix);
9042 }
9043 }
9044
9045 out.push_str("\n=== Container mount summary ===\n");
9046 if containers.is_empty() {
9047 out.push_str("- No containers found.\n");
9048 } else {
9049 for container in &containers {
9050 let _ = writeln!(
9051 out,
9052 "- {} ({}) [{}]",
9053 container.name, container.image, container.status
9054 );
9055 if container.mounts.is_empty() {
9056 out.push_str(" - no mounts reported\n");
9057 continue;
9058 }
9059 for mount in &container.mounts {
9060 let mut source = mount
9061 .name
9062 .clone()
9063 .or_else(|| mount.source.clone())
9064 .unwrap_or_else(|| "<unknown>".to_string());
9065 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
9066 source.push_str(" [missing]");
9067 }
9068 let mut extras = Vec::with_capacity(2);
9069 if let Some(rw) = mount.read_write {
9070 extras.push(if rw { "rw" } else { "ro" }.to_string());
9071 }
9072 if let Some(driver) = &mount.driver {
9073 extras.push(format!("driver={driver}"));
9074 }
9075 let extra_suffix = if extras.is_empty() {
9076 String::new()
9077 } else {
9078 format!(" ({})", extras.join(", "))
9079 };
9080 let _ = writeln!(
9081 out,
9082 " - {}: {} -> {}{}",
9083 mount.mount_type, source, mount.destination, extra_suffix
9084 );
9085 }
9086 }
9087 }
9088
9089 out.push_str("\n=== Named volumes ===\n");
9090 if volumes.is_empty() {
9091 out.push_str("- No named volumes found.\n");
9092 } else {
9093 for volume in &volumes {
9094 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
9095 if let Some(scope) = &volume.scope {
9096 let _ = write!(detail, ", scope: {scope}");
9097 }
9098 if let Some(mountpoint) = &volume.mountpoint {
9099 let _ = write!(detail, ", mountpoint: {mountpoint}");
9100 }
9101 let _ = writeln!(out, "{detail}");
9102 }
9103 }
9104
9105 #[cfg(target_os = "windows")]
9106 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
9107 out.push_str("\n=== Docker Desktop disk ===\n");
9108 let _ = writeln!(out, "- {} at {}", human_bytes(size_bytes), path.display());
9109 }
9110
9111 Ok(out.trim_end().to_string())
9112}
9113
9114fn inspect_wsl() -> Result<String, String> {
9115 let mut out = String::from("Host inspection: wsl\n\n");
9116
9117 #[cfg(target_os = "windows")]
9118 {
9119 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
9120 let raw = String::from_utf8_lossy(&o.stdout);
9121 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
9122 for line in cleaned.lines().take(4) {
9123 let t = line.trim();
9124 if !t.is_empty() {
9125 let _ = writeln!(out, " {t}");
9126 }
9127 }
9128 out.push('\n');
9129 }
9130
9131 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
9132 match list_output {
9133 Err(e) => {
9134 let _ = writeln!(out, "WSL: wsl.exe error: {e}");
9135 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
9136 }
9137 Ok(o) if !o.status.success() => {
9138 let stderr = String::from_utf8_lossy(&o.stderr);
9139 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
9140 let _ = writeln!(out, "WSL: error — {}", cleaned.trim());
9141 out.push_str("Run: wsl --install\n");
9142 }
9143 Ok(o) => {
9144 let raw = String::from_utf8_lossy(&o.stdout);
9145 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
9146 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
9147 let distro_lines: Vec<&str> = lines
9148 .iter()
9149 .filter(|l| {
9150 let t = l.trim();
9151 !t.is_empty()
9152 && !t.to_uppercase().starts_with("NAME")
9153 && !t.starts_with("---")
9154 })
9155 .copied()
9156 .collect();
9157
9158 if distro_lines.is_empty() {
9159 out.push_str("WSL: installed but no distributions found.\n");
9160 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
9161 } else {
9162 out.push_str("=== WSL Distributions ===\n");
9163 for line in &lines {
9164 let _ = writeln!(out, " {}", line.trim());
9165 }
9166 let _ = write!(out, "\nTotal distributions: {}\n", distro_lines.len());
9167 }
9168 }
9169 }
9170
9171 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
9172 let raw = String::from_utf8_lossy(&o.stdout);
9173 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
9174 let status_lines: Vec<&str> = cleaned
9175 .lines()
9176 .filter(|l| !l.trim().is_empty())
9177 .take(8)
9178 .collect();
9179 if !status_lines.is_empty() {
9180 out.push_str("\n=== WSL status ===\n");
9181 for line in status_lines {
9182 let _ = writeln!(out, " {}", line.trim());
9183 }
9184 }
9185 }
9186 }
9187
9188 #[cfg(not(target_os = "windows"))]
9189 {
9190 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
9191 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
9192 }
9193
9194 Ok(out.trim_end().to_string())
9195}
9196
9197fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
9200 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
9201
9202 #[cfg(target_os = "windows")]
9203 {
9204 let n = max_entries.clamp(3, 12);
9205 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
9206 let distros = match list_output {
9207 Err(e) => {
9208 let _ = writeln!(out, "WSL: wsl.exe error: {e}");
9209 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
9210 return Ok(out.trim_end().to_string());
9211 }
9212 Ok(o) if !o.status.success() => {
9213 let cleaned = clean_wsl_text(&o.stderr);
9214 let _ = writeln!(out, "WSL: error - {}", cleaned.trim());
9215 out.push_str("Run: wsl --install\n");
9216 return Ok(out.trim_end().to_string());
9217 }
9218 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
9219 };
9220
9221 let _ = write!(out, "Distributions detected: {}\n\n", distros.len());
9222
9223 let vhdx_files = collect_wsl_vhdx_files();
9224 let mut findings = Vec::with_capacity(4);
9225 let mut live_usage = Vec::with_capacity(n);
9226
9227 for distro in distros.iter().take(n) {
9228 if distro.state.eq_ignore_ascii_case("Running") {
9229 if let Some(usage) = wsl_root_usage(&distro.name) {
9230 if let Some(false) = usage.mnt_c_present {
9231 findings.push(AuditFinding {
9232 finding: format!(
9233 "Distro '{}' is running without /mnt/c available",
9234 distro.name
9235 ),
9236 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
9237 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
9238 });
9239 }
9240
9241 let percent_num = usage
9242 .use_percent
9243 .trim_end_matches('%')
9244 .parse::<u32>()
9245 .unwrap_or(0);
9246 if percent_num >= 85 {
9247 findings.push(AuditFinding {
9248 finding: format!(
9249 "Distro '{}' root filesystem is {} full",
9250 distro.name, usage.use_percent
9251 ),
9252 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
9253 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
9254 });
9255 }
9256 live_usage.push((distro.name.clone(), usage));
9257 }
9258 }
9259 }
9260
9261 for (path, size_bytes) in vhdx_files.iter().take(n) {
9262 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
9263 findings.push(AuditFinding {
9264 finding: format!(
9265 "Host-side WSL disk image is large: {} at {}",
9266 human_bytes(*size_bytes),
9267 path.display()
9268 ),
9269 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
9270 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
9271 });
9272 }
9273 }
9274
9275 out.push_str("=== Findings ===\n");
9276 if findings.is_empty() {
9277 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
9278 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
9279 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
9280 } else {
9281 for finding in &findings {
9282 let _ = writeln!(out, "- Finding: {}", finding.finding);
9283 let _ = writeln!(out, " Impact: {}", finding.impact);
9284 let _ = writeln!(out, " Fix: {}", finding.fix);
9285 }
9286 }
9287
9288 out.push_str("\n=== Distro bridge and root usage ===\n");
9289 if distros.is_empty() {
9290 out.push_str("- No WSL distributions found.\n");
9291 } else {
9292 for distro in distros.iter().take(n) {
9293 let _ = writeln!(
9294 out,
9295 "- {} [state: {}, version: {}]",
9296 distro.name, distro.state, distro.version
9297 );
9298 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
9299 let _ = writeln!(
9300 out,
9301 " - rootfs: {} used / {} total ({}), free: {}",
9302 human_bytes(usage.used_kb * 1024),
9303 human_bytes(usage.total_kb * 1024),
9304 usage.use_percent,
9305 human_bytes(usage.avail_kb * 1024)
9306 );
9307 match usage.mnt_c_present {
9308 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
9309 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
9310 None => out.push_str(" - /mnt/c bridge: unknown\n"),
9311 }
9312 } else if distro.state.eq_ignore_ascii_case("Running") {
9313 out.push_str(" - live rootfs check: unavailable\n");
9314 } else {
9315 out.push_str(
9316 " - live rootfs check: skipped to avoid starting a stopped distro\n",
9317 );
9318 }
9319 }
9320 }
9321
9322 out.push_str("\n=== Host-side VHDX files ===\n");
9323 if vhdx_files.is_empty() {
9324 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
9325 } else {
9326 for (path, size_bytes) in vhdx_files.iter().take(n) {
9327 let _ = writeln!(out, "- {} at {}", human_bytes(*size_bytes), path.display());
9328 }
9329 }
9330 }
9331
9332 #[cfg(not(target_os = "windows"))]
9333 {
9334 let _ = max_entries;
9335 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
9336 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
9337 }
9338
9339 Ok(out.trim_end().to_string())
9340}
9341
9342fn dirs_home() -> Option<PathBuf> {
9343 std::env::var("HOME")
9344 .ok()
9345 .map(PathBuf::from)
9346 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
9347}
9348
9349fn inspect_ssh() -> Result<String, String> {
9350 let mut out = String::from("Host inspection: ssh\n\n");
9351
9352 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
9353 let ver = if o.stdout.is_empty() {
9354 String::from_utf8_lossy(&o.stderr).trim().to_string()
9355 } else {
9356 String::from_utf8_lossy(&o.stdout).trim().to_string()
9357 };
9358 if !ver.is_empty() {
9359 let _ = writeln!(out, "SSH client: {ver}");
9360 }
9361 } else {
9362 out.push_str("SSH client: not found on PATH.\n");
9363 }
9364
9365 #[cfg(target_os = "windows")]
9366 {
9367 let script = r#"
9368$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
9369if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
9370else { "SSHD:not_installed" }
9371"#;
9372 if let Ok(o) = Command::new("powershell")
9373 .args(["-NoProfile", "-Command", script])
9374 .output()
9375 {
9376 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9377 if text.contains("not_installed") {
9378 out.push_str("SSH server (sshd): not installed\n");
9379 } else {
9380 let _ = writeln!(
9381 out,
9382 "SSH server (sshd): {}",
9383 text.trim_start_matches("SSHD:")
9384 );
9385 }
9386 }
9387 }
9388
9389 #[cfg(not(target_os = "windows"))]
9390 {
9391 if let Ok(o) = Command::new("systemctl")
9392 .args(["is-active", "sshd"])
9393 .output()
9394 {
9395 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
9396 let _ = write!(out, "SSH server (sshd): {status}\n");
9397 } else if let Ok(o) = Command::new("systemctl")
9398 .args(["is-active", "ssh"])
9399 .output()
9400 {
9401 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
9402 let _ = write!(out, "SSH server (ssh): {status}\n");
9403 }
9404 }
9405
9406 out.push('\n');
9407
9408 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
9409 if ssh_dir.exists() {
9410 let _ = writeln!(out, "~/.ssh: {}", ssh_dir.display());
9411
9412 let kh = ssh_dir.join("known_hosts");
9413 if kh.exists() {
9414 let count = fs::read_to_string(&kh)
9415 .map(|c| {
9416 c.lines()
9417 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
9418 .count()
9419 })
9420 .unwrap_or(0);
9421 let _ = writeln!(out, " known_hosts: {count} entries");
9422 } else {
9423 out.push_str(" known_hosts: not present\n");
9424 }
9425
9426 let ak = ssh_dir.join("authorized_keys");
9427 if ak.exists() {
9428 let count = fs::read_to_string(&ak)
9429 .map(|c| {
9430 c.lines()
9431 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
9432 .count()
9433 })
9434 .unwrap_or(0);
9435 let _ = writeln!(out, " authorized_keys: {count} public keys");
9436 } else {
9437 out.push_str(" authorized_keys: not present\n");
9438 }
9439
9440 let key_names = [
9441 "id_rsa",
9442 "id_ed25519",
9443 "id_ecdsa",
9444 "id_dsa",
9445 "id_ecdsa_sk",
9446 "id_ed25519_sk",
9447 ];
9448 let found_keys: Vec<&str> = key_names
9449 .iter()
9450 .filter(|k| ssh_dir.join(k).exists())
9451 .copied()
9452 .collect();
9453 if !found_keys.is_empty() {
9454 let _ = writeln!(out, " Private keys: {}", found_keys.join(", "));
9455 } else {
9456 out.push_str(" Private keys: none found\n");
9457 }
9458
9459 let config_path = ssh_dir.join("config");
9460 if config_path.exists() {
9461 out.push_str("\n=== SSH config hosts ===\n");
9462 match fs::read_to_string(&config_path) {
9463 Ok(content) => {
9464 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
9465 let mut current: Option<(String, Vec<String>)> = None;
9466 for line in content.lines() {
9467 let t = line.trim();
9468 if t.is_empty() || t.starts_with('#') {
9469 continue;
9470 }
9471 if let Some(host) = t.strip_prefix("Host ") {
9472 if let Some(prev) = current.take() {
9473 hosts.push(prev);
9474 }
9475 current = Some((host.trim().to_string(), Vec::new()));
9476 } else if let Some((_, ref mut details)) = current {
9477 let tu = t.to_uppercase();
9478 if tu.starts_with("HOSTNAME ")
9479 || tu.starts_with("USER ")
9480 || tu.starts_with("PORT ")
9481 || tu.starts_with("IDENTITYFILE ")
9482 {
9483 details.push(t.to_string());
9484 }
9485 }
9486 }
9487 if let Some(prev) = current {
9488 hosts.push(prev);
9489 }
9490
9491 if hosts.is_empty() {
9492 out.push_str(" No Host entries found.\n");
9493 } else {
9494 for (h, details) in &hosts {
9495 if details.is_empty() {
9496 let _ = writeln!(out, " Host {h}");
9497 } else {
9498 let _ = writeln!(out, " Host {h} [{}]", details.join(", "));
9499 }
9500 }
9501 let _ = write!(out, "\n Total configured hosts: {}\n", hosts.len());
9502 }
9503 }
9504 Err(e) => {
9505 let _ = writeln!(out, " Could not read config: {e}");
9506 }
9507 }
9508 } else {
9509 out.push_str(" SSH config: not present\n");
9510 }
9511 } else {
9512 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9513 }
9514 }
9515
9516 Ok(out.trim_end().to_string())
9517}
9518
9519fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9522 let mut out = String::from("Host inspection: installed_software\n\n");
9523 let n = max_entries.clamp(10, 50);
9524
9525 #[cfg(target_os = "windows")]
9526 {
9527 let winget_out = Command::new("winget")
9528 .args(["list", "--accept-source-agreements"])
9529 .output();
9530
9531 if let Ok(o) = winget_out {
9532 if o.status.success() {
9533 let raw = String::from_utf8_lossy(&o.stdout);
9534 let mut header_done = false;
9535 let mut packages: Vec<&str> = Vec::new();
9536 for line in raw.lines() {
9537 let t = line.trim();
9538 if t.starts_with("---") {
9539 header_done = true;
9540 continue;
9541 }
9542 if header_done && !t.is_empty() {
9543 packages.push(line);
9544 }
9545 }
9546 let total = packages.len();
9547 let _ = write!(
9548 out,
9549 "=== Installed software via winget ({total} packages) ===\n\n"
9550 );
9551 for line in packages.iter().take(n) {
9552 let _ = writeln!(out, " {line}");
9553 }
9554 if total > n {
9555 let _ = write!(out, "\n ... and {} more packages\n", total - n);
9556 }
9557 out.push_str("\nFor full list: winget list\n");
9558 return Ok(out.trim_end().to_string());
9559 }
9560 }
9561
9562 let script = format!(
9564 r#"
9565$apps = @()
9566$reg_paths = @(
9567 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9568 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9569 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9570)
9571foreach ($p in $reg_paths) {{
9572 try {{
9573 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9574 Where-Object {{ $_.DisplayName }} |
9575 Select-Object DisplayName, DisplayVersion, Publisher
9576 }} catch {{}}
9577}}
9578$sorted = $apps | Sort-Object DisplayName -Unique
9579"TOTAL:" + $sorted.Count
9580$sorted | Select-Object -First {n} | ForEach-Object {{
9581 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9582}}
9583"#
9584 );
9585 if let Ok(o) = Command::new("powershell")
9586 .args(["-NoProfile", "-Command", &script])
9587 .output()
9588 {
9589 let raw = String::from_utf8_lossy(&o.stdout);
9590 out.push_str("=== Installed software (registry scan) ===\n");
9591 let _ = writeln!(out, " {:<50} {:<18} Publisher", "Name", "Version");
9592 let _ = writeln!(out, " {}", "-".repeat(90));
9593 for line in raw.lines() {
9594 if let Some(rest) = line.strip_prefix("TOTAL:") {
9595 let total: usize = rest.trim().parse().unwrap_or(0);
9596 let _ = write!(out, " (Total: {total}, showing first {n})\n\n");
9597 } else if !line.trim().is_empty() {
9598 let mut it = line.splitn(3, '|');
9599 let name = it.next().map(str::trim).unwrap_or("");
9600 let ver = it.next().map(str::trim).unwrap_or("");
9601 let pub_ = it.next().map(str::trim).unwrap_or("");
9602 let _ = writeln!(out, " {:<50} {:<18} {pub_}", name, ver);
9603 }
9604 }
9605 } else {
9606 out.push_str(
9607 "Could not query installed software (winget and registry scan both failed).\n",
9608 );
9609 }
9610 }
9611
9612 #[cfg(target_os = "linux")]
9613 {
9614 let mut found = false;
9615 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9616 if o.status.success() {
9617 let raw = String::from_utf8_lossy(&o.stdout);
9618 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9619 let total = installed.len();
9620 let _ = write!(out, "=== Installed packages via dpkg ({total}) ===\n");
9621 for line in installed.iter().take(n) {
9622 let _ = write!(out, " {}\n", line.trim());
9623 }
9624 if total > n {
9625 let _ = write!(out, " ... and {} more\n", total - n);
9626 }
9627 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9628 found = true;
9629 }
9630 }
9631 if !found {
9632 if let Ok(o) = Command::new("rpm")
9633 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9634 .output()
9635 {
9636 if o.status.success() {
9637 let raw = String::from_utf8_lossy(&o.stdout);
9638 let lines: Vec<&str> = raw.lines().collect();
9639 let total = lines.len();
9640 let _ = write!(out, "=== Installed packages via rpm ({total}) ===\n");
9641 for line in lines.iter().take(n) {
9642 let _ = write!(out, " {line}\n");
9643 }
9644 if total > n {
9645 let _ = write!(out, " ... and {} more\n", total - n);
9646 }
9647 found = true;
9648 }
9649 }
9650 }
9651 if !found {
9652 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9653 if o.status.success() {
9654 let raw = String::from_utf8_lossy(&o.stdout);
9655 let lines: Vec<&str> = raw.lines().collect();
9656 let total = lines.len();
9657 let _ = write!(out, "=== Installed packages via pacman ({total}) ===\n");
9658 for line in lines.iter().take(n) {
9659 let _ = write!(out, " {line}\n");
9660 }
9661 if total > n {
9662 let _ = write!(out, " ... and {} more\n", total - n);
9663 }
9664 found = true;
9665 }
9666 }
9667 }
9668 if !found {
9669 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9670 }
9671 }
9672
9673 #[cfg(target_os = "macos")]
9674 {
9675 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9676 if o.status.success() {
9677 let raw = String::from_utf8_lossy(&o.stdout);
9678 let lines: Vec<&str> = raw.lines().collect();
9679 let total = lines.len();
9680 let _ = write!(out, "=== Homebrew packages ({total}) ===\n");
9681 for line in lines.iter().take(n) {
9682 let _ = write!(out, " {line}\n");
9683 }
9684 if total > n {
9685 let _ = write!(out, " ... and {} more\n", total - n);
9686 }
9687 out.push_str("\nFor full list: brew list --versions\n");
9688 }
9689 } else {
9690 out.push_str("Homebrew not found.\n");
9691 }
9692 if let Ok(o) = Command::new("mas").args(["list"]).output() {
9693 if o.status.success() {
9694 let raw = String::from_utf8_lossy(&o.stdout);
9695 let lines: Vec<&str> = raw.lines().collect();
9696 let _ = write!(out, "\n=== Mac App Store apps ({}) ===\n", lines.len());
9697 for line in lines.iter().take(n) {
9698 let _ = write!(out, " {line}\n");
9699 }
9700 }
9701 }
9702 }
9703
9704 Ok(out.trim_end().to_string())
9705}
9706
9707fn inspect_git_config() -> Result<String, String> {
9710 let mut out = String::from("Host inspection: git_config\n\n");
9711
9712 if let Ok(o) = Command::new("git").args(["--version"]).output() {
9713 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9714 let _ = write!(out, "Git: {ver}\n\n");
9715 } else {
9716 out.push_str("Git: not found on PATH.\n");
9717 return Ok(out.trim_end().to_string());
9718 }
9719
9720 if let Ok(o) = Command::new("git")
9721 .args(["config", "--global", "--list"])
9722 .output()
9723 {
9724 if o.status.success() {
9725 let raw = String::from_utf8_lossy(&o.stdout);
9726 let mut pairs: Vec<(String, String)> = raw
9727 .lines()
9728 .filter_map(|l| {
9729 let mut parts = l.splitn(2, '=');
9730 let k = parts.next()?.trim().to_string();
9731 let v = parts.next().unwrap_or("").trim().to_string();
9732 Some((k, v))
9733 })
9734 .collect();
9735 pairs.sort_by(|a, b| a.0.cmp(&b.0));
9736
9737 out.push_str("=== Global git config ===\n");
9738
9739 let sections: &[(&str, &[&str])] = &[
9740 ("Identity", &["user.name", "user.email", "user.signingkey"]),
9741 (
9742 "Core",
9743 &[
9744 "core.editor",
9745 "core.autocrlf",
9746 "core.eol",
9747 "core.ignorecase",
9748 "core.filemode",
9749 ],
9750 ),
9751 (
9752 "Commit/Signing",
9753 &[
9754 "commit.gpgsign",
9755 "tag.gpgsign",
9756 "gpg.format",
9757 "gpg.ssh.allowedsignersfile",
9758 ],
9759 ),
9760 (
9761 "Push/Pull",
9762 &[
9763 "push.default",
9764 "push.autosetupremote",
9765 "pull.rebase",
9766 "pull.ff",
9767 ],
9768 ),
9769 ("Credential", &["credential.helper"]),
9770 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9771 ];
9772
9773 let mut shown_keys: HashSet<String> = HashSet::new();
9774 for (section, keys) in sections {
9775 let mut section_lines: Vec<String> = Vec::new();
9776 for key in *keys {
9777 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9778 section_lines.push(format!(" {k} = {v}"));
9779 shown_keys.insert(k.clone());
9780 }
9781 }
9782 if !section_lines.is_empty() {
9783 let _ = write!(out, "\n[{section}]\n");
9784 for line in section_lines {
9785 let _ = writeln!(out, "{line}");
9786 }
9787 }
9788 }
9789
9790 let other: Vec<&(String, String)> = pairs
9791 .iter()
9792 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9793 .collect();
9794 if !other.is_empty() {
9795 out.push_str("\n[Other]\n");
9796 for (k, v) in other.iter().take(20) {
9797 let _ = writeln!(out, " {k} = {v}");
9798 }
9799 if other.len() > 20 {
9800 let _ = writeln!(out, " ... and {} more", other.len() - 20);
9801 }
9802 }
9803
9804 let _ = write!(out, "\nTotal global config keys: {}\n", pairs.len());
9805 } else {
9806 out.push_str("No global git config found.\n");
9807 out.push_str("Set up with:\n");
9808 out.push_str(" git config --global user.name \"Your Name\"\n");
9809 out.push_str(" git config --global user.email \"you@example.com\"\n");
9810 }
9811 }
9812
9813 if let Ok(o) = Command::new("git")
9814 .args(["config", "--local", "--list"])
9815 .output()
9816 {
9817 if o.status.success() {
9818 let raw = String::from_utf8_lossy(&o.stdout);
9819 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9820 if !lines.is_empty() {
9821 let _ = write!(out, "\n=== Local repo config ({} keys) ===\n", lines.len());
9822 for line in lines.iter().take(15) {
9823 let _ = writeln!(out, " {line}");
9824 }
9825 if lines.len() > 15 {
9826 let _ = writeln!(out, " ... and {} more", lines.len() - 15);
9827 }
9828 }
9829 }
9830 }
9831
9832 if let Ok(o) = Command::new("git")
9833 .args(["config", "--global", "--get-regexp", r"alias\."])
9834 .output()
9835 {
9836 if o.status.success() {
9837 let raw = String::from_utf8_lossy(&o.stdout);
9838 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9839 if !aliases.is_empty() {
9840 let _ = write!(out, "\n=== Git aliases ({}) ===\n", aliases.len());
9841 for a in aliases.iter().take(20) {
9842 let _ = writeln!(out, " {a}");
9843 }
9844 if aliases.len() > 20 {
9845 let _ = writeln!(out, " ... and {} more", aliases.len() - 20);
9846 }
9847 }
9848 }
9849 }
9850
9851 Ok(out.trim_end().to_string())
9852}
9853
9854fn inspect_databases() -> Result<String, String> {
9857 let mut out = String::from("Host inspection: databases\n\n");
9858 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9859
9860 struct DbEngine {
9861 name: &'static str,
9862 service_names: &'static [&'static str],
9863 default_port: u16,
9864 cli_name: &'static str,
9865 cli_version_args: &'static [&'static str],
9866 }
9867
9868 let engines: &[DbEngine] = &[
9869 DbEngine {
9870 name: "PostgreSQL",
9871 service_names: &[
9872 "postgresql",
9873 "postgresql-x64-14",
9874 "postgresql-x64-15",
9875 "postgresql-x64-16",
9876 "postgresql-x64-17",
9877 ],
9878
9879 default_port: 5432,
9880 cli_name: "psql",
9881 cli_version_args: &["--version"],
9882 },
9883 DbEngine {
9884 name: "MySQL",
9885 service_names: &["mysql", "mysql80", "mysql57"],
9886
9887 default_port: 3306,
9888 cli_name: "mysql",
9889 cli_version_args: &["--version"],
9890 },
9891 DbEngine {
9892 name: "MariaDB",
9893 service_names: &["mariadb", "mariadb.exe"],
9894
9895 default_port: 3306,
9896 cli_name: "mariadb",
9897 cli_version_args: &["--version"],
9898 },
9899 DbEngine {
9900 name: "MongoDB",
9901 service_names: &["mongodb", "mongod"],
9902
9903 default_port: 27017,
9904 cli_name: "mongod",
9905 cli_version_args: &["--version"],
9906 },
9907 DbEngine {
9908 name: "Redis",
9909 service_names: &["redis", "redis-server"],
9910
9911 default_port: 6379,
9912 cli_name: "redis-server",
9913 cli_version_args: &["--version"],
9914 },
9915 DbEngine {
9916 name: "SQL Server",
9917 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9918
9919 default_port: 1433,
9920 cli_name: "sqlcmd",
9921 cli_version_args: &["-?"],
9922 },
9923 DbEngine {
9924 name: "SQLite",
9925 service_names: &[], default_port: 0, cli_name: "sqlite3",
9929 cli_version_args: &["--version"],
9930 },
9931 DbEngine {
9932 name: "CouchDB",
9933 service_names: &["couchdb", "apache-couchdb"],
9934
9935 default_port: 5984,
9936 cli_name: "couchdb",
9937 cli_version_args: &["--version"],
9938 },
9939 DbEngine {
9940 name: "Cassandra",
9941 service_names: &["cassandra"],
9942
9943 default_port: 9042,
9944 cli_name: "cqlsh",
9945 cli_version_args: &["--version"],
9946 },
9947 DbEngine {
9948 name: "Elasticsearch",
9949 service_names: &["elasticsearch-service-x64", "elasticsearch"],
9950
9951 default_port: 9200,
9952 cli_name: "elasticsearch",
9953 cli_version_args: &["--version"],
9954 },
9955 ];
9956
9957 fn port_listening(port: u16) -> bool {
9959 if port == 0 {
9960 return false;
9961 }
9962 std::net::TcpStream::connect_timeout(
9964 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9965 std::time::Duration::from_millis(150),
9966 )
9967 .is_ok()
9968 }
9969
9970 let mut found_any = false;
9971
9972 for engine in engines {
9973 let mut status_parts: Vec<String> = Vec::new();
9974 let mut detected = false;
9975
9976 let version = Command::new(engine.cli_name)
9978 .args(engine.cli_version_args)
9979 .output()
9980 .ok()
9981 .and_then(|o| {
9982 let combined = if o.stdout.is_empty() {
9983 String::from_utf8_lossy(&o.stderr).trim().to_string()
9984 } else {
9985 String::from_utf8_lossy(&o.stdout).trim().to_string()
9986 };
9987 combined.lines().next().map(|l| l.trim().to_string())
9989 });
9990
9991 if let Some(ref ver) = version {
9992 if !ver.is_empty() {
9993 status_parts.push(format!("version: {ver}"));
9994 detected = true;
9995 }
9996 }
9997
9998 if engine.default_port > 0 && port_listening(engine.default_port) {
10000 status_parts.push(format!("listening on :{}", engine.default_port));
10001 detected = true;
10002 } else if engine.default_port > 0 && detected {
10003 status_parts.push(format!("not listening on :{}", engine.default_port));
10004 }
10005
10006 #[cfg(target_os = "windows")]
10008 {
10009 if !engine.service_names.is_empty() {
10010 let service_list = engine.service_names.join("','");
10011 let script = format!(
10012 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
10013 service_list
10014 );
10015 if let Ok(o) = Command::new("powershell")
10016 .args(["-NoProfile", "-Command", &script])
10017 .output()
10018 {
10019 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
10020 if !text.is_empty() {
10021 let mut it = text.splitn(2, ':');
10022 let svc_name = it.next().map(str::trim).unwrap_or("");
10023 let svc_state = it.next().map(str::trim).unwrap_or("unknown");
10024 status_parts.push(format!("service '{svc_name}': {svc_state}"));
10025 detected = true;
10026 }
10027 }
10028 }
10029 }
10030
10031 #[cfg(not(target_os = "windows"))]
10033 {
10034 for svc in engine.service_names {
10035 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
10036 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
10037 if !state.is_empty() && state != "inactive" {
10038 status_parts.push(format!("systemd '{svc}': {state}"));
10039 detected = true;
10040 break;
10041 }
10042 }
10043 }
10044 }
10045
10046 if detected {
10047 found_any = true;
10048 let label = if engine.default_port > 0 {
10049 format!("{} (default port: {})", engine.name, engine.default_port)
10050 } else {
10051 format!("{} (file-based, no port)", engine.name)
10052 };
10053 let _ = writeln!(out, "[FOUND] {label}");
10054 for part in &status_parts {
10055 let _ = writeln!(out, " {part}");
10056 }
10057 out.push('\n');
10058 }
10059 }
10060
10061 if !found_any {
10062 out.push_str("No local database engines detected.\n");
10063 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
10064 out.push_str(
10065 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
10066 );
10067 } else {
10068 out.push_str("---\n");
10069 out.push_str(
10070 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
10071 );
10072 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
10073 }
10074
10075 Ok(out.trim_end().to_string())
10076}
10077
10078fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
10081 let mut out = String::from("Host inspection: user_accounts\n\n");
10082
10083 #[cfg(target_os = "windows")]
10084 {
10085 let users_out = Command::new("powershell")
10086 .args([
10087 "-NoProfile", "-NonInteractive", "-Command",
10088 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
10089 ])
10090 .output()
10091 .ok()
10092 .and_then(|o| String::from_utf8(o.stdout).ok())
10093 .unwrap_or_default();
10094
10095 out.push_str("=== Local User Accounts ===\n");
10096 if users_out.trim().is_empty() {
10097 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
10098 } else {
10099 for line in users_out.lines().take(max_entries) {
10100 if !line.trim().is_empty() {
10101 out.push_str(line);
10102 out.push('\n');
10103 }
10104 }
10105 }
10106
10107 let admins_out = Command::new("powershell")
10108 .args([
10109 "-NoProfile", "-NonInteractive", "-Command",
10110 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
10111 ])
10112 .output()
10113 .ok()
10114 .and_then(|o| String::from_utf8(o.stdout).ok())
10115 .unwrap_or_default();
10116
10117 out.push_str("\n=== Administrators Group Members ===\n");
10118 if admins_out.trim().is_empty() {
10119 out.push_str(" (unable to retrieve)\n");
10120 } else {
10121 out.push_str(admins_out.trim());
10122 out.push('\n');
10123 }
10124
10125 let sessions_out = Command::new("powershell")
10126 .args([
10127 "-NoProfile",
10128 "-NonInteractive",
10129 "-Command",
10130 "query user 2>$null",
10131 ])
10132 .output()
10133 .ok()
10134 .and_then(|o| String::from_utf8(o.stdout).ok())
10135 .unwrap_or_default();
10136
10137 out.push_str("\n=== Active Logon Sessions ===\n");
10138 if sessions_out.trim().is_empty() {
10139 out.push_str(" (none or requires elevation)\n");
10140 } else {
10141 for line in sessions_out.lines().take(max_entries) {
10142 if !line.trim().is_empty() {
10143 let _ = writeln!(out, " {}", line);
10144 }
10145 }
10146 }
10147
10148 let is_admin = Command::new("powershell")
10149 .args([
10150 "-NoProfile", "-NonInteractive", "-Command",
10151 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
10152 ])
10153 .output()
10154 .ok()
10155 .and_then(|o| String::from_utf8(o.stdout).ok())
10156 .map(|s| s.trim().to_lowercase())
10157 .unwrap_or_default();
10158
10159 out.push_str("\n=== Current Session Elevation ===\n");
10160 let _ = writeln!(
10161 out,
10162 " Running as Administrator: {}",
10163 if is_admin.contains("true") {
10164 "YES"
10165 } else {
10166 "no"
10167 }
10168 );
10169 }
10170
10171 #[cfg(not(target_os = "windows"))]
10172 {
10173 let who_out = Command::new("who")
10174 .output()
10175 .ok()
10176 .and_then(|o| String::from_utf8(o.stdout).ok())
10177 .unwrap_or_default();
10178 out.push_str("=== Active Sessions ===\n");
10179 if who_out.trim().is_empty() {
10180 out.push_str(" (none)\n");
10181 } else {
10182 for line in who_out.lines().take(max_entries) {
10183 let _ = write!(out, " {}\n", line);
10184 }
10185 }
10186 let id_out = Command::new("id")
10187 .output()
10188 .ok()
10189 .and_then(|o| String::from_utf8(o.stdout).ok())
10190 .unwrap_or_default();
10191 let _ = write!(out, "\n=== Current User ===\n {}\n", id_out.trim());
10192 }
10193
10194 Ok(out.trim_end().to_string())
10195}
10196
10197fn inspect_audit_policy() -> Result<String, String> {
10200 let mut out = String::from("Host inspection: audit_policy\n\n");
10201
10202 #[cfg(target_os = "windows")]
10203 {
10204 let auditpol_out = Command::new("auditpol")
10205 .args(["/get", "/category:*"])
10206 .output()
10207 .ok()
10208 .and_then(|o| String::from_utf8(o.stdout).ok())
10209 .unwrap_or_default();
10210
10211 if auditpol_out.trim().is_empty()
10212 || auditpol_out.to_lowercase().contains("access is denied")
10213 {
10214 out.push_str("Audit policy requires Administrator elevation to read.\n");
10215 out.push_str(
10216 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
10217 );
10218 } else {
10219 out.push_str("=== Windows Audit Policy ===\n");
10220 let mut any_enabled = false;
10221 for line in auditpol_out.lines() {
10222 let trimmed = line.trim();
10223 if trimmed.is_empty() {
10224 continue;
10225 }
10226 if trimmed.contains("Success") || trimmed.contains("Failure") {
10227 let _ = writeln!(out, " [ENABLED] {}", trimmed);
10228 any_enabled = true;
10229 } else {
10230 let _ = writeln!(out, " {}", trimmed);
10231 }
10232 }
10233 if !any_enabled {
10234 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
10235 out.push_str(
10236 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
10237 );
10238 }
10239 }
10240
10241 let evtlog = Command::new("powershell")
10242 .args([
10243 "-NoProfile", "-NonInteractive", "-Command",
10244 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
10245 ])
10246 .output()
10247 .ok()
10248 .and_then(|o| String::from_utf8(o.stdout).ok())
10249 .map(|s| s.trim().to_string())
10250 .unwrap_or_default();
10251
10252 let _ = write!(
10253 out,
10254 "\n=== Windows Event Log Service ===\n Status: {}\n",
10255 if evtlog.is_empty() {
10256 "unknown".to_string()
10257 } else {
10258 evtlog
10259 }
10260 );
10261 }
10262
10263 #[cfg(not(target_os = "windows"))]
10264 {
10265 let auditd_status = Command::new("systemctl")
10266 .args(["is-active", "auditd"])
10267 .output()
10268 .ok()
10269 .and_then(|o| String::from_utf8(o.stdout).ok())
10270 .map(|s| s.trim().to_string())
10271 .unwrap_or_else(|| "not found".to_string());
10272
10273 let _ = write!(out, "=== auditd service ===\n Status: {}\n", auditd_status);
10274
10275 if auditd_status == "active" {
10276 let rules = Command::new("auditctl")
10277 .args(["-l"])
10278 .output()
10279 .ok()
10280 .and_then(|o| String::from_utf8(o.stdout).ok())
10281 .unwrap_or_default();
10282 out.push_str("\n=== Active Audit Rules ===\n");
10283 if rules.trim().is_empty() || rules.contains("No rules") {
10284 out.push_str(" No rules configured.\n");
10285 } else {
10286 for line in rules.lines() {
10287 let _ = write!(out, " {}\n", line);
10288 }
10289 }
10290 }
10291 }
10292
10293 Ok(out.trim_end().to_string())
10294}
10295
10296fn inspect_shares(max_entries: usize) -> Result<String, String> {
10299 let mut out = String::from("Host inspection: shares\n\n");
10300
10301 #[cfg(target_os = "windows")]
10302 {
10303 let smb_out = Command::new("powershell")
10304 .args([
10305 "-NoProfile", "-NonInteractive", "-Command",
10306 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
10307 ])
10308 .output()
10309 .ok()
10310 .and_then(|o| String::from_utf8(o.stdout).ok())
10311 .unwrap_or_default();
10312
10313 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
10314 let smb_lines: Vec<&str> = smb_out
10315 .lines()
10316 .filter(|l| !l.trim().is_empty())
10317 .take(max_entries)
10318 .collect();
10319 if smb_lines.is_empty() {
10320 out.push_str(" No SMB shares or unable to retrieve.\n");
10321 } else {
10322 for line in &smb_lines {
10323 let name = line.trim().split('|').next().unwrap_or("").trim();
10324 if name.ends_with('$') {
10325 let _ = writeln!(out, " {}", line.trim());
10326 } else {
10327 let _ = writeln!(out, " [CUSTOM] {}", line.trim());
10328 }
10329 }
10330 }
10331
10332 let smb_security = Command::new("powershell")
10333 .args([
10334 "-NoProfile", "-NonInteractive", "-Command",
10335 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
10336 ])
10337 .output()
10338 .ok()
10339 .and_then(|o| String::from_utf8(o.stdout).ok())
10340 .unwrap_or_default();
10341
10342 out.push_str("\n=== SMB Server Security Settings ===\n");
10343 if smb_security.trim().is_empty() {
10344 out.push_str(" (unable to retrieve)\n");
10345 } else {
10346 out.push_str(smb_security.trim());
10347 out.push('\n');
10348 if smb_security.to_lowercase().contains("smb1: true") {
10349 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
10350 }
10351 }
10352
10353 let drives_out = Command::new("powershell")
10354 .args([
10355 "-NoProfile", "-NonInteractive", "-Command",
10356 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
10357 ])
10358 .output()
10359 .ok()
10360 .and_then(|o| String::from_utf8(o.stdout).ok())
10361 .unwrap_or_default();
10362
10363 out.push_str("\n=== Mapped Network Drives ===\n");
10364 if drives_out.trim().is_empty() {
10365 out.push_str(" None.\n");
10366 } else {
10367 for line in drives_out.lines().take(max_entries) {
10368 if !line.trim().is_empty() {
10369 out.push_str(line);
10370 out.push('\n');
10371 }
10372 }
10373 }
10374 }
10375
10376 #[cfg(not(target_os = "windows"))]
10377 {
10378 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
10379 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
10380 if smb_conf.is_empty() {
10381 out.push_str(" Not found or Samba not installed.\n");
10382 } else {
10383 for line in smb_conf.lines().take(max_entries) {
10384 let _ = write!(out, " {}\n", line);
10385 }
10386 }
10387 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
10388 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
10389 if nfs_exports.is_empty() {
10390 out.push_str(" Not configured.\n");
10391 } else {
10392 for line in nfs_exports.lines().take(max_entries) {
10393 let _ = write!(out, " {}\n", line);
10394 }
10395 }
10396 }
10397
10398 Ok(out.trim_end().to_string())
10399}
10400
10401fn inspect_dns_servers() -> Result<String, String> {
10404 let mut out = String::from("Host inspection: dns_servers\n\n");
10405
10406 #[cfg(target_os = "windows")]
10407 {
10408 let dns_out = Command::new("powershell")
10409 .args([
10410 "-NoProfile", "-NonInteractive", "-Command",
10411 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
10412 ])
10413 .output()
10414 .ok()
10415 .and_then(|o| String::from_utf8(o.stdout).ok())
10416 .unwrap_or_default();
10417
10418 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
10419 if dns_out.trim().is_empty() {
10420 out.push_str(" (unable to retrieve)\n");
10421 } else {
10422 for line in dns_out.lines() {
10423 if line.trim().is_empty() {
10424 continue;
10425 }
10426 let mut annotation = "";
10427 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
10428 annotation = " <- Google Public DNS";
10429 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
10430 annotation = " <- Cloudflare DNS";
10431 } else if line.contains("9.9.9.9") {
10432 annotation = " <- Quad9";
10433 } else if line.contains("208.67.222") || line.contains("208.67.220") {
10434 annotation = " <- OpenDNS";
10435 }
10436 out.push_str(line);
10437 out.push_str(annotation);
10438 out.push('\n');
10439 }
10440 }
10441
10442 let doh_out = Command::new("powershell")
10443 .args([
10444 "-NoProfile", "-NonInteractive", "-Command",
10445 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
10446 ])
10447 .output()
10448 .ok()
10449 .and_then(|o| String::from_utf8(o.stdout).ok())
10450 .unwrap_or_default();
10451
10452 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
10453 if doh_out.trim().is_empty() {
10454 out.push_str(" Not configured (plain DNS).\n");
10455 } else {
10456 out.push_str(doh_out.trim());
10457 out.push('\n');
10458 }
10459
10460 let suffixes = Command::new("powershell")
10461 .args([
10462 "-NoProfile", "-NonInteractive", "-Command",
10463 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
10464 ])
10465 .output()
10466 .ok()
10467 .and_then(|o| String::from_utf8(o.stdout).ok())
10468 .unwrap_or_default();
10469
10470 if !suffixes.trim().is_empty() {
10471 out.push_str("\n=== DNS Search Suffix List ===\n");
10472 out.push_str(suffixes.trim());
10473 out.push('\n');
10474 }
10475 }
10476
10477 #[cfg(not(target_os = "windows"))]
10478 {
10479 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
10480 out.push_str("=== /etc/resolv.conf ===\n");
10481 if resolv.is_empty() {
10482 out.push_str(" Not found.\n");
10483 } else {
10484 for line in resolv.lines() {
10485 if !line.trim().is_empty() && !line.starts_with('#') {
10486 let _ = write!(out, " {}\n", line);
10487 }
10488 }
10489 }
10490 let resolved_out = Command::new("resolvectl")
10491 .args(["status", "--no-pager"])
10492 .output()
10493 .ok()
10494 .and_then(|o| String::from_utf8(o.stdout).ok())
10495 .unwrap_or_default();
10496 if !resolved_out.is_empty() {
10497 out.push_str("\n=== systemd-resolved ===\n");
10498 for line in resolved_out.lines().take(30) {
10499 let _ = write!(out, " {}\n", line);
10500 }
10501 }
10502 }
10503
10504 Ok(out.trim_end().to_string())
10505}
10506
10507fn inspect_bitlocker() -> Result<String, String> {
10508 let mut out = String::from("Host inspection: bitlocker\n\n");
10509
10510 #[cfg(target_os = "windows")]
10511 {
10512 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10513 let output = Command::new("powershell")
10514 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10515 .output()
10516 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10517
10518 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10519 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10520
10521 if !stdout.trim().is_empty() {
10522 out.push_str("=== BitLocker Volumes ===\n");
10523 for line in stdout.lines() {
10524 let _ = writeln!(out, " {}", line);
10525 }
10526 } else if !stderr.trim().is_empty() {
10527 if stderr.contains("Access is denied") {
10528 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10529 } else {
10530 let _ = writeln!(out, "Error retrieving BitLocker info: {}", stderr.trim());
10531 }
10532 } else {
10533 out.push_str("No BitLocker volumes detected or access denied.\n");
10534 }
10535 }
10536
10537 #[cfg(not(target_os = "windows"))]
10538 {
10539 out.push_str(
10540 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10541 );
10542 let lsblk = Command::new("lsblk")
10543 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10544 .output()
10545 .ok()
10546 .and_then(|o| String::from_utf8(o.stdout).ok())
10547 .unwrap_or_default();
10548 if lsblk.contains("crypto_LUKS") {
10549 out.push_str("=== LUKS Encrypted Volumes ===\n");
10550 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10551 let _ = write!(out, " {}\n", line);
10552 }
10553 } else {
10554 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10555 }
10556 }
10557
10558 Ok(out.trim_end().to_string())
10559}
10560
10561fn inspect_rdp() -> Result<String, String> {
10562 let mut out = String::from("Host inspection: rdp\n\n");
10563
10564 #[cfg(target_os = "windows")]
10565 {
10566 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10567 let f_deny = Command::new("powershell")
10568 .args([
10569 "-NoProfile",
10570 "-Command",
10571 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10572 ])
10573 .output()
10574 .ok()
10575 .and_then(|o| String::from_utf8(o.stdout).ok())
10576 .unwrap_or_default()
10577 .trim()
10578 .to_string();
10579
10580 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10581 let _ = writeln!(out, "=== RDP Status: {} ===", status);
10582
10583 let port = Command::new("powershell").args(["-NoProfile", "-Command", "Get-ItemProperty 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp' -Name PortNumber | Select-Object -ExpandProperty PortNumber"])
10584 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10585 let _ = writeln!(
10586 out,
10587 " Port: {}",
10588 if port.is_empty() {
10589 "3389 (default)"
10590 } else {
10591 &port
10592 }
10593 );
10594
10595 let nla = Command::new("powershell")
10596 .args([
10597 "-NoProfile",
10598 "-Command",
10599 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10600 ])
10601 .output()
10602 .ok()
10603 .and_then(|o| String::from_utf8(o.stdout).ok())
10604 .unwrap_or_default()
10605 .trim()
10606 .to_string();
10607 let _ = writeln!(
10608 out,
10609 " NLA Required: {}",
10610 if nla == "1" { "Yes" } else { "No" }
10611 );
10612
10613 let rdp_tcp_path =
10614 "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10615 let sec_layer = Command::new("powershell")
10616 .args([
10617 "-NoProfile",
10618 "-Command",
10619 &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10620 ])
10621 .output()
10622 .ok()
10623 .and_then(|o| String::from_utf8(o.stdout).ok())
10624 .unwrap_or_default()
10625 .trim()
10626 .to_string();
10627 let sec_label = match sec_layer.as_str() {
10628 "0" => "RDP Security (no SSL)",
10629 "1" => "Negotiate (prefer TLS)",
10630 "2" => "SSL/TLS required",
10631 _ => &sec_layer,
10632 };
10633 let _ = writeln!(out, " Security Layer: {} ({})", sec_layer, sec_label);
10634
10635 let enc_level = Command::new("powershell")
10636 .args([
10637 "-NoProfile",
10638 "-Command",
10639 &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10640 ])
10641 .output()
10642 .ok()
10643 .and_then(|o| String::from_utf8(o.stdout).ok())
10644 .unwrap_or_default()
10645 .trim()
10646 .to_string();
10647 let enc_label = match enc_level.as_str() {
10648 "1" => "Low",
10649 "2" => "Client Compatible",
10650 "3" => "High",
10651 "4" => "FIPS Compliant",
10652 _ => "Unknown",
10653 };
10654 let _ = writeln!(out, " Encryption Level: {} ({})", enc_level, enc_label);
10655
10656 out.push_str("\n=== Active Sessions ===\n");
10657 let qwinsta = Command::new("qwinsta")
10658 .output()
10659 .ok()
10660 .and_then(|o| String::from_utf8(o.stdout).ok())
10661 .unwrap_or_default();
10662 if qwinsta.trim().is_empty() {
10663 out.push_str(" No active sessions listed.\n");
10664 } else {
10665 for line in qwinsta.lines() {
10666 let _ = writeln!(out, " {}", line);
10667 }
10668 }
10669
10670 out.push_str("\n=== Firewall Rule Check ===\n");
10671 let fw = Command::new("powershell").args(["-NoProfile", "-Command", "Get-NetFirewallRule -DisplayName '*Remote Desktop*' -Enabled True | Select-Object DisplayName, Action, Direction | ForEach-Object { \" $($_.DisplayName): $($_.Action) ($($_.Direction))\" }"])
10672 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10673 if fw.trim().is_empty() {
10674 out.push_str(" No enabled RDP firewall rules found.\n");
10675 } else {
10676 out.push_str(fw.trim_end());
10677 out.push('\n');
10678 }
10679 }
10680
10681 #[cfg(not(target_os = "windows"))]
10682 {
10683 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10684 let ss = Command::new("ss")
10685 .args(["-tlnp"])
10686 .output()
10687 .ok()
10688 .and_then(|o| String::from_utf8(o.stdout).ok())
10689 .unwrap_or_default();
10690 let matches: Vec<&str> = ss
10691 .lines()
10692 .filter(|l| l.contains(":3389") || l.contains(":590"))
10693 .collect();
10694 if matches.is_empty() {
10695 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
10696 } else {
10697 for m in matches {
10698 let _ = write!(out, " {}\n", m);
10699 }
10700 }
10701 }
10702
10703 Ok(out.trim_end().to_string())
10704}
10705
10706fn inspect_shadow_copies() -> Result<String, String> {
10707 let mut out = String::from("Host inspection: shadow_copies\n\n");
10708
10709 #[cfg(target_os = "windows")]
10710 {
10711 let output = Command::new("vssadmin")
10712 .args(["list", "shadows"])
10713 .output()
10714 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10715 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10716
10717 if stdout.contains("No items found") || stdout.trim().is_empty() {
10718 out.push_str("No Volume Shadow Copies found.\n");
10719 } else {
10720 out.push_str("=== Volume Shadow Copies ===\n");
10721 for line in stdout.lines().take(50) {
10722 if line.contains("Creation Time:")
10723 || line.contains("Contents:")
10724 || line.contains("Volume Name:")
10725 {
10726 let _ = writeln!(out, " {}", line.trim());
10727 }
10728 }
10729 }
10730
10731 let age_script = r#"
10733try {
10734 $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10735 if ($snaps) {
10736 $newest = $snaps[0]
10737 $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10738 $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10739 $count = @($snaps).Count
10740 "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm')) ($age days ago) — $count total snapshots"
10741 } else { "No snapshots found via WMI." }
10742} catch { "WMI snapshot query unavailable: $_" }
10743"#;
10744 if let Ok(age_out) = Command::new("powershell")
10745 .args(["-NoProfile", "-Command", age_script])
10746 .output()
10747 {
10748 let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10749 if !age_text.is_empty() {
10750 out.push_str("\n=== Snapshot Age ===\n");
10751 let _ = writeln!(out, " {}", age_text);
10752 }
10753 }
10754
10755 out.push_str("\n=== Shadow Copy Storage ===\n");
10756 let storage_out = Command::new("vssadmin")
10757 .args(["list", "shadowstorage"])
10758 .output()
10759 .ok();
10760 if let Some(o) = storage_out {
10761 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10762 for line in stdout.lines() {
10763 if line.contains("Used Shadow Copy Storage space:")
10764 || line.contains("Max Shadow Copy Storage space:")
10765 {
10766 let _ = writeln!(out, " {}", line.trim());
10767 }
10768 }
10769 }
10770 }
10771
10772 #[cfg(not(target_os = "windows"))]
10773 {
10774 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10775 let lvs = Command::new("lvs")
10776 .output()
10777 .ok()
10778 .and_then(|o| String::from_utf8(o.stdout).ok())
10779 .unwrap_or_default();
10780 if !lvs.is_empty() {
10781 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10782 out.push_str(&lvs);
10783 } else {
10784 out.push_str("No LVM volumes detected.\n");
10785 }
10786 }
10787
10788 Ok(out.trim_end().to_string())
10789}
10790
10791fn inspect_pagefile() -> Result<String, String> {
10792 let mut out = String::from("Host inspection: pagefile\n\n");
10793
10794 #[cfg(target_os = "windows")]
10795 {
10796 let ps_cmd = "Get-CimInstance Win32_PageFileUsage | Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage | ForEach-Object { \" $($_.Name): $($_.AllocatedBaseSize)MB total, $($_.CurrentUsage)MB used (Peak: $($_.PeakUsage)MB)\" }";
10797 let output = Command::new("powershell")
10798 .args(["-NoProfile", "-Command", ps_cmd])
10799 .output()
10800 .ok()
10801 .and_then(|o| String::from_utf8(o.stdout).ok())
10802 .unwrap_or_default();
10803
10804 if output.trim().is_empty() {
10805 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10806 let managed = Command::new("powershell")
10807 .args([
10808 "-NoProfile",
10809 "-Command",
10810 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10811 ])
10812 .output()
10813 .ok()
10814 .and_then(|o| String::from_utf8(o.stdout).ok())
10815 .unwrap_or_default()
10816 .trim()
10817 .to_string();
10818 let _ = writeln!(out, "Automatic Managed Pagefile: {}", managed);
10819 } else {
10820 out.push_str("=== Page File Usage ===\n");
10821 out.push_str(&output);
10822 }
10823 }
10824
10825 #[cfg(not(target_os = "windows"))]
10826 {
10827 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10828 let swap = Command::new("swapon")
10829 .args(["--show"])
10830 .output()
10831 .ok()
10832 .and_then(|o| String::from_utf8(o.stdout).ok())
10833 .unwrap_or_default();
10834 if swap.is_empty() {
10835 let free = Command::new("free")
10836 .args(["-h"])
10837 .output()
10838 .ok()
10839 .and_then(|o| String::from_utf8(o.stdout).ok())
10840 .unwrap_or_default();
10841 out.push_str(&free);
10842 } else {
10843 out.push_str(&swap);
10844 }
10845 }
10846
10847 Ok(out.trim_end().to_string())
10848}
10849
10850fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10851 let mut out = String::from("Host inspection: windows_features\n\n");
10852
10853 #[cfg(target_os = "windows")]
10854 {
10855 out.push_str("=== Quick Check: Notable Features ===\n");
10856 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10857 let output = Command::new("powershell")
10858 .args(["-NoProfile", "-Command", quick_ps])
10859 .output()
10860 .ok();
10861
10862 if let Some(o) = output {
10863 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10864 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10865
10866 if !stdout.trim().is_empty() {
10867 for f in stdout.lines() {
10868 let _ = writeln!(out, " [ENABLED] {}", f);
10869 }
10870 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10871 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10872 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10873 out.push_str(
10874 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10875 );
10876 }
10877 }
10878
10879 let _ = write!(
10880 out,
10881 "\n=== All Enabled Features (capped at {}) ===\n",
10882 max_entries
10883 );
10884 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10885 let all_out = Command::new("powershell")
10886 .args(["-NoProfile", "-Command", &all_ps])
10887 .output()
10888 .ok();
10889 if let Some(o) = all_out {
10890 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10891 if !stdout.trim().is_empty() {
10892 out.push_str(&stdout);
10893 }
10894 }
10895 }
10896
10897 #[cfg(not(target_os = "windows"))]
10898 {
10899 let _ = max_entries;
10900 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10901 }
10902
10903 Ok(out.trim_end().to_string())
10904}
10905
10906fn inspect_audio(max_entries: usize) -> Result<String, String> {
10907 let mut out = String::from("Host inspection: audio\n\n");
10908
10909 #[cfg(target_os = "windows")]
10910 {
10911 let n = max_entries.clamp(5, 20);
10912 let services = collect_services().unwrap_or_default();
10913 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10914 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10915
10916 let core_services: Vec<&ServiceEntry> = services
10917 .iter()
10918 .filter(|entry| {
10919 core_service_names
10920 .iter()
10921 .any(|name| entry.name.eq_ignore_ascii_case(name))
10922 })
10923 .collect();
10924 let bluetooth_audio_services: Vec<&ServiceEntry> = services
10925 .iter()
10926 .filter(|entry| {
10927 bluetooth_audio_service_names
10928 .iter()
10929 .any(|name| entry.name.eq_ignore_ascii_case(name))
10930 })
10931 .collect();
10932
10933 let probe_script = r#"
10934$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10935 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10936$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10937 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10938$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10939 Select-Object Name, Status, Manufacturer, PNPDeviceID)
10940[pscustomobject]@{
10941 Media = $media
10942 Endpoints = $endpoints
10943 SoundDevices = $sound
10944} | ConvertTo-Json -Compress -Depth 4
10945"#;
10946 let probe_raw = Command::new("powershell")
10947 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10948 .output()
10949 .ok()
10950 .and_then(|o| String::from_utf8(o.stdout).ok())
10951 .unwrap_or_default();
10952 let probe_loaded = !probe_raw.trim().is_empty();
10953 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10954
10955 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10956 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10957 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10958
10959 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10960 .iter()
10961 .filter(|device| !is_microphone_like_name(&device.name))
10962 .collect();
10963 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10964 .iter()
10965 .filter(|device| is_microphone_like_name(&device.name))
10966 .collect();
10967 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10968 .iter()
10969 .filter(|device| is_bluetooth_like_name(&device.name))
10970 .collect();
10971 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10972 .iter()
10973 .filter(|device| windows_device_has_issue(device))
10974 .collect();
10975 let media_problems: Vec<&WindowsPnpDevice> = media_devices
10976 .iter()
10977 .filter(|device| windows_device_has_issue(device))
10978 .collect();
10979 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10980 .iter()
10981 .filter(|device| windows_sound_device_has_issue(device))
10982 .collect();
10983
10984 let mut findings = Vec::with_capacity(4);
10985
10986 let stopped_core_services: Vec<&ServiceEntry> = core_services
10987 .iter()
10988 .copied()
10989 .filter(|service| !service_is_running(service))
10990 .collect();
10991 if !stopped_core_services.is_empty() {
10992 let names = {
10993 let mut s = String::new();
10994 for (i, svc) in stopped_core_services.iter().enumerate() {
10995 if i > 0 {
10996 s.push_str(", ");
10997 }
10998 s.push_str(&svc.name);
10999 }
11000 s
11001 };
11002 findings.push(AuditFinding {
11003 finding: format!("Core audio services are not running: {names}"),
11004 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
11005 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
11006 });
11007 }
11008
11009 if probe_loaded
11010 && endpoints.is_empty()
11011 && media_devices.is_empty()
11012 && sound_devices.is_empty()
11013 {
11014 findings.push(AuditFinding {
11015 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
11016 impact: "Windows currently has no obvious playback or recording path to hand to apps, so 'no sound' or 'mic missing' behavior is expected.".to_string(),
11017 fix: "Check whether the audio device is disabled in Device Manager, disconnected at the hardware level, or blocked by a vendor driver package that failed to load.".to_string(),
11018 });
11019 }
11020
11021 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
11022 {
11023 let mut problem_labels = Vec::with_capacity(9);
11024 problem_labels.extend(
11025 endpoint_problems
11026 .iter()
11027 .take(3)
11028 .map(|device| device.name.clone()),
11029 );
11030 problem_labels.extend(
11031 media_problems
11032 .iter()
11033 .take(3)
11034 .map(|device| device.name.clone()),
11035 );
11036 problem_labels.extend(
11037 sound_problems
11038 .iter()
11039 .take(3)
11040 .map(|device| device.name.clone()),
11041 );
11042 findings.push(AuditFinding {
11043 finding: format!(
11044 "Windows reports audio device issues for: {}",
11045 problem_labels.join(", ")
11046 ),
11047 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
11048 fix: "Inspect the affected audio devices in Device Manager, confirm the vendor driver is healthy, and re-enable or reinstall the failing endpoint before troubleshooting apps.".to_string(),
11049 });
11050 }
11051
11052 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
11053 .iter()
11054 .copied()
11055 .filter(|service| !service_is_running(service))
11056 .collect();
11057 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
11058 let names = {
11059 let mut s = String::new();
11060 for (i, svc) in stopped_bt_audio_services.iter().enumerate() {
11061 if i > 0 {
11062 s.push_str(", ");
11063 }
11064 s.push_str(&svc.name);
11065 }
11066 s
11067 };
11068 findings.push(AuditFinding {
11069 finding: format!(
11070 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
11071 ),
11072 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
11073 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
11074 });
11075 }
11076
11077 out.push_str("=== Findings ===\n");
11078 if findings.is_empty() {
11079 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
11080 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
11081 out.push_str(" Fix: If a specific app still has no sound or mic input, compare the endpoint inventory below against that app's selected input/output devices.\n");
11082 } else {
11083 for finding in &findings {
11084 let _ = writeln!(out, "- Finding: {}", finding.finding);
11085 let _ = writeln!(out, " Impact: {}", finding.impact);
11086 let _ = writeln!(out, " Fix: {}", finding.fix);
11087 }
11088 }
11089
11090 out.push_str("\n=== Audio services ===\n");
11091 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
11092 out.push_str(
11093 "- No Windows audio services were retrieved from the service inventory.\n",
11094 );
11095 } else {
11096 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
11097 let _ = writeln!(
11098 out,
11099 "- {} | Status: {} | Startup: {}",
11100 service.name,
11101 service.status,
11102 service.startup.as_deref().unwrap_or("Unknown")
11103 );
11104 }
11105 }
11106
11107 out.push_str("\n=== Playback and recording endpoints ===\n");
11108 if !probe_loaded {
11109 out.push_str("- Windows endpoint inventory probe returned no data.\n");
11110 } else if endpoints.is_empty() {
11111 out.push_str("- No audio endpoints detected.\n");
11112 } else {
11113 let _ = writeln!(
11114 out,
11115 "- Playback-style endpoints: {} | Recording-style endpoints: {}",
11116 playback_endpoints.len(),
11117 recording_endpoints.len()
11118 );
11119 for device in playback_endpoints.iter().take(n) {
11120 let _ = writeln!(
11121 out,
11122 "- [PLAYBACK] {} | Status: {}{}",
11123 device.name,
11124 device.status,
11125 device
11126 .problem
11127 .filter(|problem| *problem != 0)
11128 .map(|problem| format!(" | ProblemCode: {problem}"))
11129 .unwrap_or_default()
11130 );
11131 }
11132 for device in recording_endpoints.iter().take(n) {
11133 let _ = writeln!(
11134 out,
11135 "- [MIC] {} | Status: {}{}",
11136 device.name,
11137 device.status,
11138 device
11139 .problem
11140 .filter(|problem| *problem != 0)
11141 .map(|problem| format!(" | ProblemCode: {problem}"))
11142 .unwrap_or_default()
11143 );
11144 }
11145 }
11146
11147 out.push_str("\n=== Sound hardware devices ===\n");
11148 if sound_devices.is_empty() {
11149 out.push_str("- No Win32_SoundDevice entries were returned.\n");
11150 } else {
11151 for device in sound_devices.iter().take(n) {
11152 let _ = writeln!(
11153 out,
11154 "- {} | Status: {}{}",
11155 device.name,
11156 device.status,
11157 device
11158 .manufacturer
11159 .as_deref()
11160 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
11161 .unwrap_or_default()
11162 );
11163 }
11164 }
11165
11166 out.push_str("\n=== Media-class device inventory ===\n");
11167 if media_devices.is_empty() {
11168 out.push_str("- No media-class PnP devices were returned.\n");
11169 } else {
11170 for device in media_devices.iter().take(n) {
11171 let _ = writeln!(
11172 out,
11173 "- {} | Status: {}{}",
11174 device.name,
11175 device.status,
11176 device
11177 .class_name
11178 .as_deref()
11179 .map(|class_name| format!(" | Class: {class_name}"))
11180 .unwrap_or_default()
11181 );
11182 }
11183 }
11184 }
11185
11186 #[cfg(not(target_os = "windows"))]
11187 {
11188 let _ = max_entries;
11189 out.push_str(
11190 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
11191 );
11192 out.push_str(
11193 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
11194 );
11195 }
11196
11197 Ok(out.trim_end().to_string())
11198}
11199
11200fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
11201 let mut out = String::from("Host inspection: bluetooth\n\n");
11202
11203 #[cfg(target_os = "windows")]
11204 {
11205 let n = max_entries.clamp(5, 20);
11206 let services = collect_services().unwrap_or_default();
11207 let bluetooth_services: Vec<&ServiceEntry> = services
11208 .iter()
11209 .filter(|entry| {
11210 entry.name.eq_ignore_ascii_case("bthserv")
11211 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
11212 || entry.name.eq_ignore_ascii_case("BTAGService")
11213 || entry.name.starts_with("BluetoothUserService")
11214 || entry
11215 .display_name
11216 .as_deref()
11217 .unwrap_or("")
11218 .to_ascii_lowercase()
11219 .contains("bluetooth")
11220 })
11221 .collect();
11222
11223 let probe_script = r#"
11224$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
11225 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
11226$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
11227 Where-Object {
11228 $_.Class -eq 'Bluetooth' -or
11229 $_.FriendlyName -match 'Bluetooth' -or
11230 $_.InstanceId -like 'BTH*'
11231 } |
11232 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
11233$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
11234 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
11235 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
11236[pscustomobject]@{
11237 Radios = $radios
11238 Devices = $devices
11239 AudioEndpoints = $audio
11240} | ConvertTo-Json -Compress -Depth 4
11241"#;
11242 let probe_raw = Command::new("powershell")
11243 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
11244 .output()
11245 .ok()
11246 .and_then(|o| String::from_utf8(o.stdout).ok())
11247 .unwrap_or_default();
11248 let probe_loaded = !probe_raw.trim().is_empty();
11249 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
11250
11251 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
11252 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
11253 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
11254 let radio_problems: Vec<&WindowsPnpDevice> = radios
11255 .iter()
11256 .filter(|device| windows_device_has_issue(device))
11257 .collect();
11258 let device_problems: Vec<&WindowsPnpDevice> = devices
11259 .iter()
11260 .filter(|device| windows_device_has_issue(device))
11261 .collect();
11262
11263 let mut findings = Vec::with_capacity(4);
11264
11265 if probe_loaded && radios.is_empty() {
11266 findings.push(AuditFinding {
11267 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
11268 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
11269 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
11270 });
11271 }
11272
11273 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
11274 .iter()
11275 .copied()
11276 .filter(|service| !service_is_running(service))
11277 .collect();
11278 if !stopped_bluetooth_services.is_empty() {
11279 let names = {
11280 let mut s = String::new();
11281 for (i, svc) in stopped_bluetooth_services.iter().enumerate() {
11282 if i > 0 {
11283 s.push_str(", ");
11284 }
11285 s.push_str(&svc.name);
11286 }
11287 s
11288 };
11289 findings.push(AuditFinding {
11290 finding: format!("Bluetooth-related services are not fully running: {names}"),
11291 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
11292 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
11293 });
11294 }
11295
11296 if !radio_problems.is_empty() || !device_problems.is_empty() {
11297 let problem_labels = {
11298 let mut s = String::new();
11299 for (i, device) in radio_problems
11300 .iter()
11301 .chain(device_problems.iter())
11302 .take(5)
11303 .enumerate()
11304 {
11305 if i > 0 {
11306 s.push_str(", ");
11307 }
11308 s.push_str(&device.name);
11309 }
11310 s
11311 };
11312 findings.push(AuditFinding {
11313 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
11314 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
11315 fix: "Inspect the failing Bluetooth devices in Device Manager, confirm the driver stack is healthy, then remove and re-pair the affected endpoint if needed.".to_string(),
11316 });
11317 }
11318
11319 if !audio_endpoints.is_empty()
11320 && bluetooth_services
11321 .iter()
11322 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
11323 && bluetooth_services
11324 .iter()
11325 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
11326 .any(|service| !service_is_running(service))
11327 {
11328 findings.push(AuditFinding {
11329 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
11330 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
11331 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
11332 });
11333 }
11334
11335 out.push_str("=== Findings ===\n");
11336 if findings.is_empty() {
11337 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
11338 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
11339 out.push_str(" Fix: If one specific device still fails, focus next on that device's pairing history, driver node, and audio endpoint role.\n");
11340 } else {
11341 for finding in &findings {
11342 let _ = writeln!(out, "- Finding: {}", finding.finding);
11343 let _ = writeln!(out, " Impact: {}", finding.impact);
11344 let _ = writeln!(out, " Fix: {}", finding.fix);
11345 }
11346 }
11347
11348 out.push_str("\n=== Bluetooth services ===\n");
11349 if bluetooth_services.is_empty() {
11350 out.push_str(
11351 "- No Bluetooth-related services were retrieved from the service inventory.\n",
11352 );
11353 } else {
11354 for service in bluetooth_services.iter().take(n) {
11355 let _ = writeln!(
11356 out,
11357 "- {} | Status: {} | Startup: {}",
11358 service.name,
11359 service.status,
11360 service.startup.as_deref().unwrap_or("Unknown")
11361 );
11362 }
11363 }
11364
11365 out.push_str("\n=== Bluetooth radios and adapters ===\n");
11366 if !probe_loaded {
11367 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
11368 } else if radios.is_empty() {
11369 out.push_str("- No Bluetooth radios detected.\n");
11370 } else {
11371 for device in radios.iter().take(n) {
11372 let _ = writeln!(
11373 out,
11374 "- {} | Status: {}{}",
11375 device.name,
11376 device.status,
11377 device
11378 .problem
11379 .filter(|problem| *problem != 0)
11380 .map(|problem| format!(" | ProblemCode: {problem}"))
11381 .unwrap_or_default()
11382 );
11383 }
11384 }
11385
11386 out.push_str("\n=== Bluetooth-associated devices ===\n");
11387 if devices.is_empty() {
11388 out.push_str("- No Bluetooth-associated device nodes detected.\n");
11389 } else {
11390 for device in devices.iter().take(n) {
11391 let _ = writeln!(
11392 out,
11393 "- {} | Status: {}{}",
11394 device.name,
11395 device.status,
11396 device
11397 .class_name
11398 .as_deref()
11399 .map(|class_name| format!(" | Class: {class_name}"))
11400 .unwrap_or_default()
11401 );
11402 }
11403 }
11404
11405 out.push_str("\n=== Bluetooth audio endpoints ===\n");
11406 if audio_endpoints.is_empty() {
11407 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
11408 } else {
11409 for device in audio_endpoints.iter().take(n) {
11410 let _ = writeln!(
11411 out,
11412 "- {} | Status: {}{}",
11413 device.name,
11414 device.status,
11415 device
11416 .instance_id
11417 .as_deref()
11418 .map(|instance_id| format!(" | Instance: {instance_id}"))
11419 .unwrap_or_default()
11420 );
11421 }
11422 }
11423 }
11424
11425 #[cfg(not(target_os = "windows"))]
11426 {
11427 let _ = max_entries;
11428 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
11429 out.push_str(
11430 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
11431 );
11432 }
11433
11434 Ok(out.trim_end().to_string())
11435}
11436
11437fn inspect_printers(max_entries: usize) -> Result<String, String> {
11438 let mut out = String::from("Host inspection: printers\n\n");
11439
11440 #[cfg(target_os = "windows")]
11441 {
11442 let list = Command::new("powershell").args(["-NoProfile", "-Command", &format!("Get-Printer | Select-Object Name, DriverName, PortName, JobCount | Select-Object -First {} | ForEach-Object {{ \" $($_.Name) [$($_.DriverName)] (Port: $($_.PortName), Jobs: $($_.JobCount))\" }}", max_entries)])
11443 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11444 if list.trim().is_empty() {
11445 out.push_str("No printers detected.\n");
11446 } else {
11447 out.push_str("=== Installed Printers ===\n");
11448 out.push_str(&list);
11449 }
11450
11451 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
11452 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11453 if !jobs.trim().is_empty() {
11454 out.push_str("\n=== Active Print Jobs ===\n");
11455 out.push_str(&jobs);
11456 }
11457 }
11458
11459 #[cfg(not(target_os = "windows"))]
11460 {
11461 let _ = max_entries;
11462 out.push_str("Checking LPSTAT for printers...\n");
11463 let lpstat = Command::new("lpstat")
11464 .args(["-p", "-d"])
11465 .output()
11466 .ok()
11467 .and_then(|o| String::from_utf8(o.stdout).ok())
11468 .unwrap_or_default();
11469 if lpstat.is_empty() {
11470 out.push_str(" No CUPS/LP printers found.\n");
11471 } else {
11472 out.push_str(&lpstat);
11473 }
11474 }
11475
11476 Ok(out.trim_end().to_string())
11477}
11478
11479fn inspect_winrm() -> Result<String, String> {
11480 let mut out = String::from("Host inspection: winrm\n\n");
11481
11482 #[cfg(target_os = "windows")]
11483 {
11484 let svc = Command::new("powershell")
11485 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
11486 .output()
11487 .ok()
11488 .and_then(|o| String::from_utf8(o.stdout).ok())
11489 .unwrap_or_default()
11490 .trim()
11491 .to_string();
11492 let _ = write!(
11493 out,
11494 "WinRM Service Status: {}\n\n",
11495 if svc.is_empty() { "NOT_FOUND" } else { &svc }
11496 );
11497
11498 out.push_str("=== WinRM Listeners ===\n");
11499 let output = Command::new("powershell")
11500 .args([
11501 "-NoProfile",
11502 "-Command",
11503 "winrm enumerate winrm/config/listener 2>$null",
11504 ])
11505 .output()
11506 .ok();
11507 if let Some(o) = output {
11508 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11509 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11510
11511 if !stdout.trim().is_empty() {
11512 for line in stdout.lines() {
11513 if line.contains("Address =")
11514 || line.contains("Transport =")
11515 || line.contains("Port =")
11516 {
11517 let _ = writeln!(out, " {}", line.trim());
11518 }
11519 }
11520 } else if stderr.contains("Access is denied") {
11521 out.push_str(" Error: Access denied to WinRM configuration.\n");
11522 } else {
11523 out.push_str(" No listeners configured.\n");
11524 }
11525 }
11526
11527 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11528 let test_out = Command::new("powershell").args(["-NoProfile", "-Command", "Test-WSMan -ErrorAction SilentlyContinue | Select-Object ProductVersion, Stack | ForEach-Object { \" SUCCESS: OS Version $($_.ProductVersion) (Stack $($_.Stack))\" }"])
11529 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11530 if test_out.trim().is_empty() {
11531 out.push_str(" WinRM not responding to local WS-Man requests.\n");
11532 } else {
11533 out.push_str(&test_out);
11534 }
11535 }
11536
11537 #[cfg(not(target_os = "windows"))]
11538 {
11539 out.push_str(
11540 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11541 );
11542 let ss = Command::new("ss")
11543 .args(["-tln"])
11544 .output()
11545 .ok()
11546 .and_then(|o| String::from_utf8(o.stdout).ok())
11547 .unwrap_or_default();
11548 if ss.contains(":5985") || ss.contains(":5986") {
11549 out.push_str(" WinRM ports (5985/5986) are listening.\n");
11550 } else {
11551 out.push_str(" WinRM ports not detected.\n");
11552 }
11553 }
11554
11555 Ok(out.trim_end().to_string())
11556}
11557
11558fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11559 let mut out = String::from("Host inspection: network_stats\n\n");
11560
11561 #[cfg(target_os = "windows")]
11562 {
11563 let ps_cmd = format!(
11564 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11565 Start-Sleep -Milliseconds 250; \
11566 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11567 $s2 | ForEach-Object {{ \
11568 $name = $_.Name; \
11569 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11570 if ($prev) {{ \
11571 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11572 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11573 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11574 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11575 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11576 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11577 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11578 }} \
11579 }}",
11580 max_entries
11581 );
11582 let output = Command::new("powershell")
11583 .args(["-NoProfile", "-Command", &ps_cmd])
11584 .output()
11585 .ok()
11586 .and_then(|o| String::from_utf8(o.stdout).ok())
11587 .unwrap_or_default();
11588 if output.trim().is_empty() {
11589 out.push_str("No network adapter statistics available.\n");
11590 } else {
11591 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11592 out.push_str(&output);
11593 }
11594
11595 let discards = Command::new("powershell").args(["-NoProfile", "-Command", "Get-NetAdapterStatistics | Select-Object Name, ReceivedPacketDiscards, OutboundPacketDiscards | ForEach-Object { if($_.ReceivedPacketDiscards -gt 0 -or $_.OutboundPacketDiscards -gt 0) { \" $($_.Name): Discards(RX/TX): $($_.ReceivedPacketDiscards)/$($_.OutboundPacketDiscards)\" } }"])
11596 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11597 if !discards.trim().is_empty() {
11598 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11599 out.push_str(&discards);
11600 }
11601 }
11602
11603 #[cfg(not(target_os = "windows"))]
11604 {
11605 let _ = max_entries;
11606 out.push_str("=== Network Stats (ip -s link) ===\n");
11607 let ip_s = Command::new("ip")
11608 .args(["-s", "link"])
11609 .output()
11610 .ok()
11611 .and_then(|o| String::from_utf8(o.stdout).ok())
11612 .unwrap_or_default();
11613 if ip_s.is_empty() {
11614 let netstat = Command::new("netstat")
11615 .args(["-i"])
11616 .output()
11617 .ok()
11618 .and_then(|o| String::from_utf8(o.stdout).ok())
11619 .unwrap_or_default();
11620 out.push_str(&netstat);
11621 } else {
11622 out.push_str(&ip_s);
11623 }
11624 }
11625
11626 Ok(out.trim_end().to_string())
11627}
11628
11629fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11630 let mut out = String::from("Host inspection: udp_ports\n\n");
11631
11632 #[cfg(target_os = "windows")]
11633 {
11634 let ps_cmd = format!("Get-NetUDPEndpoint | Select-Object LocalAddress, LocalPort, OwningProcess | Select-Object -First {} | ForEach-Object {{ $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name; \" $($_.LocalAddress):$($_.LocalPort) (PID: $($_.OwningProcess) - $($proc))\" }}", max_entries);
11635 let output = Command::new("powershell")
11636 .args(["-NoProfile", "-Command", &ps_cmd])
11637 .output()
11638 .ok();
11639
11640 if let Some(o) = output {
11641 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11642 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11643
11644 if !stdout.trim().is_empty() {
11645 out.push_str("=== UDP Listeners (Local:Port) ===\n");
11646 for line in stdout.lines() {
11647 let mut note = "";
11648 if line.contains(":53 ") {
11649 note = " [DNS]";
11650 } else if line.contains(":67 ") || line.contains(":68 ") {
11651 note = " [DHCP]";
11652 } else if line.contains(":123 ") {
11653 note = " [NTP]";
11654 } else if line.contains(":161 ") {
11655 note = " [SNMP]";
11656 } else if line.contains(":1900 ") {
11657 note = " [SSDP/UPnP]";
11658 } else if line.contains(":5353 ") {
11659 note = " [mDNS]";
11660 }
11661
11662 let _ = writeln!(out, "{}{}", line, note);
11663 }
11664 } else if stderr.contains("Access is denied") {
11665 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11666 } else {
11667 out.push_str("No UDP listeners detected.\n");
11668 }
11669 }
11670 }
11671
11672 #[cfg(not(target_os = "windows"))]
11673 {
11674 let ss_out = Command::new("ss")
11675 .args(["-ulnp"])
11676 .output()
11677 .ok()
11678 .and_then(|o| String::from_utf8(o.stdout).ok())
11679 .unwrap_or_default();
11680 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11681 if ss_out.is_empty() {
11682 let netstat_out = Command::new("netstat")
11683 .args(["-ulnp"])
11684 .output()
11685 .ok()
11686 .and_then(|o| String::from_utf8(o.stdout).ok())
11687 .unwrap_or_default();
11688 if netstat_out.is_empty() {
11689 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
11690 } else {
11691 for line in netstat_out.lines().take(max_entries) {
11692 let _ = write!(out, " {}\n", line);
11693 }
11694 }
11695 } else {
11696 for line in ss_out.lines().take(max_entries) {
11697 let _ = write!(out, " {}\n", line);
11698 }
11699 }
11700 }
11701
11702 Ok(out.trim_end().to_string())
11703}
11704
11705fn inspect_gpo() -> Result<String, String> {
11706 let mut out = String::from("Host inspection: gpo\n\n");
11707
11708 #[cfg(target_os = "windows")]
11709 {
11710 let output = Command::new("gpresult")
11711 .args(["/r", "/scope", "computer"])
11712 .output()
11713 .ok();
11714
11715 if let Some(o) = output {
11716 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11717 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11718
11719 if stdout.contains("Applied Group Policy Objects") {
11720 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11721 let mut capture = false;
11722 for line in stdout.lines() {
11723 if line.contains("Applied Group Policy Objects") {
11724 capture = true;
11725 } else if capture && line.contains("The following GPOs were not applied") {
11726 break;
11727 }
11728 if capture && !line.trim().is_empty() {
11729 let _ = writeln!(out, " {}", line.trim());
11730 }
11731 }
11732 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11733 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11734 } else {
11735 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11736 }
11737 }
11738 }
11739
11740 #[cfg(not(target_os = "windows"))]
11741 {
11742 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11743 }
11744
11745 Ok(out.trim_end().to_string())
11746}
11747
11748fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11749 let mut out = String::from("Host inspection: certificates\n\n");
11750
11751 #[cfg(target_os = "windows")]
11752 {
11753 let ps_cmd = format!(
11754 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11755 $days = ($_.NotAfter - (Get-Date)).Days; \
11756 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11757 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11758 }}",
11759 max_entries
11760 );
11761 let output = Command::new("powershell")
11762 .args(["-NoProfile", "-Command", &ps_cmd])
11763 .output()
11764 .ok();
11765
11766 if let Some(o) = output {
11767 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11768 if !stdout.trim().is_empty() {
11769 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11770 out.push_str(&stdout);
11771 } else {
11772 out.push_str("No certificates found in the Local Machine Personal store.\n");
11773 }
11774 }
11775 }
11776
11777 #[cfg(not(target_os = "windows"))]
11778 {
11779 let _ = max_entries;
11780 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11781 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11783 if Path::new(path).exists() {
11784 let _ = write!(out, " Cert directory found: {}\n", path);
11785 }
11786 }
11787 }
11788
11789 Ok(out.trim_end().to_string())
11790}
11791
11792fn inspect_integrity() -> Result<String, String> {
11793 let mut out = String::from("Host inspection: integrity\n\n");
11794
11795 #[cfg(target_os = "windows")]
11796 {
11797 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11798 let output = Command::new("powershell")
11799 .args(["-NoProfile", "-Command", ps_cmd])
11800 .output()
11801 .ok();
11802
11803 if let Some(o) = output {
11804 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11805 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11806 out.push_str("=== Windows Component Store Health (CBS) ===\n");
11807 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11808 let repair = val
11809 .get("AutoRepairNeeded")
11810 .and_then(|v| v.as_u64())
11811 .unwrap_or(0);
11812
11813 let _ = writeln!(
11814 out,
11815 " Corruption Detected: {}",
11816 if corrupt != 0 {
11817 "YES (SFC/DISM recommended)"
11818 } else {
11819 "No"
11820 }
11821 );
11822 let _ = writeln!(
11823 out,
11824 " Auto-Repair Needed: {}",
11825 if repair != 0 { "YES" } else { "No" }
11826 );
11827
11828 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11829 let _ = writeln!(out, " Last Repair Attempt: (Raw code: {})", last);
11830 }
11831 } else {
11832 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11833 }
11834 }
11835
11836 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11837 out.push_str(
11838 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11839 );
11840 }
11841 }
11842
11843 #[cfg(not(target_os = "windows"))]
11844 {
11845 out.push_str("System integrity check (Linux)\n\n");
11846 let pkg_check = Command::new("rpm")
11847 .args(["-Va"])
11848 .output()
11849 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11850 .ok();
11851 if let Some(o) = pkg_check {
11852 out.push_str(" Package verification system active.\n");
11853 if o.status.success() {
11854 out.push_str(" No major package integrity issues detected.\n");
11855 }
11856 }
11857 }
11858
11859 Ok(out.trim_end().to_string())
11860}
11861
11862fn inspect_domain() -> Result<String, String> {
11863 let mut out = String::from("Host inspection: domain\n\n");
11864
11865 #[cfg(target_os = "windows")]
11866 {
11867 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11868 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11869 let output = Command::new("powershell")
11870 .args(["-NoProfile", "-Command", ps_cmd])
11871 .output()
11872 .ok();
11873
11874 if let Some(o) = output {
11875 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11876 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11877 let part_of_domain = val
11878 .get("PartOfDomain")
11879 .and_then(|v| v.as_bool())
11880 .unwrap_or(false);
11881 let domain = val
11882 .get("Domain")
11883 .and_then(|v| v.as_str())
11884 .unwrap_or("Unknown");
11885 let workgroup = val
11886 .get("Workgroup")
11887 .and_then(|v| v.as_str())
11888 .unwrap_or("Unknown");
11889
11890 let _ = writeln!(
11891 out,
11892 " Join Status: {}",
11893 if part_of_domain {
11894 "DOMAIN JOINED"
11895 } else {
11896 "WORKGROUP"
11897 }
11898 );
11899 if part_of_domain {
11900 let _ = writeln!(out, " Active Directory Domain: {}", domain);
11901 } else {
11902 let _ = writeln!(out, " Workgroup Name: {}", workgroup);
11903 }
11904
11905 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11906 let _ = writeln!(out, " NetBIOS Name: {}", name);
11907 }
11908 } else {
11909 out.push_str(" Domain identity data unavailable from WMI.\n");
11910 }
11911 } else {
11912 out.push_str(" Domain identity data unavailable from WMI.\n");
11913 }
11914 }
11915
11916 #[cfg(not(target_os = "windows"))]
11917 {
11918 let domainname = Command::new("domainname")
11919 .output()
11920 .ok()
11921 .and_then(|o| String::from_utf8(o.stdout).ok())
11922 .unwrap_or_default();
11923 out.push_str("=== Linux Domain Identity ===\n");
11924 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11925 let _ = write!(out, " NIS/YP Domain: {}\n", domainname.trim());
11926 } else {
11927 out.push_str(" No NIS domain configured.\n");
11928 }
11929 }
11930
11931 Ok(out.trim_end().to_string())
11932}
11933
11934fn inspect_device_health() -> Result<String, String> {
11935 let mut out = String::from("Host inspection: device_health\n\n");
11936
11937 #[cfg(target_os = "windows")]
11938 {
11939 let ps_cmd = "Get-CimInstance Win32_PnPEntity | Where-Object { $_.ConfigManagerErrorCode -ne 0 } | Select-Object Name, Status, ConfigManagerErrorCode, Description | ForEach-Object { \" [ERR:$($_.ConfigManagerErrorCode)] $($_.Name) ($($_.Status)) - $($_.Description)\" }";
11940 let output = Command::new("powershell")
11941 .args(["-NoProfile", "-Command", ps_cmd])
11942 .output()
11943 .ok()
11944 .and_then(|o| String::from_utf8(o.stdout).ok())
11945 .unwrap_or_default();
11946
11947 if output.trim().is_empty() {
11948 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11949 } else {
11950 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11951 out.push_str(&output);
11952 out.push_str(
11953 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11954 );
11955 }
11956 }
11957
11958 #[cfg(not(target_os = "windows"))]
11959 {
11960 out.push_str("Checking dmesg for hardware errors...\n");
11961 let dmesg = Command::new("dmesg")
11962 .args(["--level=err,crit,alert"])
11963 .output()
11964 .ok()
11965 .and_then(|o| String::from_utf8(o.stdout).ok())
11966 .unwrap_or_default();
11967 if dmesg.is_empty() {
11968 out.push_str(" No critical hardware errors found in dmesg.\n");
11969 } else {
11970 for (i, line) in dmesg.lines().take(20).enumerate() {
11971 if i > 0 {
11972 out.push('\n');
11973 }
11974 out.push_str(line);
11975 }
11976 }
11977 }
11978
11979 Ok(out.trim_end().to_string())
11980}
11981
11982fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11983 let mut out = String::from("Host inspection: drivers\n\n");
11984
11985 #[cfg(target_os = "windows")]
11986 {
11987 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11988 let ps_cmd = format!("Get-CimInstance Win32_SystemDriver | Select-Object Name, Description, State, Status | Select-Object -First {} | ForEach-Object {{ \" $($_.Name): $($_.State) ($($_.Status)) - $($_.Description)\" }}", max_entries);
11989 let output = Command::new("powershell")
11990 .args(["-NoProfile", "-Command", &ps_cmd])
11991 .output()
11992 .ok()
11993 .and_then(|o| String::from_utf8(o.stdout).ok())
11994 .unwrap_or_default();
11995
11996 if output.trim().is_empty() {
11997 out.push_str(" No drivers retrieved via WMI.\n");
11998 } else {
11999 out.push_str(&output);
12000 }
12001 }
12002
12003 #[cfg(not(target_os = "windows"))]
12004 {
12005 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
12006 let lsmod = Command::new("lsmod")
12007 .output()
12008 .ok()
12009 .and_then(|o| String::from_utf8(o.stdout).ok())
12010 .unwrap_or_default();
12011 for (i, line) in lsmod.lines().take(max_entries).enumerate() {
12012 if i > 0 {
12013 out.push('\n');
12014 }
12015 out.push_str(line);
12016 }
12017 }
12018
12019 Ok(out.trim_end().to_string())
12020}
12021
12022fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
12023 let mut out = String::from("Host inspection: peripherals\n\n");
12024
12025 #[cfg(target_os = "windows")]
12026 {
12027 let _ = max_entries;
12028 out.push_str("=== USB Controllers & Hubs ===\n");
12029 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
12030 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12031 out.push_str(if usb.is_empty() {
12032 " None detected.\n"
12033 } else {
12034 &usb
12035 });
12036
12037 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
12038 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
12039 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12040 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
12041 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12042 out.push_str(&kb);
12043 out.push_str(&mouse);
12044
12045 out.push_str("\n=== Connected Monitors (WMI) ===\n");
12046 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
12047 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12048 out.push_str(if mon.is_empty() {
12049 " No active monitors identified via WMI.\n"
12050 } else {
12051 &mon
12052 });
12053 }
12054
12055 #[cfg(not(target_os = "windows"))]
12056 {
12057 out.push_str("=== Connected USB Devices (lsusb) ===\n");
12058 let lsusb = Command::new("lsusb")
12059 .output()
12060 .ok()
12061 .and_then(|o| String::from_utf8(o.stdout).ok())
12062 .unwrap_or_default();
12063 for (i, line) in lsusb.lines().take(max_entries).enumerate() {
12064 if i > 0 {
12065 out.push('\n');
12066 }
12067 out.push_str(line);
12068 }
12069 }
12070
12071 Ok(out.trim_end().to_string())
12072}
12073
12074fn inspect_sessions(max_entries: usize) -> Result<String, String> {
12075 let mut out = String::from("Host inspection: sessions\n\n");
12076
12077 #[cfg(target_os = "windows")]
12078 {
12079 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
12080 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
12081 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
12082}"#;
12083 if let Ok(o) = Command::new("powershell")
12084 .args(["-NoProfile", "-Command", script])
12085 .output()
12086 {
12087 let text = String::from_utf8_lossy(&o.stdout);
12088 let lines: Vec<&str> = text.lines().collect();
12089 if lines.is_empty() {
12090 out.push_str(" No active logon sessions enumerated via WMI.\n");
12091 } else {
12092 for line in lines
12093 .iter()
12094 .take(max_entries)
12095 .filter(|l| !l.trim().is_empty())
12096 {
12097 let mut it = line.trim().splitn(5, '|');
12098 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
12099 (it.next(), it.next(), it.next(), it.next())
12100 {
12101 let logon_type = match p2 {
12102 "2" => "Interactive",
12103 "3" => "Network",
12104 "4" => "Batch",
12105 "5" => "Service",
12106 "7" => "Unlock",
12107 "8" => "NetworkCleartext",
12108 "9" => "NewCredentials",
12109 "10" => "RemoteInteractive",
12110 "11" => "CachedInteractive",
12111 _ => "Other",
12112 };
12113 let _ = writeln!(
12114 out,
12115 "- ID: {} | Type: {} | Started: {} | Auth: {}",
12116 p0, logon_type, p1, p3
12117 );
12118 }
12119 }
12120 }
12121 } else {
12122 out.push_str(" Active logon session data unavailable from WMI.\n");
12123 }
12124 }
12125
12126 #[cfg(not(target_os = "windows"))]
12127 {
12128 out.push_str("=== Logged-in Users (who) ===\n");
12129 let who = Command::new("who")
12130 .output()
12131 .ok()
12132 .and_then(|o| String::from_utf8(o.stdout).ok())
12133 .unwrap_or_default();
12134 for (i, line) in who.lines().take(max_entries).enumerate() {
12135 if i > 0 {
12136 out.push('\n');
12137 }
12138 out.push_str(line);
12139 }
12140 }
12141
12142 Ok(out.trim_end().to_string())
12143}
12144
12145async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
12146 let mut out = String::from("Host inspection: disk_benchmark\n\n");
12147 let mut final_path = path;
12148
12149 if !final_path.exists() {
12150 if let Ok(current_exe) = std::env::current_exe() {
12151 let _ = writeln!(out,
12152 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.",
12153 final_path.display()
12154 );
12155 final_path = current_exe;
12156 } else {
12157 return Err(format!("Target not found: {}", final_path.display()));
12158 }
12159 }
12160
12161 let target = if final_path.is_dir() {
12162 let mut target_file = final_path.join("Cargo.toml");
12164 if !target_file.exists() {
12165 target_file = final_path.join("README.md");
12166 }
12167 if !target_file.exists() {
12168 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
12169 }
12170 target_file
12171 } else {
12172 final_path
12173 };
12174
12175 let _ = writeln!(out, "Target: {}", target.display());
12176 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
12177
12178 #[cfg(target_os = "windows")]
12179 {
12180 let target_display = target.display().to_string();
12181 let escaped_target = ps_escape_single_quoted(&target_display);
12182 let script = format!(
12183 r#"
12184$target = '{escaped_target}'
12185if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
12186
12187$diskQueue = @()
12188$readStats = @()
12189$startTime = Get-Date
12190$duration = 5
12191
12192# Background reader job
12193$job = Start-Job -ScriptBlock {{
12194 param($t, $d)
12195 $stop = (Get-Date).AddSeconds($d)
12196 while ((Get-Date) -lt $stop) {{
12197 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
12198 }}
12199}} -ArgumentList $target, $duration
12200
12201# Metrics collector loop
12202$stopTime = (Get-Date).AddSeconds($duration)
12203while ((Get-Date) -lt $stopTime) {{
12204 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
12205 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
12206
12207 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
12208 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
12209
12210 Start-Sleep -Milliseconds 250
12211}}
12212
12213Stop-Job $job
12214Receive-Job $job | Out-Null
12215Remove-Job $job
12216
12217$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
12218$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
12219$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
12220
12221"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
12222"#
12223 );
12224
12225 let output = Command::new("powershell")
12226 .args(["-NoProfile", "-Command", &script])
12227 .output()
12228 .map_err(|e| format!("Benchmark failed: {e}"))?;
12229
12230 let raw = String::from_utf8_lossy(&output.stdout);
12231 let text = raw.trim();
12232
12233 if text.starts_with("ERROR") {
12234 return Err(text.to_string());
12235 }
12236
12237 let mut lines = text.lines();
12238 if let Some(metrics_line) = lines.next() {
12239 let mut avg_q = "unknown".to_string();
12240 let mut max_q = "unknown".to_string();
12241 let mut avg_r = "unknown".to_string();
12242
12243 for p in metrics_line.split('|') {
12244 if let Some((k, v)) = p.split_once(':') {
12245 match k {
12246 "AVG_Q" => avg_q = v.to_string(),
12247 "MAX_Q" => max_q = v.to_string(),
12248 "AVG_R" => avg_r = v.to_string(),
12249 _ => {}
12250 }
12251 }
12252 }
12253
12254 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
12255 let _ = writeln!(out, "- Active Disk Queue (Avg): {}", avg_q);
12256 let _ = writeln!(out, "- Active Disk Queue (Max): {}", max_q);
12257 let _ = writeln!(out, "- Disk Throughput (Avg): {} reads/sec", avg_r);
12258 out.push_str("\nVerdict: ");
12259 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
12260 if q_num > 1.0 {
12261 out.push_str(
12262 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
12263 );
12264 } else if q_num > 0.1 {
12265 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
12266 } else {
12267 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
12268 }
12269 }
12270 }
12271
12272 #[cfg(not(target_os = "windows"))]
12273 {
12274 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
12275 out.push_str("Generic disk load simulated.\n");
12276 }
12277
12278 Ok(out)
12279}
12280
12281fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
12282 let mut out = String::from("Host inspection: permissions\n\n");
12283 let _ = write!(out, "Auditing access control for: {}\n\n", path.display());
12284
12285 #[cfg(target_os = "windows")]
12286 {
12287 let path_str = path.display().to_string();
12288 let escaped_path = ps_escape_single_quoted(&path_str);
12289 let script = format!(
12290 "Get-Acl -Path '{escaped_path}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}"
12291 );
12292 let output = Command::new("powershell")
12293 .args(["-NoProfile", "-Command", &script])
12294 .output()
12295 .map_err(|e| format!("ACL check failed: {e}"))?;
12296
12297 let text = String::from_utf8_lossy(&output.stdout);
12298 if text.trim().is_empty() {
12299 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
12300 } else {
12301 out.push_str("=== Windows NTFS Permissions ===\n");
12302 out.push_str(&text);
12303 }
12304 }
12305
12306 #[cfg(not(target_os = "windows"))]
12307 {
12308 let output = Command::new("ls")
12309 .args(["-ld", &path.to_string_lossy()])
12310 .output()
12311 .map_err(|e| format!("ls check failed: {e}"))?;
12312 out.push_str("=== Unix File Permissions ===\n");
12313 out.push_str(&String::from_utf8_lossy(&output.stdout));
12314 }
12315
12316 Ok(out.trim_end().to_string())
12317}
12318
12319fn inspect_login_history(max_entries: usize) -> Result<String, String> {
12320 let mut out = String::from("Host inspection: login_history\n\n");
12321
12322 #[cfg(target_os = "windows")]
12323 {
12324 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
12325 out.push_str("Note: This typically requires Administrator elevation.\n\n");
12326
12327 let n = max_entries.clamp(1, 50);
12328 let script = format!(
12329 r#"try {{
12330 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
12331 $events | ForEach-Object {{
12332 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
12333 # Extract target user name from the XML/Properties if possible
12334 $user = $_.Properties[5].Value
12335 $type = $_.Properties[8].Value
12336 "[$time] User: $user | Type: $type"
12337 }}
12338}} catch {{ "ERROR:" + $_.Exception.Message }}"#
12339 );
12340
12341 let output = Command::new("powershell")
12342 .args(["-NoProfile", "-Command", &script])
12343 .output()
12344 .map_err(|e| format!("Login history query failed: {e}"))?;
12345
12346 let text = String::from_utf8_lossy(&output.stdout);
12347 if text.starts_with("ERROR:") {
12348 let _ = writeln!(out, "Unable to query Security Log: {}", text);
12349 } else if text.trim().is_empty() {
12350 out.push_str("No recent logon events found or access denied.\n");
12351 } else {
12352 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
12353 out.push_str(&text);
12354 }
12355 }
12356
12357 #[cfg(not(target_os = "windows"))]
12358 {
12359 let output = Command::new("last")
12360 .args(["-n", &max_entries.to_string()])
12361 .output()
12362 .map_err(|e| format!("last command failed: {e}"))?;
12363 out.push_str("=== Unix Login History (last) ===\n");
12364 out.push_str(&String::from_utf8_lossy(&output.stdout));
12365 }
12366
12367 Ok(out.trim_end().to_string())
12368}
12369
12370fn inspect_share_access(path: PathBuf) -> Result<String, String> {
12371 let mut out = String::from("Host inspection: share_access\n\n");
12372 let _ = write!(out, "Testing accessibility of: {}\n\n", path.display());
12373
12374 #[cfg(target_os = "windows")]
12375 {
12376 let path_str = path.display().to_string();
12377 let escaped_path = ps_escape_single_quoted(&path_str);
12378 let script = format!(
12379 r#"
12380$p = '{escaped_path}'
12381$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
12382if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
12383 $res.Reachable = $true
12384 try {{
12385 $null = Get-ChildItem -Path $p -ErrorAction Stop
12386 $res.Readable = $true
12387 }} catch {{
12388 $res.Error = $_.Exception.Message
12389 }}
12390}} else {{
12391 $res.Error = "Server unreachable (Ping failed)"
12392}}
12393"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#
12394 );
12395
12396 let output = Command::new("powershell")
12397 .args(["-NoProfile", "-Command", &script])
12398 .output()
12399 .map_err(|e| format!("Share test failed: {e}"))?;
12400
12401 let text = String::from_utf8_lossy(&output.stdout);
12402 out.push_str("=== Share Triage Results ===\n");
12403 out.push_str(&text);
12404 }
12405
12406 #[cfg(not(target_os = "windows"))]
12407 {
12408 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
12409 }
12410
12411 Ok(out.trim_end().to_string())
12412}
12413
12414fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
12415 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
12416 let _ = write!(out, "Issue: {}\n\n", issue);
12417 out.push_str("Proposed Remediation Steps:\n");
12418 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
12419 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
12420 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
12421 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
12422 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
12423 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
12424 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
12425 out.push_str(
12426 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
12427 );
12428
12429 Ok(out)
12430}
12431
12432fn inspect_registry_audit() -> Result<String, String> {
12433 let mut out = String::from("Host inspection: registry_audit\n\n");
12434 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
12435
12436 #[cfg(target_os = "windows")]
12437 {
12438 let script = r#"
12439$findings = @()
12440
12441# 1. Image File Execution Options (Debugger Hijacking)
12442$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
12443if (Test-Path $ifeo) {
12444 Get-ChildItem $ifeo | ForEach-Object {
12445 $p = Get-ItemProperty $_.PSPath
12446 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
12447 }
12448}
12449
12450# 2. Winlogon Shell Integrity
12451$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
12452$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
12453if ($shell -and $shell -ne "explorer.exe") {
12454 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
12455}
12456
12457# 3. Session Manager BootExecute
12458$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
12459$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
12460if ($boot -and $boot -notcontains "autocheck autochk *") {
12461 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
12462}
12463
12464if ($findings.Count -eq 0) {
12465 "PASS: No common registry hijacking or shell overrides detected."
12466} else {
12467 $findings -join "`n"
12468}
12469"#;
12470 let output = Command::new("powershell")
12471 .args(["-NoProfile", "-Command", script])
12472 .output()
12473 .map_err(|e| format!("Registry audit failed: {e}"))?;
12474
12475 let text = String::from_utf8_lossy(&output.stdout);
12476 out.push_str("=== Persistence & Integrity Check ===\n");
12477 out.push_str(&text);
12478 }
12479
12480 #[cfg(not(target_os = "windows"))]
12481 {
12482 out.push_str("Registry auditing is specific to Windows environments.\n");
12483 }
12484
12485 Ok(out.trim_end().to_string())
12486}
12487
12488fn inspect_thermal() -> Result<String, String> {
12489 let mut out = String::from("Host inspection: thermal\n\n");
12490 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
12491
12492 #[cfg(target_os = "windows")]
12493 {
12494 let script = r#"
12495$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
12496if ($thermal) {
12497 $thermal | ForEach-Object {
12498 $temp = [math]::Round(($_.Temperature - 273.15), 1)
12499 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
12500 }
12501} else {
12502 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
12503 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
12504 "Current CPU Load: $throttling%"
12505}
12506"#;
12507 let output = Command::new("powershell")
12508 .args(["-NoProfile", "-Command", script])
12509 .output()
12510 .map_err(|e| format!("Thermal check failed: {e}"))?;
12511 out.push_str("=== Windows Thermal State ===\n");
12512 out.push_str(&String::from_utf8_lossy(&output.stdout));
12513 }
12514
12515 #[cfg(not(target_os = "windows"))]
12516 {
12517 out.push_str(
12518 "Thermal inspection is currently optimized for Windows performance counters.\n",
12519 );
12520 }
12521
12522 Ok(out.trim_end().to_string())
12523}
12524
12525fn inspect_activation() -> Result<String, String> {
12526 let mut out = String::from("Host inspection: activation\n\n");
12527 out.push_str("Auditing Windows activation and license state...\n\n");
12528
12529 #[cfg(target_os = "windows")]
12530 {
12531 let script = r#"
12532$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
12533$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
12534"Status: $($xpr.Trim())"
12535"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12536"#;
12537 let output = Command::new("powershell")
12538 .args(["-NoProfile", "-Command", script])
12539 .output()
12540 .map_err(|e| format!("Activation check failed: {e}"))?;
12541 out.push_str("=== Windows License Report ===\n");
12542 out.push_str(&String::from_utf8_lossy(&output.stdout));
12543 }
12544
12545 #[cfg(not(target_os = "windows"))]
12546 {
12547 out.push_str("Windows activation check is specific to the Windows platform.\n");
12548 }
12549
12550 Ok(out.trim_end().to_string())
12551}
12552
12553fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12554 let mut out = String::from("Host inspection: patch_history\n\n");
12555 let _ = write!(
12556 out,
12557 "Listing the last {} installed Windows updates (KBs)...\n\n",
12558 max_entries
12559 );
12560
12561 #[cfg(target_os = "windows")]
12562 {
12563 let n = max_entries.clamp(1, 50);
12564 let script = format!(
12565 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12566 n
12567 );
12568 let output = Command::new("powershell")
12569 .args(["-NoProfile", "-Command", &script])
12570 .output()
12571 .map_err(|e| format!("Patch history query failed: {e}"))?;
12572 out.push_str("=== Recent HotFixes (KBs) ===\n");
12573 out.push_str(&String::from_utf8_lossy(&output.stdout));
12574 }
12575
12576 #[cfg(not(target_os = "windows"))]
12577 {
12578 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12579 }
12580
12581 Ok(out.trim_end().to_string())
12582}
12583
12584fn inspect_ad_user(identity: &str) -> Result<String, String> {
12587 let mut out = String::from("Host inspection: ad_user\n\n");
12588 let ident = identity.trim();
12589 if ident.is_empty() {
12590 out.push_str("Status: No identity specified. Performing self-discovery...\n");
12591 #[cfg(target_os = "windows")]
12592 {
12593 let script = r#"
12594$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12595"USER: " + $u.Name
12596"SID: " + $u.User.Value
12597"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12598"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12599"#;
12600 let output = Command::new("powershell")
12601 .args(["-NoProfile", "-Command", script])
12602 .output()
12603 .ok();
12604 if let Some(o) = output {
12605 out.push_str(&String::from_utf8_lossy(&o.stdout));
12606 }
12607 }
12608 return Ok(out);
12609 }
12610
12611 #[cfg(target_os = "windows")]
12612 {
12613 let escaped_ident = ps_escape_single_quoted(ident);
12614 let script = format!(
12615 r#"
12616try {{
12617 $u = Get-ADUser -Identity '{escaped_ident}' -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12618 "NAME: " + $u.Name
12619 "SID: " + $u.SID
12620 "ENABLED: " + $u.Enabled
12621 "EXPIRED: " + $u.PasswordExpired
12622 "LOGON: " + $u.LastLogonDate
12623 "GROUPS: " + ($u.MemberOf -replace 'CN=([^,]+),.*', '$1' -join ", ")
12624}} catch {{
12625 # Fallback to net user if AD module is missing or fails
12626 $net = net user '{escaped_ident}' /domain 2>&1
12627 if ($LASTEXITCODE -eq 0) {{
12628 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12629 }} else {{
12630 "ERROR: " + $_.Exception.Message
12631 }}
12632}}"#
12633 );
12634
12635 let output = Command::new("powershell")
12636 .args(["-NoProfile", "-Command", &script])
12637 .output()
12638 .ok();
12639
12640 if let Some(o) = output {
12641 let stdout = String::from_utf8_lossy(&o.stdout);
12642 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12643 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12644 }
12645 out.push_str(&stdout);
12646 }
12647 }
12648
12649 #[cfg(not(target_os = "windows"))]
12650 {
12651 let _ = ident;
12652 out.push_str("(AD User lookup only available on Windows nodes)\n");
12653 }
12654
12655 Ok(out.trim_end().to_string())
12656}
12657
12658fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12661 let mut out = String::from("Host inspection: dns_lookup\n\n");
12662 let target = name.trim();
12663 if target.is_empty() {
12664 return Err("Missing required target name for dns_lookup.".to_string());
12665 }
12666
12667 #[cfg(target_os = "windows")]
12668 {
12669 let escaped_target = ps_escape_single_quoted(target);
12670 let safe_record_type = validate_dns_record_type(record_type);
12671 let script = format!("Resolve-DnsName -Name '{escaped_target}' -Type {safe_record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12672 let output = Command::new("powershell")
12673 .args(["-NoProfile", "-Command", &script])
12674 .output()
12675 .ok();
12676 if let Some(o) = output {
12677 let stdout = String::from_utf8_lossy(&o.stdout);
12678 if stdout.trim().is_empty() {
12679 let _ = writeln!(out, "No {record_type} records found for {target}.");
12680 } else {
12681 out.push_str(&stdout);
12682 }
12683 }
12684 }
12685
12686 #[cfg(not(target_os = "windows"))]
12687 {
12688 let output = Command::new("dig")
12689 .args([target, record_type, "+short"])
12690 .output()
12691 .ok();
12692 if let Some(o) = output {
12693 out.push_str(&String::from_utf8_lossy(&o.stdout));
12694 }
12695 }
12696
12697 Ok(out.trim_end().to_string())
12698}
12699
12700#[cfg(target_os = "windows")]
12703fn ps_exec(script: &str) -> String {
12704 Command::new("powershell")
12705 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12706 .output()
12707 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12708 .unwrap_or_default()
12709}
12710
12711fn inspect_mdm_enrollment() -> Result<String, String> {
12712 #[cfg(target_os = "windows")]
12713 {
12714 let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12715
12716 out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12718 let ps_dsreg = r#"
12719$raw = dsregcmd /status 2>$null
12720$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12721 'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12722foreach ($line in $raw) {
12723 $t = $line.Trim()
12724 foreach ($f in $fields) {
12725 if ($t -like "$f :*") {
12726 $val = ($t -split ':',2)[1].Trim()
12727 "$f`: $val"
12728 }
12729 }
12730}
12731"#;
12732 match run_powershell(ps_dsreg) {
12733 Ok(o) if !o.trim().is_empty() => {
12734 for line in o.lines() {
12735 let l = line.trim();
12736 if !l.is_empty() {
12737 let _ = writeln!(out, "- {l}");
12738 }
12739 }
12740 }
12741 Ok(_) => out.push_str(
12742 "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12743 ),
12744 Err(e) => {
12745 let _ = writeln!(out, "- dsregcmd error: {e}");
12746 }
12747 }
12748
12749 out.push_str("\n=== Enrollment accounts (registry) ===\n");
12751 let ps_enroll = r#"
12752$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12753if (Test-Path $base) {
12754 $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12755 if ($accounts) {
12756 foreach ($acct in $accounts) {
12757 $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12758 $upn = if ($p.UPN) { $p.UPN } else { '(none)' }
12759 $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12760 $type = switch ($p.EnrollmentType) {
12761 6 { 'MDM' }
12762 13 { 'MAM' }
12763 default { "Type=$($p.EnrollmentType)" }
12764 }
12765 $state = switch ($p.EnrollmentState) {
12766 1 { 'Enrolled' }
12767 2 { 'InProgress' }
12768 6 { 'Unenrolled' }
12769 default { "State=$($p.EnrollmentState)" }
12770 }
12771 "Account: $upn | $type | $state | $server"
12772 }
12773 } else { "No enrollment accounts found under $base" }
12774} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12775"#;
12776 match run_powershell(ps_enroll) {
12777 Ok(o) => {
12778 for line in o.lines() {
12779 let l = line.trim();
12780 if !l.is_empty() {
12781 let _ = writeln!(out, "- {l}");
12782 }
12783 }
12784 }
12785 Err(e) => {
12786 let _ = writeln!(out, "- Registry read error: {e}");
12787 }
12788 }
12789
12790 out.push_str("\n=== MDM services ===\n");
12792 let ps_svc = r#"
12793$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12794foreach ($n in $names) {
12795 $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12796 if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12797}
12798"#;
12799 match run_powershell(ps_svc) {
12800 Ok(o) if !o.trim().is_empty() => {
12801 for line in o.lines() {
12802 let l = line.trim();
12803 if !l.is_empty() {
12804 let _ = writeln!(out, "- {l}");
12805 }
12806 }
12807 }
12808 Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12809 Err(e) => { let _ = writeln!(out, "- Service query error: {e}"); }
12810 }
12811
12812 out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12814 let ps_evt = r#"
12815$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12816 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12817$cutoff = (Get-Date).AddHours(-24)
12818$found = $false
12819foreach ($log in $logs) {
12820 $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12821 Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12822 foreach ($e in $evts) {
12823 $found = $true
12824 $ts = $e.TimeCreated.ToString('HH:mm')
12825 $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12826 "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12827 }
12828}
12829if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12830"#;
12831 match run_powershell(ps_evt) {
12832 Ok(o) => {
12833 for line in o.lines() {
12834 let l = line.trim();
12835 if !l.is_empty() {
12836 let _ = writeln!(out, "- {l}");
12837 }
12838 }
12839 }
12840 Err(e) => {
12841 let _ = writeln!(out, "- Event log read error: {e}");
12842 }
12843 }
12844
12845 out.push_str("\n=== Findings ===\n");
12847 let body = out.clone();
12848 let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12849 let intune_running = body.contains("IntuneManagementExtension: Running");
12850 let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12851
12852 if !enrolled {
12853 out.push_str("- NOT ENROLLED: Device shows no active MDM enrollment. If Intune enrollment is expected, check AAD join state and re-run device enrollment from Settings > Accounts > Access work or school.\n");
12854 } else {
12855 out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12856 if !intune_running {
12857 out.push_str("- WARNING: Intune Management Extension service is not running — policies and app deployments may stall. Check service health and restart if needed.\n");
12858 }
12859 }
12860 if has_errors {
12861 out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12862 }
12863 if !enrolled && !has_errors {
12864 out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12865 }
12866
12867 Ok(out)
12868 }
12869
12870 #[cfg(not(target_os = "windows"))]
12871 {
12872 Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12873 }
12874}
12875
12876fn inspect_hyperv() -> Result<String, String> {
12877 #[cfg(target_os = "windows")]
12878 {
12879 let mut findings: Vec<String> = Vec::with_capacity(4);
12880 let mut out = String::with_capacity(2048);
12881
12882 let ps_role = r#"
12884$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12885$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12886$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12887$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12888"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12889 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12890 $(if ($feature) { $feature.State } else { "Unknown" }),
12891 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12892 $(if ($ram) { $ram } else { "0" })
12893"#;
12894 let role_out = ps_exec(ps_role);
12895 out.push_str("=== Hyper-V role state ===\n");
12896
12897 let mut vmms_running = false;
12898 let mut host_ram_bytes: u64 = 0;
12899
12900 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12901 let kv: std::collections::HashMap<&str, &str> = line
12902 .split('|')
12903 .filter_map(|p| {
12904 let mut it = p.splitn(2, ':');
12905 Some((it.next()?, it.next()?))
12906 })
12907 .collect();
12908 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12909 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12910 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12911 host_ram_bytes = kv
12912 .get("HostRAMBytes")
12913 .and_then(|v| v.parse().ok())
12914 .unwrap_or(0);
12915
12916 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12917 vmms_running = vmms_status.starts_with("Running");
12918
12919 let _ = writeln!(out, "- Host: {host_name}");
12920 let _ = writeln!(
12921 out,
12922 "- Hyper-V feature: {}",
12923 if hyperv_installed {
12924 "Enabled"
12925 } else {
12926 "Not installed"
12927 }
12928 );
12929 let _ = writeln!(out, "- VMMS service: {vmms_status}");
12930 if host_ram_bytes > 0 {
12931 let _ = writeln!(
12932 out,
12933 "- Host physical RAM: {} GB",
12934 host_ram_bytes / 1_073_741_824
12935 );
12936 }
12937
12938 if !hyperv_installed {
12939 findings.push(
12940 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12941 );
12942 } else if !vmms_running {
12943 findings.push(
12944 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12945 );
12946 }
12947 } else {
12948 out.push_str("- Could not determine Hyper-V role state\n");
12949 findings.push("Hyper-V does not appear to be installed on this machine.".into());
12950 }
12951
12952 out.push_str("\n=== Virtual machines ===\n");
12954 if vmms_running {
12955 let ps_vms = r#"
12956Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12957 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12958 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12959 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12960 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12961 $_.Status, $_.Generation
12962}
12963"#;
12964 let vms_out = ps_exec(ps_vms);
12965 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12966
12967 if vm_lines.is_empty() {
12968 out.push_str("- No virtual machines found on this host\n");
12969 } else {
12970 let mut total_ram_bytes: u64 = 0;
12971 let mut saved_vms: Vec<String> = Vec::new();
12972 for line in &vm_lines {
12973 let kv: std::collections::HashMap<&str, &str> = line
12974 .split('|')
12975 .filter_map(|p| {
12976 let mut it = p.splitn(2, ':');
12977 Some((it.next()?, it.next()?))
12978 })
12979 .collect();
12980 let name = kv.get("VM").copied().unwrap_or("Unknown");
12981 let state = kv.get("State").copied().unwrap_or("Unknown");
12982 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12983 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12984 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12985 let status = kv.get("Status").copied().unwrap_or("");
12986 let gen = kv.get("Generation").copied().unwrap_or("?");
12987
12988 if let Ok(r) = ram.parse::<f64>() {
12989 total_ram_bytes += (r * 1_073_741_824.0) as u64;
12990 }
12991 if state.eq_ignore_ascii_case("Saved") {
12992 saved_vms.push(name.to_string());
12993 }
12994
12995 let _ = writeln!(out,
12996 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}"
12997 );
12998 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12999 let _ = writeln!(out, " Status: {status}");
13000 }
13001 }
13002
13003 let _ = write!(out, "\n- Total VMs: {}\n", vm_lines.len());
13004 if total_ram_bytes > 0 && host_ram_bytes > 0 {
13005 let pct = (total_ram_bytes * 100) / host_ram_bytes;
13006 let _ = writeln!(
13007 out,
13008 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)",
13009 total_ram_bytes / 1_073_741_824
13010 );
13011 if pct > 90 {
13012 findings.push(format!(
13013 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
13014 ));
13015 }
13016 }
13017 if !saved_vms.is_empty() {
13018 findings.push(format!(
13019 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
13020 saved_vms.join(", ")
13021 ));
13022 }
13023 }
13024 } else {
13025 out.push_str("- VMMS not running — cannot enumerate VMs\n");
13026 }
13027
13028 out.push_str("\n=== VM network switches ===\n");
13030 if vmms_running {
13031 let ps_switches = r#"
13032Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
13033 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
13034 $_.Name, $_.SwitchType,
13035 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
13036}
13037"#;
13038 let sw_out = ps_exec(ps_switches);
13039 let switch_lines: Vec<&str> = sw_out
13040 .lines()
13041 .filter(|l| l.starts_with("Switch:"))
13042 .collect();
13043
13044 if switch_lines.is_empty() {
13045 out.push_str("- No VM switches configured\n");
13046 } else {
13047 for line in &switch_lines {
13048 let kv: std::collections::HashMap<&str, &str> = line
13049 .split('|')
13050 .filter_map(|p| {
13051 let mut it = p.splitn(2, ':');
13052 Some((it.next()?, it.next()?))
13053 })
13054 .collect();
13055 let name = kv.get("Switch").copied().unwrap_or("Unknown");
13056 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
13057 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
13058 let _ = writeln!(out, "- {name} | Type: {sw_type} | NIC: {adapter}");
13059 }
13060 }
13061 } else {
13062 out.push_str("- VMMS not running — cannot enumerate switches\n");
13063 }
13064
13065 out.push_str("\n=== VM checkpoints ===\n");
13067 if vmms_running {
13068 let ps_checkpoints = r#"
13069$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
13070if ($all) {
13071 $all | ForEach-Object {
13072 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
13073 $_.Name, $_.VMName,
13074 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
13075 $_.SnapshotType
13076 }
13077} else {
13078 "NONE"
13079}
13080"#;
13081 let cp_out = ps_exec(ps_checkpoints);
13082 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
13083 out.push_str("- No checkpoints found\n");
13084 } else {
13085 let cp_lines: Vec<&str> = cp_out
13086 .lines()
13087 .filter(|l| l.starts_with("Checkpoint:"))
13088 .collect();
13089 let mut per_vm: std::collections::HashMap<&str, usize> =
13090 std::collections::HashMap::new();
13091 for line in &cp_lines {
13092 let kv: std::collections::HashMap<&str, &str> = line
13093 .split('|')
13094 .filter_map(|p| {
13095 let mut it = p.splitn(2, ':');
13096 Some((it.next()?, it.next()?))
13097 })
13098 .collect();
13099 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
13100 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
13101 let created = kv.get("Created").copied().unwrap_or("");
13102 let cp_type = kv.get("Type").copied().unwrap_or("");
13103 let _ = writeln!(
13104 out,
13105 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}"
13106 );
13107 *per_vm.entry(vm_name).or_insert(0) += 1;
13108 }
13109 for (vm, count) in &per_vm {
13110 if *count >= 3 {
13111 findings.push(format!(
13112 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
13113 ));
13114 }
13115 }
13116 }
13117 } else {
13118 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
13119 }
13120
13121 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
13122 if findings.is_empty() {
13123 result.push_str("- No Hyper-V health issues detected.\n");
13124 } else {
13125 for f in &findings {
13126 let _ = writeln!(result, "- Finding: {f}");
13127 }
13128 }
13129 result.push('\n');
13130 result.push_str(&out);
13131 Ok(result.trim_end().to_string())
13132 }
13133
13134 #[cfg(not(target_os = "windows"))]
13135 Ok(
13136 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
13137 .into(),
13138 )
13139}
13140
13141fn inspect_ip_config() -> Result<String, String> {
13144 let mut out = String::from("Host inspection: ip_config\n\n");
13145
13146 #[cfg(target_os = "windows")]
13147 {
13148 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
13149 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
13150 '\\n Status: ' + $_.NetAdapter.Status + \
13151 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
13152 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
13153 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
13154 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
13155 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
13156 }";
13157 let output = Command::new("powershell")
13158 .args(["-NoProfile", "-Command", script])
13159 .output()
13160 .ok();
13161 if let Some(o) = output {
13162 out.push_str(&String::from_utf8_lossy(&o.stdout));
13163 }
13164 }
13165
13166 #[cfg(not(target_os = "windows"))]
13167 {
13168 let output = Command::new("ip").args(["addr", "show"]).output().ok();
13169 if let Some(o) = output {
13170 out.push_str(&String::from_utf8_lossy(&o.stdout));
13171 }
13172 }
13173
13174 Ok(out.trim_end().to_string())
13175}
13176
13177fn inspect_event_query(
13180 event_id: Option<u32>,
13181 log_name: Option<&str>,
13182 source: Option<&str>,
13183 hours: u32,
13184 level: Option<&str>,
13185 max_entries: usize,
13186) -> Result<String, String> {
13187 #[cfg(target_os = "windows")]
13188 {
13189 let mut findings: Vec<String> = Vec::with_capacity(4);
13190
13191 let log = log_name.unwrap_or("*");
13193 let cap = max_entries.min(50);
13194
13195 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
13197 Some("error") | Some("errors") => Some(2u8),
13198 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
13199 Some("information") | Some("info") => Some(4u8),
13200 _ => None,
13201 };
13202
13203 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
13205 if log != "*" {
13206 let escaped_log = ps_escape_single_quoted(log);
13207 filter_parts.push(format!("LogName = '{escaped_log}'"));
13208 }
13209 if let Some(id) = event_id {
13210 filter_parts.push(format!("Id = {id}"));
13211 }
13212 if let Some(src) = source {
13213 let escaped_src = ps_escape_single_quoted(src);
13214 filter_parts.push(format!("ProviderName = '{escaped_src}'"));
13215 }
13216 if let Some(lvl) = level_filter {
13217 filter_parts.push(format!("Level = {lvl}"));
13218 }
13219
13220 let filter_ht = filter_parts.join("; ");
13221
13222 let ps = format!(
13223 r#"
13224$filter = @{{ {filter_ht} }}
13225try {{
13226 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
13227 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
13228 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
13229 if ($events) {{
13230 $events | ForEach-Object {{
13231 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
13232 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
13233 $_.Id, $_.LevelDisplayName, $_.ProviderName,
13234 ($_.Msg -replace '\|','/')
13235 }}
13236 }} else {{
13237 "NONE"
13238 }}
13239}} catch {{
13240 "ERROR:$($_.Exception.Message)"
13241}}
13242"#
13243 );
13244
13245 let raw = ps_exec(&ps);
13246 let lines: Vec<&str> = raw.lines().collect();
13247
13248 let mut query_desc = format!("last {hours}h");
13250 if let Some(id) = event_id {
13251 let _ = write!(query_desc, ", Event ID {id}");
13252 }
13253 if let Some(src) = source {
13254 let _ = write!(query_desc, ", source '{src}'");
13255 }
13256 if log != "*" {
13257 let _ = write!(query_desc, ", log '{log}'");
13258 }
13259 if let Some(l) = level {
13260 let _ = write!(query_desc, ", level '{l}'");
13261 }
13262
13263 let mut out = format!("=== Event query: {query_desc} ===\n");
13264
13265 if lines
13266 .iter()
13267 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
13268 {
13269 out.push_str("- No matching events found.\n");
13270 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
13271 let msg = err_line.trim_start_matches("ERROR:").trim();
13272 if is_event_query_no_results_message(msg) {
13273 out.push_str("- No matching events found.\n");
13274 } else {
13275 let _ = writeln!(out, "- Query error: {msg}");
13276 findings.push(format!("Event query failed: {msg}"));
13277 }
13278 } else {
13279 let event_lines: Vec<&str> = lines
13280 .iter()
13281 .filter(|l| l.starts_with("TIME:"))
13282 .copied()
13283 .collect();
13284 if event_lines.is_empty() {
13285 out.push_str("- No matching events found.\n");
13286 } else {
13287 let mut error_count = 0usize;
13289 let mut warning_count = 0usize;
13290
13291 for line in &event_lines {
13292 let kv: std::collections::HashMap<&str, &str> = line
13293 .split('|')
13294 .filter_map(|p| {
13295 let mut it = p.splitn(2, ':');
13296 Some((it.next()?, it.next()?))
13297 })
13298 .collect();
13299 let time = kv.get("TIME").copied().unwrap_or("?");
13300 let id = kv.get("ID").copied().unwrap_or("?");
13301 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
13302 let src = kv.get("SOURCE").copied().unwrap_or("?");
13303 let msg = kv.get("MSG").copied().unwrap_or("").trim();
13304
13305 let msg_display = if msg.len() > 120 {
13307 format!("{}…", safe_head(msg, 120))
13308 } else {
13309 msg.to_string()
13310 };
13311
13312 let _ = write!(out, "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n");
13313
13314 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
13315 error_count += 1;
13316 } else if lvl.eq_ignore_ascii_case("warning") {
13317 warning_count += 1;
13318 }
13319 }
13320
13321 let _ = write!(out, "\n- Total shown: {} event(s)\n", event_lines.len());
13322
13323 if error_count > 0 {
13324 findings.push(format!(
13325 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
13326 ));
13327 }
13328 if warning_count > 5 {
13329 findings.push(format!(
13330 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
13331 ));
13332 }
13333 }
13334 }
13335
13336 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
13337 if findings.is_empty() {
13338 result.push_str("- No actionable findings from this event query.\n");
13339 } else {
13340 for f in &findings {
13341 let _ = writeln!(result, "- Finding: {f}");
13342 }
13343 }
13344 result.push('\n');
13345 result.push_str(&out);
13346 Ok(result.trim_end().to_string())
13347 }
13348
13349 #[cfg(not(target_os = "windows"))]
13350 {
13351 let _ = (event_id, log_name, source, hours, level, max_entries);
13352 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
13353 }
13354}
13355
13356fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
13359 let n = max_entries.clamp(5, 50);
13360 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
13361 let mut findings: Vec<String> = Vec::with_capacity(4);
13362 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
13363 let mut sections = String::with_capacity(2048);
13364
13365 #[cfg(target_os = "windows")]
13366 {
13367 let proc_filter_ps = match process_filter {
13368 Some(proc) => format!(
13369 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
13370 proc.replace('\'', "''")
13371 ),
13372 None => String::new(),
13373 };
13374
13375 let ps = format!(
13376 r#"
13377$results = @()
13378try {{
13379 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
13380 if ($events) {{
13381 foreach ($e in $events) {{
13382 $msg = $e.Message
13383 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
13384 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
13385 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
13386 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
13387 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
13388 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
13389 }}
13390 $results
13391 }} else {{ 'NONE' }}
13392}} catch {{ 'ERROR:' + $_.Exception.Message }}
13393"#
13394 );
13395
13396 let raw = ps_exec(&ps);
13397 let text = raw.trim();
13398
13399 let wer_ps = r#"
13401$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
13402$count = 0
13403if (Test-Path $wer) {
13404 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
13405}
13406$count
13407"#;
13408 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
13409
13410 if text == "NONE" {
13411 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
13412 } else if text.starts_with("ERROR:") {
13413 let msg = text.trim_start_matches("ERROR:").trim();
13414 let _ = write!(
13415 sections,
13416 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
13417 );
13418 } else {
13419 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
13420 let crash_count = events
13421 .iter()
13422 .filter(|l| l.split('|').nth(1) == Some("CRASH"))
13423 .count();
13424 let hang_count = events
13425 .iter()
13426 .filter(|l| l.split('|').nth(1) == Some("HANG"))
13427 .count();
13428
13429 let mut app_counts: std::collections::HashMap<String, usize> =
13431 std::collections::HashMap::new();
13432 for line in &events {
13433 let mut it = line.splitn(6, '|');
13434 if let (Some(_), Some(_), Some(app)) = (it.next(), it.next(), it.next()) {
13435 *app_counts.entry(app.to_string()).or_insert(0) += 1;
13436 }
13437 }
13438
13439 if crash_count > 0 {
13440 findings.push(format!(
13441 "{crash_count} application crash event(s) — review below for faulting app and exception code."
13442 ));
13443 }
13444 if hang_count > 0 {
13445 findings.push(format!(
13446 "{hang_count} application hang event(s) — process stopped responding."
13447 ));
13448 }
13449 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
13450 if count > 1 {
13451 findings.push(format!(
13452 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
13453 ));
13454 }
13455 }
13456 if wer_count > 10 {
13457 findings.push(format!(
13458 "{wer_count} WER reports archived — elevated crash history on this machine."
13459 ));
13460 }
13461
13462 let filter_note = match process_filter {
13463 Some(p) => format!(" (filtered: {p})"),
13464 None => String::new(),
13465 };
13466 let _ = writeln!(
13467 sections,
13468 "=== Application crashes and hangs{filter_note} ==="
13469 );
13470
13471 for line in &events {
13472 let mut it = line.splitn(6, '|');
13473 if let (Some(time), Some(kind), Some(app), Some(ver), Some(module), Some(exc)) = (
13474 it.next(),
13475 it.next(),
13476 it.next(),
13477 it.next(),
13478 it.next(),
13479 it.next(),
13480 ) {
13481 let ver_note = if !ver.is_empty() {
13482 format!(" v{ver}")
13483 } else {
13484 String::new()
13485 };
13486 let _ = writeln!(sections, " [{time}] {kind}: {app}{ver_note}");
13487 if !module.is_empty() && module != "?" {
13488 let exc_note = if !exc.is_empty() {
13489 format!(" (exc {exc})")
13490 } else {
13491 String::new()
13492 };
13493 let _ = writeln!(sections, " faulting module: {module}{exc_note}");
13494 } else if !exc.is_empty() {
13495 let _ = writeln!(sections, " exception: {exc}");
13496 }
13497 }
13498 }
13499 let _ = write!(
13500 sections,
13501 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
13502 );
13503
13504 if wer_count > 0 {
13505 let _ = write!(sections,
13506 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
13507 );
13508 }
13509 }
13510 }
13511
13512 #[cfg(not(target_os = "windows"))]
13513 {
13514 let _ = (process_filter, n);
13515 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
13516 }
13517
13518 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
13519 if findings.is_empty() {
13520 result.push_str("- No actionable findings.\n");
13521 } else {
13522 for f in &findings {
13523 let _ = writeln!(result, "- Finding: {f}");
13524 }
13525 }
13526 result.push('\n');
13527 result.push_str(§ions);
13528 Ok(result.trim_end().to_string())
13529}
13530
13531#[cfg(target_os = "windows")]
13532fn gpu_voltage_telemetry_note() -> String {
13533 let output = Command::new("nvidia-smi")
13534 .args(["--help-query-gpu"])
13535 .output();
13536
13537 match output {
13538 Ok(o) => {
13539 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
13540 if text.contains("\"voltage\"") || text.contains("voltage.") {
13541 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
13542 } else {
13543 "Unavailable on this NVIDIA driver path: `nvidia-smi` exposes clocks, fans, power, and throttle reasons here, but not a GPU voltage rail query.".to_string()
13544 }
13545 }
13546 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
13547 }
13548}
13549
13550#[cfg(target_os = "windows")]
13551fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13552 if raw == 0 {
13553 return None;
13554 }
13555 if raw & 0x80 != 0 {
13556 let tenths = raw & 0x7f;
13557 return Some(format!(
13558 "{:.1} V (firmware-reported WMI current voltage)",
13559 tenths as f64 / 10.0
13560 ));
13561 }
13562
13563 let legacy = match raw {
13564 1 => Some("5.0 V"),
13565 2 => Some("3.3 V"),
13566 4 => Some("2.9 V"),
13567 _ => None,
13568 }?;
13569 Some(format!(
13570 "{} (legacy WMI voltage capability flag, not live telemetry)",
13571 legacy
13572 ))
13573}
13574
13575async fn inspect_overclocker() -> Result<String, String> {
13576 let mut out = String::from("Host inspection: overclocker\n\n");
13577
13578 #[cfg(target_os = "windows")]
13579 {
13580 out.push_str(
13581 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13582 );
13583
13584 let nvidia = Command::new("nvidia-smi")
13586 .args([
13587 "--query-gpu=name,clocks.current.graphics,clocks.current.memory,fan.speed,power.draw,temperature.gpu,power.draw.average,power.draw.instant,power.limit,enforced.power.limit,clocks_throttle_reasons.active",
13588 "--format=csv,noheader,nounits",
13589 ])
13590 .output();
13591
13592 if let Ok(o) = nvidia {
13593 let stdout = String::from_utf8_lossy(&o.stdout);
13594 if !stdout.trim().is_empty() {
13595 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13596 let mut parts = Vec::with_capacity(16);
13597 parts.extend(stdout.trim().split(',').map(|s| s.trim()));
13598 if parts.len() >= 10 {
13599 let _ = writeln!(out, "- Model: {}", parts[0]);
13600 let _ = writeln!(out, "- Graphics: {} MHz", parts[1]);
13601 let _ = writeln!(out, "- Memory: {} MHz", parts[2]);
13602 let _ = writeln!(out, "- Fan Speed: {}%", parts[3]);
13603 let _ = writeln!(out, "- Power Draw: {} W", parts[4]);
13604 if !parts[6].eq_ignore_ascii_case("[N/A]") {
13605 let _ = writeln!(out, "- Power Avg: {} W", parts[6]);
13606 }
13607 if !parts[7].eq_ignore_ascii_case("[N/A]") {
13608 let _ = writeln!(out, "- Power Inst: {} W", parts[7]);
13609 }
13610 if !parts[8].eq_ignore_ascii_case("[N/A]") {
13611 let _ = writeln!(out, "- Power Cap: {} W requested", parts[8]);
13612 }
13613 if !parts[9].eq_ignore_ascii_case("[N/A]") {
13614 let _ = writeln!(out, "- Power Enf: {} W enforced", parts[9]);
13615 }
13616 let _ = writeln!(out, "- Temperature: {}°C", parts[5]);
13617
13618 if parts.len() > 10 {
13619 let throttle_hex = parts[10];
13620 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13621 if !reasons.is_empty() {
13622 let _ = writeln!(out, "- Throttling: YES [Reason: {}]", reasons);
13623 } else {
13624 out.push_str("- Throttling: None (Performance State: Max)\n");
13625 }
13626 }
13627 }
13628 out.push('\n');
13629 }
13630 }
13631
13632 out.push_str("=== VOLTAGE TELEMETRY ===\n");
13633 let _ = write!(out, "- GPU Voltage: {}\n\n", gpu_voltage_telemetry_note());
13634
13635 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13637 let history = gpu_state.history.read().unwrap();
13638 if history.len() >= 2 {
13639 out.push_str("=== SILICON TRENDS (Session) ===\n");
13640 let first = history.front().unwrap();
13641 let last = history.back().unwrap();
13642
13643 let temp_diff = last.temperature as i32 - first.temperature as i32;
13644 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13645
13646 let temp_trend = if temp_diff > 1 {
13647 "Rising"
13648 } else if temp_diff < -1 {
13649 "Falling"
13650 } else {
13651 "Stable"
13652 };
13653 let clock_trend = if clock_diff > 10 {
13654 "Increasing"
13655 } else if clock_diff < -10 {
13656 "Decreasing"
13657 } else {
13658 "Stable"
13659 };
13660
13661 let _ = writeln!(
13662 out,
13663 "- Temperature: {} ({}°C anomaly)",
13664 temp_trend, temp_diff
13665 );
13666 let _ = writeln!(
13667 out,
13668 "- Core Clock: {} ({} MHz delta)",
13669 clock_trend, clock_diff
13670 );
13671 out.push('\n');
13672 }
13673
13674 let ps_cmd = "Get-Counter -Counter '\\Processor Information(_Total)\\Processor Frequency', '\\Processor Information(_Total)\\% of Maximum Frequency' -SampleInterval 1 -MaxSamples 2 | ForEach-Object { $_.CounterSamples } | Group-Object Path | ForEach-Object { \"$($_.Name):$([math]::Round(($_.Group | Measure-Object CookedValue -Average).Average, 0))\" }";
13676 let cpu_stats = Command::new("powershell")
13677 .args(["-NoProfile", "-Command", ps_cmd])
13678 .output();
13679
13680 if let Ok(o) = cpu_stats {
13681 let stdout = String::from_utf8_lossy(&o.stdout);
13682 if !stdout.trim().is_empty() {
13683 out.push_str("=== SILICON CORE (CPU) ===\n");
13684 for line in stdout.lines() {
13685 if let Some((path, val)) = line.split_once(':') {
13686 let path_lower = path.to_lowercase();
13687 if path_lower.contains("processor frequency") {
13688 let _ = writeln!(out, "- Current Freq: {} MHz (2s Avg)", val);
13689 } else if path_lower.contains("% of maximum frequency") {
13690 let _ = writeln!(out, "- Throttling: {}% of Max Capacity", val);
13691 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13692 if throttle_num < 95.0 {
13693 out.push_str(
13694 " [WARNING] Active downclocking or power-saving detected.\n",
13695 );
13696 }
13697 }
13698 }
13699 }
13700 }
13701 }
13702
13703 let thermal = Command::new("powershell")
13705 .args([
13706 "-NoProfile",
13707 "-Command",
13708 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13709 ])
13710 .output();
13711 if let Ok(o) = thermal {
13712 let stdout = String::from_utf8_lossy(&o.stdout);
13713 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13714 let temp = if v.is_array() {
13715 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13716 } else {
13717 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13718 };
13719 if temp > 1.0 {
13720 let _ = writeln!(out, "- CPU Package: {}°C (ACPI Zone)", temp);
13721 }
13722 }
13723 }
13724
13725 let wmi = Command::new("powershell")
13727 .args([
13728 "-NoProfile",
13729 "-Command",
13730 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13731 ])
13732 .output();
13733
13734 if let Ok(o) = wmi {
13735 let stdout = String::from_utf8_lossy(&o.stdout);
13736 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13737 out.push_str("\n=== HARDWARE DNA ===\n");
13738 let _ = writeln!(
13739 out,
13740 "- Rated Max: {} MHz",
13741 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13742 );
13743 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13744 Some(raw) => {
13745 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13746 let _ = writeln!(out, "- CPU Voltage: {}", decoded);
13747 } else {
13748 out.push_str(
13749 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
13750 );
13751 }
13752 }
13753 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
13754 }
13755 }
13756 }
13757 }
13758
13759 #[cfg(not(target_os = "windows"))]
13760 {
13761 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13762 }
13763
13764 Ok(out.trim_end().to_string())
13765}
13766
13767#[cfg(target_os = "windows")]
13769fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13770 let hex = hex.trim().trim_start_matches("0x");
13771 let val = match u64::from_str_radix(hex, 16) {
13772 Ok(v) => v,
13773 Err(_) => return String::new(),
13774 };
13775
13776 if val == 0 {
13777 return String::new();
13778 }
13779
13780 let mut reasons = Vec::with_capacity(9);
13781 if val & 0x01 != 0 {
13782 reasons.push("GPU Idle");
13783 }
13784 if val & 0x02 != 0 {
13785 reasons.push("Applications Clocks Setting");
13786 }
13787 if val & 0x04 != 0 {
13788 reasons.push("SW Power Cap (PL1/PL2)");
13789 }
13790 if val & 0x08 != 0 {
13791 reasons.push("HW Slowdown (Thermal/Power)");
13792 }
13793 if val & 0x10 != 0 {
13794 reasons.push("Sync Boost");
13795 }
13796 if val & 0x20 != 0 {
13797 reasons.push("SW Thermal Slowdown");
13798 }
13799 if val & 0x40 != 0 {
13800 reasons.push("HW Thermal Slowdown");
13801 }
13802 if val & 0x80 != 0 {
13803 reasons.push("HW Power Brake Slowdown");
13804 }
13805 if val & 0x100 != 0 {
13806 reasons.push("Display Clock Setting");
13807 }
13808
13809 reasons.join(", ")
13810}
13811
13812#[cfg(windows)]
13815fn run_powershell(script: &str) -> Result<String, String> {
13816 use std::process::Command;
13817 let out = Command::new("powershell")
13818 .args(["-NoProfile", "-NonInteractive", "-Command", script])
13819 .output()
13820 .map_err(|e| format!("powershell launch failed: {e}"))?;
13821 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13822}
13823
13824#[cfg(windows)]
13827fn inspect_camera(max_entries: usize) -> Result<String, String> {
13828 let mut out = String::from("=== Camera devices ===\n");
13829
13830 let ps_devices = r#"
13832Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13833ForEach-Object {
13834 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13835 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13836}
13837"#;
13838 match run_powershell(ps_devices) {
13839 Ok(o) if !o.trim().is_empty() => {
13840 for line in o.lines().take(max_entries) {
13841 let l = line.trim();
13842 if !l.is_empty() {
13843 let _ = writeln!(out, "- {l}");
13844 }
13845 }
13846 }
13847 _ => out.push_str("- No camera devices found via PnP\n"),
13848 }
13849
13850 out.push_str("\n=== Windows camera privacy ===\n");
13852 let ps_privacy = r#"
13853$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13854$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13855"Global: $global"
13856$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13857 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13858 ForEach-Object {
13859 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13860 if ($v) { " $($_.PSChildName): $v" }
13861 }
13862$apps
13863"#;
13864 match run_powershell(ps_privacy) {
13865 Ok(o) if !o.trim().is_empty() => {
13866 for line in o.lines().take(max_entries) {
13867 let l = line.trim_end();
13868 if !l.is_empty() {
13869 let _ = writeln!(out, "{l}");
13870 }
13871 }
13872 }
13873 _ => out.push_str("- Could not read camera privacy registry\n"),
13874 }
13875
13876 out.push_str("\n=== Biometric / Hello camera ===\n");
13878 let ps_bio = r#"
13879Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13880ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13881"#;
13882 match run_powershell(ps_bio) {
13883 Ok(o) if !o.trim().is_empty() => {
13884 for line in o.lines().take(max_entries) {
13885 let l = line.trim();
13886 if !l.is_empty() {
13887 let _ = writeln!(out, "- {l}");
13888 }
13889 }
13890 }
13891 _ => out.push_str("- No biometric devices found\n"),
13892 }
13893
13894 let mut findings: Vec<String> = Vec::with_capacity(4);
13896 if out.contains("Status: Error") || out.contains("Status: Unknown") {
13897 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13898 }
13899 if out.contains("Global: Deny") {
13900 findings.push("Camera access is globally DENIED in Windows privacy settings — apps cannot use the camera until this is re-enabled (Settings > Privacy > Camera).".into());
13901 }
13902
13903 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13904 if findings.is_empty() {
13905 result.push_str("- No obvious camera or privacy gate issue detected.\n");
13906 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13907 } else {
13908 for f in &findings {
13909 let _ = writeln!(result, "- Finding: {f}");
13910 }
13911 }
13912 result.push('\n');
13913 result.push_str(&out);
13914 Ok(result)
13915}
13916
13917#[cfg(not(windows))]
13918fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13919 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13920}
13921
13922#[cfg(windows)]
13925fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13926 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13927
13928 let ps_hello = r#"
13930$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13931$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13932$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13933"PIN-style logon path: $helloKey"
13934"WbioSrvc start type: $faceConfigured"
13935"FingerPrint key present: $pinConfigured"
13936"#;
13937 match run_powershell(ps_hello) {
13938 Ok(o) => {
13939 for line in o.lines().take(max_entries) {
13940 let l = line.trim();
13941 if !l.is_empty() {
13942 let _ = writeln!(out, "- {l}");
13943 }
13944 }
13945 }
13946 Err(e) => {
13947 let _ = writeln!(out, "- Hello query error: {e}");
13948 }
13949 }
13950
13951 out.push_str("\n=== Biometric service ===\n");
13953 let ps_bio_svc = r#"
13954$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13955if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13956else { "WbioSrvc not found" }
13957"#;
13958 match run_powershell(ps_bio_svc) {
13959 Ok(o) => {
13960 let _ = writeln!(out, "- {}", o.trim());
13961 }
13962 Err(_) => out.push_str("- Could not query biometric service\n"),
13963 }
13964
13965 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13967 let ps_events = r#"
13968$cutoff = (Get-Date).AddHours(-24)
13969Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13970ForEach-Object {
13971 $xml = [xml]$_.ToXml()
13972 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13973 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13974 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13975} | Select-Object -First 10
13976"#;
13977 match run_powershell(ps_events) {
13978 Ok(o) if !o.trim().is_empty() => {
13979 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13980 let _ = writeln!(out, "- {count} recent logon failure(s) detected:");
13981 for line in o.lines().take(max_entries) {
13982 let l = line.trim();
13983 if !l.is_empty() {
13984 let _ = writeln!(out, " {l}");
13985 }
13986 }
13987 }
13988 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13989 }
13990
13991 out.push_str("\n=== Active credential providers ===\n");
13993 let ps_cp = r#"
13994Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13995ForEach-Object {
13996 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13997 if ($name) { $name }
13998} | Select-Object -First 15
13999"#;
14000 match run_powershell(ps_cp) {
14001 Ok(o) if !o.trim().is_empty() => {
14002 for line in o.lines().take(max_entries) {
14003 let l = line.trim();
14004 if !l.is_empty() {
14005 let _ = writeln!(out, "- {l}");
14006 }
14007 }
14008 }
14009 _ => out.push_str("- Could not enumerate credential providers\n"),
14010 }
14011
14012 let mut findings: Vec<String> = Vec::with_capacity(4);
14013 if out.contains("WbioSrvc | Status: Stopped") {
14014 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
14015 }
14016 if out.contains("recent logon failure") && !out.contains("0 recent") {
14017 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
14018 }
14019
14020 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
14021 if findings.is_empty() {
14022 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
14023 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
14024 } else {
14025 for f in &findings {
14026 let _ = writeln!(result, "- Finding: {f}");
14027 }
14028 }
14029 result.push('\n');
14030 result.push_str(&out);
14031 Ok(result)
14032}
14033
14034#[cfg(not(windows))]
14035fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
14036 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
14037}
14038
14039#[cfg(windows)]
14042fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
14043 let mut out = String::from("=== Installer engines ===\n");
14044
14045 let ps_engines = r#"
14046$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
14047foreach ($name in $services) {
14048 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
14049 if ($svc) {
14050 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14051 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
14052 "$name | Status: $($svc.Status) | StartType: $startType"
14053 } else {
14054 "$name | Not present"
14055 }
14056}
14057if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
14058 "msiexec.exe | Present: Yes"
14059} else {
14060 "msiexec.exe | Present: No"
14061}
14062"#;
14063 match run_powershell(ps_engines) {
14064 Ok(o) if !o.trim().is_empty() => {
14065 for line in o.lines().take(max_entries + 6) {
14066 let l = line.trim();
14067 if !l.is_empty() {
14068 let _ = writeln!(out, "- {l}");
14069 }
14070 }
14071 }
14072 _ => out.push_str("- Could not inspect installer engine services\n"),
14073 }
14074
14075 out.push_str("\n=== winget and App Installer ===\n");
14076 let ps_winget = r#"
14077$cmd = Get-Command winget -ErrorAction SilentlyContinue
14078if ($cmd) {
14079 try {
14080 $v = & winget --version 2>$null
14081 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
14082 } catch { "winget | Present but invocation failed" }
14083} else {
14084 "winget | Missing"
14085}
14086$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
14087if ($appInstaller) {
14088 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
14089} else {
14090 "DesktopAppInstaller | Status: Missing"
14091}
14092"#;
14093 match run_powershell(ps_winget) {
14094 Ok(o) if !o.trim().is_empty() => {
14095 for line in o.lines().take(max_entries) {
14096 let l = line.trim();
14097 if !l.is_empty() {
14098 let _ = writeln!(out, "- {l}");
14099 }
14100 }
14101 }
14102 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
14103 }
14104
14105 out.push_str("\n=== Microsoft Store packages ===\n");
14106 let ps_store = r#"
14107$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
14108if ($store) {
14109 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
14110} else {
14111 "Microsoft.WindowsStore | Status: Missing"
14112}
14113"#;
14114 match run_powershell(ps_store) {
14115 Ok(o) if !o.trim().is_empty() => {
14116 for line in o.lines().take(max_entries) {
14117 let l = line.trim();
14118 if !l.is_empty() {
14119 let _ = writeln!(out, "- {l}");
14120 }
14121 }
14122 }
14123 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
14124 }
14125
14126 out.push_str("\n=== Reboot and transaction blockers ===\n");
14127 let ps_blockers = r#"
14128$pending = $false
14129if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
14130 "RebootPending: CBS"
14131 $pending = $true
14132}
14133if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
14134 "RebootPending: WindowsUpdate"
14135 $pending = $true
14136}
14137$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
14138if ($rename) {
14139 "PendingFileRenameOperations: Yes"
14140 $pending = $true
14141}
14142if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
14143 "InstallerInProgress: Yes"
14144 $pending = $true
14145}
14146if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
14147"#;
14148 match run_powershell(ps_blockers) {
14149 Ok(o) if !o.trim().is_empty() => {
14150 for line in o.lines().take(max_entries) {
14151 let l = line.trim();
14152 if !l.is_empty() {
14153 let _ = writeln!(out, "- {l}");
14154 }
14155 }
14156 }
14157 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
14158 }
14159
14160 out.push_str("\n=== Recent installer failures (7d) ===\n");
14161 let ps_failures = r#"
14162$cutoff = (Get-Date).AddDays(-7)
14163$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
14164 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
14165$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
14166 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
14167 Select-Object -First 6 |
14168 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
14169$all = @($msi) + @($appx)
14170if ($all.Count -eq 0) {
14171 "No recent MSI/AppX installer errors detected"
14172} else {
14173 $all | Select-Object -First 8
14174}
14175"#;
14176 match run_powershell(ps_failures) {
14177 Ok(o) if !o.trim().is_empty() => {
14178 for line in o.lines().take(max_entries + 2) {
14179 let l = line.trim();
14180 if !l.is_empty() {
14181 let _ = writeln!(out, "- {l}");
14182 }
14183 }
14184 }
14185 _ => out.push_str("- Could not inspect recent installer failure events\n"),
14186 }
14187
14188 let mut findings: Vec<String> = Vec::with_capacity(4);
14189 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
14190 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
14191 }
14192 if out.contains("msiexec.exe | Present: No") {
14193 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
14194 }
14195 if out.contains("winget | Missing") {
14196 findings.push(
14197 "winget is missing - App Installer may not be installed or registered for this user."
14198 .into(),
14199 );
14200 }
14201 if out.contains("DesktopAppInstaller | Status: Missing") {
14202 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
14203 }
14204 if out.contains("Microsoft.WindowsStore | Status: Missing") {
14205 findings.push(
14206 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
14207 .into(),
14208 );
14209 }
14210 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
14211 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
14212 }
14213 if out.contains("InstallerInProgress: Yes") {
14214 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
14215 }
14216 if out.contains("MSI | ") || out.contains("AppX | ") {
14217 findings.push("Recent installer failures were recorded in the event logs - check the MSI/AppX error lines below for the failing package or deployment path.".into());
14218 }
14219
14220 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
14221 if findings.is_empty() {
14222 result.push_str("- No obvious installer-platform blocker detected.\n");
14223 } else {
14224 for finding in &findings {
14225 let _ = writeln!(result, "- Finding: {finding}");
14226 }
14227 }
14228 result.push('\n');
14229 result.push_str(&out);
14230 Ok(result)
14231}
14232
14233#[cfg(not(windows))]
14234fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
14235 Ok("Host inspection: installer_health\n\n=== Findings ===\n- Installer health is currently Windows-first. Linux/macOS package-manager triage can be added later.\n".into())
14236}
14237
14238#[cfg(windows)]
14241fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
14242 let mut out = String::from("=== OneDrive client ===\n");
14243
14244 let ps_client = r#"
14245$candidatePaths = @(
14246 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
14247 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
14248 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
14249) | Where-Object { $_ -and (Test-Path $_) }
14250$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
14251$exe = $candidatePaths | Select-Object -First 1
14252if (-not $exe -and $proc) {
14253 try { $exe = $proc.Path } catch {}
14254}
14255if ($exe) {
14256 "Installed: Yes"
14257 "Executable: $exe"
14258 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
14259} else {
14260 "Installed: Unknown"
14261}
14262if ($proc) {
14263 "Process: Running | PID: $($proc.Id)"
14264} else {
14265 "Process: Not running"
14266}
14267"#;
14268 match run_powershell(ps_client) {
14269 Ok(o) if !o.trim().is_empty() => {
14270 for line in o.lines().take(max_entries) {
14271 let l = line.trim();
14272 if !l.is_empty() {
14273 let _ = writeln!(out, "- {l}");
14274 }
14275 }
14276 }
14277 _ => out.push_str("- Could not inspect OneDrive client state\n"),
14278 }
14279
14280 out.push_str("\n=== OneDrive accounts ===\n");
14281 let ps_accounts = r#"
14282function MaskEmail([string]$Email) {
14283 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14284 $parts = $Email.Split('@', 2)
14285 $local = $parts[0]
14286 $domain = $parts[1]
14287 if ($local.Length -le 1) { return "*@$domain" }
14288 return ($local.Substring(0,1) + "***@" + $domain)
14289}
14290$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14291if (Test-Path $base) {
14292 Get-ChildItem $base -ErrorAction SilentlyContinue |
14293 Sort-Object PSChildName |
14294 Select-Object -First 12 |
14295 ForEach-Object {
14296 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14297 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
14298 $mail = MaskEmail ([string]$p.UserEmail)
14299 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
14300 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
14301 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
14302 }
14303} else {
14304 "No OneDrive accounts configured"
14305}
14306"#;
14307 match run_powershell(ps_accounts) {
14308 Ok(o) if !o.trim().is_empty() => {
14309 for line in o.lines().take(max_entries) {
14310 let l = line.trim();
14311 if !l.is_empty() {
14312 let _ = writeln!(out, "- {l}");
14313 }
14314 }
14315 }
14316 _ => out.push_str("- Could not read OneDrive account registry state\n"),
14317 }
14318
14319 out.push_str("\n=== OneDrive policy overrides ===\n");
14320 let ps_policy = r#"
14321$paths = @(
14322 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
14323 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
14324)
14325$names = @(
14326 'DisableFileSyncNGSC',
14327 'DisableLibrariesDefaultSaveToOneDrive',
14328 'KFMSilentOptIn',
14329 'KFMBlockOptIn',
14330 'SilentAccountConfig'
14331)
14332$found = $false
14333foreach ($path in $paths) {
14334 if (Test-Path $path) {
14335 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
14336 foreach ($name in $names) {
14337 $value = $p.$name
14338 if ($null -ne $value -and [string]$value -ne '') {
14339 "$path | $name=$value"
14340 $found = $true
14341 }
14342 }
14343 }
14344}
14345if (-not $found) { "No OneDrive policy overrides detected" }
14346"#;
14347 match run_powershell(ps_policy) {
14348 Ok(o) if !o.trim().is_empty() => {
14349 for line in o.lines().take(max_entries) {
14350 let l = line.trim();
14351 if !l.is_empty() {
14352 let _ = writeln!(out, "- {l}");
14353 }
14354 }
14355 }
14356 _ => out.push_str("- Could not read OneDrive policy state\n"),
14357 }
14358
14359 out.push_str("\n=== Known Folder Backup ===\n");
14360 let ps_kfm = r#"
14361$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14362$roots = @()
14363if (Test-Path $base) {
14364 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
14365 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14366 if ($p.UserFolder) {
14367 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
14368 }
14369 }
14370}
14371$roots = $roots | Select-Object -Unique
14372$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
14373if (Test-Path $shell) {
14374 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
14375 $folders = @(
14376 @{ Name='Desktop'; Value=$props.Desktop },
14377 @{ Name='Documents'; Value=$props.Personal },
14378 @{ Name='Pictures'; Value=$props.'My Pictures' }
14379 )
14380 foreach ($folder in $folders) {
14381 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
14382 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
14383 $protected = $false
14384 foreach ($root in $roots) {
14385 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
14386 $protected = $true
14387 break
14388 }
14389 }
14390 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
14391 }
14392} else {
14393 "Explorer shell folders unavailable"
14394}
14395"#;
14396 match run_powershell(ps_kfm) {
14397 Ok(o) if !o.trim().is_empty() => {
14398 for line in o.lines().take(max_entries) {
14399 let l = line.trim();
14400 if !l.is_empty() {
14401 let _ = writeln!(out, "- {l}");
14402 }
14403 }
14404 }
14405 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
14406 }
14407
14408 let mut findings: Vec<String> = Vec::with_capacity(4);
14409 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
14410 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
14411 }
14412 if out.contains("No OneDrive accounts configured") {
14413 findings.push(
14414 "No OneDrive accounts are configured - sync cannot start until the user signs in."
14415 .into(),
14416 );
14417 }
14418 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
14419 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
14420 }
14421 if out.contains("Exists: No") {
14422 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
14423 }
14424 if out.contains("DisableFileSyncNGSC=1") {
14425 findings
14426 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
14427 }
14428 if out.contains("KFMBlockOptIn=1") {
14429 findings
14430 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
14431 }
14432 if out.contains("SyncRoot: C:\\") {
14433 let mut missing_kfm: Vec<&str> = Vec::new();
14434 for folder in ["Desktop", "Documents", "Pictures"] {
14435 if out.lines().any(|line| {
14436 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
14437 }) {
14438 missing_kfm.push(folder);
14439 }
14440 }
14441 if !missing_kfm.is_empty() {
14442 findings.push(format!(
14443 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
14444 missing_kfm.join(", ")
14445 ));
14446 }
14447 }
14448
14449 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
14450 if findings.is_empty() {
14451 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
14452 } else {
14453 for finding in &findings {
14454 let _ = writeln!(result, "- Finding: {finding}");
14455 }
14456 }
14457 result.push('\n');
14458 result.push_str(&out);
14459 Ok(result)
14460}
14461
14462#[cfg(not(windows))]
14463fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
14464 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
14465}
14466
14467#[cfg(windows)]
14468fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
14469 let mut out = String::from("=== Browser inventory ===\n");
14470
14471 let ps_inventory = r#"
14472$browsers = @(
14473 @{ Name='Edge'; Paths=@(
14474 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
14475 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
14476 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
14477 @{ Name='Chrome'; Paths=@(
14478 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
14479 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
14480 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
14481 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
14482 @{ Name='Firefox'; Paths=@(
14483 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
14484 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
14485 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
14486)
14487foreach ($browser in $browsers) {
14488 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14489 if ($exe) {
14490 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14491 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
14492 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
14493 } else {
14494 "$($browser.Name) | Installed: No"
14495 }
14496}
14497$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14498$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14499$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
14500"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
14501"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
14502"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
14503"#;
14504 match run_powershell(ps_inventory) {
14505 Ok(o) if !o.trim().is_empty() => {
14506 for line in o.lines().take(max_entries + 6) {
14507 let l = line.trim();
14508 if !l.is_empty() {
14509 let _ = writeln!(out, "- {l}");
14510 }
14511 }
14512 }
14513 _ => out.push_str("- Could not inspect installed browser inventory\n"),
14514 }
14515
14516 out.push_str("\n=== Runtime state ===\n");
14517 let ps_runtime = r#"
14518$targets = 'msedge','chrome','firefox','msedgewebview2'
14519foreach ($name in $targets) {
14520 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14521 if ($procs) {
14522 $count = @($procs).Count
14523 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14524 "$name | Processes: $count | WorkingSetMB: $wsMb"
14525 } else {
14526 "$name | Processes: 0 | WorkingSetMB: 0"
14527 }
14528}
14529"#;
14530 match run_powershell(ps_runtime) {
14531 Ok(o) if !o.trim().is_empty() => {
14532 for line in o.lines().take(max_entries + 4) {
14533 let l = line.trim();
14534 if !l.is_empty() {
14535 let _ = writeln!(out, "- {l}");
14536 }
14537 }
14538 }
14539 _ => out.push_str("- Could not inspect browser runtime state\n"),
14540 }
14541
14542 out.push_str("\n=== WebView2 runtime ===\n");
14543 let ps_webview = r#"
14544$paths = @(
14545 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14546 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14547) | Where-Object { $_ -and (Test-Path $_) }
14548$runtimeDir = $paths | ForEach-Object {
14549 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14550 Where-Object { $_.Name -match '^\d+\.' } |
14551 Sort-Object Name -Descending |
14552 Select-Object -First 1
14553} | Select-Object -First 1
14554if ($runtimeDir) {
14555 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14556 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14557 "Installed: Yes"
14558 "Version: $version"
14559 "Executable: $exe"
14560} else {
14561 "Installed: No"
14562}
14563$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14564"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14565"#;
14566 match run_powershell(ps_webview) {
14567 Ok(o) if !o.trim().is_empty() => {
14568 for line in o.lines().take(max_entries) {
14569 let l = line.trim();
14570 if !l.is_empty() {
14571 let _ = writeln!(out, "- {l}");
14572 }
14573 }
14574 }
14575 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14576 }
14577
14578 out.push_str("\n=== Policy and proxy surface ===\n");
14579 let ps_policy = r#"
14580$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14581$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14582$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14583$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14584$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14585"UserProxyEnabled: $proxyEnabled"
14586"UserProxyServer: $proxyServer"
14587"UserAutoConfigURL: $autoConfig"
14588"UserAutoDetect: $autoDetect"
14589$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14590if ($winhttp) {
14591 $normalized = ($winhttp -replace '\s+', ' ').Trim()
14592 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14593 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14594 "WinHTTP: $normalized"
14595}
14596$policyTargets = @(
14597 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14598 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14599)
14600foreach ($policy in $policyTargets) {
14601 if (Test-Path $policy.Path) {
14602 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14603 foreach ($key in $policy.Keys) {
14604 $value = $item.$key
14605 if ($null -ne $value -and [string]$value -ne '') {
14606 if ($value -is [array]) {
14607 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14608 } else {
14609 "$($policy.Name)Policy | $key=$value"
14610 }
14611 }
14612 }
14613 }
14614}
14615"#;
14616 match run_powershell(ps_policy) {
14617 Ok(o) if !o.trim().is_empty() => {
14618 for line in o.lines().take(max_entries + 8) {
14619 let l = line.trim();
14620 if !l.is_empty() {
14621 let _ = writeln!(out, "- {l}");
14622 }
14623 }
14624 }
14625 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14626 }
14627
14628 out.push_str("\n=== Profile and cache pressure ===\n");
14629 let ps_profiles = r#"
14630$profiles = @(
14631 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14632 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14633 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14634)
14635foreach ($profile in $profiles) {
14636 if (Test-Path $profile.Root) {
14637 if ($profile.Name -eq 'Firefox') {
14638 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14639 } else {
14640 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14641 Where-Object {
14642 $_.Name -eq 'Default' -or
14643 $_.Name -eq 'Guest Profile' -or
14644 $_.Name -eq 'System Profile' -or
14645 $_.Name -like 'Profile *'
14646 }
14647 }
14648 $profileCount = @($dirs).Count
14649 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14650 if (-not $sizeBytes) { $sizeBytes = 0 }
14651 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14652 $extCount = 'Unknown'
14653 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14654 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14655 }
14656 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14657 } else {
14658 "$($profile.Name) | ProfileRoot: Missing"
14659 }
14660}
14661"#;
14662 match run_powershell(ps_profiles) {
14663 Ok(o) if !o.trim().is_empty() => {
14664 for line in o.lines().take(max_entries + 4) {
14665 let l = line.trim();
14666 if !l.is_empty() {
14667 let _ = writeln!(out, "- {l}");
14668 }
14669 }
14670 }
14671 _ => out.push_str("- Could not inspect browser profile pressure\n"),
14672 }
14673
14674 out.push_str("\n=== Recent browser failures (7d) ===\n");
14675 let ps_failures = r#"
14676$cutoff = (Get-Date).AddDays(-7)
14677$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14678$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14679 Where-Object {
14680 $msg = [string]$_.Message
14681 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14682 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14683 } |
14684 Select-Object -First 6
14685if ($events) {
14686 foreach ($event in $events) {
14687 $msg = ($event.Message -replace '\s+', ' ')
14688 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14689 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14690 }
14691} else {
14692 "No recent browser crash or WER events detected"
14693}
14694"#;
14695 match run_powershell(ps_failures) {
14696 Ok(o) if !o.trim().is_empty() => {
14697 for line in o.lines().take(max_entries + 2) {
14698 let l = line.trim();
14699 if !l.is_empty() {
14700 let _ = writeln!(out, "- {l}");
14701 }
14702 }
14703 }
14704 _ => out.push_str("- Could not inspect recent browser failure events\n"),
14705 }
14706
14707 let mut findings: Vec<String> = Vec::with_capacity(4);
14708 if out.contains("Edge | Installed: No")
14709 && out.contains("Chrome | Installed: No")
14710 && out.contains("Firefox | Installed: No")
14711 {
14712 findings.push(
14713 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14714 .into(),
14715 );
14716 }
14717 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14718 findings.push(
14719 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14720 .into(),
14721 );
14722 }
14723 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14724 findings.push(
14725 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14726 .into(),
14727 );
14728 }
14729 if out.contains("EdgePolicy | Proxy")
14730 || out.contains("ChromePolicy | Proxy")
14731 || out.contains("ExtensionInstallForcelist=")
14732 {
14733 findings.push(
14734 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14735 .into(),
14736 );
14737 }
14738 for browser in ["msedge", "chrome", "firefox"] {
14739 let process_marker = format!("{browser} | Processes: ");
14740 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14741 let count = line
14742 .split("| Processes: ")
14743 .nth(1)
14744 .and_then(|rest| rest.split(" |").next())
14745 .and_then(|value| value.trim().parse::<usize>().ok())
14746 .unwrap_or(0);
14747 let ws_mb = line
14748 .split("| WorkingSetMB: ")
14749 .nth(1)
14750 .and_then(|value| value.trim().parse::<f64>().ok())
14751 .unwrap_or(0.0);
14752 if count >= 25 {
14753 findings.push(format!(
14754 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14755 ));
14756 } else if ws_mb >= 2500.0 {
14757 findings.push(format!(
14758 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14759 ));
14760 }
14761 }
14762 }
14763 if out.contains("=== WebView2 runtime ===\n- Installed: No")
14764 || (out.contains("=== WebView2 runtime ===")
14765 && out.contains("- Installed: No")
14766 && out.contains("- ProcessCount: 0"))
14767 {
14768 findings.push(
14769 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14770 .into(),
14771 );
14772 }
14773 for browser in ["Edge", "Chrome", "Firefox"] {
14774 let prefix = format!("{browser} | ProfileRoot:");
14775 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14776 let size_gb = line
14777 .split("| SizeGB: ")
14778 .nth(1)
14779 .and_then(|rest| rest.split(" |").next())
14780 .and_then(|value| value.trim().parse::<f64>().ok())
14781 .unwrap_or(0.0);
14782 let ext_count = line
14783 .split("| Extensions: ")
14784 .nth(1)
14785 .and_then(|value| value.trim().parse::<usize>().ok())
14786 .unwrap_or(0);
14787 if size_gb >= 2.5 {
14788 findings.push(format!(
14789 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14790 ));
14791 }
14792 if ext_count >= 20 {
14793 findings.push(format!(
14794 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14795 ));
14796 }
14797 }
14798 }
14799 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14800 findings.push(
14801 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14802 .into(),
14803 );
14804 }
14805
14806 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14807 if findings.is_empty() {
14808 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14809 } else {
14810 for finding in &findings {
14811 let _ = writeln!(result, "- Finding: {finding}");
14812 }
14813 }
14814 result.push('\n');
14815 result.push_str(&out);
14816 Ok(result)
14817}
14818
14819#[cfg(not(windows))]
14820fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14821 Ok("Host inspection: browser_health\n\n=== Findings ===\n- Browser health is currently Windows-first. Linux/macOS browser triage can be added later.\n".into())
14822}
14823
14824#[cfg(windows)]
14825fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14826 let mut out = String::from("=== Outlook install inventory ===\n");
14827
14828 let ps_install = r#"
14829$installPaths = @(
14830 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14831 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14832 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14833 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14834 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14835 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14836)
14837$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14838if ($exe) {
14839 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14840 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14841 "Installed: Yes"
14842 "Executable: $exe"
14843 "Version: $version"
14844 "Product: $productName"
14845} else {
14846 "Installed: No"
14847}
14848$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14849if ($newOutlook) {
14850 "NewOutlook: Installed | Version: $($newOutlook.Version)"
14851} else {
14852 "NewOutlook: Not installed"
14853}
14854"#;
14855 match run_powershell(ps_install) {
14856 Ok(o) if !o.trim().is_empty() => {
14857 for line in o.lines().take(max_entries + 4) {
14858 let l = line.trim();
14859 if !l.is_empty() {
14860 let _ = writeln!(out, "- {l}");
14861 }
14862 }
14863 }
14864 _ => out.push_str("- Could not inspect Outlook install paths\n"),
14865 }
14866
14867 out.push_str("\n=== Runtime state ===\n");
14868 let ps_runtime = r#"
14869$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14870if ($proc) {
14871 $count = @($proc).Count
14872 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14873 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14874 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14875} else {
14876 "Running: No"
14877}
14878"#;
14879 match run_powershell(ps_runtime) {
14880 Ok(o) if !o.trim().is_empty() => {
14881 for line in o.lines().take(4) {
14882 let l = line.trim();
14883 if !l.is_empty() {
14884 let _ = writeln!(out, "- {l}");
14885 }
14886 }
14887 }
14888 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14889 }
14890
14891 out.push_str("\n=== Mail profiles ===\n");
14892 let ps_profiles = r#"
14893$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14894if (-not (Test-Path $profileKey)) {
14895 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14896}
14897if (Test-Path $profileKey) {
14898 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14899 $count = @($profiles).Count
14900 "ProfileCount: $count"
14901 foreach ($p in $profiles | Select-Object -First 10) {
14902 "Profile: $($p.PSChildName)"
14903 }
14904} else {
14905 "ProfileCount: 0"
14906 "No Outlook profiles found in registry"
14907}
14908"#;
14909 match run_powershell(ps_profiles) {
14910 Ok(o) if !o.trim().is_empty() => {
14911 for line in o.lines().take(max_entries + 2) {
14912 let l = line.trim();
14913 if !l.is_empty() {
14914 let _ = writeln!(out, "- {l}");
14915 }
14916 }
14917 }
14918 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14919 }
14920
14921 out.push_str("\n=== OST and PST data files ===\n");
14922 let ps_datafiles = r#"
14923$searchRoots = @(
14924 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14925 (Join-Path $env:USERPROFILE 'Documents'),
14926 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14927) | Where-Object { $_ -and (Test-Path $_) }
14928$files = foreach ($root in $searchRoots) {
14929 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14930 Select-Object FullName,
14931 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14932 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14933 LastWriteTime
14934}
14935if ($files) {
14936 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14937 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14938 }
14939} else {
14940 "No OST or PST files found in standard locations"
14941}
14942"#;
14943 match run_powershell(ps_datafiles) {
14944 Ok(o) if !o.trim().is_empty() => {
14945 for line in o.lines().take(max_entries + 4) {
14946 let l = line.trim();
14947 if !l.is_empty() {
14948 let _ = writeln!(out, "- {l}");
14949 }
14950 }
14951 }
14952 _ => out.push_str("- Could not inspect OST/PST data files\n"),
14953 }
14954
14955 out.push_str("\n=== Add-in pressure ===\n");
14956 let ps_addins = r#"
14957$addinPaths = @(
14958 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14959 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14960 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14961)
14962$addins = foreach ($path in $addinPaths) {
14963 if (Test-Path $path) {
14964 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14965 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14966 $loadBehavior = $item.LoadBehavior
14967 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14968 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14969 }
14970 }
14971}
14972$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14973$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14974"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14975foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14976 $state = switch ($a.LoadBehavior) {
14977 0 { 'Disabled' }
14978 2 { 'LoadOnStart(inactive)' }
14979 3 { 'ActiveOnStart' }
14980 8 { 'DemandLoad' }
14981 9 { 'ActiveDemand' }
14982 16 { 'ConnectedFirst' }
14983 default { "LoadBehavior=$($a.LoadBehavior)" }
14984 }
14985 "$($a.Name) | $state"
14986}
14987$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14988$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14989if (Test-Path $disabledByResiliency) {
14990 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14991 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14992 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14993}
14994"#;
14995 match run_powershell(ps_addins) {
14996 Ok(o) if !o.trim().is_empty() => {
14997 for line in o.lines().take(max_entries + 8) {
14998 let l = line.trim();
14999 if !l.is_empty() {
15000 let _ = writeln!(out, "- {l}");
15001 }
15002 }
15003 }
15004 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
15005 }
15006
15007 out.push_str("\n=== Authentication and cache friction ===\n");
15008 let ps_auth = r#"
15009$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15010$tokenCount = if (Test-Path $tokenCache) {
15011 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
15012} else { 0 }
15013"TokenBrokerCacheFiles: $tokenCount"
15014$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
15015$credsCount = @($credentialManager).Count
15016"OfficeCredentialsInVault: $credsCount"
15017$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15018if (Test-Path $samlKey) {
15019 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
15020 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
15021 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
15022 "WAMOverride: $connected"
15023 "SignedInUserId: $signedIn"
15024}
15025$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
15026if (Test-Path $outlookReg) {
15027 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
15028 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
15029}
15030"#;
15031 match run_powershell(ps_auth) {
15032 Ok(o) if !o.trim().is_empty() => {
15033 for line in o.lines().take(max_entries + 4) {
15034 let l = line.trim();
15035 if !l.is_empty() {
15036 let _ = writeln!(out, "- {l}");
15037 }
15038 }
15039 }
15040 _ => out.push_str("- Could not inspect Outlook auth state\n"),
15041 }
15042
15043 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
15044 let ps_events = r#"
15045$cutoff = (Get-Date).AddDays(-7)
15046$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15047 Where-Object {
15048 $msg = [string]$_.Message
15049 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
15050 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
15051 } |
15052 Select-Object -First 8
15053if ($events) {
15054 foreach ($event in $events) {
15055 $msg = ($event.Message -replace '\s+', ' ')
15056 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15057 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15058 }
15059} else {
15060 "No recent Outlook crash or error events detected in Application log"
15061}
15062"#;
15063 match run_powershell(ps_events) {
15064 Ok(o) if !o.trim().is_empty() => {
15065 for line in o.lines().take(max_entries + 4) {
15066 let l = line.trim();
15067 if !l.is_empty() {
15068 let _ = writeln!(out, "- {l}");
15069 }
15070 }
15071 }
15072 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
15073 }
15074
15075 let mut findings: Vec<String> = Vec::with_capacity(4);
15076
15077 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
15078 findings.push(
15079 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
15080 .into(),
15081 );
15082 }
15083
15084 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
15085 let ws_mb = line
15086 .split("WorkingSetMB: ")
15087 .nth(1)
15088 .and_then(|r| r.split(" |").next())
15089 .and_then(|v| v.trim().parse::<f64>().ok())
15090 .unwrap_or(0.0);
15091 if ws_mb >= 1500.0 {
15092 findings.push(format!(
15093 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
15094 ));
15095 }
15096 }
15097
15098 let large_ost: Vec<String> = out
15099 .lines()
15100 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
15101 .filter_map(|l| {
15102 let mb = l
15103 .split("SizeMB: ")
15104 .nth(1)
15105 .and_then(|r| r.split(" |").next())
15106 .and_then(|v| v.trim().parse::<f64>().ok())
15107 .unwrap_or(0.0);
15108 if mb >= 10_000.0 {
15109 Some(format!("{mb:.0} MB OST file detected"))
15110 } else {
15111 None
15112 }
15113 })
15114 .collect();
15115 for msg in large_ost {
15116 findings.push(format!(
15117 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
15118 ));
15119 }
15120
15121 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
15122 let active_count = line
15123 .split("Active: ")
15124 .nth(1)
15125 .and_then(|r| r.split(" |").next())
15126 .and_then(|v| v.trim().parse::<usize>().ok())
15127 .unwrap_or(0);
15128 if active_count >= 8 {
15129 findings.push(format!(
15130 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
15131 ));
15132 }
15133 }
15134
15135 if out.contains("ResiliencyDisabledItems:") {
15136 findings.push(
15137 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
15138 .into(),
15139 );
15140 }
15141
15142 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
15143 findings.push(
15144 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
15145 .into(),
15146 );
15147 }
15148
15149 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
15150 findings.push(
15151 "Recent Outlook crash evidence found in the Application event log — check the event lines below for the faulting module (mso.dll, outllib.dll, or an add-in DLL)."
15152 .into(),
15153 );
15154 }
15155
15156 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
15157 if findings.is_empty() {
15158 result.push_str("- No obvious Outlook health blocker detected.\n");
15159 } else {
15160 for finding in &findings {
15161 let _ = writeln!(result, "- Finding: {finding}");
15162 }
15163 }
15164 result.push('\n');
15165 result.push_str(&out);
15166 Ok(result)
15167}
15168
15169#[cfg(not(windows))]
15170fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
15171 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
15172}
15173
15174#[cfg(windows)]
15175fn inspect_teams(max_entries: usize) -> Result<String, String> {
15176 let mut out = String::from("=== Teams install inventory ===\n");
15177
15178 let ps_install = r#"
15179# Classic Teams (Teams 1.0)
15180$classicExe = @(
15181 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
15182 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
15183) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
15184
15185if ($classicExe) {
15186 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
15187 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
15188} else {
15189 "ClassicTeams: Not installed"
15190}
15191
15192# New Teams (Teams 2.0 / ms-teams.exe)
15193$newTeamsExe = @(
15194 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
15195 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
15196) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
15197
15198$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
15199if ($newTeamsPkg) {
15200 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
15201} elseif ($newTeamsExe) {
15202 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
15203 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
15204} else {
15205 "NewTeams: Not installed"
15206}
15207
15208# Teams Machine-Wide Installer (MSI/per-machine)
15209$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
15210 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
15211 Select-Object -First 1
15212if ($mwi) {
15213 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
15214} else {
15215 "MachineWideInstaller: Not found"
15216}
15217"#;
15218 match run_powershell(ps_install) {
15219 Ok(o) if !o.trim().is_empty() => {
15220 for line in o.lines().take(max_entries + 4) {
15221 let l = line.trim();
15222 if !l.is_empty() {
15223 let _ = writeln!(out, "- {l}");
15224 }
15225 }
15226 }
15227 _ => out.push_str("- Could not inspect Teams install paths\n"),
15228 }
15229
15230 out.push_str("\n=== Runtime state ===\n");
15231 let ps_runtime = r#"
15232$targets = @('Teams','ms-teams')
15233foreach ($name in $targets) {
15234 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
15235 if ($procs) {
15236 $count = @($procs).Count
15237 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
15238 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
15239 } else {
15240 "$name | Running: No"
15241 }
15242}
15243"#;
15244 match run_powershell(ps_runtime) {
15245 Ok(o) if !o.trim().is_empty() => {
15246 for line in o.lines().take(6) {
15247 let l = line.trim();
15248 if !l.is_empty() {
15249 let _ = writeln!(out, "- {l}");
15250 }
15251 }
15252 }
15253 _ => out.push_str("- Could not inspect Teams runtime state\n"),
15254 }
15255
15256 out.push_str("\n=== Cache directory sizing ===\n");
15257 let ps_cache = r#"
15258$cachePaths = @(
15259 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
15260 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
15261 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
15262 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
15263)
15264foreach ($entry in $cachePaths) {
15265 if (Test-Path $entry.Path) {
15266 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
15267 if (-not $sizeBytes) { $sizeBytes = 0 }
15268 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
15269 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
15270 } else {
15271 "$($entry.Name) | Path: $($entry.Path) | Not found"
15272 }
15273}
15274"#;
15275 match run_powershell(ps_cache) {
15276 Ok(o) if !o.trim().is_empty() => {
15277 for line in o.lines().take(max_entries + 4) {
15278 let l = line.trim();
15279 if !l.is_empty() {
15280 let _ = writeln!(out, "- {l}");
15281 }
15282 }
15283 }
15284 _ => out.push_str("- Could not inspect Teams cache directories\n"),
15285 }
15286
15287 out.push_str("\n=== WebView2 runtime ===\n");
15288 let ps_webview = r#"
15289$paths = @(
15290 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15291 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15292) | Where-Object { $_ -and (Test-Path $_) }
15293$runtimeDir = $paths | ForEach-Object {
15294 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15295 Where-Object { $_.Name -match '^\d+\.' } |
15296 Sort-Object Name -Descending |
15297 Select-Object -First 1
15298} | Select-Object -First 1
15299if ($runtimeDir) {
15300 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15301 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15302 "Installed: Yes | Version: $version"
15303} else {
15304 "Installed: No -- New Teams and some Office features require WebView2"
15305}
15306"#;
15307 match run_powershell(ps_webview) {
15308 Ok(o) if !o.trim().is_empty() => {
15309 for line in o.lines().take(4) {
15310 let l = line.trim();
15311 if !l.is_empty() {
15312 let _ = writeln!(out, "- {l}");
15313 }
15314 }
15315 }
15316 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15317 }
15318
15319 out.push_str("\n=== Account and sign-in state ===\n");
15320 let ps_auth = r#"
15321# Classic Teams account registry
15322$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15323if (Test-Path $classicAcct) {
15324 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
15325 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
15326 "ClassicTeamsAccount: $email"
15327} else {
15328 "ClassicTeamsAccount: Not configured"
15329}
15330# WAM / token broker state for Teams
15331$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15332$tokenCount = if (Test-Path $tokenCache) {
15333 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
15334} else { 0 }
15335"TokenBrokerCacheFiles: $tokenCount"
15336# Office identity
15337$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15338if (Test-Path $officeId) {
15339 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15340 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
15341 "OfficeSignedInUserId: $signedIn"
15342}
15343# Check if Teams is in startup
15344$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
15345$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
15346"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
15347"#;
15348 match run_powershell(ps_auth) {
15349 Ok(o) if !o.trim().is_empty() => {
15350 for line in o.lines().take(max_entries + 4) {
15351 let l = line.trim();
15352 if !l.is_empty() {
15353 let _ = writeln!(out, "- {l}");
15354 }
15355 }
15356 }
15357 _ => out.push_str("- Could not inspect Teams account state\n"),
15358 }
15359
15360 out.push_str("\n=== Audio and video device binding ===\n");
15361 let ps_devices = r#"
15362# Teams stores device prefs in the settings file
15363$settingsPaths = @(
15364 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
15365 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
15366)
15367$found = $false
15368foreach ($sp in $settingsPaths) {
15369 if (Test-Path $sp) {
15370 $found = $true
15371 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
15372 if ($raw) {
15373 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
15374 if ($json) {
15375 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
15376 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
15377 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
15378 "ConfigFile: $sp"
15379 "Microphone: $mic"
15380 "Speaker: $spk"
15381 "Camera: $cam"
15382 } else {
15383 "ConfigFile: $sp (not parseable as JSON)"
15384 }
15385 } else {
15386 "ConfigFile: $sp (empty)"
15387 }
15388 break
15389 }
15390}
15391if (-not $found) {
15392 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
15393}
15394"#;
15395 match run_powershell(ps_devices) {
15396 Ok(o) if !o.trim().is_empty() => {
15397 for line in o.lines().take(max_entries + 4) {
15398 let l = line.trim();
15399 if !l.is_empty() {
15400 let _ = writeln!(out, "- {l}");
15401 }
15402 }
15403 }
15404 _ => out.push_str("- Could not inspect Teams device binding\n"),
15405 }
15406
15407 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
15408 let ps_events = r#"
15409$cutoff = (Get-Date).AddDays(-7)
15410$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15411 Where-Object {
15412 $msg = [string]$_.Message
15413 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
15414 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
15415 } |
15416 Select-Object -First 8
15417if ($events) {
15418 foreach ($event in $events) {
15419 $msg = ($event.Message -replace '\s+', ' ')
15420 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15421 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15422 }
15423} else {
15424 "No recent Teams crash or error events detected in Application log"
15425}
15426"#;
15427 match run_powershell(ps_events) {
15428 Ok(o) if !o.trim().is_empty() => {
15429 for line in o.lines().take(max_entries + 4) {
15430 let l = line.trim();
15431 if !l.is_empty() {
15432 let _ = writeln!(out, "- {l}");
15433 }
15434 }
15435 }
15436 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
15437 }
15438
15439 let mut findings: Vec<String> = Vec::with_capacity(4);
15440
15441 let classic_installed = out.contains("- ClassicTeams: Installed");
15442 let new_installed = out.contains("- NewTeams: Installed");
15443 if !classic_installed && !new_installed {
15444 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
15445 }
15446
15447 for name in ["Teams", "ms-teams"] {
15448 let marker = format!("{name} | Running: Yes | Processes:");
15449 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
15450 let ws_mb = line
15451 .split("WorkingSetMB: ")
15452 .nth(1)
15453 .and_then(|v| v.trim().parse::<f64>().ok())
15454 .unwrap_or(0.0);
15455 if ws_mb >= 1000.0 {
15456 findings.push(format!(
15457 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
15458 ));
15459 }
15460 }
15461 }
15462
15463 for (label, threshold_mb) in [
15464 ("ClassicTeamsCache", 500.0_f64),
15465 ("ClassicTeamsSquirrel", 2000.0),
15466 ("NewTeamsCache", 500.0),
15467 ("NewTeamsAppData", 3000.0),
15468 ] {
15469 let marker = format!("{label} |");
15470 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
15471 let mb = line
15472 .split("SizeMB: ")
15473 .nth(1)
15474 .and_then(|v| v.trim().parse::<f64>().ok())
15475 .unwrap_or(0.0);
15476 if mb >= threshold_mb {
15477 findings.push(format!(
15478 "{label} is {mb:.0} MB — cache bloat at this size can cause Teams slowness, failed sign-in, and rendering glitches. Fix: quit Teams and delete the cache folder."
15479 ));
15480 }
15481 }
15482 }
15483
15484 if out.contains("- Installed: No -- New Teams") {
15485 findings.push(
15486 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
15487 .into(),
15488 );
15489 }
15490
15491 if out.contains("- ClassicTeamsAccount: Not configured")
15492 && out.contains("- OfficeSignedInUserId: None")
15493 {
15494 findings.push(
15495 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
15496 .into(),
15497 );
15498 }
15499
15500 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
15501 findings.push(
15502 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
15503 .into(),
15504 );
15505 }
15506
15507 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
15508 if findings.is_empty() {
15509 result.push_str("- No obvious Teams health blocker detected.\n");
15510 } else {
15511 for finding in &findings {
15512 let _ = writeln!(result, "- Finding: {finding}");
15513 }
15514 }
15515 result.push('\n');
15516 result.push_str(&out);
15517 Ok(result)
15518}
15519
15520#[cfg(not(windows))]
15521fn inspect_teams(_max_entries: usize) -> Result<String, String> {
15522 Ok(
15523 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
15524 .into(),
15525 )
15526}
15527
15528#[cfg(windows)]
15529fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
15530 let mut out = String::from("=== Identity broker services ===\n");
15531
15532 let ps_services = r#"
15533$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
15534foreach ($name in $serviceNames) {
15535 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
15536 if ($svc) {
15537 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
15538 } else {
15539 "$name | Not found"
15540 }
15541}
15542"#;
15543 match run_powershell(ps_services) {
15544 Ok(o) if !o.trim().is_empty() => {
15545 for line in o.lines().take(max_entries) {
15546 let l = line.trim();
15547 if !l.is_empty() {
15548 let _ = writeln!(out, "- {l}");
15549 }
15550 }
15551 }
15552 _ => out.push_str("- Could not inspect identity broker services\n"),
15553 }
15554
15555 out.push_str("\n=== Device registration ===\n");
15556 let ps_device = r#"
15557$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15558if ($dsreg) {
15559 try {
15560 $raw = & $dsreg.Source /status 2>$null
15561 $text = ($raw -join "`n")
15562 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15563 $seen = $false
15564 foreach ($key in $keys) {
15565 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15566 if ($match.Success) {
15567 "${key}: $($match.Groups[1].Value.Trim())"
15568 $seen = $true
15569 }
15570 }
15571 if (-not $seen) {
15572 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15573 }
15574 } catch {
15575 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15576 }
15577} else {
15578 "DeviceRegistration: dsregcmd unavailable"
15579}
15580"#;
15581 match run_powershell(ps_device) {
15582 Ok(o) if !o.trim().is_empty() => {
15583 for line in o.lines().take(max_entries + 4) {
15584 let l = line.trim();
15585 if !l.is_empty() {
15586 let _ = writeln!(out, "- {l}");
15587 }
15588 }
15589 }
15590 _ => out.push_str(
15591 "- DeviceRegistration: Could not inspect device registration state in this session\n",
15592 ),
15593 }
15594
15595 out.push_str("\n=== Broker packages and caches ===\n");
15596 let ps_broker = r#"
15597$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15598if ($pkg) {
15599 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15600} else {
15601 "AADBrokerPlugin: Not installed"
15602}
15603$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15604$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15605"TokenBrokerCacheFiles: $tokenCount"
15606$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15607$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15608"IdentityCacheFiles: $identityCount"
15609$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15610$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15611"OneAuthFiles: $oneAuthCount"
15612"#;
15613 match run_powershell(ps_broker) {
15614 Ok(o) if !o.trim().is_empty() => {
15615 for line in o.lines().take(max_entries + 4) {
15616 let l = line.trim();
15617 if !l.is_empty() {
15618 let _ = writeln!(out, "- {l}");
15619 }
15620 }
15621 }
15622 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15623 }
15624
15625 out.push_str("\n=== Microsoft app account signals ===\n");
15626 let ps_accounts = r#"
15627function MaskEmail([string]$Email) {
15628 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15629 $parts = $Email.Split('@', 2)
15630 $local = $parts[0]
15631 $domain = $parts[1]
15632 if ($local.Length -le 1) { return "*@$domain" }
15633 return ($local.Substring(0,1) + "***@" + $domain)
15634}
15635$allAccounts = @()
15636$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15637if (Test-Path $officeId) {
15638 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15639 if ($id.SignedInUserId) {
15640 $allAccounts += [string]$id.SignedInUserId
15641 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15642 } else {
15643 "OfficeSignedInUserId: None"
15644 }
15645} else {
15646 "OfficeSignedInUserId: Not configured"
15647}
15648$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15649if (Test-Path $teamsAcct) {
15650 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15651 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15652 if (-not [string]::IsNullOrWhiteSpace($email)) {
15653 $allAccounts += $email
15654 "TeamsAccount: $(MaskEmail $email)"
15655 } else {
15656 "TeamsAccount: Unknown"
15657 }
15658} else {
15659 "TeamsAccount: Not configured"
15660}
15661$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15662$oneDriveEmails = @()
15663if (Test-Path $oneDriveBase) {
15664 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15665 ForEach-Object {
15666 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15667 if ($p.UserEmail) { [string]$p.UserEmail }
15668 } |
15669 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15670 Sort-Object -Unique
15671}
15672$allAccounts += $oneDriveEmails
15673"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15674if (@($oneDriveEmails).Count -gt 0) {
15675 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15676}
15677$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15678"DistinctIdentityCount: $($distinct.Count)"
15679if ($distinct.Count -gt 0) {
15680 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15681}
15682"#;
15683 match run_powershell(ps_accounts) {
15684 Ok(o) if !o.trim().is_empty() => {
15685 for line in o.lines().take(max_entries + 6) {
15686 let l = line.trim();
15687 if !l.is_empty() {
15688 let _ = writeln!(out, "- {l}");
15689 }
15690 }
15691 }
15692 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15693 }
15694
15695 out.push_str("\n=== WebView2 auth dependency ===\n");
15696 let ps_webview = r#"
15697$paths = @(
15698 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15699 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15700) | Where-Object { $_ -and (Test-Path $_) }
15701$runtimeDir = $paths | ForEach-Object {
15702 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15703 Where-Object { $_.Name -match '^\d+\.' } |
15704 Sort-Object Name -Descending |
15705 Select-Object -First 1
15706} | Select-Object -First 1
15707if ($runtimeDir) {
15708 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15709 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15710 "WebView2: Installed | Version: $version"
15711} else {
15712 "WebView2: Not installed"
15713}
15714"#;
15715 match run_powershell(ps_webview) {
15716 Ok(o) if !o.trim().is_empty() => {
15717 for line in o.lines().take(4) {
15718 let l = line.trim();
15719 if !l.is_empty() {
15720 let _ = writeln!(out, "- {l}");
15721 }
15722 }
15723 }
15724 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15725 }
15726
15727 out.push_str("\n=== Recent auth-related events (24h) ===\n");
15728 let ps_events = r#"
15729try {
15730 $cutoff = (Get-Date).AddHours(-24)
15731 $events = @()
15732 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15733 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15734 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15735 Select-Object -First 4
15736 }
15737 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15738 Where-Object {
15739 ($_.LevelDisplayName -in @('Error','Warning')) -and (
15740 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15741 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15742 )
15743 } |
15744 Select-Object -First 6
15745 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15746 "AuthEventCount: $(@($events).Count)"
15747 if ($events) {
15748 foreach ($e in $events) {
15749 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15750 'No message'
15751 } else {
15752 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15753 }
15754 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15755 }
15756 } else {
15757 "No auth-related warning/error events detected"
15758 }
15759} catch {
15760 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15761}
15762"#;
15763 match run_powershell(ps_events) {
15764 Ok(o) if !o.trim().is_empty() => {
15765 for line in o.lines().take(max_entries + 8) {
15766 let l = line.trim();
15767 if !l.is_empty() {
15768 let _ = writeln!(out, "- {l}");
15769 }
15770 }
15771 }
15772 _ => out
15773 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15774 }
15775
15776 let parse_count = |prefix: &str| -> Option<u64> {
15777 out.lines().find_map(|line| {
15778 line.trim()
15779 .strip_prefix(prefix)
15780 .and_then(|value| value.trim().parse::<u64>().ok())
15781 })
15782 };
15783
15784 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15785 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15786
15787 let mut findings: Vec<String> = Vec::with_capacity(4);
15788 if out.contains("TokenBroker | Status: Stopped")
15789 || out.contains("wlidsvc | Status: Stopped")
15790 || out.contains("OneAuth | Status: Stopped")
15791 {
15792 findings.push(
15793 "One or more Microsoft identity broker services are stopped - Outlook, Teams, OneDrive, or Microsoft 365 sign-in can loop or fail until WAM services are running."
15794 .into(),
15795 );
15796 }
15797 if out.contains("AADBrokerPlugin: Not installed") {
15798 findings.push(
15799 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15800 .into(),
15801 );
15802 }
15803 if out.contains("WebView2: Not installed") {
15804 findings.push(
15805 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15806 .into(),
15807 );
15808 }
15809 if distinct_identity_count > 1 {
15810 findings.push(format!(
15811 "{distinct_identity_count} distinct Microsoft identity signals were detected across Office, Teams, and OneDrive - account mismatch can cause repeated sign-in prompts or the wrong tenant opening."
15812 ));
15813 }
15814 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15815 && distinct_identity_count > 0
15816 {
15817 findings.push(
15818 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15819 .into(),
15820 );
15821 }
15822 if out.contains("DeviceRegistration: dsregcmd")
15823 || out.contains("DeviceRegistration: Could not inspect device registration state")
15824 {
15825 findings.push(
15826 "Device-registration visibility is partial in this session - personal devices are often fine here, but managed Microsoft 365 SSO posture may need dsregcmd details to confirm."
15827 .into(),
15828 );
15829 }
15830 if auth_event_count > 0 {
15831 findings.push(format!(
15832 "{auth_event_count} recent auth-related warning/error event(s) were found - the event section may explain repeated prompts, broker failures, or account-sync issues."
15833 ));
15834 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15835 findings.push(
15836 "Auth-related event visibility is partial in this session - the machine may still be healthy, but Hematite could not confirm recent broker or sign-in events."
15837 .into(),
15838 );
15839 }
15840
15841 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15842 if findings.is_empty() {
15843 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15844 } else {
15845 for finding in &findings {
15846 let _ = writeln!(result, "- Finding: {finding}");
15847 }
15848 }
15849 result.push('\n');
15850 result.push_str(&out);
15851 Ok(result)
15852}
15853
15854#[cfg(not(windows))]
15855fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15856 Ok("Host inspection: identity_auth\n\n=== Findings ===\n- Microsoft 365 identity-broker inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
15857}
15858
15859#[cfg(windows)]
15860fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15861 let mut out = String::from("=== File History ===\n");
15862
15863 let ps_fh = r#"
15864$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15865if ($svc) {
15866 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15867} else {
15868 "FileHistoryService: Not found"
15869}
15870# File History config in registry
15871$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15872$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15873if (Test-Path $fhUser) {
15874 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15875 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15876 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15877 $lastBackup = if ($fh.ProtectedUpToTime) {
15878 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15879 } else { 'Never' }
15880 "Enabled: $enabled"
15881 "BackupDrive: $target"
15882 "LastBackup: $lastBackup"
15883} else {
15884 "Enabled: Not configured"
15885 "BackupDrive: Not configured"
15886 "LastBackup: Never"
15887}
15888"#;
15889 match run_powershell(ps_fh) {
15890 Ok(o) if !o.trim().is_empty() => {
15891 for line in o.lines().take(6) {
15892 let l = line.trim();
15893 if !l.is_empty() {
15894 let _ = writeln!(out, "- {l}");
15895 }
15896 }
15897 }
15898 _ => out.push_str("- Could not inspect File History state\n"),
15899 }
15900
15901 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15902 let ps_wbadmin = r#"
15903$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15904"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15905# Last backup from wbadmin
15906$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15907if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15908 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15909 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15910 if ($lastDate) { $lastDate.Trim() }
15911 if ($lastTarget) { $lastTarget.Trim() }
15912} else {
15913 "LastWbadminBackup: No backup versions found"
15914}
15915# Task-based backup
15916$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15917foreach ($t in $task) {
15918 "BackupTask: $($t.TaskName) | State: $($t.State)"
15919}
15920"#;
15921 match run_powershell(ps_wbadmin) {
15922 Ok(o) if !o.trim().is_empty() => {
15923 for line in o.lines().take(8) {
15924 let l = line.trim();
15925 if !l.is_empty() {
15926 let _ = writeln!(out, "- {l}");
15927 }
15928 }
15929 }
15930 _ => out.push_str("- Could not inspect Windows Backup state\n"),
15931 }
15932
15933 out.push_str("\n=== System Restore ===\n");
15934 let ps_sr = r#"
15935$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15936 Select-Object -ExpandProperty DeviceID
15937foreach ($drive in $drives) {
15938 $protection = try {
15939 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15940 } catch { $null }
15941 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15942 $rpConf = try {
15943 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15944 } catch { $null }
15945 # Check if SR is disabled for this drive
15946 $disabled = $false
15947 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15948 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15949}
15950# Most recent restore point
15951$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15952if ($points) {
15953 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15954 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15955 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15956} else {
15957 "MostRecentRestorePoint: None found"
15958}
15959$srEnabled = try {
15960 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15961 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15962} catch { 'Unknown' }
15963"SystemRestoreState: $srEnabled"
15964"#;
15965 match run_powershell(ps_sr) {
15966 Ok(o) if !o.trim().is_empty() => {
15967 for line in o.lines().take(8) {
15968 let l = line.trim();
15969 if !l.is_empty() {
15970 let _ = writeln!(out, "- {l}");
15971 }
15972 }
15973 }
15974 _ => out.push_str("- Could not inspect System Restore state\n"),
15975 }
15976
15977 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15978 let ps_kfm = r#"
15979$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15980if (Test-Path $kfmKey) {
15981 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15982 foreach ($acct in $accounts | Select-Object -First 3) {
15983 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15984 $email = $props.UserEmail
15985 $kfmDesktop = $props.'KFMSilentOptInDesktop'
15986 $kfmDocs = $props.'KFMSilentOptInDocuments'
15987 $kfmPics = $props.'KFMSilentOptInPictures'
15988 "Account: $email | KFM-Desktop: $(if ($kfmDesktop) { 'Protected' } else { 'Not enrolled' }) | KFM-Docs: $(if ($kfmDocs) { 'Protected' } else { 'Not enrolled' }) | KFM-Pics: $(if ($kfmPics) { 'Protected' } else { 'Not enrolled' })"
15989 }
15990} else {
15991 "OneDriveKFM: No OneDrive accounts found"
15992}
15993"#;
15994 match run_powershell(ps_kfm) {
15995 Ok(o) if !o.trim().is_empty() => {
15996 for line in o.lines().take(6) {
15997 let l = line.trim();
15998 if !l.is_empty() {
15999 let _ = writeln!(out, "- {l}");
16000 }
16001 }
16002 }
16003 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
16004 }
16005
16006 out.push_str("\n=== Recent backup failure events (7d) ===\n");
16007 let ps_events = r#"
16008$cutoff = (Get-Date).AddDays(-7)
16009$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
16010 Where-Object {
16011 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
16012 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
16013 } |
16014 Where-Object { $_.Level -le 3 } |
16015 Select-Object -First 6
16016if ($events) {
16017 foreach ($event in $events) {
16018 $msg = ($event.Message -replace '\s+', ' ')
16019 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
16020 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
16021 }
16022} else {
16023 "No recent backup failure events detected"
16024}
16025"#;
16026 match run_powershell(ps_events) {
16027 Ok(o) if !o.trim().is_empty() => {
16028 for line in o.lines().take(8) {
16029 let l = line.trim();
16030 if !l.is_empty() {
16031 let _ = writeln!(out, "- {l}");
16032 }
16033 }
16034 }
16035 _ => out.push_str("- Could not inspect backup failure events\n"),
16036 }
16037
16038 let mut findings: Vec<String> = Vec::with_capacity(4);
16039
16040 let fh_enabled = out.contains("- Enabled: Enabled");
16041 let fh_never =
16042 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
16043 let no_wbadmin = out.contains("No backup versions found");
16044 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
16045
16046 if !fh_enabled && no_wbadmin {
16047 findings.push(
16048 "No backup solution detected — File History is not enabled and no Windows Backup versions were found. This machine has no local recovery path if data is lost or corrupted.".into(),
16049 );
16050 } else if fh_enabled && fh_never {
16051 findings.push(
16052 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
16053 );
16054 }
16055
16056 if no_restore_point {
16057 findings.push(
16058 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
16059 );
16060 }
16061
16062 if out.contains("- FileHistoryService: Stopped")
16063 || out.contains("- FileHistoryService: Not found")
16064 {
16065 findings.push(
16066 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
16067 );
16068 }
16069
16070 if out.contains("Application Error |")
16071 || out.contains("Microsoft-Windows-Backup |")
16072 || out.contains("wbengine |")
16073 {
16074 findings.push(
16075 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
16076 );
16077 }
16078
16079 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
16080 if findings.is_empty() {
16081 result.push_str("- No obvious backup health blocker detected.\n");
16082 } else {
16083 for finding in &findings {
16084 let _ = writeln!(result, "- Finding: {finding}");
16085 }
16086 }
16087 result.push('\n');
16088 result.push_str(&out);
16089 Ok(result)
16090}
16091
16092#[cfg(not(windows))]
16093fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
16094 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
16095}
16096
16097#[cfg(windows)]
16098fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
16099 let mut out = String::from("=== Windows Search service ===\n");
16100
16101 let ps_svc = r#"
16103$svc = Get-Service WSearch -ErrorAction SilentlyContinue
16104if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
16105else { "WSearch service not found" }
16106"#;
16107 match run_powershell(ps_svc) {
16108 Ok(o) => {
16109 let _ = writeln!(out, "- {}", o.trim());
16110 }
16111 Err(_) => out.push_str("- Could not query WSearch service\n"),
16112 }
16113
16114 out.push_str("\n=== Indexer state ===\n");
16116 let ps_idx = r#"
16117$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
16118$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
16119if ($props) {
16120 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
16121 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
16122 "DataDirectory: $($props.DataDirectory)"
16123} else { "Registry key not found" }
16124"#;
16125 match run_powershell(ps_idx) {
16126 Ok(o) => {
16127 for line in o.lines() {
16128 let l = line.trim();
16129 if !l.is_empty() {
16130 let _ = writeln!(out, "- {l}");
16131 }
16132 }
16133 }
16134 Err(_) => out.push_str("- Could not read indexer registry\n"),
16135 }
16136
16137 out.push_str("\n=== Indexed locations ===\n");
16139 let ps_locs = r#"
16140$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
16141if ($comObj) {
16142 $catalog = $comObj.GetCatalog('SystemIndex')
16143 $manager = $catalog.GetCrawlScopeManager()
16144 $rules = $manager.EnumerateRoots()
16145 while ($true) {
16146 try {
16147 $root = $rules.Next(1)
16148 if ($root.Count -eq 0) { break }
16149 $r = $root[0]
16150 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
16151 } catch { break }
16152 }
16153} else { " COM admin interface not available (normal on non-admin sessions)" }
16154"#;
16155 match run_powershell(ps_locs) {
16156 Ok(o) if !o.trim().is_empty() => {
16157 for line in o.lines() {
16158 let l = line.trim_end();
16159 if !l.is_empty() {
16160 let _ = writeln!(out, "{l}");
16161 }
16162 }
16163 }
16164 _ => {
16165 let ps_reg = r#"
16167Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
16168ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
16169"#;
16170 match run_powershell(ps_reg) {
16171 Ok(o) if !o.trim().is_empty() => {
16172 for line in o.lines() {
16173 let l = line.trim_end();
16174 if !l.is_empty() {
16175 let _ = writeln!(out, "{l}");
16176 }
16177 }
16178 }
16179 _ => out.push_str(" - Could not enumerate indexed locations\n"),
16180 }
16181 }
16182 }
16183
16184 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
16186 let ps_evts = r#"
16187Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
16188Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
16189ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
16190"#;
16191 match run_powershell(ps_evts) {
16192 Ok(o) if !o.trim().is_empty() => {
16193 for line in o.lines() {
16194 let l = line.trim();
16195 if !l.is_empty() {
16196 let _ = writeln!(out, "- {l}");
16197 }
16198 }
16199 }
16200 _ => out.push_str("- No recent indexer errors found\n"),
16201 }
16202
16203 let mut findings: Vec<String> = Vec::with_capacity(4);
16204 if out.contains("Status: Stopped") {
16205 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
16206 }
16207 if out.contains("IsContentIndexingEnabled: 0")
16208 || out.contains("IsContentIndexingEnabled: False")
16209 {
16210 findings.push(
16211 "Content indexing is disabled — file content won't be searchable, only filenames."
16212 .into(),
16213 );
16214 }
16215 if out.contains("SetupCompletedSuccessfully: 0")
16216 || out.contains("SetupCompletedSuccessfully: False")
16217 {
16218 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
16219 }
16220
16221 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
16222 if findings.is_empty() {
16223 result.push_str("- Windows Search service and indexer appear healthy.\n");
16224 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
16225 } else {
16226 for f in &findings {
16227 let _ = writeln!(result, "- Finding: {f}");
16228 }
16229 }
16230 result.push('\n');
16231 result.push_str(&out);
16232 Ok(result)
16233}
16234
16235#[cfg(not(windows))]
16236fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
16237 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
16238}
16239
16240#[cfg(windows)]
16243fn inspect_display_config(max_entries: usize) -> Result<String, String> {
16244 let mut out = String::with_capacity(1024);
16245
16246 out.push_str("=== Active displays ===\n");
16248 let ps_displays = r#"
16249Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
16250Select-Object -First 20 |
16251ForEach-Object {
16252 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
16253}
16254"#;
16255 match run_powershell(ps_displays) {
16256 Ok(o) if !o.trim().is_empty() => {
16257 for line in o.lines().take(max_entries) {
16258 let l = line.trim();
16259 if !l.is_empty() {
16260 let _ = writeln!(out, "- {l}");
16261 }
16262 }
16263 }
16264 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
16265 }
16266
16267 out.push_str("\n=== Video adapters ===\n");
16269 let ps_gpu = r#"
16270Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
16271ForEach-Object {
16272 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
16273 $hz = "$($_.CurrentRefreshRate) Hz"
16274 $bits = "$($_.CurrentBitsPerPixel) bpp"
16275 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
16276}
16277"#;
16278 match run_powershell(ps_gpu) {
16279 Ok(o) if !o.trim().is_empty() => {
16280 for line in o.lines().take(max_entries) {
16281 let l = line.trim();
16282 if !l.is_empty() {
16283 let _ = writeln!(out, "- {l}");
16284 }
16285 }
16286 }
16287 _ => out.push_str("- Could not query video adapter info\n"),
16288 }
16289
16290 out.push_str("\n=== Connected monitors ===\n");
16292 let ps_monitors = r#"
16293Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
16294ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
16295"#;
16296 match run_powershell(ps_monitors) {
16297 Ok(o) if !o.trim().is_empty() => {
16298 for line in o.lines().take(max_entries) {
16299 let l = line.trim();
16300 if !l.is_empty() {
16301 let _ = writeln!(out, "- {l}");
16302 }
16303 }
16304 }
16305 _ => out.push_str("- No monitor info available via WMI\n"),
16306 }
16307
16308 out.push_str("\n=== DPI / scaling ===\n");
16310 let ps_dpi = r#"
16311Add-Type -TypeDefinition @'
16312using System; using System.Runtime.InteropServices;
16313public class DPI {
16314 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
16315 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
16316 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
16317}
16318'@ -ErrorAction SilentlyContinue
16319try {
16320 $hdc = [DPI]::GetDC([IntPtr]::Zero)
16321 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
16322 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
16323 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
16324 $scale = [Math]::Round($dpiX / 96.0 * 100)
16325 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
16326} catch { "DPI query unavailable" }
16327"#;
16328 match run_powershell(ps_dpi) {
16329 Ok(o) if !o.trim().is_empty() => {
16330 let _ = writeln!(out, "- {}", o.trim());
16331 }
16332 _ => out.push_str("- DPI info unavailable\n"),
16333 }
16334
16335 let mut findings: Vec<String> = Vec::with_capacity(4);
16336 if out.contains("0x0") || out.contains("@ 0 Hz") {
16337 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
16338 }
16339
16340 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
16341 if findings.is_empty() {
16342 result.push_str("- Display configuration appears normal.\n");
16343 } else {
16344 for f in &findings {
16345 let _ = writeln!(result, "- Finding: {f}");
16346 }
16347 }
16348 result.push('\n');
16349 result.push_str(&out);
16350 Ok(result)
16351}
16352
16353#[cfg(not(windows))]
16354fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
16355 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
16356}
16357
16358#[cfg(windows)]
16361fn inspect_ntp() -> Result<String, String> {
16362 let mut out = String::with_capacity(1024);
16363
16364 out.push_str("=== Windows Time service ===\n");
16366 let ps_svc = r#"
16367$svc = Get-Service W32Time -ErrorAction SilentlyContinue
16368if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
16369else { "W32Time service not found" }
16370"#;
16371 match run_powershell(ps_svc) {
16372 Ok(o) => {
16373 let _ = writeln!(out, "- {}", o.trim());
16374 }
16375 Err(_) => out.push_str("- Could not query W32Time service\n"),
16376 }
16377
16378 out.push_str("\n=== NTP source and sync status ===\n");
16380 let ps_sync = r#"
16381$q = w32tm /query /status 2>$null
16382if ($q) { $q } else { "w32tm query unavailable" }
16383"#;
16384 match run_powershell(ps_sync) {
16385 Ok(o) if !o.trim().is_empty() => {
16386 for line in o.lines() {
16387 let l = line.trim();
16388 if !l.is_empty() {
16389 let _ = writeln!(out, " {l}");
16390 }
16391 }
16392 }
16393 _ => out.push_str(" - Could not query w32tm status\n"),
16394 }
16395
16396 out.push_str("\n=== Configured NTP servers ===\n");
16398 let ps_peers = r#"
16399w32tm /query /peers 2>$null | Select-Object -First 10
16400"#;
16401 match run_powershell(ps_peers) {
16402 Ok(o) if !o.trim().is_empty() => {
16403 for line in o.lines() {
16404 let l = line.trim();
16405 if !l.is_empty() {
16406 let _ = writeln!(out, " {l}");
16407 }
16408 }
16409 }
16410 _ => {
16411 let ps_reg = r#"
16413(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
16414"#;
16415 match run_powershell(ps_reg) {
16416 Ok(o) if !o.trim().is_empty() => {
16417 let _ = writeln!(out, " NtpServer (registry): {}", o.trim());
16418 }
16419 _ => out.push_str(" - Could not enumerate NTP peers\n"),
16420 }
16421 }
16422 }
16423
16424 let mut findings: Vec<String> = Vec::with_capacity(4);
16425 if out.contains("W32Time | Status: Stopped") {
16426 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
16427 }
16428 if out.contains("The computer did not resync") || out.contains("Error") {
16429 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
16430 }
16431
16432 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
16433 if findings.is_empty() {
16434 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
16435 } else {
16436 for f in &findings {
16437 let _ = writeln!(result, "- Finding: {f}");
16438 }
16439 }
16440 result.push('\n');
16441 result.push_str(&out);
16442 Ok(result)
16443}
16444
16445#[cfg(not(windows))]
16446fn inspect_ntp() -> Result<String, String> {
16447 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
16449
16450 let timedatectl = std::process::Command::new("timedatectl")
16451 .arg("status")
16452 .output();
16453
16454 if let Ok(o) = timedatectl {
16455 let text = String::from_utf8_lossy(&o.stdout);
16456 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
16457 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
16458 } else {
16459 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
16460 }
16461 for line in text.lines() {
16462 let l = line.trim();
16463 if !l.is_empty() {
16464 let _ = write!(out, " {l}\n");
16465 }
16466 }
16467 return Ok(out);
16468 }
16469
16470 let sntp = std::process::Command::new("sntp")
16472 .args(["-d", "time.apple.com"])
16473 .output();
16474 if let Ok(o) = sntp {
16475 out.push_str("- NTP check via sntp:\n");
16476 out.push_str(&String::from_utf8_lossy(&o.stdout));
16477 return Ok(out);
16478 }
16479
16480 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
16481 Ok(out)
16482}
16483
16484#[cfg(windows)]
16487fn inspect_cpu_power() -> Result<String, String> {
16488 let mut out = String::with_capacity(1024);
16489
16490 out.push_str("=== Active power plan ===\n");
16492 let ps_plan = r#"
16493$plan = powercfg /getactivescheme 2>$null
16494if ($plan) { $plan } else { "Could not query power scheme" }
16495"#;
16496 match run_powershell(ps_plan) {
16497 Ok(o) if !o.trim().is_empty() => {
16498 let _ = writeln!(out, "- {}", o.trim());
16499 }
16500 _ => out.push_str("- Could not read active power plan\n"),
16501 }
16502
16503 out.push_str("\n=== Processor performance policy ===\n");
16505 let ps_proc = r#"
16506$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
16507$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16508$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16509$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16510if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
16511if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
16512if ($boost) {
16513 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
16514 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
16515 "Turbo boost mode: $bname"
16516}
16517"#;
16518 match run_powershell(ps_proc) {
16519 Ok(o) if !o.trim().is_empty() => {
16520 for line in o.lines() {
16521 let l = line.trim();
16522 if !l.is_empty() {
16523 let _ = writeln!(out, "- {l}");
16524 }
16525 }
16526 }
16527 _ => out.push_str("- Could not query processor performance settings\n"),
16528 }
16529
16530 out.push_str("\n=== CPU frequency ===\n");
16532 let ps_freq = r#"
16533Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
16534ForEach-Object {
16535 $cur = $_.CurrentClockSpeed
16536 $max = $_.MaxClockSpeed
16537 $load = $_.LoadPercentage
16538 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
16539}
16540"#;
16541 match run_powershell(ps_freq) {
16542 Ok(o) if !o.trim().is_empty() => {
16543 for line in o.lines() {
16544 let l = line.trim();
16545 if !l.is_empty() {
16546 let _ = writeln!(out, "- {l}");
16547 }
16548 }
16549 }
16550 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
16551 }
16552
16553 out.push_str("\n=== Throttling indicators ===\n");
16555 let ps_throttle = r#"
16556$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
16557if ($pwr) {
16558 $pwr | Select-Object -First 4 | ForEach-Object {
16559 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
16560 "Thermal zone $($_.InstanceName): ${c}°C"
16561 }
16562} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16563"#;
16564 match run_powershell(ps_throttle) {
16565 Ok(o) if !o.trim().is_empty() => {
16566 for line in o.lines() {
16567 let l = line.trim();
16568 if !l.is_empty() {
16569 let _ = writeln!(out, "- {l}");
16570 }
16571 }
16572 }
16573 _ => out.push_str("- Thermal zone info unavailable\n"),
16574 }
16575
16576 let mut findings: Vec<String> = Vec::with_capacity(4);
16577 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
16578 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16579 }
16580 if out.contains("Turbo boost mode: Disabled") {
16581 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16582 }
16583 if out.contains("Min processor state: 100%") {
16584 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16585 }
16586
16587 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16588 if findings.is_empty() {
16589 result.push_str("- CPU power and frequency settings appear normal.\n");
16590 } else {
16591 for f in &findings {
16592 let _ = writeln!(result, "- Finding: {f}");
16593 }
16594 }
16595 result.push('\n');
16596 result.push_str(&out);
16597 Ok(result)
16598}
16599
16600#[cfg(windows)]
16601fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16602 let mut out = String::with_capacity(1024);
16603
16604 out.push_str("=== Credential vault summary ===\n");
16605 let ps_summary = r#"
16606$raw = cmdkey /list 2>&1
16607$lines = $raw -split "`n"
16608$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16609"Total stored credentials: $total"
16610$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16611$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16612$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16613" Windows credentials: $windows"
16614" Generic credentials: $generic"
16615" Certificate-based: $cert"
16616"#;
16617 match run_powershell(ps_summary) {
16618 Ok(o) => {
16619 for line in o.lines() {
16620 let l = line.trim();
16621 if !l.is_empty() {
16622 let _ = writeln!(out, "- {l}");
16623 }
16624 }
16625 }
16626 Err(e) => {
16627 let _ = writeln!(out, "- Credential summary error: {e}");
16628 }
16629 }
16630
16631 out.push_str("\n=== Credential targets (up to 20) ===\n");
16632 let ps_list = r#"
16633$raw = cmdkey /list 2>&1
16634$entries = @(); $cur = @{}
16635foreach ($line in ($raw -split "`n")) {
16636 $l = $line.Trim()
16637 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
16638 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
16639 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16640}
16641$entries | Select-Object -Last 20 | ForEach-Object {
16642 "[$($_.Type)] $($_.Target) (user: $($_.User))"
16643}
16644"#;
16645 match run_powershell(ps_list) {
16646 Ok(o) => {
16647 let lines: Vec<&str> = o
16648 .lines()
16649 .map(|l| l.trim())
16650 .filter(|l| !l.is_empty())
16651 .collect();
16652 if lines.is_empty() {
16653 out.push_str("- No credential entries found\n");
16654 } else {
16655 for l in &lines {
16656 let _ = writeln!(out, "- {l}");
16657 }
16658 }
16659 }
16660 Err(e) => {
16661 let _ = writeln!(out, "- Credential list error: {e}");
16662 }
16663 }
16664
16665 let total_creds: usize = {
16666 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16667 run_powershell(ps_count)
16668 .ok()
16669 .and_then(|s| s.trim().parse().ok())
16670 .unwrap_or(0)
16671 };
16672
16673 let mut findings: Vec<String> = Vec::with_capacity(4);
16674 if total_creds > 30 {
16675 findings.push(format!(
16676 "{total_creds} stored credentials found — consider auditing for stale entries."
16677 ));
16678 }
16679
16680 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16681 if findings.is_empty() {
16682 result.push_str("- Credential store looks normal.\n");
16683 } else {
16684 for f in &findings {
16685 let _ = writeln!(result, "- Finding: {f}");
16686 }
16687 }
16688 result.push('\n');
16689 result.push_str(&out);
16690 Ok(result)
16691}
16692
16693#[cfg(not(windows))]
16694fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16695 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16696}
16697
16698#[cfg(windows)]
16699fn inspect_tpm() -> Result<String, String> {
16700 let mut out = String::with_capacity(1024);
16701
16702 out.push_str("=== TPM state ===\n");
16703 let ps_tpm = r#"
16704function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16705 $text = if ($null -eq $Value) { "" } else { [string]$Value }
16706 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16707 "$Name$text"
16708}
16709$t = Get-Tpm -ErrorAction SilentlyContinue
16710if ($t) {
16711 Emit-Field "TpmPresent: " $t.TpmPresent
16712 Emit-Field "TpmReady: " $t.TpmReady
16713 Emit-Field "TpmEnabled: " $t.TpmEnabled
16714 Emit-Field "TpmOwned: " $t.TpmOwned
16715 Emit-Field "RestartPending: " $t.RestartPending
16716 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
16717 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16718} else { "TPM module unavailable" }
16719"#;
16720 match run_powershell(ps_tpm) {
16721 Ok(o) => {
16722 for line in o.lines() {
16723 let l = line.trim();
16724 if !l.is_empty() {
16725 let _ = writeln!(out, "- {l}");
16726 }
16727 }
16728 }
16729 Err(e) => {
16730 let _ = writeln!(out, "- Get-Tpm error: {e}");
16731 }
16732 }
16733
16734 out.push_str("\n=== TPM spec version (WMI) ===\n");
16735 let ps_spec = r#"
16736$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16737if ($wmi) {
16738 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16739 "SpecVersion: $spec"
16740 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16741 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16742 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16743} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16744"#;
16745 match run_powershell(ps_spec) {
16746 Ok(o) => {
16747 for line in o.lines() {
16748 let l = line.trim();
16749 if !l.is_empty() {
16750 let _ = writeln!(out, "- {l}");
16751 }
16752 }
16753 }
16754 Err(e) => {
16755 let _ = writeln!(out, "- Win32_Tpm WMI error: {e}");
16756 }
16757 }
16758
16759 out.push_str("\n=== Secure Boot state ===\n");
16760 let ps_sb = r#"
16761try {
16762 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16763 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16764} catch {
16765 $msg = $_.Exception.Message
16766 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16767 "Secure Boot: Unknown (administrator privileges required)"
16768 } elseif ($msg -match "Cmdlet not supported on this platform") {
16769 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16770 } else {
16771 "Secure Boot: N/A ($msg)"
16772 }
16773}
16774"#;
16775 match run_powershell(ps_sb) {
16776 Ok(o) => {
16777 for line in o.lines() {
16778 let l = line.trim();
16779 if !l.is_empty() {
16780 let _ = writeln!(out, "- {l}");
16781 }
16782 }
16783 }
16784 Err(e) => {
16785 let _ = writeln!(out, "- Secure Boot check error: {e}");
16786 }
16787 }
16788
16789 out.push_str("\n=== Firmware type ===\n");
16790 let ps_fw = r#"
16791$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16792switch ($fw) {
16793 1 { "Firmware type: BIOS (Legacy)" }
16794 2 { "Firmware type: UEFI" }
16795 default {
16796 $bcd = bcdedit /enum firmware 2>$null
16797 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16798 else { "Firmware type: Unknown or not set" }
16799 }
16800}
16801"#;
16802 match run_powershell(ps_fw) {
16803 Ok(o) => {
16804 for line in o.lines() {
16805 let l = line.trim();
16806 if !l.is_empty() {
16807 let _ = writeln!(out, "- {l}");
16808 }
16809 }
16810 }
16811 Err(e) => {
16812 let _ = writeln!(out, "- Firmware type error: {e}");
16813 }
16814 }
16815
16816 let mut findings: Vec<String> = Vec::with_capacity(4);
16817 let mut indeterminate = false;
16818 if out.contains("TpmPresent: False") {
16819 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16820 }
16821 if out.contains("TpmReady: False") {
16822 findings.push(
16823 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16824 );
16825 }
16826 if out.contains("SpecVersion: 1.2") {
16827 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16828 }
16829 if out.contains("Secure Boot: DISABLED") {
16830 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16831 }
16832 if out.contains("Firmware type: BIOS (Legacy)") {
16833 findings.push(
16834 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16835 );
16836 }
16837
16838 if out.contains("TPM module unavailable")
16839 || out.contains("Win32_Tpm WMI class unavailable")
16840 || out.contains("Secure Boot: N/A")
16841 || out.contains("Secure Boot: Unknown")
16842 || out.contains("Firmware type: Unknown or not set")
16843 || out.contains("TpmPresent: Unknown")
16844 || out.contains("TpmReady: Unknown")
16845 || out.contains("TpmEnabled: Unknown")
16846 {
16847 indeterminate = true;
16848 }
16849 if indeterminate {
16850 findings.push(
16851 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16852 .into(),
16853 );
16854 }
16855
16856 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16857 if findings.is_empty() {
16858 result.push_str("- TPM and Secure Boot appear healthy.\n");
16859 } else {
16860 for f in &findings {
16861 let _ = writeln!(result, "- Finding: {f}");
16862 }
16863 }
16864 result.push('\n');
16865 result.push_str(&out);
16866 Ok(result)
16867}
16868
16869#[cfg(not(windows))]
16870fn inspect_tpm() -> Result<String, String> {
16871 Ok(
16872 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16873 .into(),
16874 )
16875}
16876
16877#[cfg(windows)]
16878fn inspect_latency() -> Result<String, String> {
16879 let mut out = String::with_capacity(1024);
16880
16881 let ps_gw = r#"
16883$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16884 Sort-Object RouteMetric | Select-Object -First 1).NextHop
16885if ($gw) { $gw } else { "" }
16886"#;
16887 let gateway = run_powershell(ps_gw)
16888 .ok()
16889 .map(|s| s.trim().to_string())
16890 .filter(|s| !s.is_empty());
16891
16892 let targets: Vec<(&str, String)> = {
16893 let mut t = Vec::with_capacity(3);
16894 if let Some(ref gw) = gateway {
16895 t.push(("Default gateway", gw.clone()));
16896 }
16897 t.push(("Cloudflare DNS", "1.1.1.1".into()));
16898 t.push(("Google DNS", "8.8.8.8".into()));
16899 t
16900 };
16901
16902 let mut findings: Vec<String> = Vec::with_capacity(4);
16903
16904 for (label, host) in &targets {
16905 let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16906 let ps_ping = format!(
16908 r#"
16909$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16910if ($r) {{
16911 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16912 $min = ($rtts | Measure-Object -Minimum).Minimum
16913 $max = ($rtts | Measure-Object -Maximum).Maximum
16914 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16915 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16916 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16917 "Packet loss: ${{loss}}%"
16918 "Sent: 4 Received: $($r.Count)"
16919}} else {{
16920 "UNREACHABLE — 100% packet loss"
16921}}
16922"#
16923 );
16924 match run_powershell(&ps_ping) {
16925 Ok(o) => {
16926 let body = o.trim().to_string();
16927 for line in body.lines() {
16928 let l = line.trim();
16929 if !l.is_empty() {
16930 let _ = writeln!(out, "- {l}");
16931 }
16932 }
16933 if body.contains("UNREACHABLE") {
16934 findings.push(format!(
16935 "{label} ({host}) is unreachable — possible routing or firewall issue."
16936 ));
16937 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16938 let pct: u32 = loss_line
16939 .chars()
16940 .filter(|c| c.is_ascii_digit())
16941 .collect::<String>()
16942 .parse()
16943 .unwrap_or(0);
16944 if pct >= 25 {
16945 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16946 }
16947 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16949 if let Some(avg_field) = rtt_line.split('/').nth(1) {
16951 let avg_str: String =
16952 avg_field.chars().filter(|c| c.is_ascii_digit()).collect();
16953 let avg: u32 = avg_str.parse().unwrap_or(0);
16954 if avg > 150 {
16955 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16956 }
16957 }
16958 }
16959 }
16960 }
16961 Err(e) => {
16962 let _ = writeln!(out, "- Ping error: {e}");
16963 }
16964 }
16965 }
16966
16967 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16968 if findings.is_empty() {
16969 result.push_str("- Latency and reachability look normal.\n");
16970 } else {
16971 for f in &findings {
16972 let _ = writeln!(result, "- Finding: {f}");
16973 }
16974 }
16975 result.push('\n');
16976 result.push_str(&out);
16977 Ok(result)
16978}
16979
16980#[cfg(not(windows))]
16981fn inspect_latency() -> Result<String, String> {
16982 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16983 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16984 let mut findings: Vec<String> = Vec::with_capacity(4);
16985
16986 for (label, host) in &targets {
16987 let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16988 let ping = std::process::Command::new("ping")
16989 .args(["-c", "4", "-W", "2", host])
16990 .output();
16991 match ping {
16992 Ok(o) => {
16993 let body = String::from_utf8_lossy(&o.stdout).into_owned();
16994 for line in body.lines() {
16995 let l = line.trim();
16996 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16997 let _ = write!(out, "- {l}\n");
16998 }
16999 }
17000 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
17001 findings.push(format!("{label} ({host}) is unreachable."));
17002 }
17003 }
17004 Err(e) => {
17005 let _ = write!(out, "- ping error: {e}\n");
17006 }
17007 }
17008 }
17009
17010 if findings.is_empty() {
17011 out.insert_str(
17012 "Host inspection: latency\n\n=== Findings ===\n".len(),
17013 "- Latency and reachability look normal.\n",
17014 );
17015 } else {
17016 let mut prefix = String::new();
17017 for f in &findings {
17018 let _ = write!(prefix, "- Finding: {f}\n");
17019 }
17020 out.insert_str(
17021 "Host inspection: latency\n\n=== Findings ===\n".len(),
17022 &prefix,
17023 );
17024 }
17025 Ok(out)
17026}
17027
17028#[cfg(windows)]
17029fn inspect_network_adapter() -> Result<String, String> {
17030 let mut out = String::with_capacity(1024);
17031
17032 out.push_str("=== Network adapters ===\n");
17033 let ps_adapters = r#"
17034Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
17035 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
17036 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
17037}
17038"#;
17039 match run_powershell(ps_adapters) {
17040 Ok(o) => {
17041 for line in o.lines() {
17042 let l = line.trim();
17043 if !l.is_empty() {
17044 let _ = writeln!(out, "- {l}");
17045 }
17046 }
17047 }
17048 Err(e) => {
17049 let _ = writeln!(out, "- Adapter query error: {e}");
17050 }
17051 }
17052
17053 out.push_str("\n=== Duplex and negotiated speed ===\n");
17054 let ps_duplex = r#"
17055Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
17056 $name = $_.Name
17057 $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
17058 Where-Object { $_.DisplayName -match "Duplex|Speed" } |
17059 Select-Object DisplayName, DisplayValue
17060 if ($duplex) {
17061 "--- $name ---"
17062 $duplex | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
17063 } else {
17064 "--- $name --- (no duplex/speed property exposed by driver)"
17065 }
17066}
17067"#;
17068 match run_powershell(ps_duplex) {
17069 Ok(o) => {
17070 let lines: Vec<&str> = o
17071 .lines()
17072 .map(|l| l.trim())
17073 .filter(|l| !l.is_empty())
17074 .collect();
17075 for l in &lines {
17076 let _ = writeln!(out, "- {l}");
17077 }
17078 }
17079 Err(e) => {
17080 let _ = writeln!(out, "- Duplex query error: {e}");
17081 }
17082 }
17083
17084 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
17085 let ps_offload = r#"
17086Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
17087 $name = $_.Name
17088 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
17089 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
17090 Select-Object DisplayName, DisplayValue
17091 if ($props) {
17092 "--- $name ---"
17093 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
17094 }
17095}
17096"#;
17097 match run_powershell(ps_offload) {
17098 Ok(o) => {
17099 let lines: Vec<&str> = o
17100 .lines()
17101 .map(|l| l.trim())
17102 .filter(|l| !l.is_empty())
17103 .collect();
17104 if lines.is_empty() {
17105 out.push_str(
17106 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
17107 );
17108 } else {
17109 for l in &lines {
17110 let _ = writeln!(out, "- {l}");
17111 }
17112 }
17113 }
17114 Err(e) => {
17115 let _ = writeln!(out, "- Offload query error: {e}");
17116 }
17117 }
17118
17119 out.push_str("\n=== Adapter error counters ===\n");
17120 let ps_errors = r#"
17121Get-NetAdapterStatistics | ForEach-Object {
17122 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
17123 if ($errs -gt 0) {
17124 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
17125 }
17126}
17127"#;
17128 match run_powershell(ps_errors) {
17129 Ok(o) => {
17130 let lines: Vec<&str> = o
17131 .lines()
17132 .map(|l| l.trim())
17133 .filter(|l| !l.is_empty())
17134 .collect();
17135 if lines.is_empty() {
17136 out.push_str("- No adapter errors or discards detected.\n");
17137 } else {
17138 for l in &lines {
17139 let _ = writeln!(out, "- {l}");
17140 }
17141 }
17142 }
17143 Err(e) => {
17144 let _ = writeln!(out, "- Error counter query: {e}");
17145 }
17146 }
17147
17148 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
17149 let ps_wol = r#"
17150Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
17151 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
17152 if ($wol) {
17153 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
17154 }
17155}
17156"#;
17157 match run_powershell(ps_wol) {
17158 Ok(o) => {
17159 let lines: Vec<&str> = o
17160 .lines()
17161 .map(|l| l.trim())
17162 .filter(|l| !l.is_empty())
17163 .collect();
17164 if lines.is_empty() {
17165 out.push_str("- Power management data unavailable for active adapters.\n");
17166 } else {
17167 for l in &lines {
17168 let _ = writeln!(out, "- {l}");
17169 }
17170 }
17171 }
17172 Err(e) => {
17173 let _ = writeln!(out, "- WoL query error: {e}");
17174 }
17175 }
17176
17177 let mut findings: Vec<String> = Vec::with_capacity(4);
17178 if out.contains("RX errors:") || out.contains("TX errors:") {
17180 findings
17181 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
17182 }
17183 if out.contains("Half") {
17185 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
17186 }
17187
17188 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
17189 if findings.is_empty() {
17190 result.push_str("- Network adapter configuration looks normal.\n");
17191 } else {
17192 for f in &findings {
17193 let _ = writeln!(result, "- Finding: {f}");
17194 }
17195 }
17196 result.push('\n');
17197 result.push_str(&out);
17198 Ok(result)
17199}
17200
17201#[cfg(not(windows))]
17202fn inspect_network_adapter() -> Result<String, String> {
17203 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
17204
17205 out.push_str("=== Network adapters (ip link) ===\n");
17206 let ip_link = std::process::Command::new("ip")
17207 .args(["link", "show"])
17208 .output();
17209 if let Ok(o) = ip_link {
17210 for line in String::from_utf8_lossy(&o.stdout).lines() {
17211 let l = line.trim();
17212 if !l.is_empty() {
17213 let _ = write!(out, "- {l}\n");
17214 }
17215 }
17216 }
17217
17218 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
17219 let ip_stats = std::process::Command::new("ip")
17220 .args(["-s", "link", "show"])
17221 .output();
17222 if let Ok(o) = ip_stats {
17223 for line in String::from_utf8_lossy(&o.stdout).lines() {
17224 let l = line.trim();
17225 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
17226 {
17227 let _ = write!(out, "- {l}\n");
17228 }
17229 }
17230 }
17231 Ok(out)
17232}
17233
17234#[cfg(windows)]
17235fn inspect_dhcp() -> Result<String, String> {
17236 let mut out = String::with_capacity(1024);
17237
17238 out.push_str("=== DHCP lease details (per adapter) ===\n");
17239 let ps_dhcp = r#"
17240$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17241 Where-Object { $_.IPEnabled -eq $true }
17242foreach ($a in $adapters) {
17243 "--- $($a.Description) ---"
17244 " DHCP Enabled: $($a.DHCPEnabled)"
17245 if ($a.DHCPEnabled) {
17246 " DHCP Server: $($a.DHCPServer)"
17247 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
17248 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
17249 " Lease Obtained: $obtained"
17250 " Lease Expires: $expires"
17251 }
17252 " IP Address: $($a.IPAddress -join ', ')"
17253 " Subnet Mask: $($a.IPSubnet -join ', ')"
17254 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
17255 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
17256 " MAC Address: $($a.MACAddress)"
17257 ""
17258}
17259"#;
17260 match run_powershell(ps_dhcp) {
17261 Ok(o) => {
17262 for line in o.lines() {
17263 let l = line.trim_end();
17264 if !l.is_empty() {
17265 let _ = writeln!(out, "{l}");
17266 }
17267 }
17268 }
17269 Err(e) => {
17270 let _ = writeln!(out, "- DHCP query error: {e}");
17271 }
17272 }
17273
17274 let mut findings: Vec<String> = Vec::with_capacity(4);
17276 let ps_expiry = r#"
17277$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
17278foreach ($a in $adapters) {
17279 try {
17280 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
17281 $now = Get-Date
17282 $hrs = ($exp - $now).TotalHours
17283 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
17284 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
17285 } catch {}
17286}
17287"#;
17288 if let Ok(o) = run_powershell(ps_expiry) {
17289 for line in o.lines() {
17290 let l = line.trim();
17291 if !l.is_empty() {
17292 if l.contains("EXPIRED") {
17293 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
17294 } else if l.contains("expires in") {
17295 findings.push(format!("DHCP lease expiring soon — {l}"));
17296 }
17297 }
17298 }
17299 }
17300
17301 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
17302 if findings.is_empty() {
17303 result.push_str("- DHCP leases look healthy.\n");
17304 } else {
17305 for f in &findings {
17306 let _ = writeln!(result, "- Finding: {f}");
17307 }
17308 }
17309 result.push('\n');
17310 result.push_str(&out);
17311 Ok(result)
17312}
17313
17314#[cfg(not(windows))]
17315fn inspect_dhcp() -> Result<String, String> {
17316 let mut out = String::from(
17317 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
17318 );
17319 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
17320 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
17321 if std::path::Path::new(path).exists() {
17322 let cat = std::process::Command::new("cat").arg(path).output();
17323 if let Ok(o) = cat {
17324 let text = String::from_utf8_lossy(&o.stdout);
17325 for line in text.lines().take(40) {
17326 let l = line.trim();
17327 if l.contains("lease")
17328 || l.contains("expire")
17329 || l.contains("server")
17330 || l.contains("address")
17331 {
17332 let _ = write!(out, "- {l}\n");
17333 }
17334 }
17335 }
17336 }
17337 }
17338 let ip = std::process::Command::new("ip")
17340 .args(["addr", "show"])
17341 .output();
17342 if let Ok(o) = ip {
17343 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
17344 for line in String::from_utf8_lossy(&o.stdout).lines() {
17345 let l = line.trim();
17346 if l.starts_with("inet") || l.contains("dynamic") {
17347 let _ = write!(out, "- {l}\n");
17348 }
17349 }
17350 }
17351 Ok(out)
17352}
17353
17354#[cfg(windows)]
17355fn inspect_mtu() -> Result<String, String> {
17356 let mut out = String::with_capacity(1024);
17357
17358 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
17359 let ps_mtu = r#"
17360Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
17361 Sort-Object ConnectionState, InterfaceAlias |
17362 ForEach-Object {
17363 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
17364 }
17365"#;
17366 match run_powershell(ps_mtu) {
17367 Ok(o) => {
17368 for line in o.lines() {
17369 let l = line.trim();
17370 if !l.is_empty() {
17371 let _ = writeln!(out, "- {l}");
17372 }
17373 }
17374 }
17375 Err(e) => {
17376 let _ = writeln!(out, "- MTU query error: {e}");
17377 }
17378 }
17379
17380 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
17381 let ps_mtu6 = r#"
17382Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
17383 Sort-Object ConnectionState, InterfaceAlias |
17384 ForEach-Object {
17385 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
17386 }
17387"#;
17388 match run_powershell(ps_mtu6) {
17389 Ok(o) => {
17390 for line in o.lines() {
17391 let l = line.trim();
17392 if !l.is_empty() {
17393 let _ = writeln!(out, "- {l}");
17394 }
17395 }
17396 }
17397 Err(e) => {
17398 let _ = writeln!(out, "- IPv6 MTU query error: {e}");
17399 }
17400 }
17401
17402 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
17403 let ps_pmtu = r#"
17405$sizes = @(1472, 1400, 1280, 576)
17406$result = $null
17407foreach ($s in $sizes) {
17408 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
17409 if ($r) { $result = $s; break }
17410}
17411if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
17412else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
17413"#;
17414 match run_powershell(ps_pmtu) {
17415 Ok(o) => {
17416 for line in o.lines() {
17417 let l = line.trim();
17418 if !l.is_empty() {
17419 let _ = writeln!(out, "- {l}");
17420 }
17421 }
17422 }
17423 Err(e) => {
17424 let _ = writeln!(out, "- Path MTU test error: {e}");
17425 }
17426 }
17427
17428 let mut findings: Vec<String> = Vec::with_capacity(4);
17429 if out.contains("MTU: 576 bytes") {
17430 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
17431 }
17432 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
17433 findings.push(
17434 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
17435 .into(),
17436 );
17437 }
17438 if out.contains("All test sizes failed") {
17439 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
17440 }
17441
17442 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
17443 if findings.is_empty() {
17444 result.push_str("- MTU configuration looks normal.\n");
17445 } else {
17446 for f in &findings {
17447 let _ = writeln!(result, "- Finding: {f}");
17448 }
17449 }
17450 result.push('\n');
17451 result.push_str(&out);
17452 Ok(result)
17453}
17454
17455#[cfg(not(windows))]
17456fn inspect_mtu() -> Result<String, String> {
17457 let mut out = String::from(
17458 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
17459 );
17460
17461 out.push_str("=== Per-interface MTU (ip link) ===\n");
17462 let ip = std::process::Command::new("ip")
17463 .args(["link", "show"])
17464 .output();
17465 if let Ok(o) = ip {
17466 for line in String::from_utf8_lossy(&o.stdout).lines() {
17467 let l = line.trim();
17468 if l.contains("mtu") || l.starts_with("\\d") {
17469 let _ = write!(out, "- {l}\n");
17470 }
17471 }
17472 }
17473
17474 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
17475 let ping = std::process::Command::new("ping")
17476 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
17477 .output();
17478 match ping {
17479 Ok(o) => {
17480 let body = String::from_utf8_lossy(&o.stdout);
17481 for line in body.lines() {
17482 let l = line.trim();
17483 if !l.is_empty() {
17484 let _ = write!(out, "- {l}\n");
17485 }
17486 }
17487 }
17488 Err(e) => {
17489 let _ = write!(out, "- Ping error: {e}\n");
17490 }
17491 }
17492 Ok(out)
17493}
17494
17495#[cfg(not(windows))]
17496fn inspect_cpu_power() -> Result<String, String> {
17497 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
17498
17499 out.push_str("=== CPU frequency (Linux) ===\n");
17501 let cat_scaling = std::process::Command::new("cat")
17502 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
17503 .output();
17504 if let Ok(o) = cat_scaling {
17505 let khz: u64 = String::from_utf8_lossy(&o.stdout)
17506 .trim()
17507 .parse()
17508 .unwrap_or(0);
17509 if khz > 0 {
17510 let _ = write!(out, "- Current: {} MHz\n", khz / 1000);
17511 }
17512 }
17513 let cat_max = std::process::Command::new("cat")
17514 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
17515 .output();
17516 if let Ok(o) = cat_max {
17517 let khz: u64 = String::from_utf8_lossy(&o.stdout)
17518 .trim()
17519 .parse()
17520 .unwrap_or(0);
17521 if khz > 0 {
17522 let _ = write!(out, "- Max: {} MHz\n", khz / 1000);
17523 }
17524 }
17525 let governor = std::process::Command::new("cat")
17526 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
17527 .output();
17528 if let Ok(o) = governor {
17529 let g = String::from_utf8_lossy(&o.stdout);
17530 let g = g.trim();
17531 if !g.is_empty() {
17532 let _ = write!(out, "- Governor: {g}\n");
17533 }
17534 }
17535 Ok(out)
17536}
17537
17538#[cfg(windows)]
17541fn inspect_ipv6() -> Result<String, String> {
17542 let script = r#"
17543$result = [System.Text.StringBuilder]::new()
17544
17545# Per-adapter IPv6 addresses
17546$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
17547$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17548 Where-Object { $_.IPAddress -notmatch '^::1$' } |
17549 Sort-Object InterfaceAlias
17550foreach ($a in $adapters) {
17551 $prefix = $a.PrefixOrigin
17552 $suffix = $a.SuffixOrigin
17553 $scope = $a.AddressState
17554 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
17555}
17556if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
17557
17558# Default gateway IPv6
17559$result.AppendLine("") | Out-Null
17560$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
17561$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
17562if ($gw6) {
17563 foreach ($g in $gw6) {
17564 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
17565 }
17566} else {
17567 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
17568}
17569
17570# DHCPv6 lease info
17571$result.AppendLine("") | Out-Null
17572$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
17573$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17574 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
17575if ($dhcpv6) {
17576 foreach ($d in $dhcpv6) {
17577 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
17578 }
17579} else {
17580 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
17581}
17582
17583# Privacy extensions
17584$result.AppendLine("") | Out-Null
17585$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
17586try {
17587 $priv = netsh interface ipv6 show privacy
17588 $result.AppendLine(($priv -join "`n")) | Out-Null
17589} catch {
17590 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
17591}
17592
17593# Tunnel adapters
17594$result.AppendLine("") | Out-Null
17595$result.AppendLine("=== Tunnel adapters ===") | Out-Null
17596$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17597if ($tunnels) {
17598 foreach ($t in $tunnels) {
17599 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
17600 }
17601} else {
17602 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17603}
17604
17605# Findings
17606$findings = [System.Collections.Generic.List[string]]::new()
17607$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17608 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17609if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17610$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17611if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17612
17613$result.AppendLine("") | Out-Null
17614$result.AppendLine("=== Findings ===") | Out-Null
17615if ($findings.Count -eq 0) {
17616 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17617} else {
17618 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17619}
17620
17621Write-Output $result.ToString()
17622"#;
17623 let out = run_powershell(script)?;
17624 Ok(format!("Host inspection: ipv6\n\n{out}"))
17625}
17626
17627#[cfg(not(windows))]
17628fn inspect_ipv6() -> Result<String, String> {
17629 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17630 if let Ok(o) = std::process::Command::new("ip")
17631 .args(["-6", "addr", "show"])
17632 .output()
17633 {
17634 out.push_str(&String::from_utf8_lossy(&o.stdout));
17635 }
17636 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17637 if let Ok(o) = std::process::Command::new("ip")
17638 .args(["-6", "route"])
17639 .output()
17640 {
17641 out.push_str(&String::from_utf8_lossy(&o.stdout));
17642 }
17643 Ok(out)
17644}
17645
17646#[cfg(windows)]
17649fn inspect_tcp_params() -> Result<String, String> {
17650 let script = r#"
17651$result = [System.Text.StringBuilder]::new()
17652
17653# Autotuning and global TCP settings
17654$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17655try {
17656 $global = netsh interface tcp show global
17657 foreach ($line in $global) {
17658 $l = $line.Trim()
17659 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17660 $result.AppendLine(" $l") | Out-Null
17661 }
17662 }
17663} catch {
17664 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
17665}
17666
17667# Supplemental params via Get-NetTCPSetting
17668$result.AppendLine("") | Out-Null
17669$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17670try {
17671 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17672 foreach ($s in $tcpSettings) {
17673 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
17674 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
17675 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17676 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
17677 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
17678 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
17679 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17680 $result.AppendLine("") | Out-Null
17681 }
17682} catch {
17683 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
17684}
17685
17686# Chimney offload state
17687$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17688try {
17689 $chimney = netsh interface tcp show chimney
17690 $result.AppendLine(($chimney -join "`n ")) | Out-Null
17691} catch {
17692 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
17693}
17694
17695# ECN state
17696$result.AppendLine("") | Out-Null
17697$result.AppendLine("=== ECN capability ===") | Out-Null
17698try {
17699 $ecn = netsh interface tcp show ecncapability
17700 $result.AppendLine(($ecn -join "`n ")) | Out-Null
17701} catch {
17702 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
17703}
17704
17705# Findings
17706$findings = [System.Collections.Generic.List[string]]::new()
17707try {
17708 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17709 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17710 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17711 }
17712 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17713 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17714 }
17715} catch {}
17716
17717$result.AppendLine("") | Out-Null
17718$result.AppendLine("=== Findings ===") | Out-Null
17719if ($findings.Count -eq 0) {
17720 $result.AppendLine("- TCP parameters look normal.") | Out-Null
17721} else {
17722 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17723}
17724
17725Write-Output $result.ToString()
17726"#;
17727 let out = run_powershell(script)?;
17728 Ok(format!("Host inspection: tcp_params\n\n{out}"))
17729}
17730
17731#[cfg(not(windows))]
17732fn inspect_tcp_params() -> Result<String, String> {
17733 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17734 for key in &[
17735 "net.ipv4.tcp_congestion_control",
17736 "net.ipv4.tcp_rmem",
17737 "net.ipv4.tcp_wmem",
17738 "net.ipv4.tcp_window_scaling",
17739 "net.ipv4.tcp_ecn",
17740 "net.ipv4.tcp_timestamps",
17741 ] {
17742 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17743 let _ = write!(out, " {}\n", String::from_utf8_lossy(&o.stdout).trim());
17744 }
17745 }
17746 Ok(out)
17747}
17748
17749#[cfg(windows)]
17752fn inspect_wlan_profiles() -> Result<String, String> {
17753 let script = r#"
17754$result = [System.Text.StringBuilder]::new()
17755
17756# List all saved profiles
17757$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17758try {
17759 $profilesRaw = netsh wlan show profiles
17760 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17761 $_.Matches[0].Groups[1].Value.Trim()
17762 }
17763
17764 if (-not $profiles) {
17765 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
17766 } else {
17767 foreach ($p in $profiles) {
17768 $result.AppendLine("") | Out-Null
17769 $result.AppendLine(" Profile: $p") | Out-Null
17770 # Get detail for each profile
17771 $detail = netsh wlan show profile name="$p" key=clear 2>$null
17772 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17773 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17774 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17775 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17776 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17777 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17778 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17779 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17780 }
17781 }
17782} catch {
17783 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17784}
17785
17786# Currently connected SSID
17787$result.AppendLine("") | Out-Null
17788$result.AppendLine("=== Currently connected ===") | Out-Null
17789try {
17790 $conn = netsh wlan show interfaces
17791 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17792 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17793 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17794 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17795 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17796 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17797 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17798 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17799 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
17800} catch {
17801 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
17802}
17803
17804# Findings
17805$findings = [System.Collections.Generic.List[string]]::new()
17806try {
17807 $allDetail = netsh wlan show profiles 2>$null
17808 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17809 $_.Matches[0].Groups[1].Value.Trim()
17810 }
17811 foreach ($pn in $profileNames) {
17812 $det = netsh wlan show profile name="$pn" key=clear 2>$null
17813 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17814 if ($authLine) {
17815 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17816 if ($authVal -match 'Open|WEP|None') {
17817 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17818 }
17819 }
17820 }
17821} catch {}
17822
17823$result.AppendLine("") | Out-Null
17824$result.AppendLine("=== Findings ===") | Out-Null
17825if ($findings.Count -eq 0) {
17826 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17827} else {
17828 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17829}
17830
17831Write-Output $result.ToString()
17832"#;
17833 let out = run_powershell(script)?;
17834 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17835}
17836
17837#[cfg(not(windows))]
17838fn inspect_wlan_profiles() -> Result<String, String> {
17839 let mut out =
17840 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17841 if let Ok(o) = std::process::Command::new("nmcli")
17843 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17844 .output()
17845 {
17846 for line in String::from_utf8_lossy(&o.stdout).lines() {
17847 if line.contains("wireless") || line.contains("wifi") {
17848 let _ = write!(out, " {line}\n");
17849 }
17850 }
17851 } else {
17852 out.push_str(" nmcli not available.\n");
17853 }
17854 Ok(out)
17855}
17856
17857#[cfg(windows)]
17860fn inspect_ipsec() -> Result<String, String> {
17861 let script = r#"
17862$result = [System.Text.StringBuilder]::new()
17863
17864# IPSec rules (firewall-integrated)
17865$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17866try {
17867 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17868 if ($rules) {
17869 foreach ($r in $rules) {
17870 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
17871 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
17872 $result.AppendLine(" Action: $($r.Action)") | Out-Null
17873 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
17874 }
17875 } else {
17876 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
17877 }
17878} catch {
17879 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
17880}
17881
17882# Active main-mode SAs
17883$result.AppendLine("") | Out-Null
17884$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17885try {
17886 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17887 if ($mmSAs) {
17888 foreach ($sa in $mmSAs) {
17889 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17890 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
17891 }
17892 } else {
17893 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
17894 }
17895} catch {
17896 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
17897}
17898
17899# Active quick-mode SAs
17900$result.AppendLine("") | Out-Null
17901$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17902try {
17903 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17904 if ($qmSAs) {
17905 foreach ($sa in $qmSAs) {
17906 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17907 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17908 }
17909 } else {
17910 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
17911 }
17912} catch {
17913 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17914}
17915
17916# IKE service state
17917$result.AppendLine("") | Out-Null
17918$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17919$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17920if ($ikeAgentSvc) {
17921 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17922} else {
17923 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
17924}
17925
17926# Findings
17927$findings = [System.Collections.Generic.List[string]]::new()
17928$mmSACount = 0
17929try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17930if ($mmSACount -gt 0) {
17931 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17932}
17933
17934$result.AppendLine("") | Out-Null
17935$result.AppendLine("=== Findings ===") | Out-Null
17936if ($findings.Count -eq 0) {
17937 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17938} else {
17939 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17940}
17941
17942Write-Output $result.ToString()
17943"#;
17944 let out = run_powershell(script)?;
17945 Ok(format!("Host inspection: ipsec\n\n{out}"))
17946}
17947
17948#[cfg(not(windows))]
17949fn inspect_ipsec() -> Result<String, String> {
17950 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17951 if let Ok(o) = std::process::Command::new("ip")
17952 .args(["xfrm", "state"])
17953 .output()
17954 {
17955 let body = String::from_utf8_lossy(&o.stdout);
17956 if body.trim().is_empty() {
17957 out.push_str(" No active IPSec SAs.\n");
17958 } else {
17959 out.push_str(&body);
17960 }
17961 }
17962 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17963 if let Ok(o) = std::process::Command::new("ip")
17964 .args(["xfrm", "policy"])
17965 .output()
17966 {
17967 let body = String::from_utf8_lossy(&o.stdout);
17968 if body.trim().is_empty() {
17969 out.push_str(" No IPSec policies.\n");
17970 } else {
17971 out.push_str(&body);
17972 }
17973 }
17974 Ok(out)
17975}
17976
17977#[cfg(windows)]
17980fn inspect_netbios() -> Result<String, String> {
17981 let script = r#"
17982$result = [System.Text.StringBuilder]::new()
17983
17984# NetBIOS node type and WINS per adapter
17985$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17986try {
17987 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17988 Where-Object { $_.IPEnabled -eq $true }
17989 foreach ($a in $adapters) {
17990 $nodeType = switch ($a.TcpipNetbiosOptions) {
17991 0 { "EnableNetBIOSViaDHCP" }
17992 1 { "Enabled" }
17993 2 { "Disabled" }
17994 default { "Unknown ($($a.TcpipNetbiosOptions))" }
17995 }
17996 $result.AppendLine(" [$($a.Description)]") | Out-Null
17997 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
17998 if ($a.WINSPrimaryServer) {
17999 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
18000 }
18001 if ($a.WINSSecondaryServer) {
18002 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
18003 }
18004 }
18005} catch {
18006 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
18007}
18008
18009# nbtstat -n — registered local NetBIOS names
18010$result.AppendLine("") | Out-Null
18011$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
18012try {
18013 $nbt = nbtstat -n 2>$null
18014 foreach ($line in $nbt) {
18015 $l = $line.Trim()
18016 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
18017 $result.AppendLine(" $l") | Out-Null
18018 }
18019 }
18020} catch {
18021 $result.AppendLine(" nbtstat not available.") | Out-Null
18022}
18023
18024# NetBIOS session table
18025$result.AppendLine("") | Out-Null
18026$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
18027try {
18028 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
18029 if ($sessions) {
18030 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
18031 } else {
18032 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
18033 }
18034} catch {
18035 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
18036}
18037
18038# Findings
18039$findings = [System.Collections.Generic.List[string]]::new()
18040try {
18041 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
18042 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
18043 if ($enabled) {
18044 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
18045 }
18046 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
18047 Where-Object { $_.WINSPrimaryServer }
18048 if ($wins) {
18049 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
18050 }
18051} catch {}
18052
18053$result.AppendLine("") | Out-Null
18054$result.AppendLine("=== Findings ===") | Out-Null
18055if ($findings.Count -eq 0) {
18056 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
18057} else {
18058 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18059}
18060
18061Write-Output $result.ToString()
18062"#;
18063 let out = run_powershell(script)?;
18064 Ok(format!("Host inspection: netbios\n\n{out}"))
18065}
18066
18067#[cfg(not(windows))]
18068fn inspect_netbios() -> Result<String, String> {
18069 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
18070 if let Ok(o) = std::process::Command::new("nmblookup")
18071 .arg("-A")
18072 .arg("localhost")
18073 .output()
18074 {
18075 out.push_str(&String::from_utf8_lossy(&o.stdout));
18076 } else {
18077 out.push_str(" nmblookup not available (Samba not installed).\n");
18078 }
18079 Ok(out)
18080}
18081
18082#[cfg(windows)]
18085fn inspect_nic_teaming() -> Result<String, String> {
18086 let script = r#"
18087$result = [System.Text.StringBuilder]::new()
18088
18089# Team inventory
18090$result.AppendLine("=== NIC teams ===") | Out-Null
18091try {
18092 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
18093 if ($teams) {
18094 foreach ($t in $teams) {
18095 $result.AppendLine(" Team: $($t.Name)") | Out-Null
18096 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
18097 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
18098 $result.AppendLine(" Status: $($t.Status)") | Out-Null
18099 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
18100 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
18101 }
18102 } else {
18103 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
18104 }
18105} catch {
18106 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
18107}
18108
18109# Team members detail
18110$result.AppendLine("") | Out-Null
18111$result.AppendLine("=== Team member detail ===") | Out-Null
18112try {
18113 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
18114 if ($members) {
18115 foreach ($m in $members) {
18116 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
18117 }
18118 } else {
18119 $result.AppendLine(" No team members found.") | Out-Null
18120 }
18121} catch {
18122 $result.AppendLine(" Could not query team members.") | Out-Null
18123}
18124
18125# Findings
18126$findings = [System.Collections.Generic.List[string]]::new()
18127try {
18128 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
18129 if ($degraded) {
18130 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
18131 }
18132 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
18133 if ($downMembers) {
18134 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
18135 }
18136} catch {}
18137
18138$result.AppendLine("") | Out-Null
18139$result.AppendLine("=== Findings ===") | Out-Null
18140if ($findings.Count -eq 0) {
18141 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
18142} else {
18143 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18144}
18145
18146Write-Output $result.ToString()
18147"#;
18148 let out = run_powershell(script)?;
18149 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
18150}
18151
18152#[cfg(not(windows))]
18153fn inspect_nic_teaming() -> Result<String, String> {
18154 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
18155 if let Ok(o) = std::process::Command::new("cat")
18156 .arg("/proc/net/bonding/bond0")
18157 .output()
18158 {
18159 if o.status.success() {
18160 out.push_str(&String::from_utf8_lossy(&o.stdout));
18161 } else {
18162 out.push_str(" No bond0 interface found.\n");
18163 }
18164 }
18165 if let Ok(o) = std::process::Command::new("ip")
18166 .args(["link", "show", "type", "bond"])
18167 .output()
18168 {
18169 let body = String::from_utf8_lossy(&o.stdout);
18170 if !body.trim().is_empty() {
18171 out.push_str("\n=== Bond links (ip link) ===\n");
18172 out.push_str(&body);
18173 }
18174 }
18175 Ok(out)
18176}
18177
18178#[cfg(windows)]
18181fn inspect_snmp() -> Result<String, String> {
18182 let script = r#"
18183$result = [System.Text.StringBuilder]::new()
18184
18185# SNMP service state
18186$result.AppendLine("=== SNMP service state ===") | Out-Null
18187$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
18188if ($svc) {
18189 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
18190} else {
18191 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
18192}
18193
18194$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
18195if ($svcTrap) {
18196 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
18197}
18198
18199# Community strings (presence only — values redacted)
18200$result.AppendLine("") | Out-Null
18201$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
18202try {
18203 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
18204 if ($communities) {
18205 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
18206 if ($names) {
18207 foreach ($n in $names) {
18208 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
18209 }
18210 } else {
18211 $result.AppendLine(" No community strings configured.") | Out-Null
18212 }
18213 } else {
18214 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
18215 }
18216} catch {
18217 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
18218}
18219
18220# Permitted managers
18221$result.AppendLine("") | Out-Null
18222$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
18223try {
18224 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
18225 if ($managers) {
18226 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
18227 if ($mgrs) {
18228 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
18229 } else {
18230 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
18231 }
18232 } else {
18233 $result.AppendLine(" No manager restrictions configured.") | Out-Null
18234 }
18235} catch {
18236 $result.AppendLine(" Could not read permitted managers.") | Out-Null
18237}
18238
18239# Findings
18240$findings = [System.Collections.Generic.List[string]]::new()
18241$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
18242if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
18243 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
18244 try {
18245 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
18246 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
18247 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
18248 } catch {}
18249}
18250
18251$result.AppendLine("") | Out-Null
18252$result.AppendLine("=== Findings ===") | Out-Null
18253if ($findings.Count -eq 0) {
18254 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
18255} else {
18256 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18257}
18258
18259Write-Output $result.ToString()
18260"#;
18261 let out = run_powershell(script)?;
18262 Ok(format!("Host inspection: snmp\n\n{out}"))
18263}
18264
18265#[cfg(not(windows))]
18266fn inspect_snmp() -> Result<String, String> {
18267 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
18268 for svc in &["snmpd", "snmp"] {
18269 if let Ok(o) = std::process::Command::new("systemctl")
18270 .args(["is-active", svc])
18271 .output()
18272 {
18273 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
18274 let _ = write!(out, " {svc}: {status}\n");
18275 }
18276 }
18277 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
18278 if let Ok(o) = std::process::Command::new("grep")
18279 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
18280 .output()
18281 {
18282 if o.status.success() {
18283 for line in String::from_utf8_lossy(&o.stdout).lines() {
18284 let _ = write!(out, " {line}\n");
18285 }
18286 } else {
18287 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
18288 }
18289 }
18290 Ok(out)
18291}
18292
18293#[cfg(windows)]
18296fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
18297 let target_host = host.unwrap_or("8.8.8.8");
18298 let target_port = port.unwrap_or(443);
18299 let escaped_host = ps_escape_single_quoted(target_host);
18300
18301 let script = format!(
18302 r#"
18303$result = [System.Text.StringBuilder]::new()
18304$result.AppendLine("=== Port reachability test ===") | Out-Null
18305$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
18306$result.AppendLine("") | Out-Null
18307
18308try {{
18309 $test = Test-NetConnection -ComputerName '{escaped_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
18310 if ($test) {{
18311 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
18312 $result.AppendLine(" Result: $status") | Out-Null
18313 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
18314 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
18315 if ($test.PingSucceeded) {{
18316 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
18317 }} else {{
18318 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
18319 }}
18320 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
18321 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
18322
18323 $result.AppendLine("") | Out-Null
18324 $result.AppendLine("=== Findings ===") | Out-Null
18325 if ($test.TcpTestSucceeded) {{
18326 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
18327 }} else {{
18328 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
18329 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
18330 }}
18331 }}
18332}} catch {{
18333 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
18334}}
18335
18336Write-Output $result.ToString()
18337"#
18338 );
18339 let out = run_powershell(&script)?;
18340 Ok(format!("Host inspection: port_test\n\n{out}"))
18341}
18342
18343#[cfg(not(windows))]
18344fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
18345 let target_host = host.unwrap_or("8.8.8.8");
18346 let target_port = port.unwrap_or(443);
18347 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
18348 let nc = std::process::Command::new("nc")
18350 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
18351 .output();
18352 match nc {
18353 Ok(o) => {
18354 let stderr = String::from_utf8_lossy(&o.stderr);
18355 let stdout = String::from_utf8_lossy(&o.stdout);
18356 let body = if !stdout.trim().is_empty() {
18357 stdout.as_ref()
18358 } else {
18359 stderr.as_ref()
18360 };
18361 let _ = write!(out, " {}\n", body.trim());
18362 out.push_str("\n=== Findings ===\n");
18363 if o.status.success() {
18364 let _ = write!(out, "- Port {target_port} on {target_host} is OPEN.\n");
18365 } else {
18366 let _ = write!(
18367 out,
18368 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
18369 );
18370 }
18371 }
18372 Err(e) => {
18373 let _ = write!(out, " nc not available: {e}\n");
18374 }
18375 }
18376 Ok(out)
18377}
18378
18379#[cfg(windows)]
18382fn inspect_network_profile() -> Result<String, String> {
18383 let script = r#"
18384$result = [System.Text.StringBuilder]::new()
18385
18386$result.AppendLine("=== Network location profiles ===") | Out-Null
18387try {
18388 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
18389 if ($profiles) {
18390 foreach ($p in $profiles) {
18391 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
18392 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
18393 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
18394 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
18395 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
18396 $result.AppendLine("") | Out-Null
18397 }
18398 } else {
18399 $result.AppendLine(" No network connection profiles found.") | Out-Null
18400 }
18401} catch {
18402 $result.AppendLine(" Could not query network profiles.") | Out-Null
18403}
18404
18405# Findings
18406$findings = [System.Collections.Generic.List[string]]::new()
18407try {
18408 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
18409 if ($pub) {
18410 foreach ($p in $pub) {
18411 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
18412 }
18413 }
18414 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
18415 if ($domain) {
18416 foreach ($d in $domain) {
18417 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
18418 }
18419 }
18420} catch {}
18421
18422$result.AppendLine("=== Findings ===") | Out-Null
18423if ($findings.Count -eq 0) {
18424 $result.AppendLine("- Network profiles look normal.") | Out-Null
18425} else {
18426 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18427}
18428
18429Write-Output $result.ToString()
18430"#;
18431 let out = run_powershell(script)?;
18432 Ok(format!("Host inspection: network_profile\n\n{out}"))
18433}
18434
18435#[cfg(not(windows))]
18436fn inspect_network_profile() -> Result<String, String> {
18437 let mut out = String::from(
18438 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
18439 );
18440 if let Ok(o) = std::process::Command::new("nmcli")
18441 .args([
18442 "-t",
18443 "-f",
18444 "NAME,TYPE,STATE,DEVICE",
18445 "connection",
18446 "show",
18447 "--active",
18448 ])
18449 .output()
18450 {
18451 out.push_str(&String::from_utf8_lossy(&o.stdout));
18452 } else {
18453 out.push_str(" nmcli not available.\n");
18454 }
18455 Ok(out)
18456}
18457
18458#[cfg(windows)]
18461fn inspect_storage_spaces() -> Result<String, String> {
18462 let script = r#"
18463$result = [System.Text.StringBuilder]::new()
18464
18465# Storage Pools
18466try {
18467 $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
18468 if ($pools) {
18469 $result.AppendLine("=== Storage Pools ===") | Out-Null
18470 foreach ($pool in $pools) {
18471 $health = $pool.HealthStatus
18472 $oper = $pool.OperationalStatus
18473 $sizGB = [math]::Round($pool.Size / 1GB, 1)
18474 $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
18475 $result.AppendLine(" Pool: $($pool.FriendlyName) Size: ${sizGB}GB Allocated: ${allocGB}GB Health: $health Status: $oper") | Out-Null
18476 }
18477 $result.AppendLine("") | Out-Null
18478 } else {
18479 $result.AppendLine("=== Storage Pools ===") | Out-Null
18480 $result.AppendLine(" No Storage Spaces pools configured.") | Out-Null
18481 $result.AppendLine("") | Out-Null
18482 }
18483} catch {
18484 $result.AppendLine("=== Storage Pools ===") | Out-Null
18485 $result.AppendLine(" Unable to query storage pools (may require elevation).") | Out-Null
18486 $result.AppendLine("") | Out-Null
18487}
18488
18489# Virtual Disks
18490try {
18491 $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
18492 if ($vdisks) {
18493 $result.AppendLine("=== Virtual Disks ===") | Out-Null
18494 foreach ($vd in $vdisks) {
18495 $health = $vd.HealthStatus
18496 $oper = $vd.OperationalStatus
18497 $layout = $vd.ResiliencySettingName
18498 $sizGB = [math]::Round($vd.Size / 1GB, 1)
18499 $result.AppendLine(" VDisk: $($vd.FriendlyName) Layout: $layout Size: ${sizGB}GB Health: $health Status: $oper") | Out-Null
18500 }
18501 $result.AppendLine("") | Out-Null
18502 } else {
18503 $result.AppendLine("=== Virtual Disks ===") | Out-Null
18504 $result.AppendLine(" No Storage Spaces virtual disks configured.") | Out-Null
18505 $result.AppendLine("") | Out-Null
18506 }
18507} catch {
18508 $result.AppendLine("=== Virtual Disks ===") | Out-Null
18509 $result.AppendLine(" Unable to query virtual disks.") | Out-Null
18510 $result.AppendLine("") | Out-Null
18511}
18512
18513# Physical Disks in pools
18514try {
18515 $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
18516 if ($pdisks) {
18517 $result.AppendLine("=== Physical Disks ===") | Out-Null
18518 foreach ($pd in $pdisks) {
18519 $sizGB = [math]::Round($pd.Size / 1GB, 1)
18520 $health = $pd.HealthStatus
18521 $usage = $pd.Usage
18522 $media = $pd.MediaType
18523 $result.AppendLine(" $($pd.FriendlyName) ${sizGB}GB $media Usage: $usage Health: $health") | Out-Null
18524 }
18525 $result.AppendLine("") | Out-Null
18526 }
18527} catch {}
18528
18529# Findings
18530$findings = @()
18531try {
18532 $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18533 foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
18534 $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18535 foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
18536 $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
18537 foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
18538} catch {}
18539
18540if ($findings.Count -gt 0) {
18541 $result.AppendLine("=== Findings ===") | Out-Null
18542 foreach ($f in $findings) { $result.AppendLine(" $f") | Out-Null }
18543} else {
18544 $result.AppendLine("=== Findings ===") | Out-Null
18545 $result.AppendLine(" All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
18546}
18547
18548Write-Output $result.ToString().TrimEnd()
18549"#;
18550 let out = run_powershell(script)?;
18551 Ok(format!("Host inspection: storage_spaces\n\n{out}"))
18552}
18553
18554#[cfg(not(windows))]
18555fn inspect_storage_spaces() -> Result<String, String> {
18556 let mut out = String::from("Host inspection: storage_spaces\n\n");
18557 let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
18559 if !mdstat.is_empty() {
18560 out.push_str("=== Software RAID (/proc/mdstat) ===\n");
18561 out.push_str(&mdstat);
18562 } else {
18563 out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
18564 }
18565 if let Ok(o) = Command::new("lvs")
18567 .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
18568 .output()
18569 {
18570 let lvs = String::from_utf8_lossy(&o.stdout).into_owned();
18571 if !lvs.trim().is_empty() {
18572 out.push_str("\n=== LVM Logical Volumes ===\n");
18573 out.push_str(&lvs);
18574 }
18575 }
18576 Ok(out)
18577}
18578
18579#[cfg(windows)]
18582fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
18583 let limit = max_entries.min(50);
18584 let script = format!(
18585 r#"
18586$result = [System.Text.StringBuilder]::new()
18587
18588# Current threat detections (active + quarantined)
18589try {{
18590 $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
18591 if ($threats) {{
18592 $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
18593 foreach ($t in $threats) {{
18594 $name = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
18595 if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
18596 $time = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
18597 $action = $t.ActionSuccess
18598 $status = $t.CurrentThreatExecutionStatusID
18599 $result.AppendLine(" [$time] $name ActionSuccess:$action Status:$status") | Out-Null
18600 }}
18601 $result.AppendLine("") | Out-Null
18602 }} else {{
18603 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18604 $result.AppendLine(" No threat detections on record — Defender history is clean.") | Out-Null
18605 $result.AppendLine("") | Out-Null
18606 }}
18607}} catch {{
18608 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18609 $result.AppendLine(" Unable to query threat detections: $_") | Out-Null
18610 $result.AppendLine("") | Out-Null
18611}}
18612
18613# Quarantine items
18614try {{
18615 $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18616 if ($quarantine) {{
18617 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18618 foreach ($q in $quarantine) {{
18619 $result.AppendLine(" $($q.ThreatName) Severity:$($q.SeverityID) Category:$($q.CategoryID) Active:$($q.IsActive)") | Out-Null
18620 }}
18621 $result.AppendLine("") | Out-Null
18622 }} else {{
18623 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18624 $result.AppendLine(" No quarantined threats found.") | Out-Null
18625 $result.AppendLine("") | Out-Null
18626 }}
18627}} catch {{
18628 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18629 $result.AppendLine(" Unable to query quarantine list: $_") | Out-Null
18630 $result.AppendLine("") | Out-Null
18631}}
18632
18633# Defender scan stats
18634try {{
18635 $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18636 if ($status) {{
18637 $lastScan = $status.QuickScanStartTime
18638 $lastFull = $status.FullScanStartTime
18639 $sigDate = $status.AntivirusSignatureLastUpdated
18640 $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18641 $result.AppendLine(" Last quick scan : $lastScan") | Out-Null
18642 $result.AppendLine(" Last full scan : $lastFull") | Out-Null
18643 $result.AppendLine(" Signature date : $sigDate") | Out-Null
18644 }}
18645}} catch {{}}
18646
18647Write-Output $result.ToString().TrimEnd()
18648"#,
18649 limit = limit
18650 );
18651 let out = run_powershell(&script)?;
18652 Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18653}
18654
18655#[cfg(windows)]
18658fn inspect_domain_health() -> Result<String, String> {
18659 let script = r#"
18660$result = [System.Text.StringBuilder]::new()
18661
18662# Domain membership
18663try {
18664 $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18665 $joined = $cs.PartOfDomain
18666 $domain = $cs.Domain
18667 $result.AppendLine("=== Domain Membership ===") | Out-Null
18668 $result.AppendLine(" Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18669 if ($joined) { $result.AppendLine(" Domain : $domain") | Out-Null }
18670 $result.AppendLine(" Computer : $($cs.Name)") | Out-Null
18671} catch {
18672 $result.AppendLine(" Domain membership check failed: $_") | Out-Null
18673}
18674
18675# dsregcmd device registration state
18676try {
18677 $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18678 if ($dsreg) {
18679 $result.AppendLine("") | Out-Null
18680 $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18681 foreach ($line in $dsreg) { $result.AppendLine(" $($line.Trim())") | Out-Null }
18682 }
18683} catch {}
18684
18685# DC discovery via nltest
18686$result.AppendLine("") | Out-Null
18687$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18688try {
18689 $nl = nltest /dsgetdc:. 2>&1
18690 $dc_name = $null
18691 foreach ($line in $nl) {
18692 if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18693 $result.AppendLine(" $($line.Trim())") | Out-Null
18694 }
18695 if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18696 }
18697 if ($dc_name) {
18698 $result.AppendLine("") | Out-Null
18699 $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18700 foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18701 try {
18702 $tcp = New-Object System.Net.Sockets.TcpClient
18703 $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18704 $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18705 $tcp.Close()
18706 $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18707 } catch { $status = 'FAILED' }
18708 $result.AppendLine(" Port $($entry.p) ($($entry.n)): $status") | Out-Null
18709 }
18710 }
18711} catch {
18712 $result.AppendLine(" nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18713}
18714
18715# Last GPO machine refresh time
18716try {
18717 $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18718 if (Test-Path $gpoKey) {
18719 $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18720 $result.AppendLine("") | Out-Null
18721 $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18722 $result.AppendLine(" Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18723 }
18724} catch {}
18725
18726Write-Output $result.ToString().TrimEnd()
18727"#;
18728 let out = run_powershell(script)?;
18729 Ok(format!("Host inspection: domain_health\n\n{out}"))
18730}
18731
18732#[cfg(not(windows))]
18733fn inspect_domain_health() -> Result<String, String> {
18734 let mut out = String::from("Host inspection: domain_health\n\n");
18735 for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18736 if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18737 let s = String::from_utf8_lossy(&o.stdout);
18738 if !s.trim().is_empty() {
18739 let _ = write!(out, "$ {}\n{}\n", cmd_args.join(" "), s.trim_end());
18740 }
18741 }
18742 }
18743 if out.trim_end().ends_with("domain_health") {
18744 out.push_str("Not domain-joined or realm/sssd not installed.\n");
18745 }
18746 Ok(out)
18747}
18748
18749#[cfg(windows)]
18752fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18753 let limit = max_entries.min(60);
18754 let script = format!(
18755 r#"
18756$result = [System.Text.StringBuilder]::new()
18757$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18758$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18759$result.AppendLine("") | Out-Null
18760$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18761foreach ($s in $svc) {{
18762 $req = if ($s.RequiredServices.Count -gt 0) {{ "requires: $($s.RequiredServices.Name -join ', ')" }} else {{ "" }}
18763 $dep = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18764 $parts = @($req, $dep) | Where-Object {{ $_ }}
18765 if ($parts) {{
18766 $result.AppendLine(" [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18767 }}
18768}}
18769Write-Output $result.ToString().TrimEnd()
18770"#,
18771 limit = limit
18772 );
18773 let out = run_powershell(&script)?;
18774 Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18775}
18776
18777#[cfg(not(windows))]
18778fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18779 let out = Command::new("systemctl")
18780 .args(["list-dependencies", "--no-pager", "--plain"])
18781 .output()
18782 .ok()
18783 .and_then(|o| String::from_utf8(o.stdout).ok())
18784 .unwrap_or_else(|| "systemctl not available.\n".to_string());
18785 Ok(format!(
18786 "Host inspection: service_dependencies\n\n{}",
18787 out.trim_end()
18788 ))
18789}
18790
18791#[cfg(windows)]
18794fn inspect_wmi_health() -> Result<String, String> {
18795 let script = r#"
18796$result = [System.Text.StringBuilder]::new()
18797$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18798
18799# Basic WMI query test
18800try {
18801 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18802 $result.AppendLine(" Query (Win32_OperatingSystem): OK") | Out-Null
18803 $result.AppendLine(" OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18804} catch {
18805 $result.AppendLine(" Query FAILED: $_") | Out-Null
18806 $result.AppendLine(" FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18807}
18808
18809# Repository integrity
18810try {
18811 $verify = & winmgmt /verifyrepository 2>&1
18812 $result.AppendLine(" winmgmt /verifyrepository: $verify") | Out-Null
18813} catch {
18814 $result.AppendLine(" winmgmt check unavailable: $_") | Out-Null
18815}
18816
18817# WMI service state
18818$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18819if ($svc) {
18820 $result.AppendLine(" Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18821}
18822
18823# Repository folder size
18824$repPath = "$env:SystemRoot\System32\wbem\Repository"
18825if (Test-Path $repPath) {
18826 $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18827 $mb = [math]::Round($bytes / 1MB, 1)
18828 $result.AppendLine(" Repository size: $mb MB ($repPath)") | Out-Null
18829 if ($mb -gt 200) {
18830 $result.AppendLine(" FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18831 }
18832}
18833
18834$result.AppendLine("") | Out-Null
18835$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18836$result.AppendLine(" 1. net stop winmgmt") | Out-Null
18837$result.AppendLine(" 2. winmgmt /salvagerepository (try first)") | Out-Null
18838$result.AppendLine(" 3. winmgmt /resetrepository (last resort — loses custom namespaces)") | Out-Null
18839$result.AppendLine(" 4. net start winmgmt") | Out-Null
18840
18841Write-Output $result.ToString().TrimEnd()
18842"#;
18843 let out = run_powershell(script)?;
18844 Ok(format!("Host inspection: wmi_health\n\n{out}"))
18845}
18846
18847#[cfg(not(windows))]
18848fn inspect_wmi_health() -> Result<String, String> {
18849 Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18850}
18851
18852#[cfg(windows)]
18855fn inspect_local_security_policy() -> Result<String, String> {
18856 let script = r#"
18857$result = [System.Text.StringBuilder]::new()
18858$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18859$na = net accounts 2>&1
18860foreach ($line in $na) {
18861 if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18862 $result.AppendLine(" $($line.Trim())") | Out-Null
18863 }
18864}
18865
18866$result.AppendLine("") | Out-Null
18867$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18868try {
18869 $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18870 if ($null -eq $lmLevel) { $lmLevel = 3 }
18871 $map = @{0='Send LM+NTLM'; 1='LM+NTLMv2 if negotiated'; 2='Send NTLM only'; 3='Send NTLMv2 only (default)'; 4='DC refuses LM'; 5='DC refuses LM+NTLM'}
18872 $result.AppendLine(" LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18873 if ($lmLevel -lt 3) {
18874 $result.AppendLine(" FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18875 }
18876} catch {}
18877
18878$result.AppendLine("") | Out-Null
18879$result.AppendLine("=== UAC Settings ===") | Out-Null
18880try {
18881 $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18882 if ($uac) {
18883 $result.AppendLine(" UAC Enabled : $($uac.EnableLUA) (1=on, 0=disabled)") | Out-Null
18884 $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18885 $bval = $uac.ConsentPromptBehaviorAdmin
18886 $result.AppendLine(" Admin Prompt Behavior : $bval — $($behavMap[$bval])") | Out-Null
18887 if ($uac.EnableLUA -eq 0) {
18888 $result.AppendLine(" FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18889 }
18890 }
18891} catch {}
18892
18893Write-Output $result.ToString().TrimEnd()
18894"#;
18895 let out = run_powershell(script)?;
18896 Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18897}
18898
18899#[cfg(not(windows))]
18900fn inspect_local_security_policy() -> Result<String, String> {
18901 let mut out = String::from("Host inspection: local_security_policy\n\n");
18902 if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18903 out.push_str("=== /etc/login.defs ===\n");
18904 for line in content.lines() {
18905 let t = line.trim();
18906 if !t.is_empty() && !t.starts_with('#') {
18907 let _ = write!(out, " {t}\n");
18908 }
18909 }
18910 }
18911 Ok(out)
18912}
18913
18914#[cfg(windows)]
18917fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18918 let limit = max_entries.min(50);
18919 let script = format!(
18920 r#"
18921$result = [System.Text.StringBuilder]::new()
18922$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18923$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18924if (Test-Path $usbPath) {{
18925 $count = 0
18926 $seen = @{{}}
18927 $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18928 foreach ($class in $classes) {{
18929 $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18930 foreach ($inst in $instances) {{
18931 if ($count -ge {limit}) {{ break }}
18932 try {{
18933 $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18934 $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18935 if (-not $seen[$fn]) {{
18936 $seen[$fn] = $true
18937 $result.AppendLine(" $fn") | Out-Null
18938 $count++
18939 }}
18940 }} catch {{}}
18941 }}
18942 }}
18943 if ($count -eq 0) {{
18944 $result.AppendLine(" No USB storage devices found in registry.") | Out-Null
18945 }} else {{
18946 $result.AppendLine("") | Out-Null
18947 $result.AppendLine(" ($count unique devices; requires elevation for full history)") | Out-Null
18948 }}
18949}} else {{
18950 $result.AppendLine(" USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18951}}
18952Write-Output $result.ToString().TrimEnd()
18953"#,
18954 limit = limit
18955 );
18956 let out = run_powershell(&script)?;
18957 Ok(format!("Host inspection: usb_history\n\n{out}"))
18958}
18959
18960#[cfg(not(windows))]
18961fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18962 let mut out = String::from("Host inspection: usb_history\n\n");
18963 if let Ok(o) = Command::new("journalctl")
18964 .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18965 .output()
18966 {
18967 let s = String::from_utf8_lossy(&o.stdout);
18968 let usb_lines: Vec<&str> = s
18969 .lines()
18970 .filter(|l| l.to_ascii_lowercase().contains("usb"))
18971 .take(30)
18972 .collect();
18973 if !usb_lines.is_empty() {
18974 out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18975 for line in usb_lines {
18976 let _ = write!(out, " {line}\n");
18977 }
18978 }
18979 } else {
18980 out.push_str("USB history via journalctl not available.\n");
18981 }
18982 Ok(out)
18983}
18984
18985#[cfg(windows)]
18988fn inspect_print_spooler() -> Result<String, String> {
18989 let script = r#"
18990$result = [System.Text.StringBuilder]::new()
18991
18992$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18993$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18994if ($svc) {
18995 $result.AppendLine(" Status : $($svc.Status)") | Out-Null
18996 $result.AppendLine(" Start Type : $($svc.StartType)") | Out-Null
18997} else {
18998 $result.AppendLine(" Spooler service not found.") | Out-Null
18999}
19000
19001# PrintNightmare mitigations (CVE-2021-34527)
19002$result.AppendLine("") | Out-Null
19003$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
19004try {
19005 $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
19006 if ($val -eq 1) {
19007 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
19008 } else {
19009 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
19010 $result.AppendLine(" FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
19011 }
19012} catch { $result.AppendLine(" Mitigation key not readable: $_") | Out-Null }
19013
19014try {
19015 $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
19016 if (Test-Path $pnpPath) {
19017 $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
19018 $result.AppendLine(" RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
19019 $result.AppendLine(" NoWarningNoElevationOnInstall : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
19020 if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
19021 $result.AppendLine(" FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
19022 }
19023 } else {
19024 $result.AppendLine(" No Point and Print policy (using Windows defaults).") | Out-Null
19025 }
19026} catch {}
19027
19028# Pending print jobs
19029$result.AppendLine("") | Out-Null
19030$result.AppendLine("=== Print Queue ===") | Out-Null
19031try {
19032 $jobs = Get-PrintJob -ErrorAction SilentlyContinue
19033 if ($jobs) {
19034 foreach ($j in $jobs | Select-Object -First 5) {
19035 $result.AppendLine(" $($j.DocumentName) — $($j.JobStatus)") | Out-Null
19036 }
19037 } else {
19038 $result.AppendLine(" No pending print jobs.") | Out-Null
19039 }
19040} catch {
19041 $result.AppendLine(" Print queue check requires elevation.") | Out-Null
19042}
19043
19044Write-Output $result.ToString().TrimEnd()
19045"#;
19046 let out = run_powershell(script)?;
19047 Ok(format!("Host inspection: print_spooler\n\n{out}"))
19048}
19049
19050#[cfg(not(windows))]
19051fn inspect_print_spooler() -> Result<String, String> {
19052 let mut out = String::from("Host inspection: print_spooler\n\n");
19053 if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
19054 let s = String::from_utf8_lossy(&o.stdout);
19055 if !s.trim().is_empty() {
19056 out.push_str("=== CUPS Status (lpstat -s) ===\n");
19057 out.push_str(s.trim_end());
19058 out.push('\n');
19059 }
19060 } else {
19061 out.push_str("CUPS not detected (lpstat not found).\n");
19062 }
19063 Ok(out)
19064}
19065
19066#[cfg(not(windows))]
19067fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
19068 let mut out = String::from("Host inspection: defender_quarantine\n\n");
19069 out.push_str("Windows Defender is Windows-only.\n");
19070 if let Ok(o) = Command::new("clamscan").arg("--version").output() {
19072 if o.status.success() {
19073 out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
19074 if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
19075 out.push_str("\n=== ClamAV Recent Log ===\n");
19076 for line in log.lines().rev().take(20) {
19077 let _ = write!(out, " {line}\n");
19078 }
19079 }
19080 }
19081 } else {
19082 out.push_str("No AV tool detected (ClamAV not found).\n");
19083 }
19084 Ok(out)
19085}