1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12 let mut topic = args
13 .get("topic")
14 .and_then(|v| v.as_str())
15 .unwrap_or("summary")
16 .to_string();
17 let max_entries = parse_max_entries(args);
18 let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20 if (topic == "processes" || topic == "network" || topic == "summary")
22 && (filter.contains("ad")
23 || filter.contains("sid")
24 || filter.contains("administrator")
25 || filter.contains("domain"))
26 {
27 topic = "ad_user".to_string();
28 }
29
30 let result = match topic.as_str() {
31 "summary" => inspect_summary(max_entries),
32 "toolchains" => inspect_toolchains(),
33 "path" => inspect_path(max_entries),
34 "env_doctor" => inspect_env_doctor(max_entries),
35 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
36 "network" => inspect_network(max_entries),
37 "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
38 inspect_lan_discovery(max_entries)
39 }
40 "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
41 inspect_audio(max_entries)
42 }
43 "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
44 inspect_bluetooth(max_entries)
45 }
46 "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
47 "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
48 inspect_sign_in(max_entries)
49 }
50 "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
51 inspect_installer_health(max_entries)
52 }
53 "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
54 inspect_onedrive(max_entries)
55 }
56 "browser_health" | "browser" | "webview2" | "default_browser" => {
57 inspect_browser_health(max_entries)
58 }
59 "identity_auth"
60 | "office_auth"
61 | "m365_auth"
62 | "microsoft_365_auth"
63 | "auth_broker" => inspect_identity_auth(max_entries),
64 "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
65 "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
66 "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
67 inspect_windows_backup(max_entries)
68 }
69 "search_index" | "windows_search" | "indexing" | "search" => {
70 inspect_search_index(max_entries)
71 }
72 "services" => inspect_services(parse_name_filter(args), max_entries),
73 "processes" => inspect_processes(parse_name_filter(args), max_entries),
74 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
75 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
76 "disk" => {
77 let path = resolve_optional_path(args)?;
78 inspect_disk(path, max_entries).await
79 }
80 "ports" => inspect_ports(parse_port_filter(args), max_entries),
81 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
82 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
83 "health_report" | "system_health" => inspect_health_report(),
84 "storage" => inspect_storage(max_entries),
85 "hardware" => inspect_hardware(),
86 "updates" | "windows_update" => inspect_updates(),
87 "security" | "antivirus" | "defender" => inspect_security(),
88 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
89 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
90 "battery" => inspect_battery(),
91 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
92 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
93 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
94 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
95 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
96 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
97 "vpn" => inspect_vpn(),
98 "proxy" | "proxy_settings" => inspect_proxy(),
99 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
100 "traceroute" | "tracert" | "trace_route" | "trace" => {
101 let host = args
102 .get("host")
103 .and_then(|v| v.as_str())
104 .unwrap_or("8.8.8.8")
105 .to_string();
106 inspect_traceroute(&host, max_entries)
107 }
108 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
109 "arp" | "arp_table" => inspect_arp(),
110 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
111 "os_config" | "system_config" => inspect_os_config(),
112 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
113 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
114 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
115 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
116 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
117 inspect_docker_filesystems(max_entries)
118 }
119 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
120 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
121 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
122 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
123 "git_config" | "git_global" => inspect_git_config(),
124 "databases" | "database" | "db_services" | "db" => inspect_databases(),
125 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
126 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
127 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
128 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
129 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
130 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
131 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
132 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
133 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
134 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
135 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
136 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
137 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
138 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
139 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
140 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
141 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
142 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
143 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
144 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
145 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
146 "repo_doctor" => {
147 let path = resolve_optional_path(args)?;
148 inspect_repo_doctor(path, max_entries)
149 }
150 "directory" => {
151 let raw_path = args
152 .get("path")
153 .and_then(|v| v.as_str())
154 .ok_or_else(|| {
155 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
156 .to_string()
157 })?;
158 let resolved = resolve_path(raw_path)?;
159 inspect_directory("Directory", resolved, max_entries).await
160 }
161 "disk_benchmark" | "stress_test" | "io_intensity" => {
162 let path = resolve_optional_path(args)?;
163 inspect_disk_benchmark(path).await
164 }
165 "permissions" | "acl" | "access_control" => {
166 let path = resolve_optional_path(args)?;
167 inspect_permissions(path, max_entries)
168 }
169 "login_history" | "logon_history" | "user_logins" => {
170 inspect_login_history(max_entries)
171 }
172 "share_access" | "unc_access" | "remote_share" => {
173 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
174 inspect_share_access(path)
175 }
176 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
177 "thermal" | "throttling" | "overheating" => inspect_thermal(),
178 "activation" | "license_status" | "slmgr" => inspect_activation(),
179 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
180 "ad_user" | "ad" | "domain_user" => {
181 let identity = parse_name_filter(args).unwrap_or_default();
182 inspect_ad_user(&identity)
183 }
184 "dns_lookup" | "dig" | "nslookup" => {
185 let name = parse_name_filter(args).unwrap_or_default();
186 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
187 inspect_dns_lookup(&name, record_type)
188 }
189 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
190 "ip_config" | "ip_detail" => inspect_ip_config(),
191 "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
192 "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
193 "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
194 "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
195 "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
196 "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
197 "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
198 "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
199 "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
200 "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
201 let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
202 let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
203 inspect_port_test(pt_host.as_deref(), pt_port)
204 }
205 "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
206 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
207 "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
208 inspect_display_config(max_entries)
209 }
210 "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
211 inspect_ntp()
212 }
213 "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
214 inspect_cpu_power()
215 }
216 "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
217 inspect_credentials(max_entries)
218 }
219 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
220 inspect_tpm()
221 }
222 "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
223 inspect_latency()
224 }
225 "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
226 inspect_network_adapter()
227 }
228 "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
229 let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
230 let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
231 let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
232 let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
233 let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
234 inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
235 }
236 "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
237 let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
238 inspect_app_crashes(process_filter.as_deref(), max_entries)
239 }
240 "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
241 inspect_mdm_enrollment()
242 }
243 "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
244 inspect_storage_spaces()
245 }
246 "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
247 inspect_defender_quarantine(max_entries)
248 }
249 "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
250 inspect_domain_health()
251 }
252 "service_dependencies" | "svc_deps" | "service_deps" => {
253 inspect_service_dependencies(max_entries)
254 }
255 "wmi_health" | "wmi_repository" | "wmi_status" => {
256 inspect_wmi_health()
257 }
258 "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
259 inspect_local_security_policy()
260 }
261 "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
262 inspect_usb_history(max_entries)
263 }
264 "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
265 inspect_print_spooler()
266 }
267 other => Err(format!(
268 "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, 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.",
269 other
270 )),
271
272 };
273
274 result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
275}
276
277fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
278 let Some(scope) = admin_sensitive_topic_scope(topic) else {
279 return body;
280 };
281 let lower = body.to_lowercase();
282 let privilege_limited = lower.contains("access denied")
283 || lower.contains("administrator privilege is required")
284 || lower.contains("administrator privileges required")
285 || lower.contains("requires administrator")
286 || lower.contains("requires elevation")
287 || lower.contains("non-admin session")
288 || lower.contains("could not be fully determined from this session");
289 if !privilege_limited || lower.contains("=== elevation note ===") {
290 return body;
291 }
292
293 let mut annotated = body;
294 annotated.push_str("\n=== Elevation note ===\n");
295 annotated.push_str("- Hematite should stay non-admin by default.\n");
296 annotated.push_str(
297 "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
298 );
299 annotated.push_str(&format!(
300 "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
301 ));
302 annotated
303}
304
305fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
306 match topic {
307 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
308 Some("TPM / Secure Boot / firmware")
309 }
310 "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
311 "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
312 "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
313 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
314 "windows_features" | "optional_features" | "installed_features" | "features" => {
315 Some("Windows Features")
316 }
317 "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
318 _ => None,
319 }
320}
321
322#[cfg(test)]
323mod privilege_hint_tests {
324 use super::annotate_privilege_limited_output;
325
326 #[test]
327 fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
328 let body = "Host inspection: network\nError: Access denied.\n".to_string();
329 let annotated = annotate_privilege_limited_output("network", body.clone());
330 assert_eq!(annotated, body);
331 }
332
333 #[test]
334 fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
335 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();
336 let annotated = annotate_privilege_limited_output("tpm", body);
337 assert!(annotated.contains("=== Elevation note ==="));
338 assert!(annotated.contains("stay non-admin by default"));
339 assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
340 }
341}
342
343#[cfg(test)]
344mod event_query_tests {
345 use super::is_event_query_no_results_message;
346
347 #[cfg(target_os = "windows")]
348 #[test]
349 fn treats_windows_no_results_message_as_empty_query() {
350 assert!(is_event_query_no_results_message(
351 "No events were found that match the specified selection criteria."
352 ));
353 }
354
355 #[cfg(target_os = "windows")]
356 #[test]
357 fn does_not_treat_real_errors_as_empty_query() {
358 assert!(!is_event_query_no_results_message("Access is denied."));
359 }
360}
361
362fn parse_max_entries(args: &Value) -> usize {
363 args.get("max_entries")
364 .and_then(|v| v.as_u64())
365 .map(|n| n as usize)
366 .unwrap_or(DEFAULT_MAX_ENTRIES)
367 .clamp(1, MAX_ENTRIES_CAP)
368}
369
370fn parse_port_filter(args: &Value) -> Option<u16> {
371 args.get("port")
372 .and_then(|v| v.as_u64())
373 .and_then(|n| u16::try_from(n).ok())
374}
375
376fn parse_name_filter(args: &Value) -> Option<String> {
377 args.get("name")
378 .and_then(|v| v.as_str())
379 .map(str::trim)
380 .filter(|value| !value.is_empty())
381 .map(|value| value.to_string())
382}
383
384fn parse_lookback_hours(args: &Value) -> Option<u32> {
385 args.get("lookback_hours")
386 .and_then(|v| v.as_u64())
387 .map(|n| n as u32)
388}
389
390fn parse_issue_text(args: &Value) -> Option<String> {
391 args.get("issue")
392 .and_then(|v| v.as_str())
393 .map(str::trim)
394 .filter(|value| !value.is_empty())
395 .map(|value| value.to_string())
396}
397
398#[cfg(target_os = "windows")]
399fn is_event_query_no_results_message(message: &str) -> bool {
400 let lower = message.to_ascii_lowercase();
401 lower.contains("no events were found")
402 || lower.contains("no events match the specified selection criteria")
403}
404
405fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
406 match args.get("path").and_then(|v| v.as_str()) {
407 Some(raw_path) => resolve_path(raw_path),
408 None => {
409 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
410 }
411 }
412}
413
414fn inspect_summary(max_entries: usize) -> Result<String, String> {
415 let current_dir =
416 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
417 let workspace_root = crate::tools::file_ops::workspace_root();
418 let workspace_mode = workspace_mode_label(&workspace_root);
419 let path_stats = analyze_path_env();
420 let toolchains = collect_toolchains();
421
422 let mut out = String::from("Host inspection: summary\n\n");
423 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
424 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
425 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
426 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
427 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
428 out.push_str(&format!(
429 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
430 path_stats.total_entries,
431 path_stats.unique_entries,
432 path_stats.duplicate_entries.len(),
433 path_stats.missing_entries.len()
434 ));
435
436 if toolchains.found.is_empty() {
437 out.push_str(
438 "- Toolchains found: none of the common developer tools were detected on PATH\n",
439 );
440 } else {
441 out.push_str("- Toolchains found:\n");
442 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
443 out.push_str(&format!(" - {}: {}\n", label, version));
444 }
445 if toolchains.found.len() > max_entries.min(8) {
446 out.push_str(&format!(
447 " - ... {} more found tools omitted\n",
448 toolchains.found.len() - max_entries.min(8)
449 ));
450 }
451 }
452
453 if !toolchains.missing.is_empty() {
454 out.push_str(&format!(
455 "- Common tools not detected on PATH: {}\n",
456 toolchains.missing.join(", ")
457 ));
458 }
459
460 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
461 match path {
462 Some(path) if path.exists() => match count_top_level_items(&path) {
463 Ok(count) => out.push_str(&format!(
464 "- {}: {} top-level items at {}\n",
465 label,
466 count,
467 path.display()
468 )),
469 Err(e) => out.push_str(&format!(
470 "- {}: exists at {} but could not inspect ({})\n",
471 label,
472 path.display(),
473 e
474 )),
475 },
476 Some(path) => out.push_str(&format!(
477 "- {}: expected at {} but not found\n",
478 label,
479 path.display()
480 )),
481 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
482 }
483 }
484
485 Ok(out.trim_end().to_string())
486}
487
488fn inspect_toolchains() -> Result<String, String> {
489 let report = collect_toolchains();
490 let mut out = String::from("Host inspection: toolchains\n\n");
491
492 if report.found.is_empty() {
493 out.push_str("- No common developer tools were detected on PATH.");
494 } else {
495 out.push_str("Detected developer tools:\n");
496 for (label, version) in report.found {
497 out.push_str(&format!("- {}: {}\n", label, version));
498 }
499 }
500
501 if !report.missing.is_empty() {
502 out.push_str("\nNot detected on PATH:\n");
503 for label in report.missing {
504 out.push_str(&format!("- {}\n", label));
505 }
506 }
507
508 Ok(out.trim_end().to_string())
509}
510
511fn inspect_path(max_entries: usize) -> Result<String, String> {
512 let path_stats = analyze_path_env();
513 let mut out = String::from("Host inspection: PATH\n\n");
514 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
515 out.push_str(&format!(
516 "- Unique entries: {}\n",
517 path_stats.unique_entries
518 ));
519 out.push_str(&format!(
520 "- Duplicate entries: {}\n",
521 path_stats.duplicate_entries.len()
522 ));
523 out.push_str(&format!(
524 "- Missing paths: {}\n",
525 path_stats.missing_entries.len()
526 ));
527
528 out.push_str("\nPATH entries:\n");
529 for entry in path_stats.entries.iter().take(max_entries) {
530 out.push_str(&format!("- {}\n", entry));
531 }
532 if path_stats.entries.len() > max_entries {
533 out.push_str(&format!(
534 "- ... {} more entries omitted\n",
535 path_stats.entries.len() - max_entries
536 ));
537 }
538
539 if !path_stats.duplicate_entries.is_empty() {
540 out.push_str("\nDuplicate entries:\n");
541 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
542 out.push_str(&format!("- {}\n", entry));
543 }
544 if path_stats.duplicate_entries.len() > max_entries {
545 out.push_str(&format!(
546 "- ... {} more duplicates omitted\n",
547 path_stats.duplicate_entries.len() - max_entries
548 ));
549 }
550 }
551
552 if !path_stats.missing_entries.is_empty() {
553 out.push_str("\nMissing directories:\n");
554 for entry in path_stats.missing_entries.iter().take(max_entries) {
555 out.push_str(&format!("- {}\n", entry));
556 }
557 if path_stats.missing_entries.len() > max_entries {
558 out.push_str(&format!(
559 "- ... {} more missing entries omitted\n",
560 path_stats.missing_entries.len() - max_entries
561 ));
562 }
563 }
564
565 Ok(out.trim_end().to_string())
566}
567
568fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
569 let path_stats = analyze_path_env();
570 let toolchains = collect_toolchains();
571 let package_managers = collect_package_managers();
572 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
573
574 let mut out = String::from("Host inspection: env_doctor\n\n");
575 out.push_str(&format!(
576 "- PATH health: {} duplicates, {} missing entries\n",
577 path_stats.duplicate_entries.len(),
578 path_stats.missing_entries.len()
579 ));
580 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
581 out.push_str(&format!(
582 "- Package managers found: {}\n",
583 package_managers.found.len()
584 ));
585
586 if !package_managers.found.is_empty() {
587 out.push_str("\nPackage managers:\n");
588 for (label, version) in package_managers.found.iter().take(max_entries) {
589 out.push_str(&format!("- {}: {}\n", label, version));
590 }
591 if package_managers.found.len() > max_entries {
592 out.push_str(&format!(
593 "- ... {} more package managers omitted\n",
594 package_managers.found.len() - max_entries
595 ));
596 }
597 }
598
599 if !path_stats.duplicate_entries.is_empty() {
600 out.push_str("\nDuplicate PATH entries:\n");
601 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
602 out.push_str(&format!("- {}\n", entry));
603 }
604 if path_stats.duplicate_entries.len() > max_entries.min(5) {
605 out.push_str(&format!(
606 "- ... {} more duplicate entries omitted\n",
607 path_stats.duplicate_entries.len() - max_entries.min(5)
608 ));
609 }
610 }
611
612 if !path_stats.missing_entries.is_empty() {
613 out.push_str("\nMissing PATH entries:\n");
614 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
615 out.push_str(&format!("- {}\n", entry));
616 }
617 if path_stats.missing_entries.len() > max_entries.min(5) {
618 out.push_str(&format!(
619 "- ... {} more missing entries omitted\n",
620 path_stats.missing_entries.len() - max_entries.min(5)
621 ));
622 }
623 }
624
625 if !findings.is_empty() {
626 out.push_str("\nFindings:\n");
627 for finding in findings.iter().take(max_entries.max(5)) {
628 out.push_str(&format!("- {}\n", finding));
629 }
630 if findings.len() > max_entries.max(5) {
631 out.push_str(&format!(
632 "- ... {} more findings omitted\n",
633 findings.len() - max_entries.max(5)
634 ));
635 }
636 } else {
637 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
638 }
639
640 out.push_str(
641 "\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.",
642 );
643
644 Ok(out.trim_end().to_string())
645}
646
647#[derive(Clone, Copy, Debug, Eq, PartialEq)]
648enum FixPlanKind {
649 EnvPath,
650 PortConflict,
651 LmStudio,
652 DriverInstall,
653 GroupPolicy,
654 FirewallRule,
655 SshKey,
656 WslSetup,
657 ServiceConfig,
658 WindowsActivation,
659 RegistryEdit,
660 ScheduledTaskCreate,
661 DiskCleanup,
662 DnsResolution,
663 Generic,
664}
665
666async fn inspect_fix_plan(
667 issue: Option<String>,
668 port_filter: Option<u16>,
669 max_entries: usize,
670) -> Result<String, String> {
671 let issue = issue.unwrap_or_else(|| {
672 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
673 .to_string()
674 });
675 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
676 match plan_kind {
677 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
678 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
679 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
680 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
681 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
682 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
683 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
684 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
685 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
686 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
687 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
688 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
689 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
690 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
691 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
692 }
693}
694
695fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
696 let lower = issue.to_ascii_lowercase();
697 if lower.contains("firewall rule")
700 || lower.contains("inbound rule")
701 || lower.contains("outbound rule")
702 || (lower.contains("firewall")
703 && (lower.contains("allow")
704 || lower.contains("block")
705 || lower.contains("create")
706 || lower.contains("open")))
707 {
708 FixPlanKind::FirewallRule
709 } else if port_filter.is_some()
710 || lower.contains("port ")
711 || lower.contains("address already in use")
712 || lower.contains("already in use")
713 || lower.contains("what owns port")
714 || lower.contains("listening on port")
715 {
716 FixPlanKind::PortConflict
717 } else if lower.contains("lm studio")
718 || lower.contains("localhost:1234")
719 || lower.contains("/v1/models")
720 || lower.contains("no coding model loaded")
721 || lower.contains("embedding model")
722 || lower.contains("server on port 1234")
723 || lower.contains("runtime refresh")
724 {
725 FixPlanKind::LmStudio
726 } else if lower.contains("driver")
727 || lower.contains("gpu driver")
728 || lower.contains("nvidia driver")
729 || lower.contains("amd driver")
730 || lower.contains("install driver")
731 || lower.contains("update driver")
732 {
733 FixPlanKind::DriverInstall
734 } else if lower.contains("group policy")
735 || lower.contains("gpedit")
736 || lower.contains("local policy")
737 || lower.contains("secpol")
738 || lower.contains("administrative template")
739 {
740 FixPlanKind::GroupPolicy
741 } else if lower.contains("ssh key")
742 || lower.contains("ssh-keygen")
743 || lower.contains("generate ssh")
744 || lower.contains("authorized_keys")
745 || lower.contains("id_rsa")
746 || lower.contains("id_ed25519")
747 {
748 FixPlanKind::SshKey
749 } else if lower.contains("wsl")
750 || lower.contains("windows subsystem for linux")
751 || lower.contains("install ubuntu")
752 || lower.contains("install linux on windows")
753 || lower.contains("wsl2")
754 {
755 FixPlanKind::WslSetup
756 } else if lower.contains("service")
757 && (lower.contains("start ")
758 || lower.contains("stop ")
759 || lower.contains("restart ")
760 || lower.contains("enable ")
761 || lower.contains("disable ")
762 || lower.contains("configure service"))
763 {
764 FixPlanKind::ServiceConfig
765 } else if lower.contains("activate windows")
766 || lower.contains("windows activation")
767 || lower.contains("product key")
768 || lower.contains("kms")
769 || lower.contains("not activated")
770 {
771 FixPlanKind::WindowsActivation
772 } else if lower.contains("registry")
773 || lower.contains("regedit")
774 || lower.contains("hklm")
775 || lower.contains("hkcu")
776 || lower.contains("reg add")
777 || lower.contains("reg delete")
778 || lower.contains("registry key")
779 {
780 FixPlanKind::RegistryEdit
781 } else if lower.contains("scheduled task")
782 || lower.contains("task scheduler")
783 || lower.contains("schtasks")
784 || lower.contains("create task")
785 || lower.contains("run on startup")
786 || lower.contains("run on schedule")
787 || lower.contains("cron")
788 {
789 FixPlanKind::ScheduledTaskCreate
790 } else if lower.contains("disk cleanup")
791 || lower.contains("free up disk")
792 || lower.contains("free up space")
793 || lower.contains("clear cache")
794 || lower.contains("disk full")
795 || lower.contains("low disk space")
796 || lower.contains("reclaim space")
797 {
798 FixPlanKind::DiskCleanup
799 } else if lower.contains("cargo")
800 || lower.contains("rustc")
801 || lower.contains("path")
802 || lower.contains("package manager")
803 || lower.contains("package managers")
804 || lower.contains("toolchain")
805 || lower.contains("winget")
806 || lower.contains("choco")
807 || lower.contains("scoop")
808 || lower.contains("python")
809 || lower.contains("node")
810 {
811 FixPlanKind::EnvPath
812 } else if lower.contains("dns ")
813 || lower.contains("nameserver")
814 || lower.contains("cannot resolve")
815 || lower.contains("nslookup")
816 || lower.contains("flushdns")
817 {
818 FixPlanKind::DnsResolution
819 } else {
820 FixPlanKind::Generic
821 }
822}
823
824fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
825 let path_stats = analyze_path_env();
826 let toolchains = collect_toolchains();
827 let package_managers = collect_package_managers();
828 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
829 let found_tools = toolchains
830 .found
831 .iter()
832 .map(|(label, _)| label.as_str())
833 .collect::<HashSet<_>>();
834 let found_managers = package_managers
835 .found
836 .iter()
837 .map(|(label, _)| label.as_str())
838 .collect::<HashSet<_>>();
839
840 let mut out = String::from("Host inspection: fix_plan\n\n");
841 out.push_str(&format!("- Requested issue: {}\n", issue));
842 out.push_str("- Fix-plan type: environment/path\n");
843 out.push_str(&format!(
844 "- PATH health: {} duplicates, {} missing entries\n",
845 path_stats.duplicate_entries.len(),
846 path_stats.missing_entries.len()
847 ));
848 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
849 out.push_str(&format!(
850 "- Package managers found: {}\n",
851 package_managers.found.len()
852 ));
853
854 out.push_str("\nLikely causes:\n");
855 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
856 out.push_str(
857 "- 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",
858 );
859 }
860 if path_stats.duplicate_entries.is_empty()
861 && path_stats.missing_entries.is_empty()
862 && !findings.is_empty()
863 {
864 for finding in findings.iter().take(max_entries.max(4)) {
865 out.push_str(&format!("- {}\n", finding));
866 }
867 } else {
868 if !path_stats.duplicate_entries.is_empty() {
869 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
870 }
871 if !path_stats.missing_entries.is_empty() {
872 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
873 }
874 }
875 if found_tools.contains("node")
876 && !found_managers.contains("npm")
877 && !found_managers.contains("pnpm")
878 {
879 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
880 }
881 if found_tools.contains("python")
882 && !found_managers.contains("pip")
883 && !found_managers.contains("uv")
884 && !found_managers.contains("pipx")
885 {
886 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
887 }
888
889 out.push_str("\nFix plan:\n");
890 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");
891 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
892 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");
893 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
894 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");
895 }
896 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
897 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
898 }
899 if found_tools.contains("node")
900 && !found_managers.contains("npm")
901 && !found_managers.contains("pnpm")
902 {
903 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");
904 }
905 if found_tools.contains("python")
906 && !found_managers.contains("pip")
907 && !found_managers.contains("uv")
908 && !found_managers.contains("pipx")
909 {
910 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");
911 }
912
913 if !path_stats.duplicate_entries.is_empty() {
914 out.push_str("\nExample duplicate PATH rows:\n");
915 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
916 out.push_str(&format!("- {}\n", entry));
917 }
918 }
919 if !path_stats.missing_entries.is_empty() {
920 out.push_str("\nExample missing PATH rows:\n");
921 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
922 out.push_str(&format!("- {}\n", entry));
923 }
924 }
925
926 out.push_str(
927 "\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.",
928 );
929 Ok(out.trim_end().to_string())
930}
931
932fn inspect_port_fix_plan(
933 issue: &str,
934 port_filter: Option<u16>,
935 max_entries: usize,
936) -> Result<String, String> {
937 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
938 let listeners = collect_listening_ports().unwrap_or_default();
939 let mut matching = listeners;
940 if let Some(port) = requested_port {
941 matching.retain(|entry| entry.port == port);
942 }
943 let processes = collect_processes().unwrap_or_default();
944
945 let mut out = String::from("Host inspection: fix_plan\n\n");
946 out.push_str(&format!("- Requested issue: {}\n", issue));
947 out.push_str("- Fix-plan type: port_conflict\n");
948 if let Some(port) = requested_port {
949 out.push_str(&format!("- Requested port: {}\n", port));
950 } else {
951 out.push_str("- Requested port: not parsed from the issue text\n");
952 }
953 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
954
955 if !matching.is_empty() {
956 out.push_str("\nCurrent listeners:\n");
957 for entry in matching.iter().take(max_entries.min(5)) {
958 let process_name = entry
959 .pid
960 .as_deref()
961 .and_then(|pid| pid.parse::<u32>().ok())
962 .and_then(|pid| {
963 processes
964 .iter()
965 .find(|process| process.pid == pid)
966 .map(|process| process.name.as_str())
967 })
968 .unwrap_or("unknown");
969 let pid = entry.pid.as_deref().unwrap_or("unknown");
970 out.push_str(&format!(
971 "- {} {} ({}) pid {} process {}\n",
972 entry.protocol, entry.local, entry.state, pid, process_name
973 ));
974 }
975 }
976
977 out.push_str("\nFix plan:\n");
978 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");
979 if !matching.is_empty() {
980 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");
981 } else {
982 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");
983 }
984 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
985 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");
986 out.push_str(
987 "\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.",
988 );
989 Ok(out.trim_end().to_string())
990}
991
992async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
993 let config = crate::agent::config::load_config();
994 let configured_api = config
995 .api_url
996 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
997 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
998 let reachability = probe_http_endpoint(&models_url).await;
999 let embed_model = detect_loaded_embed_model(&configured_api).await;
1000
1001 let mut out = String::from("Host inspection: fix_plan\n\n");
1002 out.push_str(&format!("- Requested issue: {}\n", issue));
1003 out.push_str("- Fix-plan type: lm_studio\n");
1004 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
1005 out.push_str(&format!("- Probe URL: {}\n", models_url));
1006 match &reachability {
1007 EndpointProbe::Reachable(status) => {
1008 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
1009 }
1010 EndpointProbe::Unreachable(detail) => {
1011 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
1012 }
1013 }
1014 out.push_str(&format!(
1015 "- Embedding model loaded: {}\n",
1016 embed_model.as_deref().unwrap_or("none detected")
1017 ));
1018
1019 out.push_str("\nFix plan:\n");
1020 match reachability {
1021 EndpointProbe::Reachable(_) => {
1022 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");
1023 }
1024 EndpointProbe::Unreachable(_) => {
1025 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");
1026 }
1027 }
1028 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");
1029 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");
1030 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");
1031 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");
1032 if let Some(model) = embed_model {
1033 out.push_str(&format!(
1034 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1035 model
1036 ));
1037 }
1038 if max_entries > 0 {
1039 out.push_str(
1040 "\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.",
1041 );
1042 }
1043 Ok(out.trim_end().to_string())
1044}
1045
1046fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1047 #[cfg(target_os = "windows")]
1049 let gpu_info = {
1050 let out = Command::new("powershell")
1051 .args([
1052 "-NoProfile",
1053 "-NonInteractive",
1054 "-Command",
1055 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1056 ])
1057 .output()
1058 .ok()
1059 .and_then(|o| String::from_utf8(o.stdout).ok())
1060 .unwrap_or_default();
1061 out.trim().to_string()
1062 };
1063 #[cfg(not(target_os = "windows"))]
1064 let gpu_info = String::from("(GPU detection not available on this platform)");
1065
1066 let mut out = String::from("Host inspection: fix_plan\n\n");
1067 out.push_str(&format!("- Requested issue: {}\n", issue));
1068 out.push_str("- Fix-plan type: driver_install\n");
1069 if !gpu_info.is_empty() {
1070 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1071 }
1072 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1073 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1074 out.push_str(
1075 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1076 );
1077 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1078 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1079 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1080 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
1081 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1082 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");
1083 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1084 out.push_str("\nVerification:\n");
1085 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1086 out.push_str("- The DriverVersion should match what you installed.\n");
1087 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.");
1088 Ok(out.trim_end().to_string())
1089}
1090
1091fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1092 #[cfg(target_os = "windows")]
1094 let edition = {
1095 Command::new("powershell")
1096 .args([
1097 "-NoProfile",
1098 "-NonInteractive",
1099 "-Command",
1100 "(Get-CimInstance Win32_OperatingSystem).Caption",
1101 ])
1102 .output()
1103 .ok()
1104 .and_then(|o| String::from_utf8(o.stdout).ok())
1105 .unwrap_or_default()
1106 .trim()
1107 .to_string()
1108 };
1109 #[cfg(not(target_os = "windows"))]
1110 let edition = String::from("(Windows edition detection not available)");
1111
1112 let is_home = edition.to_lowercase().contains("home");
1113
1114 let mut out = String::from("Host inspection: fix_plan\n\n");
1115 out.push_str(&format!("- Requested issue: {}\n", issue));
1116 out.push_str("- Fix-plan type: group_policy\n");
1117 out.push_str(&format!(
1118 "- Windows edition detected: {}\n",
1119 if edition.is_empty() {
1120 "unknown".to_string()
1121 } else {
1122 edition.clone()
1123 }
1124 ));
1125
1126 if is_home {
1127 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1128 out.push_str("Options on Home edition:\n");
1129 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");
1130 out.push_str(
1131 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1132 );
1133 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1134 } else {
1135 out.push_str("\nFix plan — Editing Local Group Policy:\n");
1136 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1137 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1138 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1139 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1140 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1141 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
1142 }
1143 out.push_str("\nVerification:\n");
1144 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1145 out.push_str(
1146 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1147 );
1148 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.");
1149 Ok(out.trim_end().to_string())
1150}
1151
1152fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1153 #[cfg(target_os = "windows")]
1154 let profile_state = {
1155 Command::new("powershell")
1156 .args([
1157 "-NoProfile",
1158 "-NonInteractive",
1159 "-Command",
1160 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1161 ])
1162 .output()
1163 .ok()
1164 .and_then(|o| String::from_utf8(o.stdout).ok())
1165 .unwrap_or_default()
1166 .trim()
1167 .to_string()
1168 };
1169 #[cfg(not(target_os = "windows"))]
1170 let profile_state = String::new();
1171
1172 let mut out = String::from("Host inspection: fix_plan\n\n");
1173 out.push_str(&format!("- Requested issue: {}\n", issue));
1174 out.push_str("- Fix-plan type: firewall_rule\n");
1175 if !profile_state.is_empty() {
1176 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1177 }
1178 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1179 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1180 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1181 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1182 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1183 out.push_str("\nTo ALLOW an application through the firewall:\n");
1184 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1185 out.push_str("\nTo REMOVE a rule you created:\n");
1186 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1187 out.push_str("\nTo see existing custom rules:\n");
1188 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1189 out.push_str("\nVerification:\n");
1190 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1191 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.");
1192 Ok(out.trim_end().to_string())
1193}
1194
1195fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1196 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1197 let ssh_dir = home.join(".ssh");
1198 let has_ssh_dir = ssh_dir.exists();
1199 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1200 let has_rsa = ssh_dir.join("id_rsa").exists();
1201 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1202
1203 let mut out = String::from("Host inspection: fix_plan\n\n");
1204 out.push_str(&format!("- Requested issue: {}\n", issue));
1205 out.push_str("- Fix-plan type: ssh_key\n");
1206 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1207 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1208 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1209 out.push_str(&format!(
1210 "- authorized_keys found: {}\n",
1211 has_authorized_keys
1212 ));
1213
1214 if has_ed25519 {
1215 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1216 }
1217
1218 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1219 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1220 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1221 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1222 out.push_str(
1223 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1224 );
1225 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1226 out.push_str("3. Start the SSH agent and add your key:\n");
1227 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1228 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1229 out.push_str(" Start-Service ssh-agent\n");
1230 out.push_str(" # Then add the key (normal PowerShell):\n");
1231 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1232 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1233 out.push_str(" # Print your public key:\n");
1234 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1235 out.push_str(" # On the target server, append it:\n");
1236 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1237 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1238 out.push_str("5. Test the connection:\n");
1239 out.push_str(" ssh user@server-address\n");
1240 out.push_str("\nFor GitHub/GitLab:\n");
1241 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1242 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1243 out.push_str("- Test: ssh -T git@github.com\n");
1244 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.");
1245 Ok(out.trim_end().to_string())
1246}
1247
1248fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1249 #[cfg(target_os = "windows")]
1250 let wsl_status = {
1251 let out = Command::new("wsl")
1252 .args(["--status"])
1253 .output()
1254 .ok()
1255 .and_then(|o| {
1256 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1257 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1258 Some(format!("{}{}", stdout, stderr))
1259 })
1260 .unwrap_or_default();
1261 out.trim().to_string()
1262 };
1263 #[cfg(not(target_os = "windows"))]
1264 let wsl_status = String::new();
1265
1266 let wsl_installed =
1267 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1268
1269 let mut out = String::from("Host inspection: fix_plan\n\n");
1270 out.push_str(&format!("- Requested issue: {}\n", issue));
1271 out.push_str("- Fix-plan type: wsl_setup\n");
1272 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1273 if !wsl_status.is_empty() {
1274 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1275 }
1276
1277 if wsl_installed {
1278 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1279 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1280 out.push_str(" Available distros: wsl --list --online\n");
1281 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1282 out.push_str("3. Create your Linux username and password when prompted.\n");
1283 } else {
1284 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1285 out.push_str("1. Open PowerShell as Administrator.\n");
1286 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1287 out.push_str(" wsl --install\n");
1288 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1289 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1290 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1291 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1292 out.push_str(" wsl --set-default-version 2\n");
1293 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1294 out.push_str(" wsl --install -d Debian\n");
1295 out.push_str(" wsl --list --online # to see all available distros\n");
1296 }
1297 out.push_str("\nVerification:\n");
1298 out.push_str("- Run: wsl --list --verbose\n");
1299 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1300 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.");
1301 Ok(out.trim_end().to_string())
1302}
1303
1304fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1305 let lower = issue.to_ascii_lowercase();
1306 let service_hint = if lower.contains("ssh") {
1308 Some("sshd")
1309 } else if lower.contains("mysql") {
1310 Some("MySQL80")
1311 } else if lower.contains("postgres") || lower.contains("postgresql") {
1312 Some("postgresql")
1313 } else if lower.contains("redis") {
1314 Some("Redis")
1315 } else if lower.contains("nginx") {
1316 Some("nginx")
1317 } else if lower.contains("apache") {
1318 Some("Apache2.4")
1319 } else {
1320 None
1321 };
1322
1323 #[cfg(target_os = "windows")]
1324 let service_state = if let Some(svc) = service_hint {
1325 Command::new("powershell")
1326 .args([
1327 "-NoProfile",
1328 "-NonInteractive",
1329 "-Command",
1330 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1331 ])
1332 .output()
1333 .ok()
1334 .and_then(|o| String::from_utf8(o.stdout).ok())
1335 .unwrap_or_default()
1336 .trim()
1337 .to_string()
1338 } else {
1339 String::new()
1340 };
1341 #[cfg(not(target_os = "windows"))]
1342 let service_state = String::new();
1343
1344 let mut out = String::from("Host inspection: fix_plan\n\n");
1345 out.push_str(&format!("- Requested issue: {}\n", issue));
1346 out.push_str("- Fix-plan type: service_config\n");
1347 if let Some(svc) = service_hint {
1348 out.push_str(&format!("- Service detected in request: {}\n", svc));
1349 }
1350 if !service_state.is_empty() {
1351 out.push_str(&format!("- Current state: {}\n", service_state));
1352 }
1353
1354 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1355 out.push_str("\nStart a service:\n");
1356 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1357 out.push_str("\nStop a service:\n");
1358 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1359 out.push_str("\nRestart a service:\n");
1360 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1361 out.push_str("\nEnable a service to start automatically:\n");
1362 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1363 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1364 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1365 out.push_str("\nFind the exact service name:\n");
1366 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1367 out.push_str("\nVerification:\n");
1368 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1369 if let Some(svc) = service_hint {
1370 out.push_str(&format!(
1371 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1372 svc, svc
1373 ));
1374 }
1375 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.");
1376 Ok(out.trim_end().to_string())
1377}
1378
1379fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1380 #[cfg(target_os = "windows")]
1381 let activation_status = {
1382 Command::new("powershell")
1383 .args([
1384 "-NoProfile",
1385 "-NonInteractive",
1386 "-Command",
1387 "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 + ')' })\" }",
1388 ])
1389 .output()
1390 .ok()
1391 .and_then(|o| String::from_utf8(o.stdout).ok())
1392 .unwrap_or_default()
1393 .trim()
1394 .to_string()
1395 };
1396 #[cfg(not(target_os = "windows"))]
1397 let activation_status = String::new();
1398
1399 let is_licensed = activation_status.to_lowercase().contains("licensed")
1400 && !activation_status.to_lowercase().contains("not licensed");
1401
1402 let mut out = String::from("Host inspection: fix_plan\n\n");
1403 out.push_str(&format!("- Requested issue: {}\n", issue));
1404 out.push_str("- Fix-plan type: windows_activation\n");
1405 if !activation_status.is_empty() {
1406 out.push_str(&format!(
1407 "- Current activation state:\n{}\n",
1408 activation_status
1409 ));
1410 }
1411
1412 if is_licensed {
1413 out.push_str(
1414 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1415 );
1416 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1417 out.push_str(" (Forces an online activation attempt)\n");
1418 out.push_str("2. Check activation details: slmgr /dli\n");
1419 } else {
1420 out.push_str("\nFix plan — Activating Windows:\n");
1421 out.push_str("1. Check your current status first:\n");
1422 out.push_str(" slmgr /dli (basic info)\n");
1423 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1424 out.push_str("\n2. If you have a retail product key:\n");
1425 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1426 out.push_str(" slmgr /ato (activate online)\n");
1427 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1428 out.push_str(" - Go to Settings → System → Activation\n");
1429 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1430 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1431 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1432 out.push_str(" - Contact your IT department for the KMS server address\n");
1433 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1434 out.push_str(" - Activate: slmgr /ato\n");
1435 }
1436 out.push_str("\nVerification:\n");
1437 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1438 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1439 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.");
1440 Ok(out.trim_end().to_string())
1441}
1442
1443fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1444 let mut out = String::from("Host inspection: fix_plan\n\n");
1445 out.push_str(&format!("- Requested issue: {}\n", issue));
1446 out.push_str("- Fix-plan type: registry_edit\n");
1447 out.push_str(
1448 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1449 );
1450 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1451 out.push_str("\n1. Back up before you touch anything:\n");
1452 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1453 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1454 out.push_str(" # Or export the whole registry (takes a while):\n");
1455 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1456 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1457 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1458 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1459 out.push_str(
1460 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1461 );
1462 out.push_str("\n4. Create a new key:\n");
1463 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1464 out.push_str("\n5. Delete a value:\n");
1465 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1466 out.push_str("\n6. Restore from backup if something breaks:\n");
1467 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1468 out.push_str("\nCommon registry hives:\n");
1469 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1470 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1471 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1472 out.push_str("\nVerification:\n");
1473 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1474 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.");
1475 Ok(out.trim_end().to_string())
1476}
1477
1478fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1479 let mut out = String::from("Host inspection: fix_plan\n\n");
1480 out.push_str(&format!("- Requested issue: {}\n", issue));
1481 out.push_str("- Fix-plan type: scheduled_task_create\n");
1482 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1483 out.push_str("\nExample: Run a script at 9 AM every day\n");
1484 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1485 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1486 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1487 out.push_str("\nExample: Run at Windows startup\n");
1488 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1489 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1490 out.push_str("\nExample: Run at user logon\n");
1491 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1492 out.push_str(
1493 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1494 );
1495 out.push_str("\nExample: Run every 30 minutes\n");
1496 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1497 out.push_str("\nView all tasks:\n");
1498 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1499 out.push_str("\nDelete a task:\n");
1500 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1501 out.push_str("\nRun a task immediately:\n");
1502 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1503 out.push_str("\nVerification:\n");
1504 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1505 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.");
1506 Ok(out.trim_end().to_string())
1507}
1508
1509fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1510 #[cfg(target_os = "windows")]
1511 let disk_info = {
1512 Command::new("powershell")
1513 .args([
1514 "-NoProfile",
1515 "-NonInteractive",
1516 "-Command",
1517 "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\" }",
1518 ])
1519 .output()
1520 .ok()
1521 .and_then(|o| String::from_utf8(o.stdout).ok())
1522 .unwrap_or_default()
1523 .trim()
1524 .to_string()
1525 };
1526 #[cfg(not(target_os = "windows"))]
1527 let disk_info = String::new();
1528
1529 let mut out = String::from("Host inspection: fix_plan\n\n");
1530 out.push_str(&format!("- Requested issue: {}\n", issue));
1531 out.push_str("- Fix-plan type: disk_cleanup\n");
1532 if !disk_info.is_empty() {
1533 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1534 }
1535 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1536 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1537 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1538 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1539 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1540 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1541 out.push_str(" Stop-Service wuauserv\n");
1542 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1543 out.push_str(" Start-Service wuauserv\n");
1544 out.push_str("\n3. Clear Windows Temp folder:\n");
1545 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1546 out.push_str(
1547 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1548 );
1549 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1550 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1551 out.push_str(" - npm cache: npm cache clean --force\n");
1552 out.push_str(" - pip cache: pip cache purge\n");
1553 out.push_str(
1554 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1555 );
1556 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1557 out.push_str("\n5. Check for large files:\n");
1558 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");
1559 out.push_str("\nVerification:\n");
1560 out.push_str(
1561 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1562 );
1563 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.");
1564 Ok(out.trim_end().to_string())
1565}
1566
1567fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1568 let mut out = String::from("Host inspection: fix_plan\n\n");
1569 out.push_str(&format!("- Requested issue: {}\n", issue));
1570 out.push_str("- Fix-plan type: generic\n");
1571 out.push_str(
1572 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1573 Structured lanes available:\n\
1574 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1575 - Port conflict (address already in use, what owns port)\n\
1576 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1577 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1578 - Group Policy (gpedit, local policy, administrative template)\n\
1579 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1580 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1581 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1582 - Service config (start/stop/restart/enable/disable a service)\n\
1583 - Windows activation (product key, not activated, kms)\n\
1584 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1585 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1586 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1587 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1588 );
1589 Ok(out.trim_end().to_string())
1590}
1591
1592fn inspect_resource_load() -> Result<String, String> {
1593 #[cfg(target_os = "windows")]
1594 {
1595 let output = Command::new("powershell")
1596 .args([
1597 "-NoProfile",
1598 "-Command",
1599 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1600 ])
1601 .output()
1602 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1603
1604 let text = String::from_utf8_lossy(&output.stdout);
1605 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1606
1607 let cpu_load = lines
1608 .next()
1609 .and_then(|l| l.parse::<u32>().ok())
1610 .unwrap_or(0);
1611 let mem_json = lines.collect::<Vec<_>>().join("");
1612 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1613
1614 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1615 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1616 let used_kb = total_kb.saturating_sub(free_kb);
1617 let mem_percent = if total_kb > 0 {
1618 (used_kb * 100) / total_kb
1619 } else {
1620 0
1621 };
1622
1623 let mut out = String::from("Host inspection: resource_load\n\n");
1624 out.push_str("**System Performance Summary:**\n");
1625 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1626 out.push_str(&format!(
1627 "- Memory Usage: {} / {} ({}%)\n",
1628 human_bytes(used_kb * 1024),
1629 human_bytes(total_kb * 1024),
1630 mem_percent
1631 ));
1632
1633 if cpu_load > 85 {
1634 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1635 }
1636 if mem_percent > 90 {
1637 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1638 }
1639
1640 Ok(out)
1641 }
1642 #[cfg(not(target_os = "windows"))]
1643 {
1644 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1645 }
1646}
1647
1648#[derive(Debug)]
1649enum EndpointProbe {
1650 Reachable(u16),
1651 Unreachable(String),
1652}
1653
1654async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1655 let client = match reqwest::Client::builder()
1656 .timeout(std::time::Duration::from_secs(3))
1657 .build()
1658 {
1659 Ok(client) => client,
1660 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1661 };
1662
1663 match client.get(url).send().await {
1664 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1665 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1666 }
1667}
1668
1669async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1670 if configured_api.contains("11434") {
1671 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1672 let url = format!("{}/api/ps", base);
1673 let client = reqwest::Client::builder()
1674 .timeout(std::time::Duration::from_secs(3))
1675 .build()
1676 .ok()?;
1677 let response = client.get(url).send().await.ok()?;
1678 let body = response.json::<serde_json::Value>().await.ok()?;
1679 let entries = body["models"].as_array()?;
1680 for entry in entries {
1681 let name = entry["name"]
1682 .as_str()
1683 .or_else(|| entry["model"].as_str())
1684 .unwrap_or_default();
1685 let lower = name.to_ascii_lowercase();
1686 if lower.contains("embed")
1687 || lower.contains("embedding")
1688 || lower.contains("minilm")
1689 || lower.contains("bge")
1690 || lower.contains("e5")
1691 {
1692 return Some(name.to_string());
1693 }
1694 }
1695 return None;
1696 }
1697
1698 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1699 let url = format!("{}/api/v0/models", base);
1700 let client = reqwest::Client::builder()
1701 .timeout(std::time::Duration::from_secs(3))
1702 .build()
1703 .ok()?;
1704
1705 #[derive(serde::Deserialize)]
1706 struct ModelList {
1707 data: Vec<ModelEntry>,
1708 }
1709 #[derive(serde::Deserialize)]
1710 struct ModelEntry {
1711 id: String,
1712 #[serde(rename = "type", default)]
1713 model_type: String,
1714 #[serde(default)]
1715 state: String,
1716 }
1717
1718 let response = client.get(url).send().await.ok()?;
1719 let models = response.json::<ModelList>().await.ok()?;
1720 models
1721 .data
1722 .into_iter()
1723 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1724 .map(|model| model.id)
1725}
1726
1727fn first_port_in_text(text: &str) -> Option<u16> {
1728 text.split(|c: char| !c.is_ascii_digit())
1729 .find(|fragment| !fragment.is_empty())
1730 .and_then(|fragment| fragment.parse::<u16>().ok())
1731}
1732
1733fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1734 let mut processes = collect_processes()?;
1735 if let Some(filter) = name_filter.as_deref() {
1736 let lowered = filter.to_ascii_lowercase();
1737 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1738 }
1739 processes.sort_by(|a, b| {
1740 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1741 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1742 b_cpu
1743 .partial_cmp(&a_cpu)
1744 .unwrap_or(std::cmp::Ordering::Equal)
1745 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1746 .then_with(|| a.name.cmp(&b.name))
1747 .then_with(|| a.pid.cmp(&b.pid))
1748 });
1749
1750 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1751
1752 let mut out = String::from("Host inspection: processes\n\n");
1753 if let Some(filter) = name_filter.as_deref() {
1754 out.push_str(&format!("- Filter name: {}\n", filter));
1755 }
1756 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1757 out.push_str(&format!(
1758 "- Total reported working set: {}\n",
1759 human_bytes(total_memory)
1760 ));
1761
1762 if processes.is_empty() {
1763 out.push_str("\nNo running processes matched.");
1764 return Ok(out);
1765 }
1766
1767 out.push_str("\nTop processes by resource usage:\n");
1768 for entry in processes.iter().take(max_entries) {
1769 let cpu_str = entry
1770 .cpu_percent
1771 .map(|p| format!(" [CPU: {:.1}%]", p))
1772 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1773 .unwrap_or_default();
1774 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1775 format!(" [I/O R:{}/W:{}]", r, w)
1776 } else {
1777 " [I/O unknown]".to_string()
1778 };
1779 out.push_str(&format!(
1780 "- {} (pid {}) - {}{}{}{}\n",
1781 entry.name,
1782 entry.pid,
1783 human_bytes(entry.memory_bytes),
1784 cpu_str,
1785 io_str,
1786 entry
1787 .detail
1788 .as_deref()
1789 .map(|detail| format!(" [{}]", detail))
1790 .unwrap_or_default()
1791 ));
1792 }
1793 if processes.len() > max_entries {
1794 out.push_str(&format!(
1795 "- ... {} more processes omitted\n",
1796 processes.len() - max_entries
1797 ));
1798 }
1799
1800 Ok(out.trim_end().to_string())
1801}
1802
1803fn inspect_network(max_entries: usize) -> Result<String, String> {
1804 let adapters = collect_network_adapters()?;
1805 let active_count = adapters
1806 .iter()
1807 .filter(|adapter| adapter.is_active())
1808 .count();
1809 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1810
1811 let mut out = String::from("Host inspection: network\n\n");
1812 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1813 out.push_str(&format!("- Active adapters: {}\n", active_count));
1814 out.push_str(&format!(
1815 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1816 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1817 ));
1818
1819 if adapters.is_empty() {
1820 out.push_str("\nNo adapter details were detected.");
1821 return Ok(out);
1822 }
1823
1824 out.push_str("\nAdapter summary:\n");
1825 for adapter in adapters.iter().take(max_entries) {
1826 let status = if adapter.is_active() {
1827 "active"
1828 } else if adapter.disconnected {
1829 "disconnected"
1830 } else {
1831 "idle"
1832 };
1833 let mut details = vec![status.to_string()];
1834 if !adapter.ipv4.is_empty() {
1835 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1836 }
1837 if !adapter.ipv6.is_empty() {
1838 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1839 }
1840 if !adapter.gateways.is_empty() {
1841 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1842 }
1843 if !adapter.dns_servers.is_empty() {
1844 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1845 }
1846 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1847 }
1848 if adapters.len() > max_entries {
1849 out.push_str(&format!(
1850 "- ... {} more adapters omitted\n",
1851 adapters.len() - max_entries
1852 ));
1853 }
1854
1855 Ok(out.trim_end().to_string())
1856}
1857
1858fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1859 let mut out = String::from("Host inspection: lan_discovery\n\n");
1860
1861 #[cfg(target_os = "windows")]
1862 {
1863 let n = max_entries.clamp(5, 20);
1864 let adapters = collect_network_adapters()?;
1865 let services = collect_services().unwrap_or_default();
1866 let active_adapters: Vec<&NetworkAdapter> = adapters
1867 .iter()
1868 .filter(|adapter| adapter.is_active())
1869 .collect();
1870 let gateways: Vec<String> = active_adapters
1871 .iter()
1872 .flat_map(|adapter| adapter.gateways.clone())
1873 .collect::<HashSet<_>>()
1874 .into_iter()
1875 .collect();
1876
1877 let neighbor_script = r#"
1878$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1879 Where-Object {
1880 $_.IPAddress -notlike '127.*' -and
1881 $_.IPAddress -notlike '169.254*' -and
1882 $_.State -notin @('Unreachable','Invalid')
1883 } |
1884 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1885$neighbors | ConvertTo-Json -Compress
1886"#;
1887 let neighbor_text = Command::new("powershell")
1888 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1889 .output()
1890 .ok()
1891 .and_then(|o| String::from_utf8(o.stdout).ok())
1892 .unwrap_or_default();
1893 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1894 .into_iter()
1895 .take(n)
1896 .collect();
1897
1898 let listener_script = r#"
1899Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1900 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1901 Select-Object LocalAddress, LocalPort, OwningProcess |
1902 ForEach-Object {
1903 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1904 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1905 }
1906"#;
1907 let listener_text = Command::new("powershell")
1908 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1909 .output()
1910 .ok()
1911 .and_then(|o| String::from_utf8(o.stdout).ok())
1912 .unwrap_or_default();
1913 let listeners: Vec<(String, u16, String, String)> = listener_text
1914 .lines()
1915 .filter_map(|line| {
1916 let parts: Vec<&str> = line.trim().split('|').collect();
1917 if parts.len() < 4 {
1918 return None;
1919 }
1920 Some((
1921 parts[0].to_string(),
1922 parts[1].parse::<u16>().ok()?,
1923 parts[2].to_string(),
1924 parts[3].to_string(),
1925 ))
1926 })
1927 .take(n)
1928 .collect();
1929
1930 let smb_mapping_script = r#"
1931Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1932 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1933"#;
1934 let smb_mappings: Vec<String> = Command::new("powershell")
1935 .args([
1936 "-NoProfile",
1937 "-NonInteractive",
1938 "-Command",
1939 smb_mapping_script,
1940 ])
1941 .output()
1942 .ok()
1943 .and_then(|o| String::from_utf8(o.stdout).ok())
1944 .unwrap_or_default()
1945 .lines()
1946 .take(n)
1947 .map(|line| line.trim().to_string())
1948 .filter(|line| !line.is_empty())
1949 .collect();
1950
1951 let smb_connections_script = r#"
1952Get-SmbConnection -ErrorAction SilentlyContinue |
1953 Select-Object ServerName, ShareName, NumOpens |
1954 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1955"#;
1956 let smb_connections: Vec<String> = Command::new("powershell")
1957 .args([
1958 "-NoProfile",
1959 "-NonInteractive",
1960 "-Command",
1961 smb_connections_script,
1962 ])
1963 .output()
1964 .ok()
1965 .and_then(|o| String::from_utf8(o.stdout).ok())
1966 .unwrap_or_default()
1967 .lines()
1968 .take(n)
1969 .map(|line| line.trim().to_string())
1970 .filter(|line| !line.is_empty())
1971 .collect();
1972
1973 let discovery_service_names = [
1974 "FDResPub",
1975 "fdPHost",
1976 "SSDPSRV",
1977 "upnphost",
1978 "LanmanServer",
1979 "LanmanWorkstation",
1980 "lmhosts",
1981 ];
1982 let discovery_services: Vec<&ServiceEntry> = services
1983 .iter()
1984 .filter(|entry| {
1985 discovery_service_names
1986 .iter()
1987 .any(|name| entry.name.eq_ignore_ascii_case(name))
1988 })
1989 .collect();
1990
1991 let mut findings = Vec::new();
1992 if active_adapters.is_empty() {
1993 findings.push(AuditFinding {
1994 finding: "No active LAN adapters were detected.".to_string(),
1995 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1996 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(),
1997 });
1998 }
1999
2000 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2001 .iter()
2002 .copied()
2003 .filter(|entry| {
2004 !entry.status.eq_ignore_ascii_case("running")
2005 && !entry.status.eq_ignore_ascii_case("active")
2006 })
2007 .collect();
2008 if !stopped_discovery_services.is_empty() {
2009 let names = stopped_discovery_services
2010 .iter()
2011 .map(|entry| entry.name.as_str())
2012 .collect::<Vec<_>>()
2013 .join(", ");
2014 findings.push(AuditFinding {
2015 finding: format!("Discovery-related services are not running: {names}"),
2016 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2017 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(),
2018 });
2019 }
2020
2021 if listeners.is_empty() {
2022 findings.push(AuditFinding {
2023 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2024 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2025 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(),
2026 });
2027 }
2028
2029 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2030 findings.push(AuditFinding {
2031 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2032 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2033 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(),
2034 });
2035 }
2036
2037 out.push_str("=== Findings ===\n");
2038 if findings.is_empty() {
2039 out.push_str(
2040 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2041 );
2042 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2043 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");
2044 } else {
2045 for finding in &findings {
2046 out.push_str(&format!("- Finding: {}\n", finding.finding));
2047 out.push_str(&format!(" Impact: {}\n", finding.impact));
2048 out.push_str(&format!(" Fix: {}\n", finding.fix));
2049 }
2050 }
2051
2052 out.push_str("\n=== Active adapter and gateway summary ===\n");
2053 if active_adapters.is_empty() {
2054 out.push_str("- No active adapters detected.\n");
2055 } else {
2056 for adapter in active_adapters.iter().take(n) {
2057 let ipv4 = if adapter.ipv4.is_empty() {
2058 "no IPv4".to_string()
2059 } else {
2060 adapter.ipv4.join(", ")
2061 };
2062 let gateway = if adapter.gateways.is_empty() {
2063 "no gateway".to_string()
2064 } else {
2065 adapter.gateways.join(", ")
2066 };
2067 out.push_str(&format!(
2068 "- {} | IPv4: {} | Gateway: {}\n",
2069 adapter.name, ipv4, gateway
2070 ));
2071 }
2072 }
2073
2074 out.push_str("\n=== Neighborhood evidence ===\n");
2075 out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2076 out.push_str(&format!(
2077 "- Neighbor entries observed: {}\n",
2078 neighbors.len()
2079 ));
2080 if neighbors.is_empty() {
2081 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2082 } else {
2083 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2084 out.push_str(&format!(
2085 "- {} on {} | MAC: {} | State: {}\n",
2086 ip, iface, mac, state
2087 ));
2088 }
2089 }
2090
2091 out.push_str("\n=== Discovery services ===\n");
2092 if discovery_services.is_empty() {
2093 out.push_str("- Discovery service status unavailable.\n");
2094 } else {
2095 for entry in discovery_services.iter().take(n) {
2096 let startup = entry.startup.as_deref().unwrap_or("unknown");
2097 out.push_str(&format!(
2098 "- {} | Status: {} | Startup: {}\n",
2099 entry.name, entry.status, startup
2100 ));
2101 }
2102 }
2103
2104 out.push_str("\n=== Discovery listener surface ===\n");
2105 if listeners.is_empty() {
2106 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2107 } else {
2108 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2109 let label = match *port {
2110 137 => "NetBIOS Name Service",
2111 138 => "NetBIOS Datagram",
2112 1900 => "SSDP/UPnP",
2113 5353 => "mDNS",
2114 5355 => "LLMNR",
2115 _ => "Discovery",
2116 };
2117 let proc_label = if proc_name.is_empty() {
2118 "unknown".to_string()
2119 } else {
2120 proc_name.clone()
2121 };
2122 out.push_str(&format!(
2123 "- {}:{} | {} | PID {} ({})\n",
2124 addr, port, label, pid, proc_label
2125 ));
2126 }
2127 }
2128
2129 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2130 if smb_mappings.is_empty() && smb_connections.is_empty() {
2131 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2132 } else {
2133 if !smb_mappings.is_empty() {
2134 out.push_str("- Mapped drives:\n");
2135 for mapping in smb_mappings.iter().take(n) {
2136 let parts: Vec<&str> = mapping.split('|').collect();
2137 if parts.len() >= 2 {
2138 out.push_str(&format!(" - {} -> {}\n", parts[0], parts[1]));
2139 }
2140 }
2141 }
2142 if !smb_connections.is_empty() {
2143 out.push_str("- Active SMB connections:\n");
2144 for connection in smb_connections.iter().take(n) {
2145 let parts: Vec<&str> = connection.split('|').collect();
2146 if parts.len() >= 3 {
2147 out.push_str(&format!(
2148 " - {}\\{} | Opens: {}\n",
2149 parts[0], parts[1], parts[2]
2150 ));
2151 }
2152 }
2153 }
2154 }
2155 }
2156
2157 #[cfg(not(target_os = "windows"))]
2158 {
2159 let n = max_entries.clamp(5, 20);
2160 let adapters = collect_network_adapters()?;
2161 let arp_output = Command::new("ip")
2162 .args(["neigh"])
2163 .output()
2164 .ok()
2165 .and_then(|o| String::from_utf8(o.stdout).ok())
2166 .unwrap_or_default();
2167 let neighbors: Vec<&str> = arp_output
2168 .lines()
2169 .filter(|line| !line.trim().is_empty())
2170 .take(n)
2171 .collect();
2172
2173 out.push_str("=== Findings ===\n");
2174 if adapters.iter().any(|adapter| adapter.is_active()) {
2175 out.push_str(
2176 "- Finding: LAN discovery support is partially available on this platform.\n",
2177 );
2178 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2179 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2180 } else {
2181 out.push_str("- Finding: No active LAN adapters were detected.\n");
2182 out.push_str(
2183 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2184 );
2185 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2186 }
2187
2188 out.push_str("\n=== Active adapter and gateway summary ===\n");
2189 if adapters.is_empty() {
2190 out.push_str("- No adapters detected.\n");
2191 } else {
2192 for adapter in adapters.iter().take(n) {
2193 let ipv4 = if adapter.ipv4.is_empty() {
2194 "no IPv4".to_string()
2195 } else {
2196 adapter.ipv4.join(", ")
2197 };
2198 let gateway = if adapter.gateways.is_empty() {
2199 "no gateway".to_string()
2200 } else {
2201 adapter.gateways.join(", ")
2202 };
2203 out.push_str(&format!(
2204 "- {} | IPv4: {} | Gateway: {}\n",
2205 adapter.name, ipv4, gateway
2206 ));
2207 }
2208 }
2209
2210 out.push_str("\n=== Neighborhood evidence ===\n");
2211 if neighbors.is_empty() {
2212 out.push_str("- No neighbor entries detected.\n");
2213 } else {
2214 for line in neighbors {
2215 out.push_str(&format!("- {}\n", line.trim()));
2216 }
2217 }
2218 }
2219
2220 Ok(out.trim_end().to_string())
2221}
2222
2223fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2224 let mut services = collect_services()?;
2225 if let Some(filter) = name_filter.as_deref() {
2226 let lowered = filter.to_ascii_lowercase();
2227 services.retain(|entry| {
2228 entry.name.to_ascii_lowercase().contains(&lowered)
2229 || entry
2230 .display_name
2231 .as_deref()
2232 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2233 .unwrap_or(false)
2234 });
2235 }
2236
2237 services.sort_by(|a, b| {
2238 let a_running =
2239 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2240 let b_running =
2241 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2242 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2243 });
2244
2245 let running = services
2246 .iter()
2247 .filter(|entry| {
2248 entry.status.eq_ignore_ascii_case("running")
2249 || entry.status.eq_ignore_ascii_case("active")
2250 })
2251 .count();
2252 let failed = services
2253 .iter()
2254 .filter(|entry| {
2255 entry.status.eq_ignore_ascii_case("failed")
2256 || entry.status.eq_ignore_ascii_case("error")
2257 || entry.status.eq_ignore_ascii_case("stopped")
2258 })
2259 .count();
2260
2261 let mut out = String::from("Host inspection: services\n\n");
2262 if let Some(filter) = name_filter.as_deref() {
2263 out.push_str(&format!("- Filter name: {}\n", filter));
2264 }
2265 out.push_str(&format!("- Services found: {}\n", services.len()));
2266 out.push_str(&format!("- Running/active: {}\n", running));
2267 out.push_str(&format!("- Failed/stopped: {}\n", failed));
2268
2269 if services.is_empty() {
2270 out.push_str("\nNo services matched.");
2271 return Ok(out);
2272 }
2273
2274 let per_section = (max_entries / 2).max(5);
2276
2277 let running_services: Vec<_> = services
2278 .iter()
2279 .filter(|e| {
2280 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2281 })
2282 .collect();
2283 let stopped_services: Vec<_> = services
2284 .iter()
2285 .filter(|e| {
2286 e.status.eq_ignore_ascii_case("stopped")
2287 || e.status.eq_ignore_ascii_case("failed")
2288 || e.status.eq_ignore_ascii_case("error")
2289 })
2290 .collect();
2291
2292 let fmt_entry = |entry: &&ServiceEntry| {
2293 let startup = entry
2294 .startup
2295 .as_deref()
2296 .map(|v| format!(" | startup {}", v))
2297 .unwrap_or_default();
2298 let logon = entry
2299 .start_name
2300 .as_deref()
2301 .map(|v| format!(" | LogOn: {}", v))
2302 .unwrap_or_default();
2303 let display = entry
2304 .display_name
2305 .as_deref()
2306 .filter(|v| *v != &entry.name)
2307 .map(|v| format!(" [{}]", v))
2308 .unwrap_or_default();
2309 format!(
2310 "- {}{} - {}{}{}\n",
2311 entry.name, display, entry.status, startup, logon
2312 )
2313 };
2314
2315 out.push_str(&format!(
2316 "\nRunning services ({} total, showing up to {}):\n",
2317 running_services.len(),
2318 per_section
2319 ));
2320 for entry in running_services.iter().take(per_section) {
2321 out.push_str(&fmt_entry(entry));
2322 }
2323 if running_services.len() > per_section {
2324 out.push_str(&format!(
2325 "- ... {} more running services omitted\n",
2326 running_services.len() - per_section
2327 ));
2328 }
2329
2330 out.push_str(&format!(
2331 "\nStopped/failed services ({} total, showing up to {}):\n",
2332 stopped_services.len(),
2333 per_section
2334 ));
2335 for entry in stopped_services.iter().take(per_section) {
2336 out.push_str(&fmt_entry(entry));
2337 }
2338 if stopped_services.len() > per_section {
2339 out.push_str(&format!(
2340 "- ... {} more stopped services omitted\n",
2341 stopped_services.len() - per_section
2342 ));
2343 }
2344
2345 Ok(out.trim_end().to_string())
2346}
2347
2348async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2349 inspect_directory("Disk", path, max_entries).await
2350}
2351
2352fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2353 let mut listeners = collect_listening_ports()?;
2354 if let Some(port) = port_filter {
2355 listeners.retain(|entry| entry.port == port);
2356 }
2357 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2358
2359 let mut out = String::from("Host inspection: ports\n\n");
2360 if let Some(port) = port_filter {
2361 out.push_str(&format!("- Filter port: {}\n", port));
2362 }
2363 out.push_str(&format!(
2364 "- Listening endpoints found: {}\n",
2365 listeners.len()
2366 ));
2367
2368 if listeners.is_empty() {
2369 out.push_str("\nNo listening endpoints matched.");
2370 return Ok(out);
2371 }
2372
2373 out.push_str("\nListening endpoints:\n");
2374 for entry in listeners.iter().take(max_entries) {
2375 let pid_str = entry
2376 .pid
2377 .as_deref()
2378 .map(|p| format!(" pid {}", p))
2379 .unwrap_or_default();
2380 let name_str = entry
2381 .process_name
2382 .as_deref()
2383 .map(|n| format!(" [{}]", n))
2384 .unwrap_or_default();
2385 out.push_str(&format!(
2386 "- {} {} ({}){}{}\n",
2387 entry.protocol, entry.local, entry.state, pid_str, name_str
2388 ));
2389 }
2390 if listeners.len() > max_entries {
2391 out.push_str(&format!(
2392 "- ... {} more listening endpoints omitted\n",
2393 listeners.len() - max_entries
2394 ));
2395 }
2396
2397 Ok(out.trim_end().to_string())
2398}
2399
2400fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2401 if !path.exists() {
2402 return Err(format!("Path does not exist: {}", path.display()));
2403 }
2404 if !path.is_dir() {
2405 return Err(format!("Path is not a directory: {}", path.display()));
2406 }
2407
2408 let markers = collect_project_markers(&path);
2409 let hematite_state = collect_hematite_state(&path);
2410 let git_state = inspect_git_state(&path);
2411 let release_state = inspect_release_artifacts(&path);
2412
2413 let mut out = String::from("Host inspection: repo_doctor\n\n");
2414 out.push_str(&format!("- Path: {}\n", path.display()));
2415 out.push_str(&format!(
2416 "- Workspace mode: {}\n",
2417 workspace_mode_for_path(&path)
2418 ));
2419
2420 if markers.is_empty() {
2421 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");
2422 } else {
2423 out.push_str("- Project markers:\n");
2424 for marker in markers.iter().take(max_entries) {
2425 out.push_str(&format!(" - {}\n", marker));
2426 }
2427 }
2428
2429 match git_state {
2430 Some(git) => {
2431 out.push_str(&format!("- Git root: {}\n", git.root.display()));
2432 out.push_str(&format!("- Git branch: {}\n", git.branch));
2433 out.push_str(&format!("- Git status: {}\n", git.status_label()));
2434 }
2435 None => out.push_str("- Git: not inside a detected work tree\n"),
2436 }
2437
2438 out.push_str(&format!(
2439 "- Hematite docs/imports/reports: {}/{}/{}\n",
2440 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2441 ));
2442 if hematite_state.workspace_profile {
2443 out.push_str("- Workspace profile: present\n");
2444 } else {
2445 out.push_str("- Workspace profile: absent\n");
2446 }
2447
2448 if let Some(release) = release_state {
2449 out.push_str(&format!("- Cargo version: {}\n", release.version));
2450 out.push_str(&format!(
2451 "- Windows artifacts for current version: {}/{}/{}\n",
2452 bool_label(release.portable_dir),
2453 bool_label(release.portable_zip),
2454 bool_label(release.setup_exe)
2455 ));
2456 }
2457
2458 Ok(out.trim_end().to_string())
2459}
2460
2461async fn inspect_known_directory(
2462 label: &str,
2463 path: Option<PathBuf>,
2464 max_entries: usize,
2465) -> Result<String, String> {
2466 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2467 inspect_directory(label, path, max_entries).await
2468}
2469
2470async fn inspect_directory(
2471 label: &str,
2472 path: PathBuf,
2473 max_entries: usize,
2474) -> Result<String, String> {
2475 let label = label.to_string();
2476 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2477 .await
2478 .map_err(|e| format!("inspect_host task failed: {e}"))?
2479}
2480
2481fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2482 if !path.exists() {
2483 return Err(format!("Path does not exist: {}", path.display()));
2484 }
2485 if !path.is_dir() {
2486 return Err(format!("Path is not a directory: {}", path.display()));
2487 }
2488
2489 let mut top_level_entries = Vec::new();
2490 for entry in fs::read_dir(path)
2491 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2492 {
2493 match entry {
2494 Ok(entry) => top_level_entries.push(entry),
2495 Err(_) => continue,
2496 }
2497 }
2498 top_level_entries.sort_by_key(|entry| entry.file_name());
2499
2500 let top_level_count = top_level_entries.len();
2501 let mut sample_names = Vec::new();
2502 let mut largest_entries = Vec::new();
2503 let mut aggregate = PathAggregate::default();
2504 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2505
2506 for entry in top_level_entries {
2507 let name = entry.file_name().to_string_lossy().to_string();
2508 if sample_names.len() < max_entries {
2509 sample_names.push(name.clone());
2510 }
2511 let kind = match entry.file_type() {
2512 Ok(ft) if ft.is_dir() => "dir",
2513 Ok(ft) if ft.is_symlink() => "symlink",
2514 _ => "file",
2515 };
2516 let stats = measure_path(&entry.path(), &mut budget);
2517 aggregate.merge(&stats);
2518 largest_entries.push(LargestEntry {
2519 name,
2520 kind,
2521 bytes: stats.total_bytes,
2522 });
2523 }
2524
2525 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2526
2527 let mut out = format!("Directory inspection: {}\n\n", label);
2528 out.push_str(&format!("- Path: {}\n", path.display()));
2529 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2530 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2531 out.push_str(&format!(
2532 "- Recursive directories: {}\n",
2533 aggregate.dir_count
2534 ));
2535 out.push_str(&format!(
2536 "- Total size: {}{}\n",
2537 human_bytes(aggregate.total_bytes),
2538 if aggregate.partial {
2539 " (partial scan)"
2540 } else {
2541 ""
2542 }
2543 ));
2544 if aggregate.skipped_entries > 0 {
2545 out.push_str(&format!(
2546 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2547 aggregate.skipped_entries
2548 ));
2549 }
2550
2551 if !largest_entries.is_empty() {
2552 out.push_str("\nLargest top-level entries:\n");
2553 for entry in largest_entries.iter().take(max_entries) {
2554 out.push_str(&format!(
2555 "- {} [{}] - {}\n",
2556 entry.name,
2557 entry.kind,
2558 human_bytes(entry.bytes)
2559 ));
2560 }
2561 }
2562
2563 if !sample_names.is_empty() {
2564 out.push_str("\nSample names:\n");
2565 for name in sample_names {
2566 out.push_str(&format!("- {}\n", name));
2567 }
2568 }
2569
2570 Ok(out.trim_end().to_string())
2571}
2572
2573fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2574 let trimmed = raw.trim();
2575 if trimmed.is_empty() {
2576 return Err("Path must not be empty.".to_string());
2577 }
2578
2579 if let Some(rest) = trimmed
2580 .strip_prefix("~/")
2581 .or_else(|| trimmed.strip_prefix("~\\"))
2582 {
2583 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2584 return Ok(home.join(rest));
2585 }
2586
2587 let path = PathBuf::from(trimmed);
2588 if path.is_absolute() {
2589 Ok(path)
2590 } else {
2591 let cwd =
2592 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2593 let full_path = cwd.join(&path);
2594
2595 if !full_path.exists()
2598 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2599 {
2600 if let Some(home) = home::home_dir() {
2601 let home_path = home.join(trimmed);
2602 if home_path.exists() {
2603 return Ok(home_path);
2604 }
2605 }
2606 }
2607
2608 Ok(full_path)
2609 }
2610}
2611
2612fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2613 workspace_mode_for_path(workspace_root)
2614}
2615
2616fn workspace_mode_for_path(path: &Path) -> &'static str {
2617 if is_project_marker_path(path) {
2618 "project"
2619 } else if path.join(".hematite").join("docs").exists()
2620 || path.join(".hematite").join("imports").exists()
2621 || path.join(".hematite").join("reports").exists()
2622 {
2623 "docs-only"
2624 } else {
2625 "general directory"
2626 }
2627}
2628
2629fn is_project_marker_path(path: &Path) -> bool {
2630 [
2631 "Cargo.toml",
2632 "package.json",
2633 "pyproject.toml",
2634 "go.mod",
2635 "composer.json",
2636 "requirements.txt",
2637 "Makefile",
2638 "justfile",
2639 ]
2640 .iter()
2641 .any(|name| path.join(name).exists())
2642 || path.join(".git").exists()
2643}
2644
2645fn preferred_shell_label() -> &'static str {
2646 #[cfg(target_os = "windows")]
2647 {
2648 "PowerShell"
2649 }
2650 #[cfg(not(target_os = "windows"))]
2651 {
2652 "sh"
2653 }
2654}
2655
2656fn desktop_dir() -> Option<PathBuf> {
2657 home::home_dir().map(|home| home.join("Desktop"))
2658}
2659
2660fn downloads_dir() -> Option<PathBuf> {
2661 home::home_dir().map(|home| home.join("Downloads"))
2662}
2663
2664fn count_top_level_items(path: &Path) -> Result<usize, String> {
2665 let mut count = 0usize;
2666 for entry in
2667 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2668 {
2669 if entry.is_ok() {
2670 count += 1;
2671 }
2672 }
2673 Ok(count)
2674}
2675
2676#[derive(Default)]
2677struct PathAggregate {
2678 total_bytes: u64,
2679 file_count: u64,
2680 dir_count: u64,
2681 skipped_entries: u64,
2682 partial: bool,
2683}
2684
2685impl PathAggregate {
2686 fn merge(&mut self, other: &PathAggregate) {
2687 self.total_bytes += other.total_bytes;
2688 self.file_count += other.file_count;
2689 self.dir_count += other.dir_count;
2690 self.skipped_entries += other.skipped_entries;
2691 self.partial |= other.partial;
2692 }
2693}
2694
2695struct LargestEntry {
2696 name: String,
2697 kind: &'static str,
2698 bytes: u64,
2699}
2700
2701fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2702 if *budget == 0 {
2703 return PathAggregate {
2704 partial: true,
2705 skipped_entries: 1,
2706 ..PathAggregate::default()
2707 };
2708 }
2709 *budget -= 1;
2710
2711 let metadata = match fs::symlink_metadata(path) {
2712 Ok(metadata) => metadata,
2713 Err(_) => {
2714 return PathAggregate {
2715 skipped_entries: 1,
2716 ..PathAggregate::default()
2717 }
2718 }
2719 };
2720
2721 let file_type = metadata.file_type();
2722 if file_type.is_symlink() {
2723 return PathAggregate {
2724 skipped_entries: 1,
2725 ..PathAggregate::default()
2726 };
2727 }
2728
2729 if metadata.is_file() {
2730 return PathAggregate {
2731 total_bytes: metadata.len(),
2732 file_count: 1,
2733 ..PathAggregate::default()
2734 };
2735 }
2736
2737 if !metadata.is_dir() {
2738 return PathAggregate::default();
2739 }
2740
2741 let mut aggregate = PathAggregate {
2742 dir_count: 1,
2743 ..PathAggregate::default()
2744 };
2745
2746 let read_dir = match fs::read_dir(path) {
2747 Ok(read_dir) => read_dir,
2748 Err(_) => {
2749 aggregate.skipped_entries += 1;
2750 return aggregate;
2751 }
2752 };
2753
2754 for child in read_dir {
2755 match child {
2756 Ok(child) => {
2757 let child_stats = measure_path(&child.path(), budget);
2758 aggregate.merge(&child_stats);
2759 }
2760 Err(_) => aggregate.skipped_entries += 1,
2761 }
2762 }
2763
2764 aggregate
2765}
2766
2767struct PathAnalysis {
2768 total_entries: usize,
2769 unique_entries: usize,
2770 entries: Vec<String>,
2771 duplicate_entries: Vec<String>,
2772 missing_entries: Vec<String>,
2773}
2774
2775fn analyze_path_env() -> PathAnalysis {
2776 let mut entries = Vec::new();
2777 let mut duplicate_entries = Vec::new();
2778 let mut missing_entries = Vec::new();
2779 let mut seen = HashSet::new();
2780
2781 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2782 for path in std::env::split_paths(&raw_path) {
2783 let display = path.display().to_string();
2784 if display.trim().is_empty() {
2785 continue;
2786 }
2787
2788 let normalized = normalize_path_entry(&display);
2789 if !seen.insert(normalized) {
2790 duplicate_entries.push(display.clone());
2791 }
2792 if !path.exists() {
2793 missing_entries.push(display.clone());
2794 }
2795 entries.push(display);
2796 }
2797
2798 let total_entries = entries.len();
2799 let unique_entries = seen.len();
2800
2801 PathAnalysis {
2802 total_entries,
2803 unique_entries,
2804 entries,
2805 duplicate_entries,
2806 missing_entries,
2807 }
2808}
2809
2810fn normalize_path_entry(value: &str) -> String {
2811 #[cfg(target_os = "windows")]
2812 {
2813 value
2814 .replace('/', "\\")
2815 .trim_end_matches(['\\', '/'])
2816 .to_ascii_lowercase()
2817 }
2818 #[cfg(not(target_os = "windows"))]
2819 {
2820 value.trim_end_matches('/').to_string()
2821 }
2822}
2823
2824struct ToolchainReport {
2825 found: Vec<(String, String)>,
2826 missing: Vec<String>,
2827}
2828
2829struct PackageManagerReport {
2830 found: Vec<(String, String)>,
2831}
2832
2833#[derive(Debug, Clone)]
2834struct ProcessEntry {
2835 name: String,
2836 pid: u32,
2837 memory_bytes: u64,
2838 cpu_seconds: Option<f64>,
2839 cpu_percent: Option<f64>,
2840 read_ops: Option<u64>,
2841 write_ops: Option<u64>,
2842 detail: Option<String>,
2843}
2844
2845#[derive(Debug, Clone)]
2846struct ServiceEntry {
2847 name: String,
2848 status: String,
2849 startup: Option<String>,
2850 display_name: Option<String>,
2851 start_name: Option<String>,
2852}
2853
2854#[derive(Debug, Clone, Default)]
2855struct NetworkAdapter {
2856 name: String,
2857 ipv4: Vec<String>,
2858 ipv6: Vec<String>,
2859 gateways: Vec<String>,
2860 dns_servers: Vec<String>,
2861 disconnected: bool,
2862}
2863
2864impl NetworkAdapter {
2865 fn is_active(&self) -> bool {
2866 !self.disconnected
2867 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2868 }
2869}
2870
2871#[derive(Debug, Clone, Copy, Default)]
2872struct ListenerExposureSummary {
2873 loopback_only: usize,
2874 wildcard_public: usize,
2875 specific_bind: usize,
2876}
2877
2878#[derive(Debug, Clone)]
2879struct ListeningPort {
2880 protocol: String,
2881 local: String,
2882 port: u16,
2883 state: String,
2884 pid: Option<String>,
2885 process_name: Option<String>,
2886}
2887
2888fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2889 #[cfg(target_os = "windows")]
2890 {
2891 collect_windows_listening_ports()
2892 }
2893 #[cfg(not(target_os = "windows"))]
2894 {
2895 collect_unix_listening_ports()
2896 }
2897}
2898
2899fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2900 #[cfg(target_os = "windows")]
2901 {
2902 collect_windows_network_adapters()
2903 }
2904 #[cfg(not(target_os = "windows"))]
2905 {
2906 collect_unix_network_adapters()
2907 }
2908}
2909
2910fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2911 #[cfg(target_os = "windows")]
2912 {
2913 collect_windows_services()
2914 }
2915 #[cfg(not(target_os = "windows"))]
2916 {
2917 collect_unix_services()
2918 }
2919}
2920
2921#[cfg(target_os = "windows")]
2922fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2923 let output = Command::new("netstat")
2924 .args(["-ano", "-p", "tcp"])
2925 .output()
2926 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2927 if !output.status.success() {
2928 return Err("netstat returned a non-success status.".to_string());
2929 }
2930
2931 let text = String::from_utf8_lossy(&output.stdout);
2932 let mut listeners = Vec::new();
2933 for line in text.lines() {
2934 let trimmed = line.trim();
2935 if !trimmed.starts_with("TCP") {
2936 continue;
2937 }
2938 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2939 if cols.len() < 5 || cols[3] != "LISTENING" {
2940 continue;
2941 }
2942 let Some(port) = extract_port_from_socket(cols[1]) else {
2943 continue;
2944 };
2945 listeners.push(ListeningPort {
2946 protocol: cols[0].to_string(),
2947 local: cols[1].to_string(),
2948 port,
2949 state: cols[3].to_string(),
2950 pid: Some(cols[4].to_string()),
2951 process_name: None,
2952 });
2953 }
2954
2955 let unique_pids: Vec<String> = listeners
2958 .iter()
2959 .filter_map(|l| l.pid.clone())
2960 .collect::<HashSet<_>>()
2961 .into_iter()
2962 .collect();
2963
2964 if !unique_pids.is_empty() {
2965 let pid_list = unique_pids.join(",");
2966 let ps_cmd = format!(
2967 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2968 pid_list
2969 );
2970 if let Ok(ps_out) = Command::new("powershell")
2971 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2972 .output()
2973 {
2974 let mut pid_map = std::collections::HashMap::<String, String>::new();
2975 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2976 for line in ps_text.lines() {
2977 let parts: Vec<&str> = line.split_whitespace().collect();
2978 if parts.len() >= 2 {
2979 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2980 }
2981 }
2982 for listener in &mut listeners {
2983 if let Some(pid) = &listener.pid {
2984 listener.process_name = pid_map.get(pid).cloned();
2985 }
2986 }
2987 }
2988 }
2989
2990 Ok(listeners)
2991}
2992
2993#[cfg(not(target_os = "windows"))]
2994fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2995 let output = Command::new("ss")
2996 .args(["-ltn"])
2997 .output()
2998 .map_err(|e| format!("Failed to run ss: {e}"))?;
2999 if !output.status.success() {
3000 return Err("ss returned a non-success status.".to_string());
3001 }
3002
3003 let text = String::from_utf8_lossy(&output.stdout);
3004 let mut listeners = Vec::new();
3005 for line in text.lines().skip(1) {
3006 let cols: Vec<&str> = line.split_whitespace().collect();
3007 if cols.len() < 4 {
3008 continue;
3009 }
3010 let Some(port) = extract_port_from_socket(cols[3]) else {
3011 continue;
3012 };
3013 listeners.push(ListeningPort {
3014 protocol: "tcp".to_string(),
3015 local: cols[3].to_string(),
3016 port,
3017 state: cols[0].to_string(),
3018 pid: None,
3019 process_name: None,
3020 });
3021 }
3022
3023 Ok(listeners)
3024}
3025
3026fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3027 #[cfg(target_os = "windows")]
3028 {
3029 collect_windows_processes()
3030 }
3031 #[cfg(not(target_os = "windows"))]
3032 {
3033 collect_unix_processes()
3034 }
3035}
3036
3037#[cfg(target_os = "windows")]
3038fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3039 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3040 let output = Command::new("powershell")
3041 .args(["-NoProfile", "-Command", command])
3042 .output()
3043 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3044 if !output.status.success() {
3045 return Err("PowerShell service inspection returned a non-success status.".to_string());
3046 }
3047
3048 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3049}
3050
3051#[cfg(not(target_os = "windows"))]
3052fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3053 let status_output = Command::new("systemctl")
3054 .args([
3055 "list-units",
3056 "--type=service",
3057 "--all",
3058 "--no-pager",
3059 "--no-legend",
3060 "--plain",
3061 ])
3062 .output()
3063 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3064 if !status_output.status.success() {
3065 return Err("systemctl list-units returned a non-success status.".to_string());
3066 }
3067
3068 let startup_output = Command::new("systemctl")
3069 .args([
3070 "list-unit-files",
3071 "--type=service",
3072 "--no-legend",
3073 "--no-pager",
3074 "--plain",
3075 ])
3076 .output()
3077 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3078 if !startup_output.status.success() {
3079 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3080 }
3081
3082 Ok(parse_unix_services(
3083 &String::from_utf8_lossy(&status_output.stdout),
3084 &String::from_utf8_lossy(&startup_output.stdout),
3085 ))
3086}
3087
3088#[cfg(target_os = "windows")]
3089fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3090 let output = Command::new("ipconfig")
3091 .args(["/all"])
3092 .output()
3093 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3094 if !output.status.success() {
3095 return Err("ipconfig returned a non-success status.".to_string());
3096 }
3097
3098 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3099 &output.stdout,
3100 )))
3101}
3102
3103#[cfg(not(target_os = "windows"))]
3104fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3105 let addr_output = Command::new("ip")
3106 .args(["-o", "addr", "show", "up"])
3107 .output()
3108 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3109 if !addr_output.status.success() {
3110 return Err("ip addr returned a non-success status.".to_string());
3111 }
3112
3113 let route_output = Command::new("ip")
3114 .args(["route", "show", "default"])
3115 .output()
3116 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3117 if !route_output.status.success() {
3118 return Err("ip route returned a non-success status.".to_string());
3119 }
3120
3121 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3122 apply_unix_default_routes(
3123 &mut adapters,
3124 &String::from_utf8_lossy(&route_output.stdout),
3125 );
3126 apply_unix_dns_servers(&mut adapters);
3127 Ok(adapters)
3128}
3129
3130#[cfg(target_os = "windows")]
3131fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3132 let script = r#"
3134 $s1 = Get-Process | Select-Object Id, CPU
3135 Start-Sleep -Milliseconds 250
3136 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3137 $s2 | ForEach-Object {
3138 $p2 = $_
3139 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3140 $pct = 0.0
3141 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3142 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3143 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3144 # Standard Task Manager style is (delta / interval) * 100.
3145 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3146 }
3147 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3148 }
3149 "#;
3150
3151 let output = Command::new("powershell")
3152 .args(["-NoProfile", "-Command", script])
3153 .output()
3154 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3155
3156 let text = String::from_utf8_lossy(&output.stdout);
3157 let mut out = Vec::new();
3158 for line in text.lines() {
3159 let parts: Vec<&str> = line.trim().split('|').collect();
3160 if parts.len() < 5 {
3161 continue;
3162 }
3163 let mut entry = ProcessEntry {
3164 name: "unknown".to_string(),
3165 pid: 0,
3166 memory_bytes: 0,
3167 cpu_seconds: None,
3168 cpu_percent: None,
3169 read_ops: None,
3170 write_ops: None,
3171 detail: None,
3172 };
3173 for p in parts {
3174 if let Some((k, v)) = p.split_once(':') {
3175 match k {
3176 "PID" => entry.pid = v.parse().unwrap_or(0),
3177 "NAME" => entry.name = v.to_string(),
3178 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3179 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3180 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3181 "READ" => entry.read_ops = v.parse().ok(),
3182 "WRITE" => entry.write_ops = v.parse().ok(),
3183 _ => {}
3184 }
3185 }
3186 }
3187 out.push(entry);
3188 }
3189 Ok(out)
3190}
3191
3192#[cfg(not(target_os = "windows"))]
3193fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3194 let output = Command::new("ps")
3195 .args(["-eo", "pid=,rss=,comm="])
3196 .output()
3197 .map_err(|e| format!("Failed to run ps: {e}"))?;
3198 if !output.status.success() {
3199 return Err("ps returned a non-success status.".to_string());
3200 }
3201
3202 let text = String::from_utf8_lossy(&output.stdout);
3203 let mut processes = Vec::new();
3204 for line in text.lines() {
3205 let cols: Vec<&str> = line.split_whitespace().collect();
3206 if cols.len() < 3 {
3207 continue;
3208 }
3209 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3210 else {
3211 continue;
3212 };
3213 processes.push(ProcessEntry {
3214 name: cols[2..].join(" "),
3215 pid,
3216 memory_bytes: rss_kib * 1024,
3217 cpu_seconds: None,
3218 cpu_percent: None,
3219 read_ops: None,
3220 write_ops: None,
3221 detail: None,
3222 });
3223 }
3224
3225 Ok(processes)
3226}
3227
3228fn extract_port_from_socket(value: &str) -> Option<u16> {
3229 let cleaned = value.trim().trim_matches(['[', ']']);
3230 let port_str = cleaned.rsplit(':').next()?;
3231 port_str.parse::<u16>().ok()
3232}
3233
3234fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3235 let mut summary = ListenerExposureSummary::default();
3236 for entry in listeners {
3237 let local = entry.local.to_ascii_lowercase();
3238 if is_loopback_listener(&local) {
3239 summary.loopback_only += 1;
3240 } else if is_wildcard_listener(&local) {
3241 summary.wildcard_public += 1;
3242 } else {
3243 summary.specific_bind += 1;
3244 }
3245 }
3246 summary
3247}
3248
3249fn is_loopback_listener(local: &str) -> bool {
3250 local.starts_with("127.")
3251 || local.starts_with("[::1]")
3252 || local.starts_with("::1")
3253 || local.starts_with("localhost:")
3254}
3255
3256fn is_wildcard_listener(local: &str) -> bool {
3257 local.starts_with("0.0.0.0:")
3258 || local.starts_with("[::]:")
3259 || local.starts_with(":::")
3260 || local == "*:*"
3261}
3262
3263struct GitState {
3264 root: PathBuf,
3265 branch: String,
3266 dirty_entries: usize,
3267}
3268
3269impl GitState {
3270 fn status_label(&self) -> String {
3271 if self.dirty_entries == 0 {
3272 "clean".to_string()
3273 } else {
3274 format!("dirty ({} changed path(s))", self.dirty_entries)
3275 }
3276 }
3277}
3278
3279fn inspect_git_state(path: &Path) -> Option<GitState> {
3280 let root = capture_first_line(
3281 "git",
3282 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3283 )?;
3284 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3285 .unwrap_or_else(|| "detached".to_string());
3286 let output = Command::new("git")
3287 .args(["-C", path.to_str()?, "status", "--short"])
3288 .output()
3289 .ok()?;
3290 if !output.status.success() {
3291 return None;
3292 }
3293 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3294 Some(GitState {
3295 root: PathBuf::from(root),
3296 branch,
3297 dirty_entries,
3298 })
3299}
3300
3301struct HematiteState {
3302 docs_count: usize,
3303 import_count: usize,
3304 report_count: usize,
3305 workspace_profile: bool,
3306}
3307
3308fn collect_hematite_state(path: &Path) -> HematiteState {
3309 let root = path.join(".hematite");
3310 HematiteState {
3311 docs_count: count_entries_if_exists(&root.join("docs")),
3312 import_count: count_entries_if_exists(&root.join("imports")),
3313 report_count: count_entries_if_exists(&root.join("reports")),
3314 workspace_profile: root.join("workspace_profile.json").exists(),
3315 }
3316}
3317
3318fn count_entries_if_exists(path: &Path) -> usize {
3319 if !path.exists() || !path.is_dir() {
3320 return 0;
3321 }
3322 fs::read_dir(path)
3323 .ok()
3324 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3325 .unwrap_or(0)
3326}
3327
3328fn collect_project_markers(path: &Path) -> Vec<String> {
3329 [
3330 "Cargo.toml",
3331 "package.json",
3332 "pyproject.toml",
3333 "go.mod",
3334 "justfile",
3335 "Makefile",
3336 ".git",
3337 ]
3338 .iter()
3339 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3340 .collect()
3341}
3342
3343struct ReleaseArtifactState {
3344 version: String,
3345 portable_dir: bool,
3346 portable_zip: bool,
3347 setup_exe: bool,
3348}
3349
3350fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3351 let cargo_toml = path.join("Cargo.toml");
3352 if !cargo_toml.exists() {
3353 return None;
3354 }
3355 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3356 let version = [regex_line_capture(
3357 &cargo_text,
3358 r#"(?m)^version\s*=\s*"([^"]+)""#,
3359 )?]
3360 .concat();
3361 let dist_windows = path.join("dist").join("windows");
3362 let prefix = format!("Hematite-{}", version);
3363 Some(ReleaseArtifactState {
3364 version,
3365 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3366 portable_zip: dist_windows
3367 .join(format!("{}-portable.zip", prefix))
3368 .exists(),
3369 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3370 })
3371}
3372
3373fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3374 let regex = regex::Regex::new(pattern).ok()?;
3375 let captures = regex.captures(text)?;
3376 captures.get(1).map(|m| m.as_str().to_string())
3377}
3378
3379fn bool_label(value: bool) -> &'static str {
3380 if value {
3381 "yes"
3382 } else {
3383 "no"
3384 }
3385}
3386
3387fn collect_toolchains() -> ToolchainReport {
3388 let checks = [
3389 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3390 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3391 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3392 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3393 ToolCheck::new(
3394 "npm",
3395 &[
3396 CommandProbe::new("npm", &["--version"]),
3397 CommandProbe::new("npm.cmd", &["--version"]),
3398 ],
3399 ),
3400 ToolCheck::new(
3401 "pnpm",
3402 &[
3403 CommandProbe::new("pnpm", &["--version"]),
3404 CommandProbe::new("pnpm.cmd", &["--version"]),
3405 ],
3406 ),
3407 ToolCheck::new(
3408 "python",
3409 &[
3410 CommandProbe::new("python", &["--version"]),
3411 CommandProbe::new("python3", &["--version"]),
3412 CommandProbe::new("py", &["-3", "--version"]),
3413 CommandProbe::new("py", &["--version"]),
3414 ],
3415 ),
3416 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3417 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3418 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3419 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3420 ];
3421
3422 let mut found = Vec::new();
3423 let mut missing = Vec::new();
3424
3425 for check in checks {
3426 match check.detect() {
3427 Some(version) => found.push((check.label.to_string(), version)),
3428 None => missing.push(check.label.to_string()),
3429 }
3430 }
3431
3432 ToolchainReport { found, missing }
3433}
3434
3435fn collect_package_managers() -> PackageManagerReport {
3436 let checks = [
3437 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3438 ToolCheck::new(
3439 "npm",
3440 &[
3441 CommandProbe::new("npm", &["--version"]),
3442 CommandProbe::new("npm.cmd", &["--version"]),
3443 ],
3444 ),
3445 ToolCheck::new(
3446 "pnpm",
3447 &[
3448 CommandProbe::new("pnpm", &["--version"]),
3449 CommandProbe::new("pnpm.cmd", &["--version"]),
3450 ],
3451 ),
3452 ToolCheck::new(
3453 "pip",
3454 &[
3455 CommandProbe::new("python", &["-m", "pip", "--version"]),
3456 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3457 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3458 CommandProbe::new("py", &["-m", "pip", "--version"]),
3459 CommandProbe::new("pip", &["--version"]),
3460 ],
3461 ),
3462 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3463 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3464 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3465 ToolCheck::new(
3466 "choco",
3467 &[
3468 CommandProbe::new("choco", &["--version"]),
3469 CommandProbe::new("choco.exe", &["--version"]),
3470 ],
3471 ),
3472 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3473 ];
3474
3475 let mut found = Vec::new();
3476 for check in checks {
3477 match check.detect() {
3478 Some(version) => found.push((check.label.to_string(), version)),
3479 None => {}
3480 }
3481 }
3482
3483 PackageManagerReport { found }
3484}
3485
3486#[derive(Clone)]
3487struct ToolCheck {
3488 label: &'static str,
3489 probes: Vec<CommandProbe>,
3490}
3491
3492impl ToolCheck {
3493 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3494 Self {
3495 label,
3496 probes: probes.to_vec(),
3497 }
3498 }
3499
3500 fn detect(&self) -> Option<String> {
3501 for probe in &self.probes {
3502 if let Some(output) = capture_first_line(probe.program, probe.args) {
3503 return Some(output);
3504 }
3505 }
3506 None
3507 }
3508}
3509
3510#[derive(Clone, Copy)]
3511struct CommandProbe {
3512 program: &'static str,
3513 args: &'static [&'static str],
3514}
3515
3516impl CommandProbe {
3517 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3518 Self { program, args }
3519 }
3520}
3521
3522fn build_env_doctor_findings(
3523 toolchains: &ToolchainReport,
3524 package_managers: &PackageManagerReport,
3525 path_stats: &PathAnalysis,
3526) -> Vec<String> {
3527 let found_tools = toolchains
3528 .found
3529 .iter()
3530 .map(|(label, _)| label.as_str())
3531 .collect::<HashSet<_>>();
3532 let found_managers = package_managers
3533 .found
3534 .iter()
3535 .map(|(label, _)| label.as_str())
3536 .collect::<HashSet<_>>();
3537
3538 let mut findings = Vec::new();
3539
3540 if path_stats.duplicate_entries.len() > 0 {
3541 findings.push(format!(
3542 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3543 path_stats.duplicate_entries.len()
3544 ));
3545 }
3546 if path_stats.missing_entries.len() > 0 {
3547 findings.push(format!(
3548 "PATH contains {} entries that do not exist on disk.",
3549 path_stats.missing_entries.len()
3550 ));
3551 }
3552 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3553 findings.push(
3554 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3555 .to_string(),
3556 );
3557 }
3558 if found_tools.contains("node")
3559 && !found_managers.contains("npm")
3560 && !found_managers.contains("pnpm")
3561 {
3562 findings.push(
3563 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3564 .to_string(),
3565 );
3566 }
3567 if found_tools.contains("python")
3568 && !found_managers.contains("pip")
3569 && !found_managers.contains("uv")
3570 && !found_managers.contains("pipx")
3571 {
3572 findings.push(
3573 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3574 .to_string(),
3575 );
3576 }
3577 let windows_manager_count = ["winget", "choco", "scoop"]
3578 .iter()
3579 .filter(|label| found_managers.contains(**label))
3580 .count();
3581 if windows_manager_count > 1 {
3582 findings.push(
3583 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3584 .to_string(),
3585 );
3586 }
3587 if findings.is_empty() && !found_managers.is_empty() {
3588 findings.push(
3589 "Core package-manager coverage looks healthy for a normal developer workstation."
3590 .to_string(),
3591 );
3592 }
3593
3594 findings
3595}
3596
3597fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3598 let output = std::process::Command::new(program)
3599 .args(args)
3600 .output()
3601 .ok()?;
3602 if !output.status.success() {
3603 return None;
3604 }
3605
3606 let stdout = if output.stdout.is_empty() {
3607 String::from_utf8_lossy(&output.stderr).into_owned()
3608 } else {
3609 String::from_utf8_lossy(&output.stdout).into_owned()
3610 };
3611
3612 stdout
3613 .lines()
3614 .map(str::trim)
3615 .find(|line| !line.is_empty())
3616 .map(|line| line.to_string())
3617}
3618
3619fn human_bytes(bytes: u64) -> String {
3620 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3621 let mut value = bytes as f64;
3622 let mut unit_index = 0usize;
3623
3624 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3625 value /= 1024.0;
3626 unit_index += 1;
3627 }
3628
3629 if unit_index == 0 {
3630 format!("{} {}", bytes, UNITS[unit_index])
3631 } else {
3632 format!("{value:.1} {}", UNITS[unit_index])
3633 }
3634}
3635
3636#[cfg(target_os = "windows")]
3637fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3638 let mut adapters = Vec::new();
3639 let mut current: Option<NetworkAdapter> = None;
3640 let mut pending_dns = false;
3641
3642 for raw_line in text.lines() {
3643 let line = raw_line.trim_end();
3644 let trimmed = line.trim();
3645 if trimmed.is_empty() {
3646 pending_dns = false;
3647 continue;
3648 }
3649
3650 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3651 if let Some(adapter) = current.take() {
3652 adapters.push(adapter);
3653 }
3654 current = Some(NetworkAdapter {
3655 name: trimmed.trim_end_matches(':').to_string(),
3656 ..NetworkAdapter::default()
3657 });
3658 pending_dns = false;
3659 continue;
3660 }
3661
3662 let Some(adapter) = current.as_mut() else {
3663 continue;
3664 };
3665
3666 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3667 adapter.disconnected = true;
3668 }
3669
3670 if let Some(value) = value_after_colon(trimmed) {
3671 let normalized = normalize_ipconfig_value(value);
3672 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3673 adapter.ipv4.push(normalized);
3674 pending_dns = false;
3675 } else if trimmed.starts_with("IPv6 Address")
3676 || trimmed.starts_with("Temporary IPv6 Address")
3677 || trimmed.starts_with("Link-local IPv6 Address")
3678 {
3679 if !normalized.is_empty() {
3680 adapter.ipv6.push(normalized);
3681 }
3682 pending_dns = false;
3683 } else if trimmed.starts_with("Default Gateway") {
3684 if !normalized.is_empty() {
3685 adapter.gateways.push(normalized);
3686 }
3687 pending_dns = false;
3688 } else if trimmed.starts_with("DNS Servers") {
3689 if !normalized.is_empty() {
3690 adapter.dns_servers.push(normalized);
3691 }
3692 pending_dns = true;
3693 } else {
3694 pending_dns = false;
3695 }
3696 } else if pending_dns {
3697 let normalized = normalize_ipconfig_value(trimmed);
3698 if !normalized.is_empty() {
3699 adapter.dns_servers.push(normalized);
3700 }
3701 }
3702 }
3703
3704 if let Some(adapter) = current.take() {
3705 adapters.push(adapter);
3706 }
3707
3708 for adapter in &mut adapters {
3709 dedup_vec(&mut adapter.ipv4);
3710 dedup_vec(&mut adapter.ipv6);
3711 dedup_vec(&mut adapter.gateways);
3712 dedup_vec(&mut adapter.dns_servers);
3713 }
3714
3715 adapters
3716}
3717
3718#[cfg(not(target_os = "windows"))]
3719fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3720 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3721
3722 for line in text.lines() {
3723 let cols: Vec<&str> = line.split_whitespace().collect();
3724 if cols.len() < 4 {
3725 continue;
3726 }
3727 let name = cols[1].trim_end_matches(':').to_string();
3728 let family = cols[2];
3729 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3730 let entry = adapters
3731 .entry(name.clone())
3732 .or_insert_with(|| NetworkAdapter {
3733 name,
3734 ..NetworkAdapter::default()
3735 });
3736 match family {
3737 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3738 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3739 _ => {}
3740 }
3741 }
3742
3743 adapters.into_values().collect()
3744}
3745
3746#[cfg(not(target_os = "windows"))]
3747fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3748 for line in text.lines() {
3749 let cols: Vec<&str> = line.split_whitespace().collect();
3750 if cols.len() < 5 {
3751 continue;
3752 }
3753 let gateway = cols
3754 .windows(2)
3755 .find(|pair| pair[0] == "via")
3756 .map(|pair| pair[1].to_string());
3757 let dev = cols
3758 .windows(2)
3759 .find(|pair| pair[0] == "dev")
3760 .map(|pair| pair[1]);
3761 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3762 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3763 adapter.gateways.push(gateway);
3764 }
3765 }
3766 }
3767
3768 for adapter in adapters {
3769 dedup_vec(&mut adapter.gateways);
3770 }
3771}
3772
3773#[cfg(not(target_os = "windows"))]
3774fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3775 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3776 return;
3777 };
3778 let mut dns_servers = text
3779 .lines()
3780 .filter_map(|line| line.strip_prefix("nameserver "))
3781 .map(str::trim)
3782 .filter(|value| !value.is_empty())
3783 .map(|value| value.to_string())
3784 .collect::<Vec<_>>();
3785 dedup_vec(&mut dns_servers);
3786 if dns_servers.is_empty() {
3787 return;
3788 }
3789 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3790 adapter.dns_servers = dns_servers.clone();
3791 }
3792}
3793
3794#[cfg(target_os = "windows")]
3795fn value_after_colon(line: &str) -> Option<&str> {
3796 line.split_once(':').map(|(_, value)| value.trim())
3797}
3798
3799#[cfg(target_os = "windows")]
3800fn normalize_ipconfig_value(value: &str) -> String {
3801 value
3802 .trim()
3803 .trim_end_matches("(Preferred)")
3804 .trim_end_matches("(Deprecated)")
3805 .trim()
3806 .trim_matches(['(', ')'])
3807 .trim()
3808 .to_string()
3809}
3810
3811#[cfg(target_os = "windows")]
3812fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3813 let mac_upper = mac.to_ascii_uppercase();
3814 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3815 return true;
3816 }
3817
3818 ip == "255.255.255.255"
3819 || ip.starts_with("224.")
3820 || ip.starts_with("225.")
3821 || ip.starts_with("226.")
3822 || ip.starts_with("227.")
3823 || ip.starts_with("228.")
3824 || ip.starts_with("229.")
3825 || ip.starts_with("230.")
3826 || ip.starts_with("231.")
3827 || ip.starts_with("232.")
3828 || ip.starts_with("233.")
3829 || ip.starts_with("234.")
3830 || ip.starts_with("235.")
3831 || ip.starts_with("236.")
3832 || ip.starts_with("237.")
3833 || ip.starts_with("238.")
3834 || ip.starts_with("239.")
3835}
3836
3837fn dedup_vec(values: &mut Vec<String>) {
3838 let mut seen = HashSet::new();
3839 values.retain(|value| seen.insert(value.clone()));
3840}
3841
3842#[cfg(target_os = "windows")]
3843fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3844 let trimmed = text.trim();
3845 if trimmed.is_empty() {
3846 return Vec::new();
3847 }
3848
3849 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3850 return Vec::new();
3851 };
3852 let entries = match value {
3853 Value::Array(items) => items,
3854 other => vec![other],
3855 };
3856
3857 let mut neighbors = Vec::new();
3858 for entry in entries {
3859 let ip = entry
3860 .get("IPAddress")
3861 .and_then(|v| v.as_str())
3862 .unwrap_or("")
3863 .to_string();
3864 if ip.is_empty() {
3865 continue;
3866 }
3867 let mac = entry
3868 .get("LinkLayerAddress")
3869 .and_then(|v| v.as_str())
3870 .unwrap_or("unknown")
3871 .to_string();
3872 let state = entry
3873 .get("State")
3874 .and_then(|v| v.as_str())
3875 .unwrap_or("unknown")
3876 .to_string();
3877 let iface = entry
3878 .get("InterfaceAlias")
3879 .and_then(|v| v.as_str())
3880 .unwrap_or("unknown")
3881 .to_string();
3882 if is_noise_lan_neighbor(&ip, &mac) {
3883 continue;
3884 }
3885 neighbors.push((ip, mac, state, iface));
3886 }
3887
3888 neighbors
3889}
3890
3891#[cfg(target_os = "windows")]
3892fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3893 let trimmed = text.trim();
3894 if trimmed.is_empty() {
3895 return Ok(Vec::new());
3896 }
3897
3898 let value: Value = serde_json::from_str(trimmed)
3899 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3900 let entries = match value {
3901 Value::Array(items) => items,
3902 other => vec![other],
3903 };
3904
3905 let mut services = Vec::new();
3906 for entry in entries {
3907 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3908 continue;
3909 };
3910 services.push(ServiceEntry {
3911 name: name.to_string(),
3912 status: entry
3913 .get("State")
3914 .and_then(|v| v.as_str())
3915 .unwrap_or("unknown")
3916 .to_string(),
3917 startup: entry
3918 .get("StartMode")
3919 .and_then(|v| v.as_str())
3920 .map(|v| v.to_string()),
3921 display_name: entry
3922 .get("DisplayName")
3923 .and_then(|v| v.as_str())
3924 .map(|v| v.to_string()),
3925 start_name: entry
3926 .get("StartName")
3927 .and_then(|v| v.as_str())
3928 .map(|v| v.to_string()),
3929 });
3930 }
3931
3932 Ok(services)
3933}
3934
3935#[cfg(target_os = "windows")]
3936fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3937 match node.cloned() {
3938 Some(Value::Array(items)) => items,
3939 Some(other) => vec![other],
3940 None => Vec::new(),
3941 }
3942}
3943
3944#[cfg(target_os = "windows")]
3945fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3946 windows_json_entries(node)
3947 .into_iter()
3948 .filter_map(|entry| {
3949 let name = entry
3950 .get("FriendlyName")
3951 .and_then(|v| v.as_str())
3952 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3953 .unwrap_or("")
3954 .trim()
3955 .to_string();
3956 if name.is_empty() {
3957 return None;
3958 }
3959 Some(WindowsPnpDevice {
3960 name,
3961 status: entry
3962 .get("Status")
3963 .and_then(|v| v.as_str())
3964 .unwrap_or("Unknown")
3965 .trim()
3966 .to_string(),
3967 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3968 entry
3969 .get("Problem")
3970 .and_then(|v| v.as_i64())
3971 .map(|v| v as u64)
3972 }),
3973 class_name: entry
3974 .get("Class")
3975 .and_then(|v| v.as_str())
3976 .map(|v| v.trim().to_string()),
3977 instance_id: entry
3978 .get("InstanceId")
3979 .and_then(|v| v.as_str())
3980 .map(|v| v.trim().to_string()),
3981 })
3982 })
3983 .collect()
3984}
3985
3986#[cfg(target_os = "windows")]
3987fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3988 windows_json_entries(node)
3989 .into_iter()
3990 .filter_map(|entry| {
3991 let name = entry
3992 .get("Name")
3993 .and_then(|v| v.as_str())
3994 .unwrap_or("")
3995 .trim()
3996 .to_string();
3997 if name.is_empty() {
3998 return None;
3999 }
4000 Some(WindowsSoundDevice {
4001 name,
4002 status: entry
4003 .get("Status")
4004 .and_then(|v| v.as_str())
4005 .unwrap_or("Unknown")
4006 .trim()
4007 .to_string(),
4008 manufacturer: entry
4009 .get("Manufacturer")
4010 .and_then(|v| v.as_str())
4011 .map(|v| v.trim().to_string()),
4012 })
4013 })
4014 .collect()
4015}
4016
4017#[cfg(target_os = "windows")]
4018fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4019 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4020 || device.problem.unwrap_or(0) != 0
4021}
4022
4023#[cfg(target_os = "windows")]
4024fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4025 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4026}
4027
4028#[cfg(target_os = "windows")]
4029fn is_microphone_like_name(name: &str) -> bool {
4030 let lower = name.to_ascii_lowercase();
4031 lower.contains("microphone")
4032 || lower.contains("mic")
4033 || lower.contains("input")
4034 || lower.contains("array")
4035 || lower.contains("capture")
4036 || lower.contains("record")
4037}
4038
4039#[cfg(target_os = "windows")]
4040fn is_bluetooth_like_name(name: &str) -> bool {
4041 let lower = name.to_ascii_lowercase();
4042 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4043}
4044
4045#[cfg(target_os = "windows")]
4046fn service_is_running(service: &ServiceEntry) -> bool {
4047 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4048}
4049
4050#[cfg(not(target_os = "windows"))]
4051fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4052 let mut startup_modes = std::collections::HashMap::<String, String>::new();
4053 for line in startup_text.lines() {
4054 let cols: Vec<&str> = line.split_whitespace().collect();
4055 if cols.len() < 2 {
4056 continue;
4057 }
4058 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4059 }
4060
4061 let mut services = Vec::new();
4062 for line in status_text.lines() {
4063 let cols: Vec<&str> = line.split_whitespace().collect();
4064 if cols.len() < 4 {
4065 continue;
4066 }
4067 let unit = cols[0];
4068 let load = cols[1];
4069 let active = cols[2];
4070 let sub = cols[3];
4071 let description = if cols.len() > 4 {
4072 Some(cols[4..].join(" "))
4073 } else {
4074 None
4075 };
4076 services.push(ServiceEntry {
4077 name: unit.to_string(),
4078 status: format!("{}/{}", active, sub),
4079 startup: startup_modes
4080 .get(unit)
4081 .cloned()
4082 .or_else(|| Some(load.to_string())),
4083 display_name: description,
4084 start_name: None,
4085 });
4086 }
4087
4088 services
4089}
4090
4091fn inspect_health_report() -> Result<String, String> {
4097 let mut needs_fix: Vec<String> = Vec::new();
4098 let mut watch: Vec<String> = Vec::new();
4099 let mut good: Vec<String> = Vec::new();
4100 let mut tips: Vec<String> = Vec::new();
4101
4102 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4103 health_check_memory(&mut watch, &mut good);
4104 health_check_network(&mut needs_fix, &mut watch, &mut good);
4105 health_check_pending_reboot(&mut watch, &mut good);
4106 health_check_services(&mut needs_fix, &mut watch, &mut good);
4107 health_check_thermal(&mut watch, &mut good);
4108 health_check_tools(&mut watch, &mut good, &mut tips);
4109 health_check_recent_errors(&mut watch, &mut tips);
4110
4111 let overall = if !needs_fix.is_empty() {
4112 "ACTION REQUIRED"
4113 } else if !watch.is_empty() {
4114 "WORTH A LOOK"
4115 } else {
4116 "ALL GOOD"
4117 };
4118
4119 let mut out = format!("System Health Report — {overall}\n\n");
4120
4121 if !needs_fix.is_empty() {
4122 out.push_str("Needs fixing:\n");
4123 for item in &needs_fix {
4124 out.push_str(&format!(" [!] {item}\n"));
4125 }
4126 out.push('\n');
4127 }
4128 if !watch.is_empty() {
4129 out.push_str("Worth watching:\n");
4130 for item in &watch {
4131 out.push_str(&format!(" [-] {item}\n"));
4132 }
4133 out.push('\n');
4134 }
4135 if !good.is_empty() {
4136 out.push_str("Looking good:\n");
4137 for item in &good {
4138 out.push_str(&format!(" [+] {item}\n"));
4139 }
4140 out.push('\n');
4141 }
4142 if !tips.is_empty() {
4143 out.push_str("To dig deeper:\n");
4144 for tip in &tips {
4145 out.push_str(&format!(" {tip}\n"));
4146 }
4147 }
4148
4149 Ok(out.trim_end().to_string())
4150}
4151
4152fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4153 #[cfg(target_os = "windows")]
4154 {
4155 let script = r#"try {
4156 $d = Get-PSDrive C -ErrorAction Stop
4157 "$($d.Free)|$($d.Used)"
4158} catch { "ERR" }"#;
4159 if let Ok(out) = Command::new("powershell")
4160 .args(["-NoProfile", "-Command", script])
4161 .output()
4162 {
4163 let text = String::from_utf8_lossy(&out.stdout);
4164 let text = text.trim();
4165 if !text.starts_with("ERR") {
4166 let parts: Vec<&str> = text.split('|').collect();
4167 if parts.len() == 2 {
4168 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4169 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4170 let total = free_bytes + used_bytes;
4171 let free_gb = free_bytes / 1_073_741_824;
4172 let pct_free = if total > 0 {
4173 (free_bytes as f64 / total as f64 * 100.0) as u64
4174 } else {
4175 0
4176 };
4177 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4178 if free_gb < 5 {
4179 needs_fix.push(format!(
4180 "{msg} — very low. Free up space or your system may slow down or stop working."
4181 ));
4182 } else if free_gb < 15 {
4183 watch.push(format!("{msg} — getting low, consider cleaning up."));
4184 } else {
4185 good.push(msg);
4186 }
4187 return;
4188 }
4189 }
4190 }
4191 watch.push("Disk: could not read free space from C: drive.".to_string());
4192 }
4193
4194 #[cfg(not(target_os = "windows"))]
4195 {
4196 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4197 let text = String::from_utf8_lossy(&out.stdout);
4198 for line in text.lines().skip(1) {
4199 let cols: Vec<&str> = line.split_whitespace().collect();
4200 if cols.len() >= 5 {
4201 let avail_str = cols[3].trim_end_matches('G');
4202 let use_pct = cols[4].trim_end_matches('%');
4203 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4204 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4205 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4206 if avail_gb < 5 {
4207 needs_fix.push(format!(
4208 "{msg} — very low. Free up space to prevent system issues."
4209 ));
4210 } else if avail_gb < 15 {
4211 watch.push(format!("{msg} — getting low."));
4212 } else {
4213 good.push(msg);
4214 }
4215 return;
4216 }
4217 }
4218 }
4219 watch.push("Disk: could not determine free space.".to_string());
4220 }
4221}
4222
4223fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4224 #[cfg(target_os = "windows")]
4225 {
4226 let script = r#"try {
4227 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4228 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4229} catch { "ERR" }"#;
4230 if let Ok(out) = Command::new("powershell")
4231 .args(["-NoProfile", "-Command", script])
4232 .output()
4233 {
4234 let text = String::from_utf8_lossy(&out.stdout);
4235 let text = text.trim();
4236 if !text.starts_with("ERR") {
4237 let parts: Vec<&str> = text.split('|').collect();
4238 if parts.len() == 2 {
4239 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4240 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4241 if total_kb > 0 {
4242 let free_gb = free_kb / 1_048_576;
4243 let total_gb = total_kb / 1_048_576;
4244 let free_pct = free_kb * 100 / total_kb;
4245 let msg = format!(
4246 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4247 );
4248 if free_pct < 10 {
4249 watch.push(format!(
4250 "{msg} — very low. Close unused apps to free up memory."
4251 ));
4252 } else if free_pct < 25 {
4253 watch.push(format!("{msg} — running a bit low."));
4254 } else {
4255 good.push(msg);
4256 }
4257 return;
4258 }
4259 }
4260 }
4261 }
4262 }
4263
4264 #[cfg(not(target_os = "windows"))]
4265 {
4266 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4267 let mut total_kb = 0u64;
4268 let mut avail_kb = 0u64;
4269 for line in content.lines() {
4270 if line.starts_with("MemTotal:") {
4271 total_kb = line
4272 .split_whitespace()
4273 .nth(1)
4274 .and_then(|v| v.parse().ok())
4275 .unwrap_or(0);
4276 } else if line.starts_with("MemAvailable:") {
4277 avail_kb = line
4278 .split_whitespace()
4279 .nth(1)
4280 .and_then(|v| v.parse().ok())
4281 .unwrap_or(0);
4282 }
4283 }
4284 if total_kb > 0 {
4285 let free_gb = avail_kb / 1_048_576;
4286 let total_gb = total_kb / 1_048_576;
4287 let free_pct = avail_kb * 100 / total_kb;
4288 let msg =
4289 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4290 if free_pct < 10 {
4291 watch.push(format!("{msg} — very low. Close unused apps."));
4292 } else if free_pct < 25 {
4293 watch.push(format!("{msg} — running a bit low."));
4294 } else {
4295 good.push(msg);
4296 }
4297 }
4298 }
4299 }
4300}
4301
4302fn probe_tool(cmd: &str, arg: &str) -> bool {
4306 if Command::new(cmd)
4307 .arg(arg)
4308 .stdout(std::process::Stdio::null())
4309 .stderr(std::process::Stdio::null())
4310 .status()
4311 .map(|s| s.success())
4312 .unwrap_or(false)
4313 {
4314 return true;
4315 }
4316 #[cfg(windows)]
4318 {
4319 let home = std::env::var("USERPROFILE").unwrap_or_default();
4320 let fallback: Option<String> = match cmd {
4321 "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4322 "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4323 "npm" => Some(format!(r"C:\Program Files\nodejs\npm.cmd")),
4324 _ => None,
4325 };
4326 if let Some(path) = fallback {
4327 return Command::new(&path)
4328 .arg(arg)
4329 .stdout(std::process::Stdio::null())
4330 .stderr(std::process::Stdio::null())
4331 .status()
4332 .map(|s| s.success())
4333 .unwrap_or(false);
4334 }
4335 }
4336 false
4337}
4338
4339fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4340 let tool_checks: &[(&str, &str, &str)] = &[
4341 ("git", "--version", "Git"),
4342 ("cargo", "--version", "Rust / Cargo"),
4343 ("node", "--version", "Node.js"),
4344 ("python", "--version", "Python"),
4345 ("python3", "--version", "Python 3"),
4346 ("npm", "--version", "npm"),
4347 ];
4348
4349 let mut found: Vec<String> = Vec::new();
4350 let mut missing: Vec<String> = Vec::new();
4351 let mut python_found = false;
4352
4353 for (cmd, arg, label) in tool_checks {
4354 if cmd.starts_with("python") && python_found {
4355 continue;
4356 }
4357 let ok = probe_tool(cmd, arg);
4358 if ok {
4359 found.push((*label).to_string());
4360 if cmd.starts_with("python") {
4361 python_found = true;
4362 }
4363 } else if !cmd.starts_with("python") || !python_found {
4364 missing.push((*label).to_string());
4365 }
4366 }
4367
4368 if !found.is_empty() {
4369 good.push(format!("Dev tools found: {}", found.join(", ")));
4370 }
4371 if !missing.is_empty() {
4372 watch.push(format!(
4373 "Not installed (or not on PATH): {} — only matters if you need them",
4374 missing.join(", ")
4375 ));
4376 tips.push(
4377 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4378 .to_string(),
4379 );
4380 }
4381}
4382
4383fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4384 #[cfg(target_os = "windows")]
4385 {
4386 let script = r#"try {
4387 $cutoff = (Get-Date).AddHours(-24)
4388 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4389 $count
4390} catch { "0" }"#;
4391 if let Ok(out) = Command::new("powershell")
4392 .args(["-NoProfile", "-Command", script])
4393 .output()
4394 {
4395 let text = String::from_utf8_lossy(&out.stdout);
4396 let count: u64 = text.trim().parse().unwrap_or(0);
4397 if count > 0 {
4398 watch.push(format!(
4399 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4400 if count == 1 { "" } else { "s" }
4401 ));
4402 tips.push(
4403 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4404 .to_string(),
4405 );
4406 }
4407 }
4408 }
4409
4410 #[cfg(not(target_os = "windows"))]
4411 {
4412 if let Ok(out) = Command::new("journalctl")
4413 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4414 .output()
4415 {
4416 let text = String::from_utf8_lossy(&out.stdout);
4417 if !text.trim().is_empty() {
4418 watch.push("Critical/error entries found in the system journal.".to_string());
4419 tips.push(
4420 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4421 );
4422 }
4423 }
4424 }
4425}
4426
4427fn health_check_network(
4428 needs_fix: &mut Vec<String>,
4429 watch: &mut Vec<String>,
4430 good: &mut Vec<String>,
4431) {
4432 #[cfg(target_os = "windows")]
4433 {
4434 let script = r#"try {
4436 $ping = New-Object System.Net.NetworkInformation.Ping
4437 $r = $ping.Send("1.1.1.1", 2000)
4438 if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4439} catch { "FAIL" }"#;
4440 if let Ok(out) = Command::new("powershell")
4441 .args(["-NoProfile", "-Command", script])
4442 .output()
4443 {
4444 let text = String::from_utf8_lossy(&out.stdout);
4445 let text = text.trim();
4446 if text.starts_with("OK") {
4447 let latency = text.split('|').nth(1).unwrap_or("?");
4448 let latency_ms: u64 = latency.parse().unwrap_or(0);
4449 let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4450 if latency_ms > 300 {
4451 watch.push(format!("{msg} — high latency, may indicate network issue."));
4452 } else {
4453 good.push(msg);
4454 }
4455 } else {
4456 needs_fix.push(
4457 "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4458 Check adapter, gateway, or DNS."
4459 .to_string(),
4460 );
4461 }
4462 return;
4463 }
4464 watch.push("Network: could not run connectivity check.".to_string());
4465 }
4466
4467 #[cfg(not(target_os = "windows"))]
4468 {
4469 let _ = watch;
4470 let ok = Command::new("ping")
4471 .args(["-c", "1", "-W", "2", "1.1.1.1"])
4472 .stdout(std::process::Stdio::null())
4473 .stderr(std::process::Stdio::null())
4474 .status()
4475 .map(|s| s.success())
4476 .unwrap_or(false);
4477 if ok {
4478 good.push("Internet connectivity: reachable.".to_string());
4479 } else {
4480 needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4481 }
4482 }
4483}
4484
4485fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4486 #[cfg(target_os = "windows")]
4487 {
4488 let script = r#"try {
4489 $pending = $false
4490 $reasons = @()
4491 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4492 $pending = $true; $reasons += 'CBS/component update'
4493 }
4494 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4495 $pending = $true; $reasons += 'Windows Update'
4496 }
4497 $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4498 if ($pfr -and $pfr.PendingFileRenameOperations) {
4499 $pending = $true; $reasons += 'file rename ops'
4500 }
4501 if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4502} catch { "OK" }"#;
4503 if let Ok(out) = Command::new("powershell")
4504 .args(["-NoProfile", "-Command", script])
4505 .output()
4506 {
4507 let text = String::from_utf8_lossy(&out.stdout);
4508 let text = text.trim();
4509 if text.starts_with("PENDING") {
4510 let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4511 watch.push(format!(
4512 "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4513 ));
4514 } else {
4515 good.push("No pending reboot.".to_string());
4516 }
4517 }
4518 }
4519
4520 #[cfg(not(target_os = "windows"))]
4521 {
4522 if std::path::Path::new("/var/run/reboot-required").exists() {
4524 watch.push(
4525 "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4526 );
4527 } else {
4528 good.push("No pending reboot.".to_string());
4529 }
4530 }
4531}
4532
4533fn health_check_services(
4534 needs_fix: &mut Vec<String>,
4535 watch: &mut Vec<String>,
4536 good: &mut Vec<String>,
4537) {
4538 #[cfg(not(target_os = "windows"))]
4539 let _ = (&needs_fix, &good);
4540 #[cfg(target_os = "windows")]
4541 let _ = &watch;
4542
4543 #[cfg(target_os = "windows")]
4544 {
4545 let script = r#"try {
4547 $names = @('EventLog','WinDefend','Dnscache')
4548 $stopped = @()
4549 foreach ($n in $names) {
4550 $s = Get-Service $n -ErrorAction SilentlyContinue
4551 if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4552 }
4553 if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4554} catch { "OK" }"#;
4555 if let Ok(out) = Command::new("powershell")
4556 .args(["-NoProfile", "-Command", script])
4557 .output()
4558 {
4559 let text = String::from_utf8_lossy(&out.stdout);
4560 let text = text.trim();
4561 if text.starts_with("STOPPED") {
4562 let names = text.split('|').nth(1).unwrap_or("unknown");
4563 needs_fix.push(format!(
4564 "Critical service(s) not running: {names} — these should always be active."
4565 ));
4566 } else {
4567 good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4568 }
4569 }
4570 }
4571
4572 #[cfg(not(target_os = "windows"))]
4573 {
4574 if let Ok(out) = Command::new("systemctl")
4576 .args(["--failed", "--no-legend", "--plain"])
4577 .output()
4578 {
4579 let text = String::from_utf8_lossy(&out.stdout);
4580 let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4581 if !failed.is_empty() {
4582 watch.push(format!(
4583 "{} failed systemd unit(s): {}",
4584 failed.len(),
4585 failed.join(", ")
4586 ));
4587 } else {
4588 good.push("No failed systemd units.".to_string());
4589 }
4590 }
4591 }
4592}
4593
4594fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4595 #[cfg(target_os = "windows")]
4596 {
4597 let script = r#"try {
4599 $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4600 $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4601 $max = ($temps | Measure-Object -Maximum).Maximum
4602 "$max"
4603} catch { "NA" }"#;
4604 if let Ok(out) = Command::new("powershell")
4605 .args(["-NoProfile", "-Command", script])
4606 .output()
4607 {
4608 let text = String::from_utf8_lossy(&out.stdout);
4609 let text = text.trim();
4610 if text != "NA" && !text.is_empty() {
4611 if let Ok(temp) = text.parse::<f64>() {
4612 let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4613 if temp >= 90.0 {
4614 watch.push(format!("{msg} — very high, check cooling and airflow."));
4615 } else if temp >= 75.0 {
4616 watch.push(format!(
4617 "{msg} — elevated under load, monitor for throttling."
4618 ));
4619 } else {
4620 good.push(format!("{msg} — normal."));
4621 }
4622 }
4623 }
4624 }
4626 }
4627
4628 #[cfg(not(target_os = "windows"))]
4629 {
4630 let paths = [
4632 "/sys/class/thermal/thermal_zone0/temp",
4633 "/sys/class/hwmon/hwmon0/temp1_input",
4634 ];
4635 for path in &paths {
4636 if let Ok(content) = std::fs::read_to_string(path) {
4637 if let Ok(raw) = content.trim().parse::<u64>() {
4638 let temp_c = raw / 1000;
4639 let msg = format!("CPU thermal: {temp_c}°C");
4640 if temp_c >= 90 {
4641 watch.push(format!("{msg} — very high, check cooling."));
4642 } else if temp_c >= 75 {
4643 watch.push(format!("{msg} — elevated under load."));
4644 } else {
4645 good.push(format!("{msg} — normal."));
4646 }
4647 return;
4648 }
4649 }
4650 }
4651 }
4652}
4653
4654fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4657 let mut out = String::from("Host inspection: log_check\n\n");
4658
4659 #[cfg(target_os = "windows")]
4660 {
4661 let hours = lookback_hours.unwrap_or(24);
4663 out.push_str(&format!(
4664 "Checking System/Application logs from the last {} hours...\n\n",
4665 hours
4666 ));
4667
4668 let n = max_entries.clamp(1, 50);
4669 let script = format!(
4670 r#"try {{
4671 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4672 if (-not $events) {{ "NO_EVENTS"; exit }}
4673 $events | Select-Object -First {n} | ForEach-Object {{
4674 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4675 $line
4676 }}
4677}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4678 hours = hours,
4679 n = n
4680 );
4681 let output = Command::new("powershell")
4682 .args(["-NoProfile", "-Command", &script])
4683 .output()
4684 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4685
4686 let raw = String::from_utf8_lossy(&output.stdout);
4687 let text = raw.trim();
4688
4689 if text.is_empty() || text == "NO_EVENTS" {
4690 out.push_str("No critical or error events found in Application/System logs.\n");
4691 return Ok(out.trim_end().to_string());
4692 }
4693 if text.starts_with("ERROR:") {
4694 out.push_str(&format!("Warning: event log query returned: {text}\n"));
4695 return Ok(out.trim_end().to_string());
4696 }
4697
4698 let mut count = 0usize;
4699 for line in text.lines() {
4700 let parts: Vec<&str> = line.splitn(4, '|').collect();
4701 if parts.len() == 4 {
4702 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4703 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4704 count += 1;
4705 }
4706 }
4707 out.push_str(&format!(
4708 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4709 ));
4710 }
4711
4712 #[cfg(not(target_os = "windows"))]
4713 {
4714 let _ = lookback_hours;
4715 let n = max_entries.clamp(1, 50).to_string();
4717 let output = Command::new("journalctl")
4718 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4719 .output();
4720
4721 match output {
4722 Ok(o) if o.status.success() => {
4723 let text = String::from_utf8_lossy(&o.stdout);
4724 let trimmed = text.trim();
4725 if trimmed.is_empty() || trimmed.contains("No entries") {
4726 out.push_str("No critical or error entries found in the system journal.\n");
4727 } else {
4728 out.push_str(trimmed);
4729 out.push('\n');
4730 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4731 }
4732 }
4733 _ => {
4734 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4736 let mut found = false;
4737 for log_path in &log_paths {
4738 if let Ok(content) = std::fs::read_to_string(log_path) {
4739 let lines: Vec<&str> = content.lines().collect();
4740 let tail: Vec<&str> = lines
4741 .iter()
4742 .rev()
4743 .filter(|l| {
4744 let l_lower = l.to_ascii_lowercase();
4745 l_lower.contains("error") || l_lower.contains("crit")
4746 })
4747 .take(max_entries)
4748 .copied()
4749 .collect::<Vec<_>>()
4750 .into_iter()
4751 .rev()
4752 .collect();
4753 if !tail.is_empty() {
4754 out.push_str(&format!("Source: {log_path}\n"));
4755 for l in &tail {
4756 out.push_str(l);
4757 out.push('\n');
4758 }
4759 found = true;
4760 break;
4761 }
4762 }
4763 }
4764 if !found {
4765 out.push_str(
4766 "journalctl not found and no readable syslog detected on this system.\n",
4767 );
4768 }
4769 }
4770 }
4771 }
4772
4773 Ok(out.trim_end().to_string())
4774}
4775
4776fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4779 let mut out = String::from("Host inspection: startup_items\n\n");
4780
4781 #[cfg(target_os = "windows")]
4782 {
4783 let script = r#"
4785$hives = @(
4786 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4787 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4788 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4789)
4790foreach ($h in $hives) {
4791 try {
4792 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4793 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4794 "$($h.Hive)|$($_.Name)|$($_.Value)"
4795 }
4796 } catch {}
4797}
4798"#;
4799 let output = Command::new("powershell")
4800 .args(["-NoProfile", "-Command", script])
4801 .output()
4802 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4803
4804 let raw = String::from_utf8_lossy(&output.stdout);
4805 let text = raw.trim();
4806
4807 let entries: Vec<(String, String, String)> = text
4808 .lines()
4809 .filter_map(|l| {
4810 let parts: Vec<&str> = l.splitn(3, '|').collect();
4811 if parts.len() == 3 {
4812 Some((
4813 parts[0].to_string(),
4814 parts[1].to_string(),
4815 parts[2].to_string(),
4816 ))
4817 } else {
4818 None
4819 }
4820 })
4821 .take(max_entries)
4822 .collect();
4823
4824 if entries.is_empty() {
4825 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4826 } else {
4827 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4828 let mut last_hive = String::new();
4829 for (hive, name, value) in &entries {
4830 if *hive != last_hive {
4831 out.push_str(&format!("[{}]\n", hive));
4832 last_hive = hive.clone();
4833 }
4834 let display = if value.len() > 100 {
4836 format!("{}…", &value[..100])
4837 } else {
4838 value.clone()
4839 };
4840 out.push_str(&format!(" {name}: {display}\n"));
4841 }
4842 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4843 }
4844
4845 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4847 if let Ok(unified_out) = Command::new("powershell")
4848 .args(["-NoProfile", "-Command", unified_script])
4849 .output()
4850 {
4851 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4852 let trimmed = unified_text.trim();
4853 if !trimmed.is_empty() {
4854 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4855 out.push_str(trimmed);
4856 out.push('\n');
4857 }
4858 }
4859 }
4860
4861 #[cfg(not(target_os = "windows"))]
4862 {
4863 let output = Command::new("systemctl")
4865 .args([
4866 "list-unit-files",
4867 "--type=service",
4868 "--state=enabled",
4869 "--no-legend",
4870 "--no-pager",
4871 "--plain",
4872 ])
4873 .output();
4874
4875 match output {
4876 Ok(o) if o.status.success() => {
4877 let text = String::from_utf8_lossy(&o.stdout);
4878 let services: Vec<&str> = text
4879 .lines()
4880 .filter(|l| !l.trim().is_empty())
4881 .take(max_entries)
4882 .collect();
4883 if services.is_empty() {
4884 out.push_str("No enabled systemd services found.\n");
4885 } else {
4886 out.push_str("Enabled systemd services (run at boot):\n\n");
4887 for s in &services {
4888 out.push_str(&format!(" {s}\n"));
4889 }
4890 out.push_str(&format!(
4891 "\nShowing {} of enabled services.\n",
4892 services.len()
4893 ));
4894 }
4895 }
4896 _ => {
4897 out.push_str(
4898 "systemctl not found on this system. Cannot enumerate startup services.\n",
4899 );
4900 }
4901 }
4902
4903 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4905 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4906 let reboot_entries: Vec<&str> = cron_text
4907 .lines()
4908 .filter(|l| l.trim_start().starts_with("@reboot"))
4909 .collect();
4910 if !reboot_entries.is_empty() {
4911 out.push_str("\nCron @reboot entries:\n");
4912 for e in reboot_entries {
4913 out.push_str(&format!(" {e}\n"));
4914 }
4915 }
4916 }
4917 }
4918
4919 Ok(out.trim_end().to_string())
4920}
4921
4922fn inspect_os_config() -> Result<String, String> {
4923 let mut out = String::from("Host inspection: OS Configuration\n\n");
4924
4925 #[cfg(target_os = "windows")]
4926 {
4927 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4929 let power_str = String::from_utf8_lossy(&power_out.stdout);
4930 out.push_str("=== Power Plan ===\n");
4931 out.push_str(power_str.trim());
4932 out.push_str("\n\n");
4933 }
4934
4935 let fw_script =
4937 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4938 if let Ok(fw_out) = Command::new("powershell")
4939 .args(["-NoProfile", "-Command", fw_script])
4940 .output()
4941 {
4942 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4943 out.push_str("=== Firewall Profiles ===\n");
4944 out.push_str(fw_str.trim());
4945 out.push_str("\n\n");
4946 }
4947
4948 let uptime_script =
4950 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4951 if let Ok(uptime_out) = Command::new("powershell")
4952 .args(["-NoProfile", "-Command", uptime_script])
4953 .output()
4954 {
4955 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4956 out.push_str("=== System Uptime (Last Boot) ===\n");
4957 out.push_str(uptime_str.trim());
4958 out.push_str("\n\n");
4959 }
4960 }
4961
4962 #[cfg(not(target_os = "windows"))]
4963 {
4964 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4966 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4967 out.push_str("=== System Uptime ===\n");
4968 out.push_str(uptime_str.trim());
4969 out.push_str("\n\n");
4970 }
4971
4972 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4974 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4975 if !ufw_str.trim().is_empty() {
4976 out.push_str("=== Firewall (UFW) ===\n");
4977 out.push_str(ufw_str.trim());
4978 out.push_str("\n\n");
4979 }
4980 }
4981 }
4982 Ok(out.trim_end().to_string())
4983}
4984
4985pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4986 let action = args
4987 .get("action")
4988 .and_then(|v| v.as_str())
4989 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4990
4991 let target = args
4992 .get("target")
4993 .and_then(|v| v.as_str())
4994 .unwrap_or("")
4995 .trim();
4996
4997 if target.is_empty() && action != "clear_temp" {
4998 return Err("Missing required argument: 'target' for this action".to_string());
4999 }
5000
5001 match action {
5002 "install_package" => {
5003 #[cfg(target_os = "windows")]
5004 {
5005 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5006 match Command::new("powershell")
5007 .args(["-NoProfile", "-Command", &cmd])
5008 .output()
5009 {
5010 Ok(out) => Ok(format!(
5011 "Executed remediation (winget install):\n{}",
5012 String::from_utf8_lossy(&out.stdout)
5013 )),
5014 Err(e) => Err(format!("Failed to run winget: {}", e)),
5015 }
5016 }
5017 #[cfg(not(target_os = "windows"))]
5018 {
5019 Err(
5020 "install_package via wrapper is only supported on Windows currently (winget)"
5021 .to_string(),
5022 )
5023 }
5024 }
5025 "restart_service" => {
5026 #[cfg(target_os = "windows")]
5027 {
5028 let cmd = format!("Restart-Service -Name {} -Force", target);
5029 match Command::new("powershell")
5030 .args(["-NoProfile", "-Command", &cmd])
5031 .output()
5032 {
5033 Ok(out) => {
5034 let err_str = String::from_utf8_lossy(&out.stderr);
5035 if !err_str.is_empty() {
5036 return Err(format!("Error restarting service:\n{}", err_str));
5037 }
5038 Ok(format!("Successfully restarted service: {}", target))
5039 }
5040 Err(e) => Err(format!("Failed to restart service: {}", e)),
5041 }
5042 }
5043 #[cfg(not(target_os = "windows"))]
5044 {
5045 Err(
5046 "restart_service via wrapper is only supported on Windows currently"
5047 .to_string(),
5048 )
5049 }
5050 }
5051 "clear_temp" => {
5052 #[cfg(target_os = "windows")]
5053 {
5054 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5055 match Command::new("powershell")
5056 .args(["-NoProfile", "-Command", cmd])
5057 .output()
5058 {
5059 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5060 Err(e) => Err(format!("Failed to clear temp: {}", e)),
5061 }
5062 }
5063 #[cfg(not(target_os = "windows"))]
5064 {
5065 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5066 }
5067 }
5068 other => Err(format!("Unknown remediation action: {}", other)),
5069 }
5070}
5071
5072fn inspect_storage(max_entries: usize) -> Result<String, String> {
5075 let mut out = String::from("Host inspection: storage\n\n");
5076 let _ = max_entries; out.push_str("Drives:\n");
5080
5081 #[cfg(target_os = "windows")]
5082 {
5083 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5084 $free = $_.Free
5085 $used = $_.Used
5086 if ($free -eq $null) { $free = 0 }
5087 if ($used -eq $null) { $used = 0 }
5088 $total = $free + $used
5089 "$($_.Name)|$free|$used|$total"
5090}"#;
5091 match Command::new("powershell")
5092 .args(["-NoProfile", "-Command", script])
5093 .output()
5094 {
5095 Ok(o) => {
5096 let text = String::from_utf8_lossy(&o.stdout);
5097 let mut drive_count = 0usize;
5098 for line in text.lines() {
5099 let parts: Vec<&str> = line.trim().split('|').collect();
5100 if parts.len() == 4 {
5101 let name = parts[0];
5102 let free: u64 = parts[1].parse().unwrap_or(0);
5103 let total: u64 = parts[3].parse().unwrap_or(0);
5104 if total == 0 {
5105 continue;
5106 }
5107 let free_gb = free / 1_073_741_824;
5108 let total_gb = total / 1_073_741_824;
5109 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5110 let bar_len = 20usize;
5111 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5112 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5113 let warn = if free_gb < 5 {
5114 " [!] CRITICALLY LOW"
5115 } else if free_gb < 15 {
5116 " [-] LOW"
5117 } else {
5118 ""
5119 };
5120 out.push_str(&format!(
5121 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
5122 ));
5123 drive_count += 1;
5124 }
5125 }
5126 if drive_count == 0 {
5127 out.push_str(" (could not enumerate drives)\n");
5128 }
5129 }
5130 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
5131 }
5132
5133 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5135 match Command::new("powershell")
5136 .args(["-NoProfile", "-Command", latency_script])
5137 .output()
5138 {
5139 Ok(o) => {
5140 out.push_str("\nReal-time Disk Intensity:\n");
5141 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5142 if !text.is_empty() {
5143 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
5144 if let Ok(q) = text.parse::<f64>() {
5145 if q > 2.0 {
5146 out.push_str(
5147 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5148 );
5149 } else {
5150 out.push_str(" [~] Disk latency is within healthy bounds.\n");
5151 }
5152 }
5153 } else {
5154 out.push_str(" Average Disk Queue Length: unavailable\n");
5155 }
5156 }
5157 Err(_) => {
5158 out.push_str("\nReal-time Disk Intensity:\n");
5159 out.push_str(" Average Disk Queue Length: unavailable\n");
5160 }
5161 }
5162 }
5163
5164 #[cfg(not(target_os = "windows"))]
5165 {
5166 match Command::new("df")
5167 .args(["-h", "--output=target,size,avail,pcent"])
5168 .output()
5169 {
5170 Ok(o) => {
5171 let text = String::from_utf8_lossy(&o.stdout);
5172 let mut count = 0usize;
5173 for line in text.lines().skip(1) {
5174 let cols: Vec<&str> = line.split_whitespace().collect();
5175 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
5176 out.push_str(&format!(
5177 " {} size: {} avail: {} used: {}\n",
5178 cols[0], cols[1], cols[2], cols[3]
5179 ));
5180 count += 1;
5181 if count >= max_entries {
5182 break;
5183 }
5184 }
5185 }
5186 }
5187 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
5188 }
5189 }
5190
5191 out.push_str("\nLarge developer cache directories (if present):\n");
5193
5194 #[cfg(target_os = "windows")]
5195 {
5196 let home = std::env::var("USERPROFILE").unwrap_or_default();
5197 let check_dirs: &[(&str, &str)] = &[
5198 ("Temp", r"AppData\Local\Temp"),
5199 ("npm cache", r"AppData\Roaming\npm-cache"),
5200 ("Cargo registry", r".cargo\registry"),
5201 ("Cargo git", r".cargo\git"),
5202 ("pip cache", r"AppData\Local\pip\cache"),
5203 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5204 (".rustup toolchains", r".rustup\toolchains"),
5205 ("node_modules (home)", r"node_modules"),
5206 ];
5207
5208 let mut found_any = false;
5209 for (label, rel) in check_dirs {
5210 let full = format!(r"{}\{}", home, rel);
5211 let path = std::path::Path::new(&full);
5212 if path.exists() {
5213 let size_script = format!(
5215 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5216 full.replace('\'', "''")
5217 );
5218 let size_mb = Command::new("powershell")
5219 .args(["-NoProfile", "-Command", &size_script])
5220 .output()
5221 .ok()
5222 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5223 .unwrap_or_else(|| "?".to_string());
5224 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
5225 found_any = true;
5226 }
5227 }
5228 if !found_any {
5229 out.push_str(" (none of the common cache directories found)\n");
5230 }
5231
5232 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5233 }
5234
5235 #[cfg(not(target_os = "windows"))]
5236 {
5237 let home = std::env::var("HOME").unwrap_or_default();
5238 let check_dirs: &[(&str, &str)] = &[
5239 ("npm cache", ".npm"),
5240 ("Cargo registry", ".cargo/registry"),
5241 ("pip cache", ".cache/pip"),
5242 (".rustup toolchains", ".rustup/toolchains"),
5243 ("Yarn cache", ".cache/yarn"),
5244 ];
5245 let mut found_any = false;
5246 for (label, rel) in check_dirs {
5247 let full = format!("{}/{}", home, rel);
5248 if std::path::Path::new(&full).exists() {
5249 let size = Command::new("du")
5250 .args(["-sh", &full])
5251 .output()
5252 .ok()
5253 .map(|o| {
5254 let s = String::from_utf8_lossy(&o.stdout);
5255 s.split_whitespace().next().unwrap_or("?").to_string()
5256 })
5257 .unwrap_or_else(|| "?".to_string());
5258 out.push_str(&format!(" {label}: {size} ({full})\n"));
5259 found_any = true;
5260 }
5261 }
5262 if !found_any {
5263 out.push_str(" (none of the common cache directories found)\n");
5264 }
5265 }
5266
5267 Ok(out.trim_end().to_string())
5268}
5269
5270fn inspect_hardware() -> Result<String, String> {
5273 let mut out = String::from("Host inspection: hardware\n\n");
5274
5275 #[cfg(target_os = "windows")]
5276 {
5277 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5279 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5280} | Select-Object -First 1"#;
5281 if let Ok(o) = Command::new("powershell")
5282 .args(["-NoProfile", "-Command", cpu_script])
5283 .output()
5284 {
5285 let text = String::from_utf8_lossy(&o.stdout);
5286 let text = text.trim();
5287 let parts: Vec<&str> = text.split('|').collect();
5288 if parts.len() == 4 {
5289 out.push_str(&format!(
5290 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
5291 parts[0],
5292 parts[1],
5293 parts[2],
5294 parts[3].parse::<f32>().unwrap_or(0.0)
5295 ));
5296 } else {
5297 out.push_str(&format!("CPU: {text}\n\n"));
5298 }
5299 }
5300
5301 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5303$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5304$speed = ($sticks | Select-Object -First 1).Speed
5305"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5306 if let Ok(o) = Command::new("powershell")
5307 .args(["-NoProfile", "-Command", ram_script])
5308 .output()
5309 {
5310 let text = String::from_utf8_lossy(&o.stdout);
5311 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5312 }
5313
5314 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5316 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5317}"#;
5318 if let Ok(o) = Command::new("powershell")
5319 .args(["-NoProfile", "-Command", gpu_script])
5320 .output()
5321 {
5322 let text = String::from_utf8_lossy(&o.stdout);
5323 let lines: Vec<&str> = text.lines().collect();
5324 if !lines.is_empty() {
5325 out.push_str("GPU(s):\n");
5326 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5327 let parts: Vec<&str> = line.trim().split('|').collect();
5328 if parts.len() == 3 {
5329 let res = if parts[2] == "x" || parts[2].starts_with('0') {
5330 String::new()
5331 } else {
5332 format!(" — {}@display", parts[2])
5333 };
5334 out.push_str(&format!(
5335 " {}\n Driver: {}{}\n",
5336 parts[0], parts[1], res
5337 ));
5338 } else {
5339 out.push_str(&format!(" {}\n", line.trim()));
5340 }
5341 }
5342 out.push('\n');
5343 }
5344 }
5345
5346 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5348$bios = Get-CimInstance Win32_BIOS
5349$cs = Get-CimInstance Win32_ComputerSystem
5350$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5351$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5352"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5353 if let Ok(o) = Command::new("powershell")
5354 .args(["-NoProfile", "-Command", mb_script])
5355 .output()
5356 {
5357 let text = String::from_utf8_lossy(&o.stdout);
5358 let text = text.trim().trim_matches('"');
5359 let parts: Vec<&str> = text.split('|').collect();
5360 if parts.len() == 4 {
5361 out.push_str(&format!(
5362 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5363 parts[0].trim(),
5364 parts[1].trim(),
5365 parts[2].trim(),
5366 parts[3].trim()
5367 ));
5368 }
5369 }
5370
5371 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5373 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5374}"#;
5375 if let Ok(o) = Command::new("powershell")
5376 .args(["-NoProfile", "-Command", disp_script])
5377 .output()
5378 {
5379 let text = String::from_utf8_lossy(&o.stdout);
5380 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5381 if !lines.is_empty() {
5382 out.push_str("Display(s):\n");
5383 for line in &lines {
5384 let parts: Vec<&str> = line.trim().split('|').collect();
5385 if parts.len() == 2 {
5386 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
5387 }
5388 }
5389 }
5390 }
5391 }
5392
5393 #[cfg(not(target_os = "windows"))]
5394 {
5395 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5397 let model = content
5398 .lines()
5399 .find(|l| l.starts_with("model name"))
5400 .and_then(|l| l.split(':').nth(1))
5401 .map(str::trim)
5402 .unwrap_or("unknown");
5403 let cores = content
5404 .lines()
5405 .filter(|l| l.starts_with("processor"))
5406 .count();
5407 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
5408 }
5409
5410 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5412 let total_kb: u64 = content
5413 .lines()
5414 .find(|l| l.starts_with("MemTotal:"))
5415 .and_then(|l| l.split_whitespace().nth(1))
5416 .and_then(|v| v.parse().ok())
5417 .unwrap_or(0);
5418 let total_gb = total_kb / 1_048_576;
5419 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5420 }
5421
5422 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5424 let text = String::from_utf8_lossy(&o.stdout);
5425 let gpu_lines: Vec<&str> = text
5426 .lines()
5427 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5428 .collect();
5429 if !gpu_lines.is_empty() {
5430 out.push_str("GPU(s):\n");
5431 for l in gpu_lines {
5432 out.push_str(&format!(" {l}\n"));
5433 }
5434 out.push('\n');
5435 }
5436 }
5437
5438 if let Ok(o) = Command::new("dmidecode")
5440 .args(["-t", "baseboard", "-t", "bios"])
5441 .output()
5442 {
5443 let text = String::from_utf8_lossy(&o.stdout);
5444 out.push_str("Motherboard/BIOS:\n");
5445 for line in text
5446 .lines()
5447 .filter(|l| {
5448 l.contains("Manufacturer:")
5449 || l.contains("Product Name:")
5450 || l.contains("Version:")
5451 })
5452 .take(6)
5453 {
5454 out.push_str(&format!(" {}\n", line.trim()));
5455 }
5456 }
5457 }
5458
5459 Ok(out.trim_end().to_string())
5460}
5461
5462fn inspect_updates() -> Result<String, String> {
5465 let mut out = String::from("Host inspection: updates\n\n");
5466
5467 #[cfg(target_os = "windows")]
5468 {
5469 let script = r#"
5471try {
5472 $sess = New-Object -ComObject Microsoft.Update.Session
5473 $searcher = $sess.CreateUpdateSearcher()
5474 $count = $searcher.GetTotalHistoryCount()
5475 if ($count -gt 0) {
5476 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5477 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5478 } else { "NONE|LAST_INSTALL" }
5479} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5480"#;
5481 if let Ok(o) = Command::new("powershell")
5482 .args(["-NoProfile", "-Command", script])
5483 .output()
5484 {
5485 let raw = String::from_utf8_lossy(&o.stdout);
5486 let text = raw.trim();
5487 if text.starts_with("ERROR:") {
5488 out.push_str("Last update install: (unable to query)\n");
5489 } else if text.contains("NONE") {
5490 out.push_str("Last update install: No update history found\n");
5491 } else {
5492 let date = text.replace("|LAST_INSTALL", "");
5493 out.push_str(&format!("Last update install: {date}\n"));
5494 }
5495 }
5496
5497 let pending_script = r#"
5499try {
5500 $sess = New-Object -ComObject Microsoft.Update.Session
5501 $searcher = $sess.CreateUpdateSearcher()
5502 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5503 $results.Updates.Count.ToString() + "|PENDING"
5504} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5505"#;
5506 if let Ok(o) = Command::new("powershell")
5507 .args(["-NoProfile", "-Command", pending_script])
5508 .output()
5509 {
5510 let raw = String::from_utf8_lossy(&o.stdout);
5511 let text = raw.trim();
5512 if text.starts_with("ERROR:") {
5513 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5514 } else {
5515 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5516 if count == 0 {
5517 out.push_str("Pending updates: Up to date — no updates waiting\n");
5518 } else if count > 0 {
5519 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5520 out.push_str(
5521 " → Open Windows Update (Settings > Windows Update) to install\n",
5522 );
5523 }
5524 }
5525 }
5526
5527 let svc_script = r#"
5529$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5530if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5531"#;
5532 if let Ok(o) = Command::new("powershell")
5533 .args(["-NoProfile", "-Command", svc_script])
5534 .output()
5535 {
5536 let raw = String::from_utf8_lossy(&o.stdout);
5537 let status = raw.trim();
5538 out.push_str(&format!("Windows Update service: {status}\n"));
5539 }
5540 }
5541
5542 #[cfg(not(target_os = "windows"))]
5543 {
5544 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5545 let mut found = false;
5546 if let Ok(o) = apt_out {
5547 let text = String::from_utf8_lossy(&o.stdout);
5548 let lines: Vec<&str> = text
5549 .lines()
5550 .filter(|l| l.contains('/') && !l.contains("Listing"))
5551 .collect();
5552 if !lines.is_empty() {
5553 out.push_str(&format!(
5554 "{} package(s) can be upgraded (apt)\n",
5555 lines.len()
5556 ));
5557 out.push_str(" → Run: sudo apt upgrade\n");
5558 found = true;
5559 }
5560 }
5561 if !found {
5562 if let Ok(o) = Command::new("dnf")
5563 .args(["check-update", "--quiet"])
5564 .output()
5565 {
5566 let text = String::from_utf8_lossy(&o.stdout);
5567 let count = text
5568 .lines()
5569 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5570 .count();
5571 if count > 0 {
5572 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5573 out.push_str(" → Run: sudo dnf upgrade\n");
5574 } else {
5575 out.push_str("System is up to date.\n");
5576 }
5577 } else {
5578 out.push_str("Could not query package manager for updates.\n");
5579 }
5580 }
5581 }
5582
5583 Ok(out.trim_end().to_string())
5584}
5585
5586fn inspect_security() -> Result<String, String> {
5589 let mut out = String::from("Host inspection: security\n\n");
5590
5591 #[cfg(target_os = "windows")]
5592 {
5593 let defender_script = r#"
5595try {
5596 $status = Get-MpComputerStatus -ErrorAction Stop
5597 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5598} catch { "ERROR:" + $_.Exception.Message }
5599"#;
5600 if let Ok(o) = Command::new("powershell")
5601 .args(["-NoProfile", "-Command", defender_script])
5602 .output()
5603 {
5604 let raw = String::from_utf8_lossy(&o.stdout);
5605 let text = raw.trim();
5606 if text.starts_with("ERROR:") {
5607 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5608 } else {
5609 let get = |key: &str| -> String {
5610 text.split('|')
5611 .find(|s| s.starts_with(key))
5612 .and_then(|s| s.splitn(2, ':').nth(1))
5613 .unwrap_or("unknown")
5614 .to_string()
5615 };
5616 let rtp = get("RTP");
5617 let last_scan = {
5618 text.split('|')
5620 .find(|s| s.starts_with("SCAN:"))
5621 .and_then(|s| s.get(5..))
5622 .unwrap_or("unknown")
5623 .to_string()
5624 };
5625 let def_ver = get("VER");
5626 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5627
5628 let rtp_label = if rtp == "True" {
5629 "ENABLED"
5630 } else {
5631 "DISABLED [!]"
5632 };
5633 out.push_str(&format!(
5634 "Windows Defender real-time protection: {rtp_label}\n"
5635 ));
5636 out.push_str(&format!("Last quick scan: {last_scan}\n"));
5637 out.push_str(&format!("Signature version: {def_ver}\n"));
5638 if age_days >= 0 {
5639 let freshness = if age_days == 0 {
5640 "up to date".to_string()
5641 } else if age_days <= 3 {
5642 format!("{age_days} day(s) old — OK")
5643 } else if age_days <= 7 {
5644 format!("{age_days} day(s) old — consider updating")
5645 } else {
5646 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5647 };
5648 out.push_str(&format!("Signature age: {freshness}\n"));
5649 }
5650 if rtp != "True" {
5651 out.push_str(
5652 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5653 );
5654 out.push_str(
5655 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5656 );
5657 }
5658 }
5659 }
5660
5661 out.push('\n');
5662
5663 let fw_script = r#"
5665try {
5666 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5667} catch { "ERROR:" + $_.Exception.Message }
5668"#;
5669 if let Ok(o) = Command::new("powershell")
5670 .args(["-NoProfile", "-Command", fw_script])
5671 .output()
5672 {
5673 let raw = String::from_utf8_lossy(&o.stdout);
5674 let text = raw.trim();
5675 if !text.starts_with("ERROR:") && !text.is_empty() {
5676 out.push_str("Windows Firewall:\n");
5677 for line in text.lines() {
5678 if let Some((name, enabled)) = line.split_once(':') {
5679 let state = if enabled.trim() == "True" {
5680 "ON"
5681 } else {
5682 "OFF [!]"
5683 };
5684 out.push_str(&format!(" {name}: {state}\n"));
5685 }
5686 }
5687 out.push('\n');
5688 }
5689 }
5690
5691 let act_script = r#"
5693try {
5694 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5695 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5696} catch { "UNKNOWN" }
5697"#;
5698 if let Ok(o) = Command::new("powershell")
5699 .args(["-NoProfile", "-Command", act_script])
5700 .output()
5701 {
5702 let raw = String::from_utf8_lossy(&o.stdout);
5703 match raw.trim() {
5704 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5705 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5706 _ => out.push_str("Windows activation: Unable to determine\n"),
5707 }
5708 }
5709
5710 let uac_script = r#"
5712$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5713if ($val -eq 1) { "ON" } else { "OFF" }
5714"#;
5715 if let Ok(o) = Command::new("powershell")
5716 .args(["-NoProfile", "-Command", uac_script])
5717 .output()
5718 {
5719 let raw = String::from_utf8_lossy(&o.stdout);
5720 let state = raw.trim();
5721 let label = if state == "ON" {
5722 "Enabled"
5723 } else {
5724 "DISABLED [!] — recommended to re-enable via secpol.msc"
5725 };
5726 out.push_str(&format!("UAC (User Account Control): {label}\n"));
5727 }
5728 }
5729
5730 #[cfg(not(target_os = "windows"))]
5731 {
5732 if let Ok(o) = Command::new("ufw").arg("status").output() {
5733 let text = String::from_utf8_lossy(&o.stdout);
5734 out.push_str(&format!(
5735 "UFW: {}\n",
5736 text.lines().next().unwrap_or("unknown")
5737 ));
5738 }
5739 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5740 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5741 out.push_str(&format!("{line}\n"));
5742 }
5743 }
5744 }
5745
5746 Ok(out.trim_end().to_string())
5747}
5748
5749fn inspect_pending_reboot() -> Result<String, String> {
5752 let mut out = String::from("Host inspection: pending_reboot\n\n");
5753
5754 #[cfg(target_os = "windows")]
5755 {
5756 let script = r#"
5757$reasons = @()
5758if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5759 $reasons += "Windows Update requires a restart"
5760}
5761if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5762 $reasons += "Windows component install/update requires a restart"
5763}
5764$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5765if ($pfro -and $pfro.PendingFileRenameOperations) {
5766 $reasons += "Pending file rename operations (driver or system file replacement)"
5767}
5768if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5769"#;
5770 let output = Command::new("powershell")
5771 .args(["-NoProfile", "-Command", script])
5772 .output()
5773 .map_err(|e| format!("pending_reboot: {e}"))?;
5774
5775 let raw = String::from_utf8_lossy(&output.stdout);
5776 let text = raw.trim();
5777
5778 if text == "NO_REBOOT_NEEDED" {
5779 out.push_str("No restart required — system is up to date and stable.\n");
5780 } else if text.is_empty() {
5781 out.push_str("Could not determine reboot status.\n");
5782 } else {
5783 out.push_str("[!] A system restart is pending:\n\n");
5784 for reason in text.split("|REASON|") {
5785 out.push_str(&format!(" • {}\n", reason.trim()));
5786 }
5787 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5788 }
5789 }
5790
5791 #[cfg(not(target_os = "windows"))]
5792 {
5793 if std::path::Path::new("/var/run/reboot-required").exists() {
5794 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5795 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5796 out.push_str("Packages requiring restart:\n");
5797 for p in pkgs.lines().take(10) {
5798 out.push_str(&format!(" • {p}\n"));
5799 }
5800 }
5801 } else {
5802 out.push_str("No restart required.\n");
5803 }
5804 }
5805
5806 Ok(out.trim_end().to_string())
5807}
5808
5809fn inspect_disk_health() -> Result<String, String> {
5812 let mut out = String::from("Host inspection: disk_health\n\n");
5813
5814 #[cfg(target_os = "windows")]
5815 {
5816 let script = r#"
5817try {
5818 $disks = Get-PhysicalDisk -ErrorAction Stop
5819 foreach ($d in $disks) {
5820 $size_gb = [math]::Round($d.Size / 1GB, 0)
5821 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5822 }
5823} catch { "ERROR:" + $_.Exception.Message }
5824"#;
5825 let output = Command::new("powershell")
5826 .args(["-NoProfile", "-Command", script])
5827 .output()
5828 .map_err(|e| format!("disk_health: {e}"))?;
5829
5830 let raw = String::from_utf8_lossy(&output.stdout);
5831 let text = raw.trim();
5832
5833 if text.starts_with("ERROR:") {
5834 out.push_str(&format!("Unable to query disk health: {text}\n"));
5835 out.push_str("This may require running as administrator.\n");
5836 } else if text.is_empty() {
5837 out.push_str("No physical disks found.\n");
5838 } else {
5839 out.push_str("Physical Drive Health:\n\n");
5840 for line in text.lines() {
5841 let parts: Vec<&str> = line.splitn(5, '|').collect();
5842 if parts.len() >= 4 {
5843 let name = parts[0];
5844 let media = parts[1];
5845 let size = parts[2];
5846 let health = parts[3];
5847 let op_status = parts.get(4).unwrap_or(&"");
5848 let health_label = match health.trim() {
5849 "Healthy" => "OK",
5850 "Warning" => "[!] WARNING",
5851 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5852 other => other,
5853 };
5854 out.push_str(&format!(" {name}\n"));
5855 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
5856 out.push_str(&format!(" Health: {health_label}\n"));
5857 if !op_status.is_empty() {
5858 out.push_str(&format!(" Status: {op_status}\n"));
5859 }
5860 out.push('\n');
5861 }
5862 }
5863 }
5864
5865 let smart_script = r#"
5867try {
5868 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5869 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5870} catch { "" }
5871"#;
5872 if let Ok(o) = Command::new("powershell")
5873 .args(["-NoProfile", "-Command", smart_script])
5874 .output()
5875 {
5876 let raw2 = String::from_utf8_lossy(&o.stdout);
5877 let text2 = raw2.trim();
5878 if !text2.is_empty() {
5879 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5880 if failures.is_empty() {
5881 out.push_str("SMART failure prediction: No failures predicted\n");
5882 } else {
5883 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5884 for f in failures {
5885 let name = f.split('|').next().unwrap_or(f);
5886 out.push_str(&format!(" • {name}\n"));
5887 }
5888 out.push_str(
5889 "\nBack up your data immediately and replace the failing drive.\n",
5890 );
5891 }
5892 }
5893 }
5894 }
5895
5896 #[cfg(not(target_os = "windows"))]
5897 {
5898 if let Ok(o) = Command::new("lsblk")
5899 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5900 .output()
5901 {
5902 let text = String::from_utf8_lossy(&o.stdout);
5903 out.push_str("Block devices:\n");
5904 out.push_str(text.trim());
5905 out.push('\n');
5906 }
5907 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5908 let devices = String::from_utf8_lossy(&scan.stdout);
5909 for dev_line in devices.lines().take(4) {
5910 let dev = dev_line.split_whitespace().next().unwrap_or("");
5911 if dev.is_empty() {
5912 continue;
5913 }
5914 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5915 let health = String::from_utf8_lossy(&o.stdout);
5916 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5917 {
5918 out.push_str(&format!("{dev}: {}\n", line.trim()));
5919 }
5920 }
5921 }
5922 } else {
5923 out.push_str("(install smartmontools for SMART health data)\n");
5924 }
5925 }
5926
5927 Ok(out.trim_end().to_string())
5928}
5929
5930fn inspect_battery() -> Result<String, String> {
5933 let mut out = String::from("Host inspection: battery\n\n");
5934
5935 #[cfg(target_os = "windows")]
5936 {
5937 let script = r#"
5938try {
5939 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5940 if (-not $bats) { "NO_BATTERY"; exit }
5941
5942 # Modern Battery Health (Cycle count + Capacity health)
5943 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5944 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
5945 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5946
5947 foreach ($b in $bats) {
5948 $state = switch ($b.BatteryStatus) {
5949 1 { "Discharging" }
5950 2 { "AC Power (Fully Charged)" }
5951 3 { "AC Power (Charging)" }
5952 default { "Status $($b.BatteryStatus)" }
5953 }
5954
5955 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5956 $health = if ($static -and $full) {
5957 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5958 } else { "unknown" }
5959
5960 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5961 }
5962} catch { "ERROR:" + $_.Exception.Message }
5963"#;
5964 let output = Command::new("powershell")
5965 .args(["-NoProfile", "-Command", script])
5966 .output()
5967 .map_err(|e| format!("battery: {e}"))?;
5968
5969 let raw = String::from_utf8_lossy(&output.stdout);
5970 let text = raw.trim();
5971
5972 if text == "NO_BATTERY" {
5973 out.push_str("No battery detected — desktop or AC-only system.\n");
5974 return Ok(out.trim_end().to_string());
5975 }
5976 if text.starts_with("ERROR:") {
5977 out.push_str(&format!("Unable to query battery: {text}\n"));
5978 return Ok(out.trim_end().to_string());
5979 }
5980
5981 for line in text.lines() {
5982 let parts: Vec<&str> = line.split('|').collect();
5983 if parts.len() == 5 {
5984 let name = parts[0];
5985 let charge: i64 = parts[1].parse().unwrap_or(-1);
5986 let state = parts[2];
5987 let cycles = parts[3];
5988 let health = parts[4];
5989
5990 out.push_str(&format!("Battery: {name}\n"));
5991 if charge >= 0 {
5992 let bar_filled = (charge as usize * 20) / 100;
5993 out.push_str(&format!(
5994 " Charge: [{}{}] {}%\n",
5995 "#".repeat(bar_filled),
5996 ".".repeat(20 - bar_filled),
5997 charge
5998 ));
5999 }
6000 out.push_str(&format!(" Status: {state}\n"));
6001 out.push_str(&format!(" Cycles: {cycles}\n"));
6002 out.push_str(&format!(
6003 " Health: {health}% (Actual vs Design Capacity)\n\n"
6004 ));
6005 }
6006 }
6007 }
6008
6009 #[cfg(not(target_os = "windows"))]
6010 {
6011 let power_path = std::path::Path::new("/sys/class/power_supply");
6012 let mut found = false;
6013 if power_path.exists() {
6014 if let Ok(entries) = std::fs::read_dir(power_path) {
6015 for entry in entries.flatten() {
6016 let p = entry.path();
6017 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6018 if t.trim() == "Battery" {
6019 found = true;
6020 let name = p
6021 .file_name()
6022 .unwrap_or_default()
6023 .to_string_lossy()
6024 .to_string();
6025 out.push_str(&format!("Battery: {name}\n"));
6026 let read = |f: &str| {
6027 std::fs::read_to_string(p.join(f))
6028 .ok()
6029 .map(|s| s.trim().to_string())
6030 };
6031 if let Some(cap) = read("capacity") {
6032 out.push_str(&format!(" Charge: {cap}%\n"));
6033 }
6034 if let Some(status) = read("status") {
6035 out.push_str(&format!(" Status: {status}\n"));
6036 }
6037 if let (Some(full), Some(design)) =
6038 (read("energy_full"), read("energy_full_design"))
6039 {
6040 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6041 {
6042 if d > 0.0 {
6043 out.push_str(&format!(
6044 " Wear level: {:.1}% of design capacity\n",
6045 (f / d) * 100.0
6046 ));
6047 }
6048 }
6049 }
6050 }
6051 }
6052 }
6053 }
6054 }
6055 if !found {
6056 out.push_str("No battery found.\n");
6057 }
6058 }
6059
6060 Ok(out.trim_end().to_string())
6061}
6062
6063fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6066 let mut out = String::from("Host inspection: recent_crashes\n\n");
6067 let n = max_entries.clamp(1, 30);
6068
6069 #[cfg(target_os = "windows")]
6070 {
6071 let bsod_script = format!(
6073 r#"
6074try {{
6075 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6076 if ($events) {{
6077 $events | ForEach-Object {{
6078 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6079 }}
6080 }} else {{ "NO_BSOD" }}
6081}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6082 );
6083
6084 if let Ok(o) = Command::new("powershell")
6085 .args(["-NoProfile", "-Command", &bsod_script])
6086 .output()
6087 {
6088 let raw = String::from_utf8_lossy(&o.stdout);
6089 let text = raw.trim();
6090 if text == "NO_BSOD" {
6091 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6092 } else if text.starts_with("ERROR:") {
6093 out.push_str("System crashes: unable to query\n");
6094 } else {
6095 out.push_str("System crashes / unexpected shutdowns:\n");
6096 for line in text.lines() {
6097 let parts: Vec<&str> = line.splitn(3, '|').collect();
6098 if parts.len() >= 3 {
6099 let time = parts[0];
6100 let id = parts[1];
6101 let msg = parts[2];
6102 let label = if id == "41" {
6103 "Unexpected shutdown"
6104 } else {
6105 "BSOD (BugCheck)"
6106 };
6107 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
6108 }
6109 }
6110 out.push('\n');
6111 }
6112 }
6113
6114 let app_script = format!(
6116 r#"
6117try {{
6118 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6119 if ($crashes) {{
6120 $crashes | ForEach-Object {{
6121 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6122 }}
6123 }} else {{ "NO_CRASHES" }}
6124}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6125 );
6126
6127 if let Ok(o) = Command::new("powershell")
6128 .args(["-NoProfile", "-Command", &app_script])
6129 .output()
6130 {
6131 let raw = String::from_utf8_lossy(&o.stdout);
6132 let text = raw.trim();
6133 if text == "NO_CRASHES" {
6134 out.push_str("Application crashes: None in recent history\n");
6135 } else if text.starts_with("ERROR_APP:") {
6136 out.push_str("Application crashes: unable to query\n");
6137 } else {
6138 out.push_str("Application crashes:\n");
6139 for line in text.lines().take(n) {
6140 let parts: Vec<&str> = line.splitn(2, '|').collect();
6141 if parts.len() >= 2 {
6142 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
6143 }
6144 }
6145 }
6146 }
6147 }
6148
6149 #[cfg(not(target_os = "windows"))]
6150 {
6151 let n_str = n.to_string();
6152 if let Ok(o) = Command::new("journalctl")
6153 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6154 .output()
6155 {
6156 let text = String::from_utf8_lossy(&o.stdout);
6157 let trimmed = text.trim();
6158 if trimmed.is_empty() || trimmed.contains("No entries") {
6159 out.push_str("No kernel panics or critical crashes found.\n");
6160 } else {
6161 out.push_str("Kernel critical events:\n");
6162 out.push_str(trimmed);
6163 out.push('\n');
6164 }
6165 }
6166 if let Ok(o) = Command::new("coredumpctl")
6167 .args(["list", "--no-pager"])
6168 .output()
6169 {
6170 let text = String::from_utf8_lossy(&o.stdout);
6171 let count = text
6172 .lines()
6173 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6174 .count();
6175 if count > 0 {
6176 out.push_str(&format!(
6177 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
6178 ));
6179 }
6180 }
6181 }
6182
6183 Ok(out.trim_end().to_string())
6184}
6185
6186fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6189 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6190 let n = max_entries.clamp(1, 30);
6191
6192 #[cfg(target_os = "windows")]
6193 {
6194 let script = format!(
6195 r#"
6196try {{
6197 $tasks = Get-ScheduledTask -ErrorAction Stop |
6198 Where-Object {{ $_.State -ne 'Disabled' }} |
6199 ForEach-Object {{
6200 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6201 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6202 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6203 }} else {{ "never" }}
6204 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6205 $exec = ($_.Actions | Select-Object -First 1).Execute
6206 if (-not $exec) {{ $exec = "(no exec)" }}
6207 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6208 }}
6209 $tasks | Select-Object -First {n}
6210}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6211 );
6212
6213 let output = Command::new("powershell")
6214 .args(["-NoProfile", "-Command", &script])
6215 .output()
6216 .map_err(|e| format!("scheduled_tasks: {e}"))?;
6217
6218 let raw = String::from_utf8_lossy(&output.stdout);
6219 let text = raw.trim();
6220
6221 if text.starts_with("ERROR:") {
6222 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
6223 } else if text.is_empty() {
6224 out.push_str("No active scheduled tasks found.\n");
6225 } else {
6226 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
6227 for line in text.lines() {
6228 let parts: Vec<&str> = line.splitn(6, '|').collect();
6229 if parts.len() >= 5 {
6230 let name = parts[0];
6231 let path = parts[1];
6232 let state = parts[2];
6233 let last = parts[3];
6234 let res = parts[4];
6235 let exec = parts.get(5).unwrap_or(&"").trim();
6236 let display_path = path.trim_matches('\\');
6237 let display_path = if display_path.is_empty() {
6238 "Root"
6239 } else {
6240 display_path
6241 };
6242 out.push_str(&format!(" {name} [{display_path}]\n"));
6243 out.push_str(&format!(
6244 " State: {state} | Last run: {last} | Result: {res}\n"
6245 ));
6246 if !exec.is_empty() && exec != "(no exec)" {
6247 let short = if exec.len() > 80 { &exec[..80] } else { exec };
6248 out.push_str(&format!(" Runs: {short}\n"));
6249 }
6250 }
6251 }
6252 }
6253 }
6254
6255 #[cfg(not(target_os = "windows"))]
6256 {
6257 if let Ok(o) = Command::new("systemctl")
6258 .args(["list-timers", "--no-pager", "--all"])
6259 .output()
6260 {
6261 let text = String::from_utf8_lossy(&o.stdout);
6262 out.push_str("Systemd timers:\n");
6263 for l in text
6264 .lines()
6265 .filter(|l| {
6266 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6267 })
6268 .take(n)
6269 {
6270 out.push_str(&format!(" {l}\n"));
6271 }
6272 out.push('\n');
6273 }
6274 if let Ok(o) = Command::new("crontab").arg("-l").output() {
6275 let text = String::from_utf8_lossy(&o.stdout);
6276 let jobs: Vec<&str> = text
6277 .lines()
6278 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6279 .collect();
6280 if !jobs.is_empty() {
6281 out.push_str("User crontab:\n");
6282 for j in jobs.iter().take(n) {
6283 out.push_str(&format!(" {j}\n"));
6284 }
6285 }
6286 }
6287 }
6288
6289 Ok(out.trim_end().to_string())
6290}
6291
6292fn inspect_dev_conflicts() -> Result<String, String> {
6295 let mut out = String::from("Host inspection: dev_conflicts\n\n");
6296 let mut conflicts: Vec<String> = Vec::new();
6297 let mut notes: Vec<String> = Vec::new();
6298
6299 {
6301 let node_ver = Command::new("node")
6302 .arg("--version")
6303 .output()
6304 .ok()
6305 .and_then(|o| String::from_utf8(o.stdout).ok())
6306 .map(|s| s.trim().to_string());
6307 let nvm_active = Command::new("nvm")
6308 .arg("current")
6309 .output()
6310 .ok()
6311 .and_then(|o| String::from_utf8(o.stdout).ok())
6312 .map(|s| s.trim().to_string())
6313 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6314 let fnm_active = Command::new("fnm")
6315 .arg("current")
6316 .output()
6317 .ok()
6318 .and_then(|o| String::from_utf8(o.stdout).ok())
6319 .map(|s| s.trim().to_string())
6320 .filter(|s| !s.is_empty() && !s.contains("none"));
6321 let volta_active = Command::new("volta")
6322 .args(["which", "node"])
6323 .output()
6324 .ok()
6325 .and_then(|o| String::from_utf8(o.stdout).ok())
6326 .map(|s| s.trim().to_string())
6327 .filter(|s| !s.is_empty());
6328
6329 out.push_str("Node.js:\n");
6330 if let Some(ref v) = node_ver {
6331 out.push_str(&format!(" Active: {v}\n"));
6332 } else {
6333 out.push_str(" Not installed\n");
6334 }
6335 let managers: Vec<&str> = [
6336 nvm_active.as_deref(),
6337 fnm_active.as_deref(),
6338 volta_active.as_deref(),
6339 ]
6340 .iter()
6341 .filter_map(|x| *x)
6342 .collect();
6343 if managers.len() > 1 {
6344 conflicts.push(format!(
6345 "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
6346 ));
6347 } else if !managers.is_empty() {
6348 out.push_str(&format!(" Version manager: {}\n", managers[0]));
6349 }
6350 out.push('\n');
6351 }
6352
6353 {
6355 let py3 = Command::new("python3")
6356 .arg("--version")
6357 .output()
6358 .ok()
6359 .and_then(|o| {
6360 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6361 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6362 let v = if stdout.is_empty() { stderr } else { stdout };
6363 if v.is_empty() {
6364 None
6365 } else {
6366 Some(v)
6367 }
6368 });
6369 let py = Command::new("python")
6370 .arg("--version")
6371 .output()
6372 .ok()
6373 .and_then(|o| {
6374 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6375 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6376 let v = if stdout.is_empty() { stderr } else { stdout };
6377 if v.is_empty() {
6378 None
6379 } else {
6380 Some(v)
6381 }
6382 });
6383 let pyenv = Command::new("pyenv")
6384 .arg("version")
6385 .output()
6386 .ok()
6387 .and_then(|o| String::from_utf8(o.stdout).ok())
6388 .map(|s| s.trim().to_string())
6389 .filter(|s| !s.is_empty());
6390 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6391
6392 out.push_str("Python:\n");
6393 match (&py3, &py) {
6394 (Some(v3), Some(v)) if v3 != v => {
6395 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
6396 if v.contains("2.") {
6397 conflicts.push(
6398 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6399 );
6400 } else {
6401 notes.push(
6402 "python and python3 resolve to different minor versions.".to_string(),
6403 );
6404 }
6405 }
6406 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
6407 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
6408 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
6409 (None, None) => out.push_str(" Not installed\n"),
6410 }
6411 if let Some(ref pe) = pyenv {
6412 out.push_str(&format!(" pyenv: {pe}\n"));
6413 }
6414 if let Some(env) = conda_env {
6415 if env == "base" {
6416 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6417 } else {
6418 out.push_str(&format!(" conda env: {env}\n"));
6419 }
6420 }
6421 out.push('\n');
6422 }
6423
6424 {
6426 let toolchain = Command::new("rustup")
6427 .args(["show", "active-toolchain"])
6428 .output()
6429 .ok()
6430 .and_then(|o| String::from_utf8(o.stdout).ok())
6431 .map(|s| s.trim().to_string())
6432 .filter(|s| !s.is_empty());
6433 let cargo_ver = Command::new("cargo")
6434 .arg("--version")
6435 .output()
6436 .ok()
6437 .and_then(|o| String::from_utf8(o.stdout).ok())
6438 .map(|s| s.trim().to_string());
6439 let rustc_ver = Command::new("rustc")
6440 .arg("--version")
6441 .output()
6442 .ok()
6443 .and_then(|o| String::from_utf8(o.stdout).ok())
6444 .map(|s| s.trim().to_string());
6445
6446 out.push_str("Rust:\n");
6447 if let Some(ref t) = toolchain {
6448 out.push_str(&format!(" Active toolchain: {t}\n"));
6449 }
6450 if let Some(ref c) = cargo_ver {
6451 out.push_str(&format!(" {c}\n"));
6452 }
6453 if let Some(ref r) = rustc_ver {
6454 out.push_str(&format!(" {r}\n"));
6455 }
6456 if cargo_ver.is_none() && rustc_ver.is_none() {
6457 out.push_str(" Not installed\n");
6458 }
6459
6460 #[cfg(not(target_os = "windows"))]
6462 if let Ok(o) = Command::new("which").arg("rustc").output() {
6463 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6464 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6465 conflicts.push(format!(
6466 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6467 ));
6468 }
6469 }
6470 out.push('\n');
6471 }
6472
6473 {
6475 let git_ver = Command::new("git")
6476 .arg("--version")
6477 .output()
6478 .ok()
6479 .and_then(|o| String::from_utf8(o.stdout).ok())
6480 .map(|s| s.trim().to_string());
6481 out.push_str("Git:\n");
6482 if let Some(ref v) = git_ver {
6483 out.push_str(&format!(" {v}\n"));
6484 let email = Command::new("git")
6485 .args(["config", "--global", "user.email"])
6486 .output()
6487 .ok()
6488 .and_then(|o| String::from_utf8(o.stdout).ok())
6489 .map(|s| s.trim().to_string());
6490 if let Some(ref e) = email {
6491 if e.is_empty() {
6492 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6493 } else {
6494 out.push_str(&format!(" user.email: {e}\n"));
6495 }
6496 }
6497 let gpg_sign = Command::new("git")
6498 .args(["config", "--global", "commit.gpgsign"])
6499 .output()
6500 .ok()
6501 .and_then(|o| String::from_utf8(o.stdout).ok())
6502 .map(|s| s.trim().to_string());
6503 if gpg_sign.as_deref() == Some("true") {
6504 let key = Command::new("git")
6505 .args(["config", "--global", "user.signingkey"])
6506 .output()
6507 .ok()
6508 .and_then(|o| String::from_utf8(o.stdout).ok())
6509 .map(|s| s.trim().to_string());
6510 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6511 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6512 }
6513 }
6514 } else {
6515 out.push_str(" Not installed\n");
6516 }
6517 out.push('\n');
6518 }
6519
6520 {
6522 let path_env = std::env::var("PATH").unwrap_or_default();
6523 let sep = if cfg!(windows) { ';' } else { ':' };
6524 let mut seen = HashSet::new();
6525 let mut dupes: Vec<String> = Vec::new();
6526 for p in path_env.split(sep) {
6527 let norm = p.trim().to_lowercase();
6528 if !norm.is_empty() && !seen.insert(norm) {
6529 dupes.push(p.to_string());
6530 }
6531 }
6532 if !dupes.is_empty() {
6533 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6534 notes.push(format!(
6535 "Duplicate PATH entries: {} {}",
6536 shown.join(", "),
6537 if dupes.len() > 3 {
6538 format!("+{} more", dupes.len() - 3)
6539 } else {
6540 String::new()
6541 }
6542 ));
6543 }
6544 }
6545
6546 if conflicts.is_empty() && notes.is_empty() {
6548 out.push_str("No conflicts detected — dev environment looks clean.\n");
6549 } else {
6550 if !conflicts.is_empty() {
6551 out.push_str("CONFLICTS:\n");
6552 for c in &conflicts {
6553 out.push_str(&format!(" [!] {c}\n"));
6554 }
6555 out.push('\n');
6556 }
6557 if !notes.is_empty() {
6558 out.push_str("NOTES:\n");
6559 for n in ¬es {
6560 out.push_str(&format!(" [-] {n}\n"));
6561 }
6562 }
6563 }
6564
6565 Ok(out.trim_end().to_string())
6566}
6567
6568fn inspect_connectivity() -> Result<String, String> {
6571 let mut out = String::from("Host inspection: connectivity\n\n");
6572
6573 #[cfg(target_os = "windows")]
6574 {
6575 let inet_script = r#"
6576try {
6577 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6578 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6579} catch { "ERROR:" + $_.Exception.Message }
6580"#;
6581 if let Ok(o) = Command::new("powershell")
6582 .args(["-NoProfile", "-Command", inet_script])
6583 .output()
6584 {
6585 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6586 match text.as_str() {
6587 "REACHABLE" => out.push_str("Internet: reachable\n"),
6588 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6589 _ => out.push_str(&format!(
6590 "Internet: {}\n",
6591 text.trim_start_matches("ERROR:").trim()
6592 )),
6593 }
6594 }
6595
6596 let dns_script = r#"
6597try {
6598 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6599 "DNS:ok"
6600} catch { "DNS:fail:" + $_.Exception.Message }
6601"#;
6602 if let Ok(o) = Command::new("powershell")
6603 .args(["-NoProfile", "-Command", dns_script])
6604 .output()
6605 {
6606 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6607 if text == "DNS:ok" {
6608 out.push_str("DNS: resolving correctly\n");
6609 } else {
6610 let detail = text.trim_start_matches("DNS:fail:").trim();
6611 out.push_str(&format!("DNS: failed — {}\n", detail));
6612 }
6613 }
6614
6615 let gw_script = r#"
6616(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6617"#;
6618 if let Ok(o) = Command::new("powershell")
6619 .args(["-NoProfile", "-Command", gw_script])
6620 .output()
6621 {
6622 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6623 if !gw.is_empty() && gw != "0.0.0.0" {
6624 out.push_str(&format!("Default gateway: {}\n", gw));
6625 }
6626 }
6627 }
6628
6629 #[cfg(not(target_os = "windows"))]
6630 {
6631 let reachable = Command::new("ping")
6632 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6633 .output()
6634 .map(|o| o.status.success())
6635 .unwrap_or(false);
6636 out.push_str(if reachable {
6637 "Internet: reachable\n"
6638 } else {
6639 "Internet: unreachable\n"
6640 });
6641 let dns_ok = Command::new("getent")
6642 .args(["hosts", "dns.google"])
6643 .output()
6644 .map(|o| o.status.success())
6645 .unwrap_or(false);
6646 out.push_str(if dns_ok {
6647 "DNS: resolving correctly\n"
6648 } else {
6649 "DNS: failed\n"
6650 });
6651 if let Ok(o) = Command::new("ip")
6652 .args(["route", "show", "default"])
6653 .output()
6654 {
6655 let text = String::from_utf8_lossy(&o.stdout);
6656 if let Some(line) = text.lines().next() {
6657 out.push_str(&format!("Default gateway: {}\n", line.trim()));
6658 }
6659 }
6660 }
6661
6662 Ok(out.trim_end().to_string())
6663}
6664
6665fn inspect_wifi() -> Result<String, String> {
6668 let mut out = String::from("Host inspection: wifi\n\n");
6669
6670 #[cfg(target_os = "windows")]
6671 {
6672 let output = Command::new("netsh")
6673 .args(["wlan", "show", "interfaces"])
6674 .output()
6675 .map_err(|e| format!("wifi: {e}"))?;
6676 let text = String::from_utf8_lossy(&output.stdout).to_string();
6677
6678 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6679 out.push_str("No wireless interface detected on this machine.\n");
6680 return Ok(out.trim_end().to_string());
6681 }
6682
6683 let fields = [
6684 ("SSID", "SSID"),
6685 ("State", "State"),
6686 ("Signal", "Signal"),
6687 ("Radio type", "Radio type"),
6688 ("Channel", "Channel"),
6689 ("Receive rate (Mbps)", "Download speed (Mbps)"),
6690 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6691 ("Authentication", "Authentication"),
6692 ("Network type", "Network type"),
6693 ];
6694
6695 let mut any = false;
6696 for line in text.lines() {
6697 let trimmed = line.trim();
6698 for (key, label) in &fields {
6699 if trimmed.starts_with(key) && trimmed.contains(':') {
6700 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6701 if !val.is_empty() {
6702 out.push_str(&format!(" {label}: {val}\n"));
6703 any = true;
6704 }
6705 }
6706 }
6707 }
6708 if !any {
6709 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
6710 }
6711 }
6712
6713 #[cfg(not(target_os = "windows"))]
6714 {
6715 if let Ok(o) = Command::new("nmcli")
6716 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6717 .output()
6718 {
6719 let text = String::from_utf8_lossy(&o.stdout).to_string();
6720 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6721 if lines.is_empty() {
6722 out.push_str("No Wi-Fi devices found.\n");
6723 } else {
6724 for l in lines {
6725 out.push_str(&format!(" {l}\n"));
6726 }
6727 }
6728 } else if let Ok(o) = Command::new("iwconfig").output() {
6729 let text = String::from_utf8_lossy(&o.stdout).to_string();
6730 if !text.trim().is_empty() {
6731 out.push_str(text.trim());
6732 out.push('\n');
6733 }
6734 } else {
6735 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6736 }
6737 }
6738
6739 Ok(out.trim_end().to_string())
6740}
6741
6742fn inspect_connections(max_entries: usize) -> Result<String, String> {
6745 let mut out = String::from("Host inspection: connections\n\n");
6746 let n = max_entries.clamp(1, 25);
6747
6748 #[cfg(target_os = "windows")]
6749 {
6750 let script = format!(
6751 r#"
6752try {{
6753 $procs = @{{}}
6754 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6755 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6756 Sort-Object OwningProcess
6757 "TOTAL:" + $all.Count
6758 $all | Select-Object -First {n} | ForEach-Object {{
6759 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6760 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6761 }}
6762}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6763 );
6764
6765 let output = Command::new("powershell")
6766 .args(["-NoProfile", "-Command", &script])
6767 .output()
6768 .map_err(|e| format!("connections: {e}"))?;
6769
6770 let raw = String::from_utf8_lossy(&output.stdout);
6771 let text = raw.trim();
6772
6773 if text.starts_with("ERROR:") {
6774 out.push_str(&format!("Unable to query connections: {text}\n"));
6775 } else {
6776 let mut total = 0usize;
6777 let mut rows = Vec::new();
6778 for line in text.lines() {
6779 if let Some(rest) = line.strip_prefix("TOTAL:") {
6780 total = rest.trim().parse().unwrap_or(0);
6781 } else {
6782 rows.push(line);
6783 }
6784 }
6785 out.push_str(&format!("Established TCP connections: {total}\n\n"));
6786 for row in &rows {
6787 let parts: Vec<&str> = row.splitn(4, '|').collect();
6788 if parts.len() == 4 {
6789 out.push_str(&format!(
6790 " {:<15} (pid {:<5}) | {} → {}\n",
6791 parts[0], parts[1], parts[2], parts[3]
6792 ));
6793 }
6794 }
6795 if total > n {
6796 out.push_str(&format!(
6797 "\n ... {} more connections not shown\n",
6798 total.saturating_sub(n)
6799 ));
6800 }
6801 }
6802 }
6803
6804 #[cfg(not(target_os = "windows"))]
6805 {
6806 if let Ok(o) = Command::new("ss")
6807 .args(["-tnp", "state", "established"])
6808 .output()
6809 {
6810 let text = String::from_utf8_lossy(&o.stdout);
6811 let lines: Vec<&str> = text
6812 .lines()
6813 .skip(1)
6814 .filter(|l| !l.trim().is_empty())
6815 .collect();
6816 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6817 for line in lines.iter().take(n) {
6818 out.push_str(&format!(" {}\n", line.trim()));
6819 }
6820 if lines.len() > n {
6821 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
6822 }
6823 } else {
6824 out.push_str("ss not available — install iproute2\n");
6825 }
6826 }
6827
6828 Ok(out.trim_end().to_string())
6829}
6830
6831fn inspect_vpn() -> Result<String, String> {
6834 let mut out = String::from("Host inspection: vpn\n\n");
6835
6836 #[cfg(target_os = "windows")]
6837 {
6838 let script = r#"
6839try {
6840 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6841 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6842 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6843 }
6844 if ($vpn) {
6845 foreach ($a in $vpn) {
6846 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6847 }
6848 } else { "NONE" }
6849} catch { "ERROR:" + $_.Exception.Message }
6850"#;
6851 let output = Command::new("powershell")
6852 .args(["-NoProfile", "-Command", script])
6853 .output()
6854 .map_err(|e| format!("vpn: {e}"))?;
6855
6856 let raw = String::from_utf8_lossy(&output.stdout);
6857 let text = raw.trim();
6858
6859 if text == "NONE" {
6860 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6861 } else if text.starts_with("ERROR:") {
6862 out.push_str(&format!("Unable to query adapters: {text}\n"));
6863 } else {
6864 out.push_str("VPN adapters:\n\n");
6865 for line in text.lines() {
6866 let parts: Vec<&str> = line.splitn(4, '|').collect();
6867 if parts.len() >= 3 {
6868 let name = parts[0];
6869 let desc = parts[1];
6870 let status = parts[2];
6871 let media = parts.get(3).unwrap_or(&"unknown");
6872 let label = if status.trim() == "Up" {
6873 "CONNECTED"
6874 } else {
6875 "disconnected"
6876 };
6877 out.push_str(&format!(
6878 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
6879 ));
6880 }
6881 }
6882 }
6883
6884 let ras_script = r#"
6886try {
6887 $c = Get-VpnConnection -ErrorAction Stop
6888 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6889 else { "NO_RAS" }
6890} catch { "NO_RAS" }
6891"#;
6892 if let Ok(o) = Command::new("powershell")
6893 .args(["-NoProfile", "-Command", ras_script])
6894 .output()
6895 {
6896 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6897 if t != "NO_RAS" && !t.is_empty() {
6898 out.push_str("Windows VPN connections:\n");
6899 for line in t.lines() {
6900 let parts: Vec<&str> = line.splitn(3, '|').collect();
6901 if parts.len() >= 2 {
6902 let name = parts[0];
6903 let status = parts[1];
6904 let server = parts.get(2).unwrap_or(&"");
6905 out.push_str(&format!(" {name} → {server} [{status}]\n"));
6906 }
6907 }
6908 }
6909 }
6910 }
6911
6912 #[cfg(not(target_os = "windows"))]
6913 {
6914 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6915 let text = String::from_utf8_lossy(&o.stdout);
6916 let vpn_ifaces: Vec<&str> = text
6917 .lines()
6918 .filter(|l| {
6919 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6920 })
6921 .collect();
6922 if vpn_ifaces.is_empty() {
6923 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6924 } else {
6925 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6926 for l in vpn_ifaces {
6927 out.push_str(&format!(" {}\n", l.trim()));
6928 }
6929 }
6930 }
6931 }
6932
6933 Ok(out.trim_end().to_string())
6934}
6935
6936fn inspect_proxy() -> Result<String, String> {
6939 let mut out = String::from("Host inspection: proxy\n\n");
6940
6941 #[cfg(target_os = "windows")]
6942 {
6943 let script = r#"
6944$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6945if ($ie) {
6946 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6947} else { "NONE" }
6948"#;
6949 if let Ok(o) = Command::new("powershell")
6950 .args(["-NoProfile", "-Command", script])
6951 .output()
6952 {
6953 let raw = String::from_utf8_lossy(&o.stdout);
6954 let text = raw.trim();
6955 if text != "NONE" && !text.is_empty() {
6956 let get = |key: &str| -> &str {
6957 text.split('|')
6958 .find(|s| s.starts_with(key))
6959 .and_then(|s| s.splitn(2, ':').nth(1))
6960 .unwrap_or("")
6961 };
6962 let enabled = get("ENABLE");
6963 let server = get("SERVER");
6964 let overrides = get("OVERRIDE");
6965 out.push_str("WinINET / IE proxy:\n");
6966 out.push_str(&format!(
6967 " Enabled: {}\n",
6968 if enabled == "1" { "yes" } else { "no" }
6969 ));
6970 if !server.is_empty() && server != "None" {
6971 out.push_str(&format!(" Proxy server: {server}\n"));
6972 }
6973 if !overrides.is_empty() && overrides != "None" {
6974 out.push_str(&format!(" Bypass list: {overrides}\n"));
6975 }
6976 out.push('\n');
6977 }
6978 }
6979
6980 if let Ok(o) = Command::new("netsh")
6981 .args(["winhttp", "show", "proxy"])
6982 .output()
6983 {
6984 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6985 out.push_str("WinHTTP proxy:\n");
6986 for line in text.lines() {
6987 let l = line.trim();
6988 if !l.is_empty() {
6989 out.push_str(&format!(" {l}\n"));
6990 }
6991 }
6992 out.push('\n');
6993 }
6994
6995 let mut env_found = false;
6996 for var in &[
6997 "http_proxy",
6998 "https_proxy",
6999 "HTTP_PROXY",
7000 "HTTPS_PROXY",
7001 "no_proxy",
7002 "NO_PROXY",
7003 ] {
7004 if let Ok(val) = std::env::var(var) {
7005 if !env_found {
7006 out.push_str("Environment proxy variables:\n");
7007 env_found = true;
7008 }
7009 out.push_str(&format!(" {var}: {val}\n"));
7010 }
7011 }
7012 if !env_found {
7013 out.push_str("No proxy environment variables set.\n");
7014 }
7015 }
7016
7017 #[cfg(not(target_os = "windows"))]
7018 {
7019 let mut found = false;
7020 for var in &[
7021 "http_proxy",
7022 "https_proxy",
7023 "HTTP_PROXY",
7024 "HTTPS_PROXY",
7025 "no_proxy",
7026 "NO_PROXY",
7027 "ALL_PROXY",
7028 "all_proxy",
7029 ] {
7030 if let Ok(val) = std::env::var(var) {
7031 if !found {
7032 out.push_str("Proxy environment variables:\n");
7033 found = true;
7034 }
7035 out.push_str(&format!(" {var}: {val}\n"));
7036 }
7037 }
7038 if !found {
7039 out.push_str("No proxy environment variables set.\n");
7040 }
7041 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7042 let proxy_lines: Vec<&str> = content
7043 .lines()
7044 .filter(|l| l.to_lowercase().contains("proxy"))
7045 .collect();
7046 if !proxy_lines.is_empty() {
7047 out.push_str("\nSystem proxy (/etc/environment):\n");
7048 for l in proxy_lines {
7049 out.push_str(&format!(" {l}\n"));
7050 }
7051 }
7052 }
7053 }
7054
7055 Ok(out.trim_end().to_string())
7056}
7057
7058fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7061 let mut out = String::from("Host inspection: firewall_rules\n\n");
7062 let n = max_entries.clamp(1, 20);
7063
7064 #[cfg(target_os = "windows")]
7065 {
7066 let script = format!(
7067 r#"
7068try {{
7069 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7070 Where-Object {{
7071 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7072 $_.Owner -eq $null
7073 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7074 "TOTAL:" + $rules.Count
7075 $rules | ForEach-Object {{
7076 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7077 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7078 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7079 }}
7080}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7081 );
7082
7083 let output = Command::new("powershell")
7084 .args(["-NoProfile", "-Command", &script])
7085 .output()
7086 .map_err(|e| format!("firewall_rules: {e}"))?;
7087
7088 let raw = String::from_utf8_lossy(&output.stdout);
7089 let text = raw.trim();
7090
7091 if text.starts_with("ERROR:") {
7092 out.push_str(&format!(
7093 "Unable to query firewall rules: {}\n",
7094 text.trim_start_matches("ERROR:").trim()
7095 ));
7096 out.push_str("This query may require running as administrator.\n");
7097 } else if text.is_empty() {
7098 out.push_str("No non-default enabled firewall rules found.\n");
7099 } else {
7100 let mut total = 0usize;
7101 for line in text.lines() {
7102 if let Some(rest) = line.strip_prefix("TOTAL:") {
7103 total = rest.trim().parse().unwrap_or(0);
7104 out.push_str(&format!(
7105 "Non-default enabled rules (showing up to {n}):\n\n"
7106 ));
7107 } else {
7108 let parts: Vec<&str> = line.splitn(4, '|').collect();
7109 if parts.len() >= 3 {
7110 let name = parts[0];
7111 let dir = parts[1];
7112 let action = parts[2];
7113 let profile = parts.get(3).unwrap_or(&"Any");
7114 let icon = if action == "Block" { "[!]" } else { " " };
7115 out.push_str(&format!(
7116 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
7117 ));
7118 }
7119 }
7120 }
7121 if total == 0 {
7122 out.push_str("No non-default enabled rules found.\n");
7123 }
7124 }
7125 }
7126
7127 #[cfg(not(target_os = "windows"))]
7128 {
7129 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7130 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7131 if !text.is_empty() {
7132 out.push_str(&text);
7133 out.push('\n');
7134 }
7135 } else if let Ok(o) = Command::new("iptables")
7136 .args(["-L", "-n", "--line-numbers"])
7137 .output()
7138 {
7139 let text = String::from_utf8_lossy(&o.stdout);
7140 for l in text.lines().take(n * 2) {
7141 out.push_str(&format!(" {l}\n"));
7142 }
7143 } else {
7144 out.push_str("ufw and iptables not available or insufficient permissions.\n");
7145 }
7146 }
7147
7148 Ok(out.trim_end().to_string())
7149}
7150
7151fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7154 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7155 let hops = max_entries.clamp(5, 30);
7156
7157 #[cfg(target_os = "windows")]
7158 {
7159 let output = Command::new("tracert")
7160 .args(["-d", "-h", &hops.to_string(), host])
7161 .output()
7162 .map_err(|e| format!("tracert: {e}"))?;
7163 let raw = String::from_utf8_lossy(&output.stdout);
7164 let mut hop_count = 0usize;
7165 for line in raw.lines() {
7166 let trimmed = line.trim();
7167 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7168 hop_count += 1;
7169 out.push_str(&format!(" {trimmed}\n"));
7170 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7171 out.push_str(&format!("{trimmed}\n"));
7172 }
7173 }
7174 if hop_count == 0 {
7175 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7176 }
7177 }
7178
7179 #[cfg(not(target_os = "windows"))]
7180 {
7181 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7182 || std::path::Path::new("/usr/sbin/traceroute").exists()
7183 {
7184 "traceroute"
7185 } else {
7186 "tracepath"
7187 };
7188 let output = Command::new(cmd)
7189 .args(["-m", &hops.to_string(), "-n", host])
7190 .output()
7191 .map_err(|e| format!("{cmd}: {e}"))?;
7192 let raw = String::from_utf8_lossy(&output.stdout);
7193 let mut hop_count = 0usize;
7194 for line in raw.lines().take(hops + 2) {
7195 let trimmed = line.trim();
7196 if !trimmed.is_empty() {
7197 hop_count += 1;
7198 out.push_str(&format!(" {trimmed}\n"));
7199 }
7200 }
7201 if hop_count == 0 {
7202 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7203 }
7204 }
7205
7206 Ok(out.trim_end().to_string())
7207}
7208
7209fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7212 let mut out = String::from("Host inspection: dns_cache\n\n");
7213 let n = max_entries.clamp(10, 100);
7214
7215 #[cfg(target_os = "windows")]
7216 {
7217 let output = Command::new("powershell")
7218 .args([
7219 "-NoProfile",
7220 "-Command",
7221 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7222 ])
7223 .output()
7224 .map_err(|e| format!("dns_cache: {e}"))?;
7225
7226 let raw = String::from_utf8_lossy(&output.stdout);
7227 let lines: Vec<&str> = raw.lines().skip(1).collect();
7228 let total = lines.len();
7229
7230 if total == 0 {
7231 out.push_str("DNS cache is empty or could not be read.\n");
7232 } else {
7233 out.push_str(&format!(
7234 "DNS cache entries (showing up to {n} of {total}):\n\n"
7235 ));
7236 let mut shown = 0usize;
7237 for line in lines.iter().take(n) {
7238 let cols: Vec<&str> = line.splitn(4, ',').collect();
7239 if cols.len() >= 3 {
7240 let entry = cols[0].trim_matches('"');
7241 let rtype = cols[1].trim_matches('"');
7242 let data = cols[2].trim_matches('"');
7243 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
7244 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
7245 shown += 1;
7246 }
7247 }
7248 if total > shown {
7249 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
7250 }
7251 }
7252 }
7253
7254 #[cfg(not(target_os = "windows"))]
7255 {
7256 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7257 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7258 if !text.is_empty() {
7259 out.push_str("systemd-resolved statistics:\n");
7260 for line in text.lines().take(n) {
7261 out.push_str(&format!(" {line}\n"));
7262 }
7263 out.push('\n');
7264 }
7265 }
7266 if let Ok(o) = Command::new("dscacheutil")
7267 .args(["-cachedump", "-entries", "Host"])
7268 .output()
7269 {
7270 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7271 if !text.is_empty() {
7272 out.push_str("DNS cache (macOS dscacheutil):\n");
7273 for line in text.lines().take(n) {
7274 out.push_str(&format!(" {line}\n"));
7275 }
7276 } else {
7277 out.push_str("DNS cache is empty or not accessible on this platform.\n");
7278 }
7279 } else {
7280 out.push_str(
7281 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7282 );
7283 }
7284 }
7285
7286 Ok(out.trim_end().to_string())
7287}
7288
7289fn inspect_arp() -> Result<String, String> {
7292 let mut out = String::from("Host inspection: arp\n\n");
7293
7294 #[cfg(target_os = "windows")]
7295 {
7296 let output = Command::new("arp")
7297 .args(["-a"])
7298 .output()
7299 .map_err(|e| format!("arp: {e}"))?;
7300 let raw = String::from_utf8_lossy(&output.stdout);
7301 let mut count = 0usize;
7302 for line in raw.lines() {
7303 let t = line.trim();
7304 if t.is_empty() {
7305 continue;
7306 }
7307 out.push_str(&format!(" {t}\n"));
7308 if t.contains("dynamic") || t.contains("static") {
7309 count += 1;
7310 }
7311 }
7312 out.push_str(&format!("\nTotal entries: {count}\n"));
7313 }
7314
7315 #[cfg(not(target_os = "windows"))]
7316 {
7317 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7318 let raw = String::from_utf8_lossy(&o.stdout);
7319 let mut count = 0usize;
7320 for line in raw.lines() {
7321 let t = line.trim();
7322 if !t.is_empty() {
7323 out.push_str(&format!(" {t}\n"));
7324 count += 1;
7325 }
7326 }
7327 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7328 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7329 let raw = String::from_utf8_lossy(&o.stdout);
7330 let mut count = 0usize;
7331 for line in raw.lines() {
7332 let t = line.trim();
7333 if !t.is_empty() {
7334 out.push_str(&format!(" {t}\n"));
7335 count += 1;
7336 }
7337 }
7338 out.push_str(&format!("\nTotal entries: {count}\n"));
7339 } else {
7340 out.push_str("arp and ip neigh not available.\n");
7341 }
7342 }
7343
7344 Ok(out.trim_end().to_string())
7345}
7346
7347fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7350 let mut out = String::from("Host inspection: route_table\n\n");
7351 let n = max_entries.clamp(10, 50);
7352
7353 #[cfg(target_os = "windows")]
7354 {
7355 let script = r#"
7356try {
7357 $routes = Get-NetRoute -ErrorAction Stop |
7358 Where-Object { $_.RouteMetric -lt 9000 } |
7359 Sort-Object RouteMetric |
7360 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7361 "TOTAL:" + $routes.Count
7362 $routes | ForEach-Object {
7363 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7364 }
7365} catch { "ERROR:" + $_.Exception.Message }
7366"#;
7367 let output = Command::new("powershell")
7368 .args(["-NoProfile", "-Command", script])
7369 .output()
7370 .map_err(|e| format!("route_table: {e}"))?;
7371 let raw = String::from_utf8_lossy(&output.stdout);
7372 let text = raw.trim();
7373
7374 if text.starts_with("ERROR:") {
7375 out.push_str(&format!(
7376 "Unable to read route table: {}\n",
7377 text.trim_start_matches("ERROR:").trim()
7378 ));
7379 } else {
7380 let mut shown = 0usize;
7381 for line in text.lines() {
7382 if let Some(rest) = line.strip_prefix("TOTAL:") {
7383 let total: usize = rest.trim().parse().unwrap_or(0);
7384 out.push_str(&format!(
7385 "Routing table (showing up to {n} of {total} routes):\n\n"
7386 ));
7387 out.push_str(&format!(
7388 " {:<22} {:<18} {:>8} Interface\n",
7389 "Destination", "Next Hop", "Metric"
7390 ));
7391 out.push_str(&format!(" {}\n", "-".repeat(70)));
7392 } else if shown < n {
7393 let parts: Vec<&str> = line.splitn(4, '|').collect();
7394 if parts.len() == 4 {
7395 let dest = parts[0];
7396 let hop =
7397 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7398 "on-link"
7399 } else {
7400 parts[1]
7401 };
7402 let metric = parts[2];
7403 let iface = parts[3];
7404 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
7405 shown += 1;
7406 }
7407 }
7408 }
7409 }
7410 }
7411
7412 #[cfg(not(target_os = "windows"))]
7413 {
7414 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7415 let raw = String::from_utf8_lossy(&o.stdout);
7416 let lines: Vec<&str> = raw.lines().collect();
7417 let total = lines.len();
7418 out.push_str(&format!(
7419 "Routing table (showing up to {n} of {total} routes):\n\n"
7420 ));
7421 for line in lines.iter().take(n) {
7422 out.push_str(&format!(" {line}\n"));
7423 }
7424 if total > n {
7425 out.push_str(&format!("\n ... and {} more routes\n", total - n));
7426 }
7427 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7428 let raw = String::from_utf8_lossy(&o.stdout);
7429 for line in raw.lines().take(n) {
7430 out.push_str(&format!(" {line}\n"));
7431 }
7432 } else {
7433 out.push_str("ip route and netstat not available.\n");
7434 }
7435 }
7436
7437 Ok(out.trim_end().to_string())
7438}
7439
7440fn inspect_env(max_entries: usize) -> Result<String, String> {
7443 let mut out = String::from("Host inspection: env\n\n");
7444 let n = max_entries.clamp(10, 50);
7445
7446 fn looks_like_secret(name: &str) -> bool {
7447 let n = name.to_uppercase();
7448 n.contains("KEY")
7449 || n.contains("SECRET")
7450 || n.contains("TOKEN")
7451 || n.contains("PASSWORD")
7452 || n.contains("PASSWD")
7453 || n.contains("CREDENTIAL")
7454 || n.contains("AUTH")
7455 || n.contains("CERT")
7456 || n.contains("PRIVATE")
7457 }
7458
7459 let known_dev_vars: &[&str] = &[
7460 "CARGO_HOME",
7461 "RUSTUP_HOME",
7462 "GOPATH",
7463 "GOROOT",
7464 "GOBIN",
7465 "JAVA_HOME",
7466 "ANDROID_HOME",
7467 "ANDROID_SDK_ROOT",
7468 "PYTHONPATH",
7469 "PYTHONHOME",
7470 "VIRTUAL_ENV",
7471 "CONDA_DEFAULT_ENV",
7472 "CONDA_PREFIX",
7473 "NODE_PATH",
7474 "NVM_DIR",
7475 "NVM_BIN",
7476 "PNPM_HOME",
7477 "DENO_INSTALL",
7478 "DENO_DIR",
7479 "DOTNET_ROOT",
7480 "NUGET_PACKAGES",
7481 "CMAKE_HOME",
7482 "VCPKG_ROOT",
7483 "AWS_PROFILE",
7484 "AWS_REGION",
7485 "AWS_DEFAULT_REGION",
7486 "GCP_PROJECT",
7487 "GOOGLE_CLOUD_PROJECT",
7488 "GOOGLE_APPLICATION_CREDENTIALS",
7489 "AZURE_SUBSCRIPTION_ID",
7490 "DATABASE_URL",
7491 "REDIS_URL",
7492 "MONGO_URI",
7493 "EDITOR",
7494 "VISUAL",
7495 "SHELL",
7496 "TERM",
7497 "XDG_CONFIG_HOME",
7498 "XDG_DATA_HOME",
7499 "XDG_CACHE_HOME",
7500 "HOME",
7501 "USERPROFILE",
7502 "APPDATA",
7503 "LOCALAPPDATA",
7504 "TEMP",
7505 "TMP",
7506 "COMPUTERNAME",
7507 "USERNAME",
7508 "USERDOMAIN",
7509 "PROCESSOR_ARCHITECTURE",
7510 "NUMBER_OF_PROCESSORS",
7511 "OS",
7512 "HOMEDRIVE",
7513 "HOMEPATH",
7514 "HTTP_PROXY",
7515 "HTTPS_PROXY",
7516 "NO_PROXY",
7517 "ALL_PROXY",
7518 "http_proxy",
7519 "https_proxy",
7520 "no_proxy",
7521 "DOCKER_HOST",
7522 "DOCKER_BUILDKIT",
7523 "COMPOSE_PROJECT_NAME",
7524 "KUBECONFIG",
7525 "KUBE_CONTEXT",
7526 "CI",
7527 "GITHUB_ACTIONS",
7528 "GITLAB_CI",
7529 "LMSTUDIO_HOME",
7530 "HEMATITE_URL",
7531 ];
7532
7533 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7534 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7535 let total = all_vars.len();
7536
7537 let mut dev_found: Vec<String> = Vec::new();
7538 let mut secret_found: Vec<String> = Vec::new();
7539
7540 for (k, v) in &all_vars {
7541 if k == "PATH" {
7542 continue;
7543 }
7544 if looks_like_secret(k) {
7545 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7546 } else {
7547 let k_upper = k.to_uppercase();
7548 let is_known = known_dev_vars
7549 .iter()
7550 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7551 if is_known {
7552 let display = if v.len() > 120 {
7553 format!("{k} = {}…", &v[..117])
7554 } else {
7555 format!("{k} = {v}")
7556 };
7557 dev_found.push(display);
7558 }
7559 }
7560 }
7561
7562 out.push_str(&format!("Total environment variables: {total}\n\n"));
7563
7564 if let Ok(p) = std::env::var("PATH") {
7565 let sep = if cfg!(target_os = "windows") {
7566 ';'
7567 } else {
7568 ':'
7569 };
7570 let count = p.split(sep).count();
7571 out.push_str(&format!(
7572 "PATH: {count} entries (use topic=path for full audit)\n\n"
7573 ));
7574 }
7575
7576 if !secret_found.is_empty() {
7577 out.push_str(&format!(
7578 "=== Secret/credential variables ({} detected, values hidden) ===\n",
7579 secret_found.len()
7580 ));
7581 for s in secret_found.iter().take(n) {
7582 out.push_str(&format!(" {s}\n"));
7583 }
7584 out.push('\n');
7585 }
7586
7587 if !dev_found.is_empty() {
7588 out.push_str(&format!(
7589 "=== Developer & tool variables ({}) ===\n",
7590 dev_found.len()
7591 ));
7592 for d in dev_found.iter().take(n) {
7593 out.push_str(&format!(" {d}\n"));
7594 }
7595 out.push('\n');
7596 }
7597
7598 let other_count = all_vars
7599 .iter()
7600 .filter(|(k, _)| {
7601 k != "PATH"
7602 && !looks_like_secret(k)
7603 && !known_dev_vars
7604 .iter()
7605 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7606 })
7607 .count();
7608 if other_count > 0 {
7609 out.push_str(&format!(
7610 "Other variables: {other_count} (use 'env' in shell to see all)\n"
7611 ));
7612 }
7613
7614 Ok(out.trim_end().to_string())
7615}
7616
7617fn inspect_hosts_file() -> Result<String, String> {
7620 let mut out = String::from("Host inspection: hosts_file\n\n");
7621
7622 let hosts_path = if cfg!(target_os = "windows") {
7623 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7624 } else {
7625 std::path::PathBuf::from("/etc/hosts")
7626 };
7627
7628 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7629
7630 match fs::read_to_string(&hosts_path) {
7631 Ok(content) => {
7632 let mut active_entries: Vec<String> = Vec::new();
7633 let mut comment_lines = 0usize;
7634 let mut blank_lines = 0usize;
7635
7636 for line in content.lines() {
7637 let t = line.trim();
7638 if t.is_empty() {
7639 blank_lines += 1;
7640 } else if t.starts_with('#') {
7641 comment_lines += 1;
7642 } else {
7643 active_entries.push(line.to_string());
7644 }
7645 }
7646
7647 out.push_str(&format!(
7648 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7649 active_entries.len(),
7650 comment_lines,
7651 blank_lines
7652 ));
7653
7654 if active_entries.is_empty() {
7655 out.push_str(
7656 "No active host entries (file contains only comments/blanks — standard default state).\n",
7657 );
7658 } else {
7659 out.push_str("=== Active entries ===\n");
7660 for entry in &active_entries {
7661 out.push_str(&format!(" {entry}\n"));
7662 }
7663 out.push('\n');
7664
7665 let custom: Vec<&String> = active_entries
7666 .iter()
7667 .filter(|e| {
7668 let t = e.trim_start();
7669 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7670 })
7671 .collect();
7672 if !custom.is_empty() {
7673 out.push_str(&format!(
7674 "[!] Custom (non-loopback) entries: {}\n",
7675 custom.len()
7676 ));
7677 for e in &custom {
7678 out.push_str(&format!(" {e}\n"));
7679 }
7680 } else {
7681 out.push_str("All active entries are standard loopback or block entries.\n");
7682 }
7683 }
7684
7685 out.push_str("\n=== Full file ===\n");
7686 for line in content.lines() {
7687 out.push_str(&format!(" {line}\n"));
7688 }
7689 }
7690 Err(e) => {
7691 out.push_str(&format!("Could not read hosts file: {e}\n"));
7692 if cfg!(target_os = "windows") {
7693 out.push_str(
7694 "On Windows, run Hematite as Administrator if permission is denied.\n",
7695 );
7696 }
7697 }
7698 }
7699
7700 Ok(out.trim_end().to_string())
7701}
7702
7703struct AuditFinding {
7706 finding: String,
7707 impact: String,
7708 fix: String,
7709}
7710
7711#[cfg(target_os = "windows")]
7712#[derive(Debug, Clone)]
7713struct WindowsPnpDevice {
7714 name: String,
7715 status: String,
7716 problem: Option<u64>,
7717 class_name: Option<String>,
7718 instance_id: Option<String>,
7719}
7720
7721#[cfg(target_os = "windows")]
7722#[derive(Debug, Clone)]
7723struct WindowsSoundDevice {
7724 name: String,
7725 status: String,
7726 manufacturer: Option<String>,
7727}
7728
7729struct DockerMountAudit {
7730 mount_type: String,
7731 source: Option<String>,
7732 destination: String,
7733 name: Option<String>,
7734 read_write: Option<bool>,
7735 driver: Option<String>,
7736 exists_on_host: Option<bool>,
7737}
7738
7739struct DockerContainerAudit {
7740 name: String,
7741 image: String,
7742 status: String,
7743 mounts: Vec<DockerMountAudit>,
7744}
7745
7746struct DockerVolumeAudit {
7747 name: String,
7748 driver: String,
7749 mountpoint: Option<String>,
7750 scope: Option<String>,
7751}
7752
7753#[cfg(target_os = "windows")]
7754struct WslDistroAudit {
7755 name: String,
7756 state: String,
7757 version: String,
7758}
7759
7760#[cfg(target_os = "windows")]
7761struct WslRootUsage {
7762 total_kb: u64,
7763 used_kb: u64,
7764 avail_kb: u64,
7765 use_percent: String,
7766 mnt_c_present: Option<bool>,
7767}
7768
7769fn docker_engine_version() -> Result<String, String> {
7770 let version_output = Command::new("docker")
7771 .args(["version", "--format", "{{.Server.Version}}"])
7772 .output();
7773
7774 match version_output {
7775 Err(_) => Err(
7776 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7777 ),
7778 Ok(o) if !o.status.success() => {
7779 let stderr = String::from_utf8_lossy(&o.stderr);
7780 if stderr.contains("cannot connect")
7781 || stderr.contains("Is the docker daemon running")
7782 || stderr.contains("pipe")
7783 || stderr.contains("socket")
7784 {
7785 Err(
7786 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7787 )
7788 } else {
7789 Err(format!("Docker: error - {}", stderr.trim()))
7790 }
7791 }
7792 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7793 }
7794}
7795
7796fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7797 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7798 return Vec::new();
7799 };
7800 let Value::Array(entries) = value else {
7801 return Vec::new();
7802 };
7803
7804 let mut mounts = Vec::new();
7805 for entry in entries {
7806 let mount_type = entry
7807 .get("Type")
7808 .and_then(|v| v.as_str())
7809 .unwrap_or("unknown")
7810 .to_string();
7811 let source = entry
7812 .get("Source")
7813 .and_then(|v| v.as_str())
7814 .map(|v| v.to_string());
7815 let destination = entry
7816 .get("Destination")
7817 .and_then(|v| v.as_str())
7818 .unwrap_or("?")
7819 .to_string();
7820 let name = entry
7821 .get("Name")
7822 .and_then(|v| v.as_str())
7823 .map(|v| v.to_string());
7824 let read_write = entry.get("RW").and_then(|v| v.as_bool());
7825 let driver = entry
7826 .get("Driver")
7827 .and_then(|v| v.as_str())
7828 .map(|v| v.to_string());
7829 let exists_on_host = if mount_type == "bind" {
7830 source.as_deref().map(|path| Path::new(path).exists())
7831 } else {
7832 None
7833 };
7834 mounts.push(DockerMountAudit {
7835 mount_type,
7836 source,
7837 destination,
7838 name,
7839 read_write,
7840 driver,
7841 exists_on_host,
7842 });
7843 }
7844
7845 mounts
7846}
7847
7848fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7849 let mut audit = DockerVolumeAudit {
7850 name: name.to_string(),
7851 driver: "unknown".to_string(),
7852 mountpoint: None,
7853 scope: None,
7854 };
7855
7856 if let Ok(output) = Command::new("docker")
7857 .args(["volume", "inspect", name, "--format", "{{json .}}"])
7858 .output()
7859 {
7860 if output.status.success() {
7861 if let Ok(value) =
7862 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7863 {
7864 audit.driver = value
7865 .get("Driver")
7866 .and_then(|v| v.as_str())
7867 .unwrap_or("unknown")
7868 .to_string();
7869 audit.mountpoint = value
7870 .get("Mountpoint")
7871 .and_then(|v| v.as_str())
7872 .map(|v| v.to_string());
7873 audit.scope = value
7874 .get("Scope")
7875 .and_then(|v| v.as_str())
7876 .map(|v| v.to_string());
7877 }
7878 }
7879 }
7880
7881 audit
7882}
7883
7884#[cfg(target_os = "windows")]
7885fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7886 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7887 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7888 let path = local_app_data
7889 .join("Docker")
7890 .join("wsl")
7891 .join("disk")
7892 .join(file_name);
7893 if let Ok(metadata) = fs::metadata(&path) {
7894 return Some((path, metadata.len()));
7895 }
7896 }
7897 None
7898}
7899
7900#[cfg(target_os = "windows")]
7901fn clean_wsl_text(raw: &[u8]) -> String {
7902 String::from_utf8_lossy(raw)
7903 .chars()
7904 .filter(|c| *c != '\0')
7905 .collect()
7906}
7907
7908#[cfg(target_os = "windows")]
7909fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7910 let mut distros = Vec::new();
7911 for line in raw.lines() {
7912 let trimmed = line.trim();
7913 if trimmed.is_empty()
7914 || trimmed.to_uppercase().starts_with("NAME")
7915 || trimmed.starts_with("---")
7916 {
7917 continue;
7918 }
7919 let normalized = trimmed.trim_start_matches('*').trim();
7920 let cols: Vec<&str> = normalized.split_whitespace().collect();
7921 if cols.len() < 3 {
7922 continue;
7923 }
7924 let version = cols[cols.len() - 1].to_string();
7925 let state = cols[cols.len() - 2].to_string();
7926 let name = cols[..cols.len() - 2].join(" ");
7927 if !name.is_empty() {
7928 distros.push(WslDistroAudit {
7929 name,
7930 state,
7931 version,
7932 });
7933 }
7934 }
7935 distros
7936}
7937
7938#[cfg(target_os = "windows")]
7939fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7940 let output = Command::new("wsl")
7941 .args([
7942 "-d",
7943 distro_name,
7944 "--",
7945 "sh",
7946 "-lc",
7947 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7948 ])
7949 .output()
7950 .ok()?;
7951 if !output.status.success() {
7952 return None;
7953 }
7954
7955 let text = clean_wsl_text(&output.stdout);
7956 let mut total_kb = 0;
7957 let mut used_kb = 0;
7958 let mut avail_kb = 0;
7959 let mut use_percent = String::from("unknown");
7960 let mut mnt_c_present = None;
7961
7962 for line in text.lines() {
7963 let trimmed = line.trim();
7964 if trimmed.starts_with("__MNTC__:") {
7965 mnt_c_present = Some(trimmed.ends_with("ok"));
7966 continue;
7967 }
7968 let cols: Vec<&str> = trimmed.split_whitespace().collect();
7969 if cols.len() >= 6 {
7970 total_kb = cols[1].parse::<u64>().unwrap_or(0);
7971 used_kb = cols[2].parse::<u64>().unwrap_or(0);
7972 avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7973 use_percent = cols[4].to_string();
7974 }
7975 }
7976
7977 Some(WslRootUsage {
7978 total_kb,
7979 used_kb,
7980 avail_kb,
7981 use_percent,
7982 mnt_c_present,
7983 })
7984}
7985
7986#[cfg(target_os = "windows")]
7987fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7988 let mut vhds = Vec::new();
7989 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7990 return vhds;
7991 };
7992 let packages_dir = local_app_data.join("Packages");
7993 let Ok(entries) = fs::read_dir(packages_dir) else {
7994 return vhds;
7995 };
7996
7997 for entry in entries.flatten() {
7998 let path = entry.path().join("LocalState").join("ext4.vhdx");
7999 if let Ok(metadata) = fs::metadata(&path) {
8000 vhds.push((path, metadata.len()));
8001 }
8002 }
8003 vhds.sort_by(|a, b| b.1.cmp(&a.1));
8004 vhds
8005}
8006
8007fn inspect_docker(max_entries: usize) -> Result<String, String> {
8008 let mut out = String::from("Host inspection: docker\n\n");
8009 let n = max_entries.clamp(5, 25);
8010
8011 let version_output = Command::new("docker")
8012 .args(["version", "--format", "{{.Server.Version}}"])
8013 .output();
8014
8015 match version_output {
8016 Err(_) => {
8017 out.push_str("Docker: not found on PATH.\n");
8018 out.push_str(
8019 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8020 );
8021 return Ok(out.trim_end().to_string());
8022 }
8023 Ok(o) if !o.status.success() => {
8024 let stderr = String::from_utf8_lossy(&o.stderr);
8025 if stderr.contains("cannot connect")
8026 || stderr.contains("Is the docker daemon running")
8027 || stderr.contains("pipe")
8028 || stderr.contains("socket")
8029 {
8030 out.push_str("Docker: installed but daemon is NOT running.\n");
8031 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8032 } else {
8033 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
8034 }
8035 return Ok(out.trim_end().to_string());
8036 }
8037 Ok(o) => {
8038 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8039 out.push_str(&format!("Docker Engine: {version}\n"));
8040 }
8041 }
8042
8043 if let Ok(o) = Command::new("docker")
8044 .args([
8045 "info",
8046 "--format",
8047 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8048 ])
8049 .output()
8050 {
8051 let info = String::from_utf8_lossy(&o.stdout);
8052 for line in info.lines() {
8053 let t = line.trim();
8054 if !t.is_empty() {
8055 out.push_str(&format!(" {t}\n"));
8056 }
8057 }
8058 out.push('\n');
8059 }
8060
8061 if let Ok(o) = Command::new("docker")
8062 .args([
8063 "ps",
8064 "--format",
8065 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8066 ])
8067 .output()
8068 {
8069 let raw = String::from_utf8_lossy(&o.stdout);
8070 let lines: Vec<&str> = raw.lines().collect();
8071 if lines.len() <= 1 {
8072 out.push_str("Running containers: none\n\n");
8073 } else {
8074 out.push_str(&format!(
8075 "=== Running containers ({}) ===\n",
8076 lines.len().saturating_sub(1)
8077 ));
8078 for line in lines.iter().take(n + 1) {
8079 out.push_str(&format!(" {line}\n"));
8080 }
8081 if lines.len() > n + 1 {
8082 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
8083 }
8084 out.push('\n');
8085 }
8086 }
8087
8088 if let Ok(o) = Command::new("docker")
8089 .args([
8090 "images",
8091 "--format",
8092 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8093 ])
8094 .output()
8095 {
8096 let raw = String::from_utf8_lossy(&o.stdout);
8097 let lines: Vec<&str> = raw.lines().collect();
8098 if lines.len() > 1 {
8099 out.push_str(&format!(
8100 "=== Local images ({}) ===\n",
8101 lines.len().saturating_sub(1)
8102 ));
8103 for line in lines.iter().take(n + 1) {
8104 out.push_str(&format!(" {line}\n"));
8105 }
8106 if lines.len() > n + 1 {
8107 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
8108 }
8109 out.push('\n');
8110 }
8111 }
8112
8113 if let Ok(o) = Command::new("docker")
8114 .args([
8115 "compose",
8116 "ls",
8117 "--format",
8118 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8119 ])
8120 .output()
8121 {
8122 let raw = String::from_utf8_lossy(&o.stdout);
8123 let lines: Vec<&str> = raw.lines().collect();
8124 if lines.len() > 1 {
8125 out.push_str(&format!(
8126 "=== Compose projects ({}) ===\n",
8127 lines.len().saturating_sub(1)
8128 ));
8129 for line in lines.iter().take(n + 1) {
8130 out.push_str(&format!(" {line}\n"));
8131 }
8132 out.push('\n');
8133 }
8134 }
8135
8136 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8137 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8138 if !ctx.is_empty() {
8139 out.push_str(&format!("Active context: {ctx}\n"));
8140 }
8141 }
8142
8143 Ok(out.trim_end().to_string())
8144}
8145
8146fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8149 let mut out = String::from("Host inspection: docker_filesystems\n\n");
8150 let n = max_entries.clamp(3, 12);
8151
8152 match docker_engine_version() {
8153 Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
8154 Err(message) => {
8155 out.push_str(&message);
8156 return Ok(out.trim_end().to_string());
8157 }
8158 }
8159
8160 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8161 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8162 if !ctx.is_empty() {
8163 out.push_str(&format!("Active context: {ctx}\n"));
8164 }
8165 }
8166 out.push('\n');
8167
8168 let mut containers = Vec::new();
8169 if let Ok(o) = Command::new("docker")
8170 .args([
8171 "ps",
8172 "-a",
8173 "--format",
8174 "{{.Names}}\t{{.Image}}\t{{.Status}}",
8175 ])
8176 .output()
8177 {
8178 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8179 let cols: Vec<&str> = line.split('\t').collect();
8180 if cols.len() < 3 {
8181 continue;
8182 }
8183 let name = cols[0].trim().to_string();
8184 if name.is_empty() {
8185 continue;
8186 }
8187 let inspect_output = Command::new("docker")
8188 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8189 .output();
8190 let mounts = match inspect_output {
8191 Ok(result) if result.status.success() => {
8192 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8193 }
8194 _ => Vec::new(),
8195 };
8196 containers.push(DockerContainerAudit {
8197 name,
8198 image: cols[1].trim().to_string(),
8199 status: cols[2].trim().to_string(),
8200 mounts,
8201 });
8202 }
8203 }
8204
8205 let mut volumes = Vec::new();
8206 if let Ok(o) = Command::new("docker")
8207 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8208 .output()
8209 {
8210 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8211 let cols: Vec<&str> = line.split('\t').collect();
8212 let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8213 continue;
8214 };
8215 let mut audit = inspect_docker_volume(name);
8216 if audit.driver == "unknown" {
8217 audit.driver = cols
8218 .get(1)
8219 .map(|v| v.trim())
8220 .filter(|v| !v.is_empty())
8221 .unwrap_or("unknown")
8222 .to_string();
8223 }
8224 volumes.push(audit);
8225 }
8226 }
8227
8228 let mut findings = Vec::new();
8229 for container in &containers {
8230 for mount in &container.mounts {
8231 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8232 let source = mount.source.as_deref().unwrap_or("<unknown>");
8233 findings.push(AuditFinding {
8234 finding: format!(
8235 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8236 container.name, source, mount.destination
8237 ),
8238 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8239 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8240 });
8241 }
8242 }
8243 }
8244
8245 #[cfg(target_os = "windows")]
8246 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8247 if size_bytes >= 20 * 1024 * 1024 * 1024 {
8248 findings.push(AuditFinding {
8249 finding: format!(
8250 "Docker Desktop disk image is large: {} at {}",
8251 human_bytes(size_bytes),
8252 path.display()
8253 ),
8254 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8255 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(),
8256 });
8257 }
8258 }
8259
8260 out.push_str("=== Findings ===\n");
8261 if findings.is_empty() {
8262 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8263 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8264 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8265 } else {
8266 for finding in &findings {
8267 out.push_str(&format!("- Finding: {}\n", finding.finding));
8268 out.push_str(&format!(" Impact: {}\n", finding.impact));
8269 out.push_str(&format!(" Fix: {}\n", finding.fix));
8270 }
8271 }
8272
8273 out.push_str("\n=== Container mount summary ===\n");
8274 if containers.is_empty() {
8275 out.push_str("- No containers found.\n");
8276 } else {
8277 for container in &containers {
8278 out.push_str(&format!(
8279 "- {} ({}) [{}]\n",
8280 container.name, container.image, container.status
8281 ));
8282 if container.mounts.is_empty() {
8283 out.push_str(" - no mounts reported\n");
8284 continue;
8285 }
8286 for mount in &container.mounts {
8287 let mut source = mount
8288 .name
8289 .clone()
8290 .or_else(|| mount.source.clone())
8291 .unwrap_or_else(|| "<unknown>".to_string());
8292 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8293 source.push_str(" [missing]");
8294 }
8295 let mut extras = Vec::new();
8296 if let Some(rw) = mount.read_write {
8297 extras.push(if rw { "rw" } else { "ro" }.to_string());
8298 }
8299 if let Some(driver) = &mount.driver {
8300 extras.push(format!("driver={driver}"));
8301 }
8302 let extra_suffix = if extras.is_empty() {
8303 String::new()
8304 } else {
8305 format!(" ({})", extras.join(", "))
8306 };
8307 out.push_str(&format!(
8308 " - {}: {} -> {}{}\n",
8309 mount.mount_type, source, mount.destination, extra_suffix
8310 ));
8311 }
8312 }
8313 }
8314
8315 out.push_str("\n=== Named volumes ===\n");
8316 if volumes.is_empty() {
8317 out.push_str("- No named volumes found.\n");
8318 } else {
8319 for volume in &volumes {
8320 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8321 if let Some(scope) = &volume.scope {
8322 detail.push_str(&format!(", scope: {scope}"));
8323 }
8324 if let Some(mountpoint) = &volume.mountpoint {
8325 detail.push_str(&format!(", mountpoint: {mountpoint}"));
8326 }
8327 out.push_str(&format!("{detail}\n"));
8328 }
8329 }
8330
8331 #[cfg(target_os = "windows")]
8332 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8333 out.push_str("\n=== Docker Desktop disk ===\n");
8334 out.push_str(&format!(
8335 "- {} at {}\n",
8336 human_bytes(size_bytes),
8337 path.display()
8338 ));
8339 }
8340
8341 Ok(out.trim_end().to_string())
8342}
8343
8344fn inspect_wsl() -> Result<String, String> {
8345 let mut out = String::from("Host inspection: wsl\n\n");
8346
8347 #[cfg(target_os = "windows")]
8348 {
8349 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8350 let raw = String::from_utf8_lossy(&o.stdout);
8351 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8352 for line in cleaned.lines().take(4) {
8353 let t = line.trim();
8354 if !t.is_empty() {
8355 out.push_str(&format!(" {t}\n"));
8356 }
8357 }
8358 out.push('\n');
8359 }
8360
8361 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8362 match list_output {
8363 Err(e) => {
8364 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8365 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8366 }
8367 Ok(o) if !o.status.success() => {
8368 let stderr = String::from_utf8_lossy(&o.stderr);
8369 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8370 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8371 out.push_str("Run: wsl --install\n");
8372 }
8373 Ok(o) => {
8374 let raw = String::from_utf8_lossy(&o.stdout);
8375 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8376 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8377 let distro_lines: Vec<&str> = lines
8378 .iter()
8379 .filter(|l| {
8380 let t = l.trim();
8381 !t.is_empty()
8382 && !t.to_uppercase().starts_with("NAME")
8383 && !t.starts_with("---")
8384 })
8385 .copied()
8386 .collect();
8387
8388 if distro_lines.is_empty() {
8389 out.push_str("WSL: installed but no distributions found.\n");
8390 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8391 } else {
8392 out.push_str("=== WSL Distributions ===\n");
8393 for line in &lines {
8394 out.push_str(&format!(" {}\n", line.trim()));
8395 }
8396 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8397 }
8398 }
8399 }
8400
8401 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8402 let raw = String::from_utf8_lossy(&o.stdout);
8403 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8404 let status_lines: Vec<&str> = cleaned
8405 .lines()
8406 .filter(|l| !l.trim().is_empty())
8407 .take(8)
8408 .collect();
8409 if !status_lines.is_empty() {
8410 out.push_str("\n=== WSL status ===\n");
8411 for line in status_lines {
8412 out.push_str(&format!(" {}\n", line.trim()));
8413 }
8414 }
8415 }
8416 }
8417
8418 #[cfg(not(target_os = "windows"))]
8419 {
8420 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8421 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8422 }
8423
8424 Ok(out.trim_end().to_string())
8425}
8426
8427fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8430 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8431
8432 #[cfg(target_os = "windows")]
8433 {
8434 let n = max_entries.clamp(3, 12);
8435 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8436 let distros = match list_output {
8437 Err(e) => {
8438 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8439 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8440 return Ok(out.trim_end().to_string());
8441 }
8442 Ok(o) if !o.status.success() => {
8443 let cleaned = clean_wsl_text(&o.stderr);
8444 out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8445 out.push_str("Run: wsl --install\n");
8446 return Ok(out.trim_end().to_string());
8447 }
8448 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8449 };
8450
8451 out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8452
8453 let vhdx_files = collect_wsl_vhdx_files();
8454 let mut findings = Vec::new();
8455 let mut live_usage = Vec::new();
8456
8457 for distro in distros.iter().take(n) {
8458 if distro.state.eq_ignore_ascii_case("Running") {
8459 if let Some(usage) = wsl_root_usage(&distro.name) {
8460 if let Some(false) = usage.mnt_c_present {
8461 findings.push(AuditFinding {
8462 finding: format!(
8463 "Distro '{}' is running without /mnt/c available",
8464 distro.name
8465 ),
8466 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8467 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8468 });
8469 }
8470
8471 let percent_num = usage
8472 .use_percent
8473 .trim_end_matches('%')
8474 .parse::<u32>()
8475 .unwrap_or(0);
8476 if percent_num >= 85 {
8477 findings.push(AuditFinding {
8478 finding: format!(
8479 "Distro '{}' root filesystem is {} full",
8480 distro.name, usage.use_percent
8481 ),
8482 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8483 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8484 });
8485 }
8486 live_usage.push((distro.name.clone(), usage));
8487 }
8488 }
8489 }
8490
8491 for (path, size_bytes) in vhdx_files.iter().take(n) {
8492 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8493 findings.push(AuditFinding {
8494 finding: format!(
8495 "Host-side WSL disk image is large: {} at {}",
8496 human_bytes(*size_bytes),
8497 path.display()
8498 ),
8499 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8500 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8501 });
8502 }
8503 }
8504
8505 out.push_str("=== Findings ===\n");
8506 if findings.is_empty() {
8507 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8508 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8509 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8510 } else {
8511 for finding in &findings {
8512 out.push_str(&format!("- Finding: {}\n", finding.finding));
8513 out.push_str(&format!(" Impact: {}\n", finding.impact));
8514 out.push_str(&format!(" Fix: {}\n", finding.fix));
8515 }
8516 }
8517
8518 out.push_str("\n=== Distro bridge and root usage ===\n");
8519 if distros.is_empty() {
8520 out.push_str("- No WSL distributions found.\n");
8521 } else {
8522 for distro in distros.iter().take(n) {
8523 out.push_str(&format!(
8524 "- {} [state: {}, version: {}]\n",
8525 distro.name, distro.state, distro.version
8526 ));
8527 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8528 out.push_str(&format!(
8529 " - rootfs: {} used / {} total ({}), free: {}\n",
8530 human_bytes(usage.used_kb * 1024),
8531 human_bytes(usage.total_kb * 1024),
8532 usage.use_percent,
8533 human_bytes(usage.avail_kb * 1024)
8534 ));
8535 match usage.mnt_c_present {
8536 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8537 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8538 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8539 }
8540 } else if distro.state.eq_ignore_ascii_case("Running") {
8541 out.push_str(" - live rootfs check: unavailable\n");
8542 } else {
8543 out.push_str(
8544 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8545 );
8546 }
8547 }
8548 }
8549
8550 out.push_str("\n=== Host-side VHDX files ===\n");
8551 if vhdx_files.is_empty() {
8552 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8553 } else {
8554 for (path, size_bytes) in vhdx_files.iter().take(n) {
8555 out.push_str(&format!(
8556 "- {} at {}\n",
8557 human_bytes(*size_bytes),
8558 path.display()
8559 ));
8560 }
8561 }
8562 }
8563
8564 #[cfg(not(target_os = "windows"))]
8565 {
8566 let _ = max_entries;
8567 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8568 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8569 }
8570
8571 Ok(out.trim_end().to_string())
8572}
8573
8574fn dirs_home() -> Option<PathBuf> {
8575 std::env::var("HOME")
8576 .ok()
8577 .map(PathBuf::from)
8578 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8579}
8580
8581fn inspect_ssh() -> Result<String, String> {
8582 let mut out = String::from("Host inspection: ssh\n\n");
8583
8584 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8585 let ver = if o.stdout.is_empty() {
8586 String::from_utf8_lossy(&o.stderr).trim().to_string()
8587 } else {
8588 String::from_utf8_lossy(&o.stdout).trim().to_string()
8589 };
8590 if !ver.is_empty() {
8591 out.push_str(&format!("SSH client: {ver}\n"));
8592 }
8593 } else {
8594 out.push_str("SSH client: not found on PATH.\n");
8595 }
8596
8597 #[cfg(target_os = "windows")]
8598 {
8599 let script = r#"
8600$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8601if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8602else { "SSHD:not_installed" }
8603"#;
8604 if let Ok(o) = Command::new("powershell")
8605 .args(["-NoProfile", "-Command", script])
8606 .output()
8607 {
8608 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8609 if text.contains("not_installed") {
8610 out.push_str("SSH server (sshd): not installed\n");
8611 } else {
8612 out.push_str(&format!(
8613 "SSH server (sshd): {}\n",
8614 text.trim_start_matches("SSHD:")
8615 ));
8616 }
8617 }
8618 }
8619
8620 #[cfg(not(target_os = "windows"))]
8621 {
8622 if let Ok(o) = Command::new("systemctl")
8623 .args(["is-active", "sshd"])
8624 .output()
8625 {
8626 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8627 out.push_str(&format!("SSH server (sshd): {status}\n"));
8628 } else if let Ok(o) = Command::new("systemctl")
8629 .args(["is-active", "ssh"])
8630 .output()
8631 {
8632 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8633 out.push_str(&format!("SSH server (ssh): {status}\n"));
8634 }
8635 }
8636
8637 out.push('\n');
8638
8639 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8640 if ssh_dir.exists() {
8641 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8642
8643 let kh = ssh_dir.join("known_hosts");
8644 if kh.exists() {
8645 let count = fs::read_to_string(&kh)
8646 .map(|c| {
8647 c.lines()
8648 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8649 .count()
8650 })
8651 .unwrap_or(0);
8652 out.push_str(&format!(" known_hosts: {count} entries\n"));
8653 } else {
8654 out.push_str(" known_hosts: not present\n");
8655 }
8656
8657 let ak = ssh_dir.join("authorized_keys");
8658 if ak.exists() {
8659 let count = fs::read_to_string(&ak)
8660 .map(|c| {
8661 c.lines()
8662 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8663 .count()
8664 })
8665 .unwrap_or(0);
8666 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
8667 } else {
8668 out.push_str(" authorized_keys: not present\n");
8669 }
8670
8671 let key_names = [
8672 "id_rsa",
8673 "id_ed25519",
8674 "id_ecdsa",
8675 "id_dsa",
8676 "id_ecdsa_sk",
8677 "id_ed25519_sk",
8678 ];
8679 let found_keys: Vec<&str> = key_names
8680 .iter()
8681 .filter(|k| ssh_dir.join(k).exists())
8682 .copied()
8683 .collect();
8684 if !found_keys.is_empty() {
8685 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
8686 } else {
8687 out.push_str(" Private keys: none found\n");
8688 }
8689
8690 let config_path = ssh_dir.join("config");
8691 if config_path.exists() {
8692 out.push_str("\n=== SSH config hosts ===\n");
8693 match fs::read_to_string(&config_path) {
8694 Ok(content) => {
8695 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8696 let mut current: Option<(String, Vec<String>)> = None;
8697 for line in content.lines() {
8698 let t = line.trim();
8699 if t.is_empty() || t.starts_with('#') {
8700 continue;
8701 }
8702 if let Some(host) = t.strip_prefix("Host ") {
8703 if let Some(prev) = current.take() {
8704 hosts.push(prev);
8705 }
8706 current = Some((host.trim().to_string(), Vec::new()));
8707 } else if let Some((_, ref mut details)) = current {
8708 let tu = t.to_uppercase();
8709 if tu.starts_with("HOSTNAME ")
8710 || tu.starts_with("USER ")
8711 || tu.starts_with("PORT ")
8712 || tu.starts_with("IDENTITYFILE ")
8713 {
8714 details.push(t.to_string());
8715 }
8716 }
8717 }
8718 if let Some(prev) = current {
8719 hosts.push(prev);
8720 }
8721
8722 if hosts.is_empty() {
8723 out.push_str(" No Host entries found.\n");
8724 } else {
8725 for (h, details) in &hosts {
8726 if details.is_empty() {
8727 out.push_str(&format!(" Host {h}\n"));
8728 } else {
8729 out.push_str(&format!(
8730 " Host {h} [{}]\n",
8731 details.join(", ")
8732 ));
8733 }
8734 }
8735 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
8736 }
8737 }
8738 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
8739 }
8740 } else {
8741 out.push_str(" SSH config: not present\n");
8742 }
8743 } else {
8744 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8745 }
8746 }
8747
8748 Ok(out.trim_end().to_string())
8749}
8750
8751fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8754 let mut out = String::from("Host inspection: installed_software\n\n");
8755 let n = max_entries.clamp(10, 50);
8756
8757 #[cfg(target_os = "windows")]
8758 {
8759 let winget_out = Command::new("winget")
8760 .args(["list", "--accept-source-agreements"])
8761 .output();
8762
8763 if let Ok(o) = winget_out {
8764 if o.status.success() {
8765 let raw = String::from_utf8_lossy(&o.stdout);
8766 let mut header_done = false;
8767 let mut packages: Vec<&str> = Vec::new();
8768 for line in raw.lines() {
8769 let t = line.trim();
8770 if t.starts_with("---") {
8771 header_done = true;
8772 continue;
8773 }
8774 if header_done && !t.is_empty() {
8775 packages.push(line);
8776 }
8777 }
8778 let total = packages.len();
8779 out.push_str(&format!(
8780 "=== Installed software via winget ({total} packages) ===\n\n"
8781 ));
8782 for line in packages.iter().take(n) {
8783 out.push_str(&format!(" {line}\n"));
8784 }
8785 if total > n {
8786 out.push_str(&format!("\n ... and {} more packages\n", total - n));
8787 }
8788 out.push_str("\nFor full list: winget list\n");
8789 return Ok(out.trim_end().to_string());
8790 }
8791 }
8792
8793 let script = format!(
8795 r#"
8796$apps = @()
8797$reg_paths = @(
8798 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8799 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8800 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8801)
8802foreach ($p in $reg_paths) {{
8803 try {{
8804 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8805 Where-Object {{ $_.DisplayName }} |
8806 Select-Object DisplayName, DisplayVersion, Publisher
8807 }} catch {{}}
8808}}
8809$sorted = $apps | Sort-Object DisplayName -Unique
8810"TOTAL:" + $sorted.Count
8811$sorted | Select-Object -First {n} | ForEach-Object {{
8812 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8813}}
8814"#
8815 );
8816 if let Ok(o) = Command::new("powershell")
8817 .args(["-NoProfile", "-Command", &script])
8818 .output()
8819 {
8820 let raw = String::from_utf8_lossy(&o.stdout);
8821 out.push_str("=== Installed software (registry scan) ===\n");
8822 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
8823 out.push_str(&format!(" {}\n", "-".repeat(90)));
8824 for line in raw.lines() {
8825 if let Some(rest) = line.strip_prefix("TOTAL:") {
8826 let total: usize = rest.trim().parse().unwrap_or(0);
8827 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
8828 } else if !line.trim().is_empty() {
8829 let parts: Vec<&str> = line.splitn(3, '|').collect();
8830 let name = parts.first().map(|s| s.trim()).unwrap_or("");
8831 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8832 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8833 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
8834 }
8835 }
8836 } else {
8837 out.push_str(
8838 "Could not query installed software (winget and registry scan both failed).\n",
8839 );
8840 }
8841 }
8842
8843 #[cfg(target_os = "linux")]
8844 {
8845 let mut found = false;
8846 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8847 if o.status.success() {
8848 let raw = String::from_utf8_lossy(&o.stdout);
8849 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8850 let total = installed.len();
8851 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8852 for line in installed.iter().take(n) {
8853 out.push_str(&format!(" {}\n", line.trim()));
8854 }
8855 if total > n {
8856 out.push_str(&format!(" ... and {} more\n", total - n));
8857 }
8858 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8859 found = true;
8860 }
8861 }
8862 if !found {
8863 if let Ok(o) = Command::new("rpm")
8864 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8865 .output()
8866 {
8867 if o.status.success() {
8868 let raw = String::from_utf8_lossy(&o.stdout);
8869 let lines: Vec<&str> = raw.lines().collect();
8870 let total = lines.len();
8871 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8872 for line in lines.iter().take(n) {
8873 out.push_str(&format!(" {line}\n"));
8874 }
8875 if total > n {
8876 out.push_str(&format!(" ... and {} more\n", total - n));
8877 }
8878 found = true;
8879 }
8880 }
8881 }
8882 if !found {
8883 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8884 if o.status.success() {
8885 let raw = String::from_utf8_lossy(&o.stdout);
8886 let lines: Vec<&str> = raw.lines().collect();
8887 let total = lines.len();
8888 out.push_str(&format!(
8889 "=== Installed packages via pacman ({total}) ===\n"
8890 ));
8891 for line in lines.iter().take(n) {
8892 out.push_str(&format!(" {line}\n"));
8893 }
8894 if total > n {
8895 out.push_str(&format!(" ... and {} more\n", total - n));
8896 }
8897 found = true;
8898 }
8899 }
8900 }
8901 if !found {
8902 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8903 }
8904 }
8905
8906 #[cfg(target_os = "macos")]
8907 {
8908 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8909 if o.status.success() {
8910 let raw = String::from_utf8_lossy(&o.stdout);
8911 let lines: Vec<&str> = raw.lines().collect();
8912 let total = lines.len();
8913 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8914 for line in lines.iter().take(n) {
8915 out.push_str(&format!(" {line}\n"));
8916 }
8917 if total > n {
8918 out.push_str(&format!(" ... and {} more\n", total - n));
8919 }
8920 out.push_str("\nFor full list: brew list --versions\n");
8921 }
8922 } else {
8923 out.push_str("Homebrew not found.\n");
8924 }
8925 if let Ok(o) = Command::new("mas").args(["list"]).output() {
8926 if o.status.success() {
8927 let raw = String::from_utf8_lossy(&o.stdout);
8928 let lines: Vec<&str> = raw.lines().collect();
8929 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8930 for line in lines.iter().take(n) {
8931 out.push_str(&format!(" {line}\n"));
8932 }
8933 }
8934 }
8935 }
8936
8937 Ok(out.trim_end().to_string())
8938}
8939
8940fn inspect_git_config() -> Result<String, String> {
8943 let mut out = String::from("Host inspection: git_config\n\n");
8944
8945 if let Ok(o) = Command::new("git").args(["--version"]).output() {
8946 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8947 out.push_str(&format!("Git: {ver}\n\n"));
8948 } else {
8949 out.push_str("Git: not found on PATH.\n");
8950 return Ok(out.trim_end().to_string());
8951 }
8952
8953 if let Ok(o) = Command::new("git")
8954 .args(["config", "--global", "--list"])
8955 .output()
8956 {
8957 if o.status.success() {
8958 let raw = String::from_utf8_lossy(&o.stdout);
8959 let mut pairs: Vec<(String, String)> = raw
8960 .lines()
8961 .filter_map(|l| {
8962 let mut parts = l.splitn(2, '=');
8963 let k = parts.next()?.trim().to_string();
8964 let v = parts.next().unwrap_or("").trim().to_string();
8965 Some((k, v))
8966 })
8967 .collect();
8968 pairs.sort_by(|a, b| a.0.cmp(&b.0));
8969
8970 out.push_str("=== Global git config ===\n");
8971
8972 let sections: &[(&str, &[&str])] = &[
8973 ("Identity", &["user.name", "user.email", "user.signingkey"]),
8974 (
8975 "Core",
8976 &[
8977 "core.editor",
8978 "core.autocrlf",
8979 "core.eol",
8980 "core.ignorecase",
8981 "core.filemode",
8982 ],
8983 ),
8984 (
8985 "Commit/Signing",
8986 &[
8987 "commit.gpgsign",
8988 "tag.gpgsign",
8989 "gpg.format",
8990 "gpg.ssh.allowedsignersfile",
8991 ],
8992 ),
8993 (
8994 "Push/Pull",
8995 &[
8996 "push.default",
8997 "push.autosetupremote",
8998 "pull.rebase",
8999 "pull.ff",
9000 ],
9001 ),
9002 ("Credential", &["credential.helper"]),
9003 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9004 ];
9005
9006 let mut shown_keys: HashSet<String> = HashSet::new();
9007 for (section, keys) in sections {
9008 let mut section_lines: Vec<String> = Vec::new();
9009 for key in *keys {
9010 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9011 section_lines.push(format!(" {k} = {v}"));
9012 shown_keys.insert(k.clone());
9013 }
9014 }
9015 if !section_lines.is_empty() {
9016 out.push_str(&format!("\n[{section}]\n"));
9017 for line in section_lines {
9018 out.push_str(&format!("{line}\n"));
9019 }
9020 }
9021 }
9022
9023 let other: Vec<&(String, String)> = pairs
9024 .iter()
9025 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9026 .collect();
9027 if !other.is_empty() {
9028 out.push_str("\n[Other]\n");
9029 for (k, v) in other.iter().take(20) {
9030 out.push_str(&format!(" {k} = {v}\n"));
9031 }
9032 if other.len() > 20 {
9033 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
9034 }
9035 }
9036
9037 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
9038 } else {
9039 out.push_str("No global git config found.\n");
9040 out.push_str("Set up with:\n");
9041 out.push_str(" git config --global user.name \"Your Name\"\n");
9042 out.push_str(" git config --global user.email \"you@example.com\"\n");
9043 }
9044 }
9045
9046 if let Ok(o) = Command::new("git")
9047 .args(["config", "--local", "--list"])
9048 .output()
9049 {
9050 if o.status.success() {
9051 let raw = String::from_utf8_lossy(&o.stdout);
9052 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9053 if !lines.is_empty() {
9054 out.push_str(&format!(
9055 "\n=== Local repo config ({} keys) ===\n",
9056 lines.len()
9057 ));
9058 for line in lines.iter().take(15) {
9059 out.push_str(&format!(" {line}\n"));
9060 }
9061 if lines.len() > 15 {
9062 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
9063 }
9064 }
9065 }
9066 }
9067
9068 if let Ok(o) = Command::new("git")
9069 .args(["config", "--global", "--get-regexp", r"alias\."])
9070 .output()
9071 {
9072 if o.status.success() {
9073 let raw = String::from_utf8_lossy(&o.stdout);
9074 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9075 if !aliases.is_empty() {
9076 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
9077 for a in aliases.iter().take(20) {
9078 out.push_str(&format!(" {a}\n"));
9079 }
9080 if aliases.len() > 20 {
9081 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
9082 }
9083 }
9084 }
9085 }
9086
9087 Ok(out.trim_end().to_string())
9088}
9089
9090fn inspect_databases() -> Result<String, String> {
9093 let mut out = String::from("Host inspection: databases\n\n");
9094 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9095
9096 struct DbEngine {
9097 name: &'static str,
9098 service_names: &'static [&'static str],
9099 default_port: u16,
9100 cli_name: &'static str,
9101 cli_version_args: &'static [&'static str],
9102 }
9103
9104 let engines: &[DbEngine] = &[
9105 DbEngine {
9106 name: "PostgreSQL",
9107 service_names: &[
9108 "postgresql",
9109 "postgresql-x64-14",
9110 "postgresql-x64-15",
9111 "postgresql-x64-16",
9112 "postgresql-x64-17",
9113 ],
9114
9115 default_port: 5432,
9116 cli_name: "psql",
9117 cli_version_args: &["--version"],
9118 },
9119 DbEngine {
9120 name: "MySQL",
9121 service_names: &["mysql", "mysql80", "mysql57"],
9122
9123 default_port: 3306,
9124 cli_name: "mysql",
9125 cli_version_args: &["--version"],
9126 },
9127 DbEngine {
9128 name: "MariaDB",
9129 service_names: &["mariadb", "mariadb.exe"],
9130
9131 default_port: 3306,
9132 cli_name: "mariadb",
9133 cli_version_args: &["--version"],
9134 },
9135 DbEngine {
9136 name: "MongoDB",
9137 service_names: &["mongodb", "mongod"],
9138
9139 default_port: 27017,
9140 cli_name: "mongod",
9141 cli_version_args: &["--version"],
9142 },
9143 DbEngine {
9144 name: "Redis",
9145 service_names: &["redis", "redis-server"],
9146
9147 default_port: 6379,
9148 cli_name: "redis-server",
9149 cli_version_args: &["--version"],
9150 },
9151 DbEngine {
9152 name: "SQL Server",
9153 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9154
9155 default_port: 1433,
9156 cli_name: "sqlcmd",
9157 cli_version_args: &["-?"],
9158 },
9159 DbEngine {
9160 name: "SQLite",
9161 service_names: &[], default_port: 0, cli_name: "sqlite3",
9165 cli_version_args: &["--version"],
9166 },
9167 DbEngine {
9168 name: "CouchDB",
9169 service_names: &["couchdb", "apache-couchdb"],
9170
9171 default_port: 5984,
9172 cli_name: "couchdb",
9173 cli_version_args: &["--version"],
9174 },
9175 DbEngine {
9176 name: "Cassandra",
9177 service_names: &["cassandra"],
9178
9179 default_port: 9042,
9180 cli_name: "cqlsh",
9181 cli_version_args: &["--version"],
9182 },
9183 DbEngine {
9184 name: "Elasticsearch",
9185 service_names: &["elasticsearch-service-x64", "elasticsearch"],
9186
9187 default_port: 9200,
9188 cli_name: "elasticsearch",
9189 cli_version_args: &["--version"],
9190 },
9191 ];
9192
9193 fn port_listening(port: u16) -> bool {
9195 if port == 0 {
9196 return false;
9197 }
9198 std::net::TcpStream::connect_timeout(
9200 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9201 std::time::Duration::from_millis(150),
9202 )
9203 .is_ok()
9204 }
9205
9206 let mut found_any = false;
9207
9208 for engine in engines {
9209 let mut status_parts: Vec<String> = Vec::new();
9210 let mut detected = false;
9211
9212 let version = Command::new(engine.cli_name)
9214 .args(engine.cli_version_args)
9215 .output()
9216 .ok()
9217 .and_then(|o| {
9218 let combined = if o.stdout.is_empty() {
9219 String::from_utf8_lossy(&o.stderr).trim().to_string()
9220 } else {
9221 String::from_utf8_lossy(&o.stdout).trim().to_string()
9222 };
9223 combined.lines().next().map(|l| l.trim().to_string())
9225 });
9226
9227 if let Some(ref ver) = version {
9228 if !ver.is_empty() {
9229 status_parts.push(format!("version: {ver}"));
9230 detected = true;
9231 }
9232 }
9233
9234 if engine.default_port > 0 && port_listening(engine.default_port) {
9236 status_parts.push(format!("listening on :{}", engine.default_port));
9237 detected = true;
9238 } else if engine.default_port > 0 && detected {
9239 status_parts.push(format!("not listening on :{}", engine.default_port));
9240 }
9241
9242 #[cfg(target_os = "windows")]
9244 {
9245 if !engine.service_names.is_empty() {
9246 let service_list = engine.service_names.join("','");
9247 let script = format!(
9248 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9249 service_list
9250 );
9251 if let Ok(o) = Command::new("powershell")
9252 .args(["-NoProfile", "-Command", &script])
9253 .output()
9254 {
9255 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9256 if !text.is_empty() {
9257 let parts: Vec<&str> = text.splitn(2, ':').collect();
9258 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
9259 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
9260 status_parts.push(format!("service '{svc_name}': {svc_state}"));
9261 detected = true;
9262 }
9263 }
9264 }
9265 }
9266
9267 #[cfg(not(target_os = "windows"))]
9269 {
9270 for svc in engine.service_names {
9271 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9272 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9273 if !state.is_empty() && state != "inactive" {
9274 status_parts.push(format!("systemd '{svc}': {state}"));
9275 detected = true;
9276 break;
9277 }
9278 }
9279 }
9280 }
9281
9282 if detected {
9283 found_any = true;
9284 let label = if engine.default_port > 0 {
9285 format!("{} (default port: {})", engine.name, engine.default_port)
9286 } else {
9287 format!("{} (file-based, no port)", engine.name)
9288 };
9289 out.push_str(&format!("[FOUND] {label}\n"));
9290 for part in &status_parts {
9291 out.push_str(&format!(" {part}\n"));
9292 }
9293 out.push('\n');
9294 }
9295 }
9296
9297 if !found_any {
9298 out.push_str("No local database engines detected.\n");
9299 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9300 out.push_str(
9301 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9302 );
9303 } else {
9304 out.push_str("---\n");
9305 out.push_str(
9306 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9307 );
9308 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9309 }
9310
9311 Ok(out.trim_end().to_string())
9312}
9313
9314fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9317 let mut out = String::from("Host inspection: user_accounts\n\n");
9318
9319 #[cfg(target_os = "windows")]
9320 {
9321 let users_out = Command::new("powershell")
9322 .args([
9323 "-NoProfile", "-NonInteractive", "-Command",
9324 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9325 ])
9326 .output()
9327 .ok()
9328 .and_then(|o| String::from_utf8(o.stdout).ok())
9329 .unwrap_or_default();
9330
9331 out.push_str("=== Local User Accounts ===\n");
9332 if users_out.trim().is_empty() {
9333 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
9334 } else {
9335 for line in users_out.lines().take(max_entries) {
9336 if !line.trim().is_empty() {
9337 out.push_str(line);
9338 out.push('\n');
9339 }
9340 }
9341 }
9342
9343 let admins_out = Command::new("powershell")
9344 .args([
9345 "-NoProfile", "-NonInteractive", "-Command",
9346 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
9347 ])
9348 .output()
9349 .ok()
9350 .and_then(|o| String::from_utf8(o.stdout).ok())
9351 .unwrap_or_default();
9352
9353 out.push_str("\n=== Administrators Group Members ===\n");
9354 if admins_out.trim().is_empty() {
9355 out.push_str(" (unable to retrieve)\n");
9356 } else {
9357 out.push_str(admins_out.trim());
9358 out.push('\n');
9359 }
9360
9361 let sessions_out = Command::new("powershell")
9362 .args([
9363 "-NoProfile",
9364 "-NonInteractive",
9365 "-Command",
9366 "query user 2>$null",
9367 ])
9368 .output()
9369 .ok()
9370 .and_then(|o| String::from_utf8(o.stdout).ok())
9371 .unwrap_or_default();
9372
9373 out.push_str("\n=== Active Logon Sessions ===\n");
9374 if sessions_out.trim().is_empty() {
9375 out.push_str(" (none or requires elevation)\n");
9376 } else {
9377 for line in sessions_out.lines().take(max_entries) {
9378 if !line.trim().is_empty() {
9379 out.push_str(&format!(" {}\n", line));
9380 }
9381 }
9382 }
9383
9384 let is_admin = Command::new("powershell")
9385 .args([
9386 "-NoProfile", "-NonInteractive", "-Command",
9387 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9388 ])
9389 .output()
9390 .ok()
9391 .and_then(|o| String::from_utf8(o.stdout).ok())
9392 .map(|s| s.trim().to_lowercase())
9393 .unwrap_or_default();
9394
9395 out.push_str("\n=== Current Session Elevation ===\n");
9396 out.push_str(&format!(
9397 " Running as Administrator: {}\n",
9398 if is_admin.contains("true") {
9399 "YES"
9400 } else {
9401 "no"
9402 }
9403 ));
9404 }
9405
9406 #[cfg(not(target_os = "windows"))]
9407 {
9408 let who_out = Command::new("who")
9409 .output()
9410 .ok()
9411 .and_then(|o| String::from_utf8(o.stdout).ok())
9412 .unwrap_or_default();
9413 out.push_str("=== Active Sessions ===\n");
9414 if who_out.trim().is_empty() {
9415 out.push_str(" (none)\n");
9416 } else {
9417 for line in who_out.lines().take(max_entries) {
9418 out.push_str(&format!(" {}\n", line));
9419 }
9420 }
9421 let id_out = Command::new("id")
9422 .output()
9423 .ok()
9424 .and_then(|o| String::from_utf8(o.stdout).ok())
9425 .unwrap_or_default();
9426 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
9427 }
9428
9429 Ok(out.trim_end().to_string())
9430}
9431
9432fn inspect_audit_policy() -> Result<String, String> {
9435 let mut out = String::from("Host inspection: audit_policy\n\n");
9436
9437 #[cfg(target_os = "windows")]
9438 {
9439 let auditpol_out = Command::new("auditpol")
9440 .args(["/get", "/category:*"])
9441 .output()
9442 .ok()
9443 .and_then(|o| String::from_utf8(o.stdout).ok())
9444 .unwrap_or_default();
9445
9446 if auditpol_out.trim().is_empty()
9447 || auditpol_out.to_lowercase().contains("access is denied")
9448 {
9449 out.push_str("Audit policy requires Administrator elevation to read.\n");
9450 out.push_str(
9451 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9452 );
9453 } else {
9454 out.push_str("=== Windows Audit Policy ===\n");
9455 let mut any_enabled = false;
9456 for line in auditpol_out.lines() {
9457 let trimmed = line.trim();
9458 if trimmed.is_empty() {
9459 continue;
9460 }
9461 if trimmed.contains("Success") || trimmed.contains("Failure") {
9462 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
9463 any_enabled = true;
9464 } else {
9465 out.push_str(&format!(" {}\n", trimmed));
9466 }
9467 }
9468 if !any_enabled {
9469 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9470 out.push_str(
9471 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9472 );
9473 }
9474 }
9475
9476 let evtlog = Command::new("powershell")
9477 .args([
9478 "-NoProfile", "-NonInteractive", "-Command",
9479 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9480 ])
9481 .output()
9482 .ok()
9483 .and_then(|o| String::from_utf8(o.stdout).ok())
9484 .map(|s| s.trim().to_string())
9485 .unwrap_or_default();
9486
9487 out.push_str(&format!(
9488 "\n=== Windows Event Log Service ===\n Status: {}\n",
9489 if evtlog.is_empty() {
9490 "unknown".to_string()
9491 } else {
9492 evtlog
9493 }
9494 ));
9495 }
9496
9497 #[cfg(not(target_os = "windows"))]
9498 {
9499 let auditd_status = Command::new("systemctl")
9500 .args(["is-active", "auditd"])
9501 .output()
9502 .ok()
9503 .and_then(|o| String::from_utf8(o.stdout).ok())
9504 .map(|s| s.trim().to_string())
9505 .unwrap_or_else(|| "not found".to_string());
9506
9507 out.push_str(&format!(
9508 "=== auditd service ===\n Status: {}\n",
9509 auditd_status
9510 ));
9511
9512 if auditd_status == "active" {
9513 let rules = Command::new("auditctl")
9514 .args(["-l"])
9515 .output()
9516 .ok()
9517 .and_then(|o| String::from_utf8(o.stdout).ok())
9518 .unwrap_or_default();
9519 out.push_str("\n=== Active Audit Rules ===\n");
9520 if rules.trim().is_empty() || rules.contains("No rules") {
9521 out.push_str(" No rules configured.\n");
9522 } else {
9523 for line in rules.lines() {
9524 out.push_str(&format!(" {}\n", line));
9525 }
9526 }
9527 }
9528 }
9529
9530 Ok(out.trim_end().to_string())
9531}
9532
9533fn inspect_shares(max_entries: usize) -> Result<String, String> {
9536 let mut out = String::from("Host inspection: shares\n\n");
9537
9538 #[cfg(target_os = "windows")]
9539 {
9540 let smb_out = Command::new("powershell")
9541 .args([
9542 "-NoProfile", "-NonInteractive", "-Command",
9543 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9544 ])
9545 .output()
9546 .ok()
9547 .and_then(|o| String::from_utf8(o.stdout).ok())
9548 .unwrap_or_default();
9549
9550 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9551 let smb_lines: Vec<&str> = smb_out
9552 .lines()
9553 .filter(|l| !l.trim().is_empty())
9554 .take(max_entries)
9555 .collect();
9556 if smb_lines.is_empty() {
9557 out.push_str(" No SMB shares or unable to retrieve.\n");
9558 } else {
9559 for line in &smb_lines {
9560 let name = line.trim().split('|').next().unwrap_or("").trim();
9561 if name.ends_with('$') {
9562 out.push_str(&format!(" {}\n", line.trim()));
9563 } else {
9564 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
9565 }
9566 }
9567 }
9568
9569 let smb_security = Command::new("powershell")
9570 .args([
9571 "-NoProfile", "-NonInteractive", "-Command",
9572 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9573 ])
9574 .output()
9575 .ok()
9576 .and_then(|o| String::from_utf8(o.stdout).ok())
9577 .unwrap_or_default();
9578
9579 out.push_str("\n=== SMB Server Security Settings ===\n");
9580 if smb_security.trim().is_empty() {
9581 out.push_str(" (unable to retrieve)\n");
9582 } else {
9583 out.push_str(smb_security.trim());
9584 out.push('\n');
9585 if smb_security.to_lowercase().contains("smb1: true") {
9586 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9587 }
9588 }
9589
9590 let drives_out = Command::new("powershell")
9591 .args([
9592 "-NoProfile", "-NonInteractive", "-Command",
9593 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9594 ])
9595 .output()
9596 .ok()
9597 .and_then(|o| String::from_utf8(o.stdout).ok())
9598 .unwrap_or_default();
9599
9600 out.push_str("\n=== Mapped Network Drives ===\n");
9601 if drives_out.trim().is_empty() {
9602 out.push_str(" None.\n");
9603 } else {
9604 for line in drives_out.lines().take(max_entries) {
9605 if !line.trim().is_empty() {
9606 out.push_str(line);
9607 out.push('\n');
9608 }
9609 }
9610 }
9611 }
9612
9613 #[cfg(not(target_os = "windows"))]
9614 {
9615 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9616 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9617 if smb_conf.is_empty() {
9618 out.push_str(" Not found or Samba not installed.\n");
9619 } else {
9620 for line in smb_conf.lines().take(max_entries) {
9621 out.push_str(&format!(" {}\n", line));
9622 }
9623 }
9624 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9625 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9626 if nfs_exports.is_empty() {
9627 out.push_str(" Not configured.\n");
9628 } else {
9629 for line in nfs_exports.lines().take(max_entries) {
9630 out.push_str(&format!(" {}\n", line));
9631 }
9632 }
9633 }
9634
9635 Ok(out.trim_end().to_string())
9636}
9637
9638fn inspect_dns_servers() -> Result<String, String> {
9641 let mut out = String::from("Host inspection: dns_servers\n\n");
9642
9643 #[cfg(target_os = "windows")]
9644 {
9645 let dns_out = Command::new("powershell")
9646 .args([
9647 "-NoProfile", "-NonInteractive", "-Command",
9648 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9649 ])
9650 .output()
9651 .ok()
9652 .and_then(|o| String::from_utf8(o.stdout).ok())
9653 .unwrap_or_default();
9654
9655 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9656 if dns_out.trim().is_empty() {
9657 out.push_str(" (unable to retrieve)\n");
9658 } else {
9659 for line in dns_out.lines() {
9660 if line.trim().is_empty() {
9661 continue;
9662 }
9663 let mut annotation = "";
9664 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9665 annotation = " <- Google Public DNS";
9666 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9667 annotation = " <- Cloudflare DNS";
9668 } else if line.contains("9.9.9.9") {
9669 annotation = " <- Quad9";
9670 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9671 annotation = " <- OpenDNS";
9672 }
9673 out.push_str(line);
9674 out.push_str(annotation);
9675 out.push('\n');
9676 }
9677 }
9678
9679 let doh_out = Command::new("powershell")
9680 .args([
9681 "-NoProfile", "-NonInteractive", "-Command",
9682 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
9683 ])
9684 .output()
9685 .ok()
9686 .and_then(|o| String::from_utf8(o.stdout).ok())
9687 .unwrap_or_default();
9688
9689 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9690 if doh_out.trim().is_empty() {
9691 out.push_str(" Not configured (plain DNS).\n");
9692 } else {
9693 out.push_str(doh_out.trim());
9694 out.push('\n');
9695 }
9696
9697 let suffixes = Command::new("powershell")
9698 .args([
9699 "-NoProfile", "-NonInteractive", "-Command",
9700 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
9701 ])
9702 .output()
9703 .ok()
9704 .and_then(|o| String::from_utf8(o.stdout).ok())
9705 .unwrap_or_default();
9706
9707 if !suffixes.trim().is_empty() {
9708 out.push_str("\n=== DNS Search Suffix List ===\n");
9709 out.push_str(suffixes.trim());
9710 out.push('\n');
9711 }
9712 }
9713
9714 #[cfg(not(target_os = "windows"))]
9715 {
9716 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9717 out.push_str("=== /etc/resolv.conf ===\n");
9718 if resolv.is_empty() {
9719 out.push_str(" Not found.\n");
9720 } else {
9721 for line in resolv.lines() {
9722 if !line.trim().is_empty() && !line.starts_with('#') {
9723 out.push_str(&format!(" {}\n", line));
9724 }
9725 }
9726 }
9727 let resolved_out = Command::new("resolvectl")
9728 .args(["status", "--no-pager"])
9729 .output()
9730 .ok()
9731 .and_then(|o| String::from_utf8(o.stdout).ok())
9732 .unwrap_or_default();
9733 if !resolved_out.is_empty() {
9734 out.push_str("\n=== systemd-resolved ===\n");
9735 for line in resolved_out.lines().take(30) {
9736 out.push_str(&format!(" {}\n", line));
9737 }
9738 }
9739 }
9740
9741 Ok(out.trim_end().to_string())
9742}
9743
9744fn inspect_bitlocker() -> Result<String, String> {
9745 let mut out = String::from("Host inspection: bitlocker\n\n");
9746
9747 #[cfg(target_os = "windows")]
9748 {
9749 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9750 let output = Command::new("powershell")
9751 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9752 .output()
9753 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9754
9755 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9756 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9757
9758 if !stdout.trim().is_empty() {
9759 out.push_str("=== BitLocker Volumes ===\n");
9760 for line in stdout.lines() {
9761 out.push_str(&format!(" {}\n", line));
9762 }
9763 } else if !stderr.trim().is_empty() {
9764 if stderr.contains("Access is denied") {
9765 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9766 } else {
9767 out.push_str(&format!(
9768 "Error retrieving BitLocker info: {}\n",
9769 stderr.trim()
9770 ));
9771 }
9772 } else {
9773 out.push_str("No BitLocker volumes detected or access denied.\n");
9774 }
9775 }
9776
9777 #[cfg(not(target_os = "windows"))]
9778 {
9779 out.push_str(
9780 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9781 );
9782 let lsblk = Command::new("lsblk")
9783 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9784 .output()
9785 .ok()
9786 .and_then(|o| String::from_utf8(o.stdout).ok())
9787 .unwrap_or_default();
9788 if lsblk.contains("crypto_LUKS") {
9789 out.push_str("=== LUKS Encrypted Volumes ===\n");
9790 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9791 out.push_str(&format!(" {}\n", line));
9792 }
9793 } else {
9794 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9795 }
9796 }
9797
9798 Ok(out.trim_end().to_string())
9799}
9800
9801fn inspect_rdp() -> Result<String, String> {
9802 let mut out = String::from("Host inspection: rdp\n\n");
9803
9804 #[cfg(target_os = "windows")]
9805 {
9806 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9807 let f_deny = Command::new("powershell")
9808 .args([
9809 "-NoProfile",
9810 "-Command",
9811 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9812 ])
9813 .output()
9814 .ok()
9815 .and_then(|o| String::from_utf8(o.stdout).ok())
9816 .unwrap_or_default()
9817 .trim()
9818 .to_string();
9819
9820 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9821 out.push_str(&format!("=== RDP Status: {} ===\n", status));
9822
9823 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"])
9824 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9825 out.push_str(&format!(
9826 " Port: {}\n",
9827 if port.is_empty() {
9828 "3389 (default)"
9829 } else {
9830 &port
9831 }
9832 ));
9833
9834 let nla = Command::new("powershell")
9835 .args([
9836 "-NoProfile",
9837 "-Command",
9838 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9839 ])
9840 .output()
9841 .ok()
9842 .and_then(|o| String::from_utf8(o.stdout).ok())
9843 .unwrap_or_default()
9844 .trim()
9845 .to_string();
9846 out.push_str(&format!(
9847 " NLA Required: {}\n",
9848 if nla == "1" { "Yes" } else { "No" }
9849 ));
9850
9851 let rdp_tcp_path =
9852 "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
9853 let sec_layer = Command::new("powershell")
9854 .args([
9855 "-NoProfile",
9856 "-Command",
9857 &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
9858 ])
9859 .output()
9860 .ok()
9861 .and_then(|o| String::from_utf8(o.stdout).ok())
9862 .unwrap_or_default()
9863 .trim()
9864 .to_string();
9865 let sec_label = match sec_layer.as_str() {
9866 "0" => "RDP Security (no SSL)",
9867 "1" => "Negotiate (prefer TLS)",
9868 "2" => "SSL/TLS required",
9869 _ => &sec_layer,
9870 };
9871 out.push_str(&format!(
9872 " Security Layer: {} ({})\n",
9873 sec_layer, sec_label
9874 ));
9875
9876 let enc_level = Command::new("powershell")
9877 .args([
9878 "-NoProfile",
9879 "-Command",
9880 &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
9881 ])
9882 .output()
9883 .ok()
9884 .and_then(|o| String::from_utf8(o.stdout).ok())
9885 .unwrap_or_default()
9886 .trim()
9887 .to_string();
9888 let enc_label = match enc_level.as_str() {
9889 "1" => "Low",
9890 "2" => "Client Compatible",
9891 "3" => "High",
9892 "4" => "FIPS Compliant",
9893 _ => "Unknown",
9894 };
9895 out.push_str(&format!(
9896 " Encryption Level: {} ({})\n",
9897 enc_level, enc_label
9898 ));
9899
9900 out.push_str("\n=== Active Sessions ===\n");
9901 let qwinsta = Command::new("qwinsta")
9902 .output()
9903 .ok()
9904 .and_then(|o| String::from_utf8(o.stdout).ok())
9905 .unwrap_or_default();
9906 if qwinsta.trim().is_empty() {
9907 out.push_str(" No active sessions listed.\n");
9908 } else {
9909 for line in qwinsta.lines() {
9910 out.push_str(&format!(" {}\n", line));
9911 }
9912 }
9913
9914 out.push_str("\n=== Firewall Rule Check ===\n");
9915 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))\" }"])
9916 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9917 if fw.trim().is_empty() {
9918 out.push_str(" No enabled RDP firewall rules found.\n");
9919 } else {
9920 out.push_str(fw.trim_end());
9921 out.push('\n');
9922 }
9923 }
9924
9925 #[cfg(not(target_os = "windows"))]
9926 {
9927 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9928 let ss = Command::new("ss")
9929 .args(["-tlnp"])
9930 .output()
9931 .ok()
9932 .and_then(|o| String::from_utf8(o.stdout).ok())
9933 .unwrap_or_default();
9934 let matches: Vec<&str> = ss
9935 .lines()
9936 .filter(|l| l.contains(":3389") || l.contains(":590"))
9937 .collect();
9938 if matches.is_empty() {
9939 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
9940 } else {
9941 for m in matches {
9942 out.push_str(&format!(" {}\n", m));
9943 }
9944 }
9945 }
9946
9947 Ok(out.trim_end().to_string())
9948}
9949
9950fn inspect_shadow_copies() -> Result<String, String> {
9951 let mut out = String::from("Host inspection: shadow_copies\n\n");
9952
9953 #[cfg(target_os = "windows")]
9954 {
9955 let output = Command::new("vssadmin")
9956 .args(["list", "shadows"])
9957 .output()
9958 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9959 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9960
9961 if stdout.contains("No items found") || stdout.trim().is_empty() {
9962 out.push_str("No Volume Shadow Copies found.\n");
9963 } else {
9964 out.push_str("=== Volume Shadow Copies ===\n");
9965 for line in stdout.lines().take(50) {
9966 if line.contains("Creation Time:")
9967 || line.contains("Contents:")
9968 || line.contains("Volume Name:")
9969 {
9970 out.push_str(&format!(" {}\n", line.trim()));
9971 }
9972 }
9973 }
9974
9975 let age_script = r#"
9977try {
9978 $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
9979 if ($snaps) {
9980 $newest = $snaps[0]
9981 $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
9982 $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
9983 $count = @($snaps).Count
9984 "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm')) ($age days ago) — $count total snapshots"
9985 } else { "No snapshots found via WMI." }
9986} catch { "WMI snapshot query unavailable: $_" }
9987"#;
9988 if let Ok(age_out) = Command::new("powershell")
9989 .args(["-NoProfile", "-Command", age_script])
9990 .output()
9991 {
9992 let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
9993 if !age_text.is_empty() {
9994 out.push_str("\n=== Snapshot Age ===\n");
9995 out.push_str(&format!(" {}\n", age_text));
9996 }
9997 }
9998
9999 out.push_str("\n=== Shadow Copy Storage ===\n");
10000 let storage_out = Command::new("vssadmin")
10001 .args(["list", "shadowstorage"])
10002 .output()
10003 .ok();
10004 if let Some(o) = storage_out {
10005 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10006 for line in stdout.lines() {
10007 if line.contains("Used Shadow Copy Storage space:")
10008 || line.contains("Max Shadow Copy Storage space:")
10009 {
10010 out.push_str(&format!(" {}\n", line.trim()));
10011 }
10012 }
10013 }
10014 }
10015
10016 #[cfg(not(target_os = "windows"))]
10017 {
10018 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10019 let lvs = Command::new("lvs")
10020 .output()
10021 .ok()
10022 .and_then(|o| String::from_utf8(o.stdout).ok())
10023 .unwrap_or_default();
10024 if !lvs.is_empty() {
10025 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10026 out.push_str(&lvs);
10027 } else {
10028 out.push_str("No LVM volumes detected.\n");
10029 }
10030 }
10031
10032 Ok(out.trim_end().to_string())
10033}
10034
10035fn inspect_pagefile() -> Result<String, String> {
10036 let mut out = String::from("Host inspection: pagefile\n\n");
10037
10038 #[cfg(target_os = "windows")]
10039 {
10040 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)\" }";
10041 let output = Command::new("powershell")
10042 .args(["-NoProfile", "-Command", ps_cmd])
10043 .output()
10044 .ok()
10045 .and_then(|o| String::from_utf8(o.stdout).ok())
10046 .unwrap_or_default();
10047
10048 if output.trim().is_empty() {
10049 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10050 let managed = Command::new("powershell")
10051 .args([
10052 "-NoProfile",
10053 "-Command",
10054 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10055 ])
10056 .output()
10057 .ok()
10058 .and_then(|o| String::from_utf8(o.stdout).ok())
10059 .unwrap_or_default()
10060 .trim()
10061 .to_string();
10062 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
10063 } else {
10064 out.push_str("=== Page File Usage ===\n");
10065 out.push_str(&output);
10066 }
10067 }
10068
10069 #[cfg(not(target_os = "windows"))]
10070 {
10071 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10072 let swap = Command::new("swapon")
10073 .args(["--show"])
10074 .output()
10075 .ok()
10076 .and_then(|o| String::from_utf8(o.stdout).ok())
10077 .unwrap_or_default();
10078 if swap.is_empty() {
10079 let free = Command::new("free")
10080 .args(["-h"])
10081 .output()
10082 .ok()
10083 .and_then(|o| String::from_utf8(o.stdout).ok())
10084 .unwrap_or_default();
10085 out.push_str(&free);
10086 } else {
10087 out.push_str(&swap);
10088 }
10089 }
10090
10091 Ok(out.trim_end().to_string())
10092}
10093
10094fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10095 let mut out = String::from("Host inspection: windows_features\n\n");
10096
10097 #[cfg(target_os = "windows")]
10098 {
10099 out.push_str("=== Quick Check: Notable Features ===\n");
10100 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10101 let output = Command::new("powershell")
10102 .args(["-NoProfile", "-Command", quick_ps])
10103 .output()
10104 .ok();
10105
10106 if let Some(o) = output {
10107 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10108 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10109
10110 if !stdout.trim().is_empty() {
10111 for f in stdout.lines() {
10112 out.push_str(&format!(" [ENABLED] {}\n", f));
10113 }
10114 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10115 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10116 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10117 out.push_str(
10118 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10119 );
10120 }
10121 }
10122
10123 out.push_str(&format!(
10124 "\n=== All Enabled Features (capped at {}) ===\n",
10125 max_entries
10126 ));
10127 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10128 let all_out = Command::new("powershell")
10129 .args(["-NoProfile", "-Command", &all_ps])
10130 .output()
10131 .ok();
10132 if let Some(o) = all_out {
10133 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10134 if !stdout.trim().is_empty() {
10135 out.push_str(&stdout);
10136 }
10137 }
10138 }
10139
10140 #[cfg(not(target_os = "windows"))]
10141 {
10142 let _ = max_entries;
10143 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10144 }
10145
10146 Ok(out.trim_end().to_string())
10147}
10148
10149fn inspect_audio(max_entries: usize) -> Result<String, String> {
10150 let mut out = String::from("Host inspection: audio\n\n");
10151
10152 #[cfg(target_os = "windows")]
10153 {
10154 let n = max_entries.clamp(5, 20);
10155 let services = collect_services().unwrap_or_default();
10156 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10157 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10158
10159 let core_services: Vec<&ServiceEntry> = services
10160 .iter()
10161 .filter(|entry| {
10162 core_service_names
10163 .iter()
10164 .any(|name| entry.name.eq_ignore_ascii_case(name))
10165 })
10166 .collect();
10167 let bluetooth_audio_services: Vec<&ServiceEntry> = services
10168 .iter()
10169 .filter(|entry| {
10170 bluetooth_audio_service_names
10171 .iter()
10172 .any(|name| entry.name.eq_ignore_ascii_case(name))
10173 })
10174 .collect();
10175
10176 let probe_script = r#"
10177$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10178 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10179$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10180 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10181$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10182 Select-Object Name, Status, Manufacturer, PNPDeviceID)
10183[pscustomobject]@{
10184 Media = $media
10185 Endpoints = $endpoints
10186 SoundDevices = $sound
10187} | ConvertTo-Json -Compress -Depth 4
10188"#;
10189 let probe_raw = Command::new("powershell")
10190 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10191 .output()
10192 .ok()
10193 .and_then(|o| String::from_utf8(o.stdout).ok())
10194 .unwrap_or_default();
10195 let probe_loaded = !probe_raw.trim().is_empty();
10196 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10197
10198 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10199 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10200 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10201
10202 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10203 .iter()
10204 .filter(|device| !is_microphone_like_name(&device.name))
10205 .collect();
10206 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10207 .iter()
10208 .filter(|device| is_microphone_like_name(&device.name))
10209 .collect();
10210 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10211 .iter()
10212 .filter(|device| is_bluetooth_like_name(&device.name))
10213 .collect();
10214 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10215 .iter()
10216 .filter(|device| windows_device_has_issue(device))
10217 .collect();
10218 let media_problems: Vec<&WindowsPnpDevice> = media_devices
10219 .iter()
10220 .filter(|device| windows_device_has_issue(device))
10221 .collect();
10222 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10223 .iter()
10224 .filter(|device| windows_sound_device_has_issue(device))
10225 .collect();
10226
10227 let mut findings = Vec::new();
10228
10229 let stopped_core_services: Vec<&ServiceEntry> = core_services
10230 .iter()
10231 .copied()
10232 .filter(|service| !service_is_running(service))
10233 .collect();
10234 if !stopped_core_services.is_empty() {
10235 let names = stopped_core_services
10236 .iter()
10237 .map(|service| service.name.as_str())
10238 .collect::<Vec<_>>()
10239 .join(", ");
10240 findings.push(AuditFinding {
10241 finding: format!("Core audio services are not running: {names}"),
10242 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10243 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10244 });
10245 }
10246
10247 if probe_loaded
10248 && endpoints.is_empty()
10249 && media_devices.is_empty()
10250 && sound_devices.is_empty()
10251 {
10252 findings.push(AuditFinding {
10253 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10254 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(),
10255 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(),
10256 });
10257 }
10258
10259 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10260 {
10261 let mut problem_labels = Vec::new();
10262 problem_labels.extend(
10263 endpoint_problems
10264 .iter()
10265 .take(3)
10266 .map(|device| device.name.clone()),
10267 );
10268 problem_labels.extend(
10269 media_problems
10270 .iter()
10271 .take(3)
10272 .map(|device| device.name.clone()),
10273 );
10274 problem_labels.extend(
10275 sound_problems
10276 .iter()
10277 .take(3)
10278 .map(|device| device.name.clone()),
10279 );
10280 findings.push(AuditFinding {
10281 finding: format!(
10282 "Windows reports audio device issues for: {}",
10283 problem_labels.join(", ")
10284 ),
10285 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10286 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(),
10287 });
10288 }
10289
10290 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10291 .iter()
10292 .copied()
10293 .filter(|service| !service_is_running(service))
10294 .collect();
10295 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10296 let names = stopped_bt_audio_services
10297 .iter()
10298 .map(|service| service.name.as_str())
10299 .collect::<Vec<_>>()
10300 .join(", ");
10301 findings.push(AuditFinding {
10302 finding: format!(
10303 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10304 ),
10305 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10306 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10307 });
10308 }
10309
10310 out.push_str("=== Findings ===\n");
10311 if findings.is_empty() {
10312 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10313 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
10314 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");
10315 } else {
10316 for finding in &findings {
10317 out.push_str(&format!("- Finding: {}\n", finding.finding));
10318 out.push_str(&format!(" Impact: {}\n", finding.impact));
10319 out.push_str(&format!(" Fix: {}\n", finding.fix));
10320 }
10321 }
10322
10323 out.push_str("\n=== Audio services ===\n");
10324 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10325 out.push_str(
10326 "- No Windows audio services were retrieved from the service inventory.\n",
10327 );
10328 } else {
10329 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10330 out.push_str(&format!(
10331 "- {} | Status: {} | Startup: {}\n",
10332 service.name,
10333 service.status,
10334 service.startup.as_deref().unwrap_or("Unknown")
10335 ));
10336 }
10337 }
10338
10339 out.push_str("\n=== Playback and recording endpoints ===\n");
10340 if !probe_loaded {
10341 out.push_str("- Windows endpoint inventory probe returned no data.\n");
10342 } else if endpoints.is_empty() {
10343 out.push_str("- No audio endpoints detected.\n");
10344 } else {
10345 out.push_str(&format!(
10346 "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
10347 playback_endpoints.len(),
10348 recording_endpoints.len()
10349 ));
10350 for device in playback_endpoints.iter().take(n) {
10351 out.push_str(&format!(
10352 "- [PLAYBACK] {} | Status: {}{}\n",
10353 device.name,
10354 device.status,
10355 device
10356 .problem
10357 .filter(|problem| *problem != 0)
10358 .map(|problem| format!(" | ProblemCode: {problem}"))
10359 .unwrap_or_default()
10360 ));
10361 }
10362 for device in recording_endpoints.iter().take(n) {
10363 out.push_str(&format!(
10364 "- [MIC] {} | Status: {}{}\n",
10365 device.name,
10366 device.status,
10367 device
10368 .problem
10369 .filter(|problem| *problem != 0)
10370 .map(|problem| format!(" | ProblemCode: {problem}"))
10371 .unwrap_or_default()
10372 ));
10373 }
10374 }
10375
10376 out.push_str("\n=== Sound hardware devices ===\n");
10377 if sound_devices.is_empty() {
10378 out.push_str("- No Win32_SoundDevice entries were returned.\n");
10379 } else {
10380 for device in sound_devices.iter().take(n) {
10381 out.push_str(&format!(
10382 "- {} | Status: {}{}\n",
10383 device.name,
10384 device.status,
10385 device
10386 .manufacturer
10387 .as_deref()
10388 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10389 .unwrap_or_default()
10390 ));
10391 }
10392 }
10393
10394 out.push_str("\n=== Media-class device inventory ===\n");
10395 if media_devices.is_empty() {
10396 out.push_str("- No media-class PnP devices were returned.\n");
10397 } else {
10398 for device in media_devices.iter().take(n) {
10399 out.push_str(&format!(
10400 "- {} | Status: {}{}\n",
10401 device.name,
10402 device.status,
10403 device
10404 .class_name
10405 .as_deref()
10406 .map(|class_name| format!(" | Class: {class_name}"))
10407 .unwrap_or_default()
10408 ));
10409 }
10410 }
10411 }
10412
10413 #[cfg(not(target_os = "windows"))]
10414 {
10415 let _ = max_entries;
10416 out.push_str(
10417 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10418 );
10419 out.push_str(
10420 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10421 );
10422 }
10423
10424 Ok(out.trim_end().to_string())
10425}
10426
10427fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10428 let mut out = String::from("Host inspection: bluetooth\n\n");
10429
10430 #[cfg(target_os = "windows")]
10431 {
10432 let n = max_entries.clamp(5, 20);
10433 let services = collect_services().unwrap_or_default();
10434 let bluetooth_services: Vec<&ServiceEntry> = services
10435 .iter()
10436 .filter(|entry| {
10437 entry.name.eq_ignore_ascii_case("bthserv")
10438 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10439 || entry.name.eq_ignore_ascii_case("BTAGService")
10440 || entry.name.starts_with("BluetoothUserService")
10441 || entry
10442 .display_name
10443 .as_deref()
10444 .unwrap_or("")
10445 .to_ascii_lowercase()
10446 .contains("bluetooth")
10447 })
10448 .collect();
10449
10450 let probe_script = r#"
10451$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10452 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10453$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10454 Where-Object {
10455 $_.Class -eq 'Bluetooth' -or
10456 $_.FriendlyName -match 'Bluetooth' -or
10457 $_.InstanceId -like 'BTH*'
10458 } |
10459 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10460$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10461 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10462 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10463[pscustomobject]@{
10464 Radios = $radios
10465 Devices = $devices
10466 AudioEndpoints = $audio
10467} | ConvertTo-Json -Compress -Depth 4
10468"#;
10469 let probe_raw = Command::new("powershell")
10470 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10471 .output()
10472 .ok()
10473 .and_then(|o| String::from_utf8(o.stdout).ok())
10474 .unwrap_or_default();
10475 let probe_loaded = !probe_raw.trim().is_empty();
10476 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10477
10478 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10479 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10480 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10481 let radio_problems: Vec<&WindowsPnpDevice> = radios
10482 .iter()
10483 .filter(|device| windows_device_has_issue(device))
10484 .collect();
10485 let device_problems: Vec<&WindowsPnpDevice> = devices
10486 .iter()
10487 .filter(|device| windows_device_has_issue(device))
10488 .collect();
10489
10490 let mut findings = Vec::new();
10491
10492 if probe_loaded && radios.is_empty() {
10493 findings.push(AuditFinding {
10494 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10495 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10496 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10497 });
10498 }
10499
10500 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10501 .iter()
10502 .copied()
10503 .filter(|service| !service_is_running(service))
10504 .collect();
10505 if !stopped_bluetooth_services.is_empty() {
10506 let names = stopped_bluetooth_services
10507 .iter()
10508 .map(|service| service.name.as_str())
10509 .collect::<Vec<_>>()
10510 .join(", ");
10511 findings.push(AuditFinding {
10512 finding: format!("Bluetooth-related services are not fully running: {names}"),
10513 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10514 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10515 });
10516 }
10517
10518 if !radio_problems.is_empty() || !device_problems.is_empty() {
10519 let problem_labels = radio_problems
10520 .iter()
10521 .chain(device_problems.iter())
10522 .take(5)
10523 .map(|device| device.name.as_str())
10524 .collect::<Vec<_>>()
10525 .join(", ");
10526 findings.push(AuditFinding {
10527 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10528 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10529 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(),
10530 });
10531 }
10532
10533 if !audio_endpoints.is_empty()
10534 && bluetooth_services
10535 .iter()
10536 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10537 && bluetooth_services
10538 .iter()
10539 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10540 .any(|service| !service_is_running(service))
10541 {
10542 findings.push(AuditFinding {
10543 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10544 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10545 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10546 });
10547 }
10548
10549 out.push_str("=== Findings ===\n");
10550 if findings.is_empty() {
10551 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10552 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10553 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");
10554 } else {
10555 for finding in &findings {
10556 out.push_str(&format!("- Finding: {}\n", finding.finding));
10557 out.push_str(&format!(" Impact: {}\n", finding.impact));
10558 out.push_str(&format!(" Fix: {}\n", finding.fix));
10559 }
10560 }
10561
10562 out.push_str("\n=== Bluetooth services ===\n");
10563 if bluetooth_services.is_empty() {
10564 out.push_str(
10565 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10566 );
10567 } else {
10568 for service in bluetooth_services.iter().take(n) {
10569 out.push_str(&format!(
10570 "- {} | Status: {} | Startup: {}\n",
10571 service.name,
10572 service.status,
10573 service.startup.as_deref().unwrap_or("Unknown")
10574 ));
10575 }
10576 }
10577
10578 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10579 if !probe_loaded {
10580 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10581 } else if radios.is_empty() {
10582 out.push_str("- No Bluetooth radios detected.\n");
10583 } else {
10584 for device in radios.iter().take(n) {
10585 out.push_str(&format!(
10586 "- {} | Status: {}{}\n",
10587 device.name,
10588 device.status,
10589 device
10590 .problem
10591 .filter(|problem| *problem != 0)
10592 .map(|problem| format!(" | ProblemCode: {problem}"))
10593 .unwrap_or_default()
10594 ));
10595 }
10596 }
10597
10598 out.push_str("\n=== Bluetooth-associated devices ===\n");
10599 if devices.is_empty() {
10600 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10601 } else {
10602 for device in devices.iter().take(n) {
10603 out.push_str(&format!(
10604 "- {} | Status: {}{}\n",
10605 device.name,
10606 device.status,
10607 device
10608 .class_name
10609 .as_deref()
10610 .map(|class_name| format!(" | Class: {class_name}"))
10611 .unwrap_or_default()
10612 ));
10613 }
10614 }
10615
10616 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10617 if audio_endpoints.is_empty() {
10618 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10619 } else {
10620 for device in audio_endpoints.iter().take(n) {
10621 out.push_str(&format!(
10622 "- {} | Status: {}{}\n",
10623 device.name,
10624 device.status,
10625 device
10626 .instance_id
10627 .as_deref()
10628 .map(|instance_id| format!(" | Instance: {instance_id}"))
10629 .unwrap_or_default()
10630 ));
10631 }
10632 }
10633 }
10634
10635 #[cfg(not(target_os = "windows"))]
10636 {
10637 let _ = max_entries;
10638 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10639 out.push_str(
10640 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10641 );
10642 }
10643
10644 Ok(out.trim_end().to_string())
10645}
10646
10647fn inspect_printers(max_entries: usize) -> Result<String, String> {
10648 let mut out = String::from("Host inspection: printers\n\n");
10649
10650 #[cfg(target_os = "windows")]
10651 {
10652 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)])
10653 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10654 if list.trim().is_empty() {
10655 out.push_str("No printers detected.\n");
10656 } else {
10657 out.push_str("=== Installed Printers ===\n");
10658 out.push_str(&list);
10659 }
10660
10661 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10662 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10663 if !jobs.trim().is_empty() {
10664 out.push_str("\n=== Active Print Jobs ===\n");
10665 out.push_str(&jobs);
10666 }
10667 }
10668
10669 #[cfg(not(target_os = "windows"))]
10670 {
10671 let _ = max_entries;
10672 out.push_str("Checking LPSTAT for printers...\n");
10673 let lpstat = Command::new("lpstat")
10674 .args(["-p", "-d"])
10675 .output()
10676 .ok()
10677 .and_then(|o| String::from_utf8(o.stdout).ok())
10678 .unwrap_or_default();
10679 if lpstat.is_empty() {
10680 out.push_str(" No CUPS/LP printers found.\n");
10681 } else {
10682 out.push_str(&lpstat);
10683 }
10684 }
10685
10686 Ok(out.trim_end().to_string())
10687}
10688
10689fn inspect_winrm() -> Result<String, String> {
10690 let mut out = String::from("Host inspection: winrm\n\n");
10691
10692 #[cfg(target_os = "windows")]
10693 {
10694 let svc = Command::new("powershell")
10695 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10696 .output()
10697 .ok()
10698 .and_then(|o| String::from_utf8(o.stdout).ok())
10699 .unwrap_or_default()
10700 .trim()
10701 .to_string();
10702 out.push_str(&format!(
10703 "WinRM Service Status: {}\n\n",
10704 if svc.is_empty() { "NOT_FOUND" } else { &svc }
10705 ));
10706
10707 out.push_str("=== WinRM Listeners ===\n");
10708 let output = Command::new("powershell")
10709 .args([
10710 "-NoProfile",
10711 "-Command",
10712 "winrm enumerate winrm/config/listener 2>$null",
10713 ])
10714 .output()
10715 .ok();
10716 if let Some(o) = output {
10717 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10718 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10719
10720 if !stdout.trim().is_empty() {
10721 for line in stdout.lines() {
10722 if line.contains("Address =")
10723 || line.contains("Transport =")
10724 || line.contains("Port =")
10725 {
10726 out.push_str(&format!(" {}\n", line.trim()));
10727 }
10728 }
10729 } else if stderr.contains("Access is denied") {
10730 out.push_str(" Error: Access denied to WinRM configuration.\n");
10731 } else {
10732 out.push_str(" No listeners configured.\n");
10733 }
10734 }
10735
10736 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10737 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))\" }"])
10738 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10739 if test_out.trim().is_empty() {
10740 out.push_str(" WinRM not responding to local WS-Man requests.\n");
10741 } else {
10742 out.push_str(&test_out);
10743 }
10744 }
10745
10746 #[cfg(not(target_os = "windows"))]
10747 {
10748 out.push_str(
10749 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10750 );
10751 let ss = Command::new("ss")
10752 .args(["-tln"])
10753 .output()
10754 .ok()
10755 .and_then(|o| String::from_utf8(o.stdout).ok())
10756 .unwrap_or_default();
10757 if ss.contains(":5985") || ss.contains(":5986") {
10758 out.push_str(" WinRM ports (5985/5986) are listening.\n");
10759 } else {
10760 out.push_str(" WinRM ports not detected.\n");
10761 }
10762 }
10763
10764 Ok(out.trim_end().to_string())
10765}
10766
10767fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10768 let mut out = String::from("Host inspection: network_stats\n\n");
10769
10770 #[cfg(target_os = "windows")]
10771 {
10772 let ps_cmd = format!(
10773 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10774 Start-Sleep -Milliseconds 250; \
10775 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10776 $s2 | ForEach-Object {{ \
10777 $name = $_.Name; \
10778 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10779 if ($prev) {{ \
10780 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10781 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10782 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10783 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10784 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10785 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10786 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10787 }} \
10788 }}",
10789 max_entries
10790 );
10791 let output = Command::new("powershell")
10792 .args(["-NoProfile", "-Command", &ps_cmd])
10793 .output()
10794 .ok()
10795 .and_then(|o| String::from_utf8(o.stdout).ok())
10796 .unwrap_or_default();
10797 if output.trim().is_empty() {
10798 out.push_str("No network adapter statistics available.\n");
10799 } else {
10800 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10801 out.push_str(&output);
10802 }
10803
10804 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)\" } }"])
10805 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10806 if !discards.trim().is_empty() {
10807 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10808 out.push_str(&discards);
10809 }
10810 }
10811
10812 #[cfg(not(target_os = "windows"))]
10813 {
10814 let _ = max_entries;
10815 out.push_str("=== Network Stats (ip -s link) ===\n");
10816 let ip_s = Command::new("ip")
10817 .args(["-s", "link"])
10818 .output()
10819 .ok()
10820 .and_then(|o| String::from_utf8(o.stdout).ok())
10821 .unwrap_or_default();
10822 if ip_s.is_empty() {
10823 let netstat = Command::new("netstat")
10824 .args(["-i"])
10825 .output()
10826 .ok()
10827 .and_then(|o| String::from_utf8(o.stdout).ok())
10828 .unwrap_or_default();
10829 out.push_str(&netstat);
10830 } else {
10831 out.push_str(&ip_s);
10832 }
10833 }
10834
10835 Ok(out.trim_end().to_string())
10836}
10837
10838fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10839 let mut out = String::from("Host inspection: udp_ports\n\n");
10840
10841 #[cfg(target_os = "windows")]
10842 {
10843 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);
10844 let output = Command::new("powershell")
10845 .args(["-NoProfile", "-Command", &ps_cmd])
10846 .output()
10847 .ok();
10848
10849 if let Some(o) = output {
10850 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10851 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10852
10853 if !stdout.trim().is_empty() {
10854 out.push_str("=== UDP Listeners (Local:Port) ===\n");
10855 for line in stdout.lines() {
10856 let mut note = "";
10857 if line.contains(":53 ") {
10858 note = " [DNS]";
10859 } else if line.contains(":67 ") || line.contains(":68 ") {
10860 note = " [DHCP]";
10861 } else if line.contains(":123 ") {
10862 note = " [NTP]";
10863 } else if line.contains(":161 ") {
10864 note = " [SNMP]";
10865 } else if line.contains(":1900 ") {
10866 note = " [SSDP/UPnP]";
10867 } else if line.contains(":5353 ") {
10868 note = " [mDNS]";
10869 }
10870
10871 out.push_str(&format!("{}{}\n", line, note));
10872 }
10873 } else if stderr.contains("Access is denied") {
10874 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10875 } else {
10876 out.push_str("No UDP listeners detected.\n");
10877 }
10878 }
10879 }
10880
10881 #[cfg(not(target_os = "windows"))]
10882 {
10883 let ss_out = Command::new("ss")
10884 .args(["-ulnp"])
10885 .output()
10886 .ok()
10887 .and_then(|o| String::from_utf8(o.stdout).ok())
10888 .unwrap_or_default();
10889 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10890 if ss_out.is_empty() {
10891 let netstat_out = Command::new("netstat")
10892 .args(["-ulnp"])
10893 .output()
10894 .ok()
10895 .and_then(|o| String::from_utf8(o.stdout).ok())
10896 .unwrap_or_default();
10897 if netstat_out.is_empty() {
10898 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
10899 } else {
10900 for line in netstat_out.lines().take(max_entries) {
10901 out.push_str(&format!(" {}\n", line));
10902 }
10903 }
10904 } else {
10905 for line in ss_out.lines().take(max_entries) {
10906 out.push_str(&format!(" {}\n", line));
10907 }
10908 }
10909 }
10910
10911 Ok(out.trim_end().to_string())
10912}
10913
10914fn inspect_gpo() -> Result<String, String> {
10915 let mut out = String::from("Host inspection: gpo\n\n");
10916
10917 #[cfg(target_os = "windows")]
10918 {
10919 let output = Command::new("gpresult")
10920 .args(["/r", "/scope", "computer"])
10921 .output()
10922 .ok();
10923
10924 if let Some(o) = output {
10925 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10926 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10927
10928 if stdout.contains("Applied Group Policy Objects") {
10929 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10930 let mut capture = false;
10931 for line in stdout.lines() {
10932 if line.contains("Applied Group Policy Objects") {
10933 capture = true;
10934 } else if capture && line.contains("The following GPOs were not applied") {
10935 break;
10936 }
10937 if capture && !line.trim().is_empty() {
10938 out.push_str(&format!(" {}\n", line.trim()));
10939 }
10940 }
10941 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10942 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10943 } else {
10944 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10945 }
10946 }
10947 }
10948
10949 #[cfg(not(target_os = "windows"))]
10950 {
10951 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10952 }
10953
10954 Ok(out.trim_end().to_string())
10955}
10956
10957fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10958 let mut out = String::from("Host inspection: certificates\n\n");
10959
10960 #[cfg(target_os = "windows")]
10961 {
10962 let ps_cmd = format!(
10963 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10964 $days = ($_.NotAfter - (Get-Date)).Days; \
10965 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10966 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10967 }}",
10968 max_entries
10969 );
10970 let output = Command::new("powershell")
10971 .args(["-NoProfile", "-Command", &ps_cmd])
10972 .output()
10973 .ok();
10974
10975 if let Some(o) = output {
10976 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10977 if !stdout.trim().is_empty() {
10978 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10979 out.push_str(&stdout);
10980 } else {
10981 out.push_str("No certificates found in the Local Machine Personal store.\n");
10982 }
10983 }
10984 }
10985
10986 #[cfg(not(target_os = "windows"))]
10987 {
10988 let _ = max_entries;
10989 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10990 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10992 if Path::new(path).exists() {
10993 out.push_str(&format!(" Cert directory found: {}\n", path));
10994 }
10995 }
10996 }
10997
10998 Ok(out.trim_end().to_string())
10999}
11000
11001fn inspect_integrity() -> Result<String, String> {
11002 let mut out = String::from("Host inspection: integrity\n\n");
11003
11004 #[cfg(target_os = "windows")]
11005 {
11006 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11007 let output = Command::new("powershell")
11008 .args(["-NoProfile", "-Command", &ps_cmd])
11009 .output()
11010 .ok();
11011
11012 if let Some(o) = output {
11013 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11014 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11015 out.push_str("=== Windows Component Store Health (CBS) ===\n");
11016 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11017 let repair = val
11018 .get("AutoRepairNeeded")
11019 .and_then(|v| v.as_u64())
11020 .unwrap_or(0);
11021
11022 out.push_str(&format!(
11023 " Corruption Detected: {}\n",
11024 if corrupt != 0 {
11025 "YES (SFC/DISM recommended)"
11026 } else {
11027 "No"
11028 }
11029 ));
11030 out.push_str(&format!(
11031 " Auto-Repair Needed: {}\n",
11032 if repair != 0 { "YES" } else { "No" }
11033 ));
11034
11035 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11036 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
11037 }
11038 } else {
11039 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11040 }
11041 }
11042
11043 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11044 out.push_str(
11045 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11046 );
11047 }
11048 }
11049
11050 #[cfg(not(target_os = "windows"))]
11051 {
11052 out.push_str("System integrity check (Linux)\n\n");
11053 let pkg_check = Command::new("rpm")
11054 .args(["-Va"])
11055 .output()
11056 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11057 .ok();
11058 if let Some(o) = pkg_check {
11059 out.push_str(" Package verification system active.\n");
11060 if o.status.success() {
11061 out.push_str(" No major package integrity issues detected.\n");
11062 }
11063 }
11064 }
11065
11066 Ok(out.trim_end().to_string())
11067}
11068
11069fn inspect_domain() -> Result<String, String> {
11070 let mut out = String::from("Host inspection: domain\n\n");
11071
11072 #[cfg(target_os = "windows")]
11073 {
11074 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11075 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11076 let output = Command::new("powershell")
11077 .args(["-NoProfile", "-Command", &ps_cmd])
11078 .output()
11079 .ok();
11080
11081 if let Some(o) = output {
11082 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11083 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11084 let part_of_domain = val
11085 .get("PartOfDomain")
11086 .and_then(|v| v.as_bool())
11087 .unwrap_or(false);
11088 let domain = val
11089 .get("Domain")
11090 .and_then(|v| v.as_str())
11091 .unwrap_or("Unknown");
11092 let workgroup = val
11093 .get("Workgroup")
11094 .and_then(|v| v.as_str())
11095 .unwrap_or("Unknown");
11096
11097 out.push_str(&format!(
11098 " Join Status: {}\n",
11099 if part_of_domain {
11100 "DOMAIN JOINED"
11101 } else {
11102 "WORKGROUP"
11103 }
11104 ));
11105 if part_of_domain {
11106 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
11107 } else {
11108 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
11109 }
11110
11111 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11112 out.push_str(&format!(" NetBIOS Name: {}\n", name));
11113 }
11114 } else {
11115 out.push_str(" Domain identity data unavailable from WMI.\n");
11116 }
11117 } else {
11118 out.push_str(" Domain identity data unavailable from WMI.\n");
11119 }
11120 }
11121
11122 #[cfg(not(target_os = "windows"))]
11123 {
11124 let domainname = Command::new("domainname")
11125 .output()
11126 .ok()
11127 .and_then(|o| String::from_utf8(o.stdout).ok())
11128 .unwrap_or_default();
11129 out.push_str("=== Linux Domain Identity ===\n");
11130 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11131 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
11132 } else {
11133 out.push_str(" No NIS domain configured.\n");
11134 }
11135 }
11136
11137 Ok(out.trim_end().to_string())
11138}
11139
11140fn inspect_device_health() -> Result<String, String> {
11141 let mut out = String::from("Host inspection: device_health\n\n");
11142
11143 #[cfg(target_os = "windows")]
11144 {
11145 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)\" }";
11146 let output = Command::new("powershell")
11147 .args(["-NoProfile", "-Command", ps_cmd])
11148 .output()
11149 .ok()
11150 .and_then(|o| String::from_utf8(o.stdout).ok())
11151 .unwrap_or_default();
11152
11153 if output.trim().is_empty() {
11154 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11155 } else {
11156 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11157 out.push_str(&output);
11158 out.push_str(
11159 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11160 );
11161 }
11162 }
11163
11164 #[cfg(not(target_os = "windows"))]
11165 {
11166 out.push_str("Checking dmesg for hardware errors...\n");
11167 let dmesg = Command::new("dmesg")
11168 .args(["--level=err,crit,alert"])
11169 .output()
11170 .ok()
11171 .and_then(|o| String::from_utf8(o.stdout).ok())
11172 .unwrap_or_default();
11173 if dmesg.is_empty() {
11174 out.push_str(" No critical hardware errors found in dmesg.\n");
11175 } else {
11176 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
11177 }
11178 }
11179
11180 Ok(out.trim_end().to_string())
11181}
11182
11183fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11184 let mut out = String::from("Host inspection: drivers\n\n");
11185
11186 #[cfg(target_os = "windows")]
11187 {
11188 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11189 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);
11190 let output = Command::new("powershell")
11191 .args(["-NoProfile", "-Command", &ps_cmd])
11192 .output()
11193 .ok()
11194 .and_then(|o| String::from_utf8(o.stdout).ok())
11195 .unwrap_or_default();
11196
11197 if output.trim().is_empty() {
11198 out.push_str(" No drivers retrieved via WMI.\n");
11199 } else {
11200 out.push_str(&output);
11201 }
11202 }
11203
11204 #[cfg(not(target_os = "windows"))]
11205 {
11206 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11207 let lsmod = Command::new("lsmod")
11208 .output()
11209 .ok()
11210 .and_then(|o| String::from_utf8(o.stdout).ok())
11211 .unwrap_or_default();
11212 out.push_str(
11213 &lsmod
11214 .lines()
11215 .take(max_entries)
11216 .collect::<Vec<_>>()
11217 .join("\n"),
11218 );
11219 }
11220
11221 Ok(out.trim_end().to_string())
11222}
11223
11224fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11225 let mut out = String::from("Host inspection: peripherals\n\n");
11226
11227 #[cfg(target_os = "windows")]
11228 {
11229 let _ = max_entries;
11230 out.push_str("=== USB Controllers & Hubs ===\n");
11231 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
11232 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11233 out.push_str(if usb.is_empty() {
11234 " None detected.\n"
11235 } else {
11236 &usb
11237 });
11238
11239 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11240 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
11241 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11242 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
11243 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11244 out.push_str(&kb);
11245 out.push_str(&mouse);
11246
11247 out.push_str("\n=== Connected Monitors (WMI) ===\n");
11248 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11249 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11250 out.push_str(if mon.is_empty() {
11251 " No active monitors identified via WMI.\n"
11252 } else {
11253 &mon
11254 });
11255 }
11256
11257 #[cfg(not(target_os = "windows"))]
11258 {
11259 out.push_str("=== Connected USB Devices (lsusb) ===\n");
11260 let lsusb = Command::new("lsusb")
11261 .output()
11262 .ok()
11263 .and_then(|o| String::from_utf8(o.stdout).ok())
11264 .unwrap_or_default();
11265 out.push_str(
11266 &lsusb
11267 .lines()
11268 .take(max_entries)
11269 .collect::<Vec<_>>()
11270 .join("\n"),
11271 );
11272 }
11273
11274 Ok(out.trim_end().to_string())
11275}
11276
11277fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11278 let mut out = String::from("Host inspection: sessions\n\n");
11279
11280 #[cfg(target_os = "windows")]
11281 {
11282 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11283 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11284 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11285}"#;
11286 if let Ok(o) = Command::new("powershell")
11287 .args(["-NoProfile", "-Command", script])
11288 .output()
11289 {
11290 let text = String::from_utf8_lossy(&o.stdout);
11291 let lines: Vec<&str> = text.lines().collect();
11292 if lines.is_empty() {
11293 out.push_str(" No active logon sessions enumerated via WMI.\n");
11294 } else {
11295 for line in lines
11296 .iter()
11297 .take(max_entries)
11298 .filter(|l| !l.trim().is_empty())
11299 {
11300 let parts: Vec<&str> = line.trim().split('|').collect();
11301 if parts.len() == 4 {
11302 let logon_type = match parts[2] {
11303 "2" => "Interactive",
11304 "3" => "Network",
11305 "4" => "Batch",
11306 "5" => "Service",
11307 "7" => "Unlock",
11308 "8" => "NetworkCleartext",
11309 "9" => "NewCredentials",
11310 "10" => "RemoteInteractive",
11311 "11" => "CachedInteractive",
11312 _ => "Other",
11313 };
11314 out.push_str(&format!(
11315 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
11316 parts[0], logon_type, parts[1], parts[3]
11317 ));
11318 }
11319 }
11320 }
11321 } else {
11322 out.push_str(" Active logon session data unavailable from WMI.\n");
11323 }
11324 }
11325
11326 #[cfg(not(target_os = "windows"))]
11327 {
11328 out.push_str("=== Logged-in Users (who) ===\n");
11329 let who = Command::new("who")
11330 .output()
11331 .ok()
11332 .and_then(|o| String::from_utf8(o.stdout).ok())
11333 .unwrap_or_default();
11334 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
11335 }
11336
11337 Ok(out.trim_end().to_string())
11338}
11339
11340async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11341 let mut out = String::from("Host inspection: disk_benchmark\n\n");
11342 let mut final_path = path;
11343
11344 if !final_path.exists() {
11345 if let Ok(current_exe) = std::env::current_exe() {
11346 out.push_str(&format!(
11347 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
11348 final_path.display()
11349 ));
11350 final_path = current_exe;
11351 } else {
11352 return Err(format!("Target not found: {}", final_path.display()));
11353 }
11354 }
11355
11356 let target = if final_path.is_dir() {
11357 let mut target_file = final_path.join("Cargo.toml");
11359 if !target_file.exists() {
11360 target_file = final_path.join("README.md");
11361 }
11362 if !target_file.exists() {
11363 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11364 }
11365 target_file
11366 } else {
11367 final_path
11368 };
11369
11370 out.push_str(&format!("Target: {}\n", target.display()));
11371 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11372
11373 #[cfg(target_os = "windows")]
11374 {
11375 let script = format!(
11376 r#"
11377$target = "{}"
11378if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11379
11380$diskQueue = @()
11381$readStats = @()
11382$startTime = Get-Date
11383$duration = 5
11384
11385# Background reader job
11386$job = Start-Job -ScriptBlock {{
11387 param($t, $d)
11388 $stop = (Get-Date).AddSeconds($d)
11389 while ((Get-Date) -lt $stop) {{
11390 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11391 }}
11392}} -ArgumentList $target, $duration
11393
11394# Metrics collector loop
11395$stopTime = (Get-Date).AddSeconds($duration)
11396while ((Get-Date) -lt $stopTime) {{
11397 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11398 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11399
11400 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11401 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11402
11403 Start-Sleep -Milliseconds 250
11404}}
11405
11406Stop-Job $job
11407Receive-Job $job | Out-Null
11408Remove-Job $job
11409
11410$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11411$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11412$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11413
11414"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11415"#,
11416 target.display()
11417 );
11418
11419 let output = Command::new("powershell")
11420 .args(["-NoProfile", "-Command", &script])
11421 .output()
11422 .map_err(|e| format!("Benchmark failed: {e}"))?;
11423
11424 let raw = String::from_utf8_lossy(&output.stdout);
11425 let text = raw.trim();
11426
11427 if text.starts_with("ERROR") {
11428 return Err(text.to_string());
11429 }
11430
11431 let mut lines = text.lines();
11432 if let Some(metrics_line) = lines.next() {
11433 let parts: Vec<&str> = metrics_line.split('|').collect();
11434 let mut avg_q = "unknown".to_string();
11435 let mut max_q = "unknown".to_string();
11436 let mut avg_r = "unknown".to_string();
11437
11438 for p in parts {
11439 if let Some((k, v)) = p.split_once(':') {
11440 match k {
11441 "AVG_Q" => avg_q = v.to_string(),
11442 "MAX_Q" => max_q = v.to_string(),
11443 "AVG_R" => avg_r = v.to_string(),
11444 _ => {}
11445 }
11446 }
11447 }
11448
11449 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11450 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11451 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11452 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
11453 out.push_str("\nVerdict: ");
11454 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11455 if q_num > 1.0 {
11456 out.push_str(
11457 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11458 );
11459 } else if q_num > 0.1 {
11460 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11461 } else {
11462 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11463 }
11464 }
11465 }
11466
11467 #[cfg(not(target_os = "windows"))]
11468 {
11469 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11470 out.push_str("Generic disk load simulated.\n");
11471 }
11472
11473 Ok(out)
11474}
11475
11476fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11477 let mut out = String::from("Host inspection: permissions\n\n");
11478 out.push_str(&format!(
11479 "Auditing access control for: {}\n\n",
11480 path.display()
11481 ));
11482
11483 #[cfg(target_os = "windows")]
11484 {
11485 let script = format!(
11486 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11487 path.display()
11488 );
11489 let output = Command::new("powershell")
11490 .args(["-NoProfile", "-Command", &script])
11491 .output()
11492 .map_err(|e| format!("ACL check failed: {e}"))?;
11493
11494 let text = String::from_utf8_lossy(&output.stdout);
11495 if text.trim().is_empty() {
11496 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11497 } else {
11498 out.push_str("=== Windows NTFS Permissions ===\n");
11499 out.push_str(&text);
11500 }
11501 }
11502
11503 #[cfg(not(target_os = "windows"))]
11504 {
11505 let output = Command::new("ls")
11506 .args(["-ld", &path.to_string_lossy()])
11507 .output()
11508 .map_err(|e| format!("ls check failed: {e}"))?;
11509 out.push_str("=== Unix File Permissions ===\n");
11510 out.push_str(&String::from_utf8_lossy(&output.stdout));
11511 }
11512
11513 Ok(out.trim_end().to_string())
11514}
11515
11516fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11517 let mut out = String::from("Host inspection: login_history\n\n");
11518
11519 #[cfg(target_os = "windows")]
11520 {
11521 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11522 out.push_str("Note: This typically requires Administrator elevation.\n\n");
11523
11524 let n = max_entries.clamp(1, 50);
11525 let script = format!(
11526 r#"try {{
11527 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11528 $events | ForEach-Object {{
11529 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11530 # Extract target user name from the XML/Properties if possible
11531 $user = $_.Properties[5].Value
11532 $type = $_.Properties[8].Value
11533 "[$time] User: $user | Type: $type"
11534 }}
11535}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11536 );
11537
11538 let output = Command::new("powershell")
11539 .args(["-NoProfile", "-Command", &script])
11540 .output()
11541 .map_err(|e| format!("Login history query failed: {e}"))?;
11542
11543 let text = String::from_utf8_lossy(&output.stdout);
11544 if text.starts_with("ERROR:") {
11545 out.push_str(&format!("Unable to query Security Log: {}\n", text));
11546 } else if text.trim().is_empty() {
11547 out.push_str("No recent logon events found or access denied.\n");
11548 } else {
11549 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11550 out.push_str(&text);
11551 }
11552 }
11553
11554 #[cfg(not(target_os = "windows"))]
11555 {
11556 let output = Command::new("last")
11557 .args(["-n", &max_entries.to_string()])
11558 .output()
11559 .map_err(|e| format!("last command failed: {e}"))?;
11560 out.push_str("=== Unix Login History (last) ===\n");
11561 out.push_str(&String::from_utf8_lossy(&output.stdout));
11562 }
11563
11564 Ok(out.trim_end().to_string())
11565}
11566
11567fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11568 let mut out = String::from("Host inspection: share_access\n\n");
11569 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11570
11571 #[cfg(target_os = "windows")]
11572 {
11573 let script = format!(
11574 r#"
11575$p = '{}'
11576$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11577if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11578 $res.Reachable = $true
11579 try {{
11580 $null = Get-ChildItem -Path $p -ErrorAction Stop
11581 $res.Readable = $true
11582 }} catch {{
11583 $res.Error = $_.Exception.Message
11584 }}
11585}} else {{
11586 $res.Error = "Server unreachable (Ping failed)"
11587}}
11588"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11589 path.display()
11590 );
11591
11592 let output = Command::new("powershell")
11593 .args(["-NoProfile", "-Command", &script])
11594 .output()
11595 .map_err(|e| format!("Share test failed: {e}"))?;
11596
11597 let text = String::from_utf8_lossy(&output.stdout);
11598 out.push_str("=== Share Triage Results ===\n");
11599 out.push_str(&text);
11600 }
11601
11602 #[cfg(not(target_os = "windows"))]
11603 {
11604 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11605 }
11606
11607 Ok(out.trim_end().to_string())
11608}
11609
11610fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11611 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11612 out.push_str(&format!("Issue: {}\n\n", issue));
11613 out.push_str("Proposed Remediation Steps:\n");
11614 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11615 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11616 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11617 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11618 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11619 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11620 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11621 out.push_str(
11622 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11623 );
11624
11625 Ok(out)
11626}
11627
11628fn inspect_registry_audit() -> Result<String, String> {
11629 let mut out = String::from("Host inspection: registry_audit\n\n");
11630 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11631
11632 #[cfg(target_os = "windows")]
11633 {
11634 let script = r#"
11635$findings = @()
11636
11637# 1. Image File Execution Options (Debugger Hijacking)
11638$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11639if (Test-Path $ifeo) {
11640 Get-ChildItem $ifeo | ForEach-Object {
11641 $p = Get-ItemProperty $_.PSPath
11642 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11643 }
11644}
11645
11646# 2. Winlogon Shell Integrity
11647$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11648$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11649if ($shell -and $shell -ne "explorer.exe") {
11650 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11651}
11652
11653# 3. Session Manager BootExecute
11654$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11655$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11656if ($boot -and $boot -notcontains "autocheck autochk *") {
11657 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11658}
11659
11660if ($findings.Count -eq 0) {
11661 "PASS: No common registry hijacking or shell overrides detected."
11662} else {
11663 $findings -join "`n"
11664}
11665"#;
11666 let output = Command::new("powershell")
11667 .args(["-NoProfile", "-Command", &script])
11668 .output()
11669 .map_err(|e| format!("Registry audit failed: {e}"))?;
11670
11671 let text = String::from_utf8_lossy(&output.stdout);
11672 out.push_str("=== Persistence & Integrity Check ===\n");
11673 out.push_str(&text);
11674 }
11675
11676 #[cfg(not(target_os = "windows"))]
11677 {
11678 out.push_str("Registry auditing is specific to Windows environments.\n");
11679 }
11680
11681 Ok(out.trim_end().to_string())
11682}
11683
11684fn inspect_thermal() -> Result<String, String> {
11685 let mut out = String::from("Host inspection: thermal\n\n");
11686 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11687
11688 #[cfg(target_os = "windows")]
11689 {
11690 let script = r#"
11691$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11692if ($thermal) {
11693 $thermal | ForEach-Object {
11694 $temp = [math]::Round(($_.Temperature - 273.15), 1)
11695 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11696 }
11697} else {
11698 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11699 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11700 "Current CPU Load: $throttling%"
11701}
11702"#;
11703 let output = Command::new("powershell")
11704 .args(["-NoProfile", "-Command", script])
11705 .output()
11706 .map_err(|e| format!("Thermal check failed: {e}"))?;
11707 out.push_str("=== Windows Thermal State ===\n");
11708 out.push_str(&String::from_utf8_lossy(&output.stdout));
11709 }
11710
11711 #[cfg(not(target_os = "windows"))]
11712 {
11713 out.push_str(
11714 "Thermal inspection is currently optimized for Windows performance counters.\n",
11715 );
11716 }
11717
11718 Ok(out.trim_end().to_string())
11719}
11720
11721fn inspect_activation() -> Result<String, String> {
11722 let mut out = String::from("Host inspection: activation\n\n");
11723 out.push_str("Auditing Windows activation and license state...\n\n");
11724
11725 #[cfg(target_os = "windows")]
11726 {
11727 let script = r#"
11728$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11729$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11730"Status: $($xpr.Trim())"
11731"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11732"#;
11733 let output = Command::new("powershell")
11734 .args(["-NoProfile", "-Command", script])
11735 .output()
11736 .map_err(|e| format!("Activation check failed: {e}"))?;
11737 out.push_str("=== Windows License Report ===\n");
11738 out.push_str(&String::from_utf8_lossy(&output.stdout));
11739 }
11740
11741 #[cfg(not(target_os = "windows"))]
11742 {
11743 out.push_str("Windows activation check is specific to the Windows platform.\n");
11744 }
11745
11746 Ok(out.trim_end().to_string())
11747}
11748
11749fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11750 let mut out = String::from("Host inspection: patch_history\n\n");
11751 out.push_str(&format!(
11752 "Listing the last {} installed Windows updates (KBs)...\n\n",
11753 max_entries
11754 ));
11755
11756 #[cfg(target_os = "windows")]
11757 {
11758 let n = max_entries.clamp(1, 50);
11759 let script = format!(
11760 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11761 n
11762 );
11763 let output = Command::new("powershell")
11764 .args(["-NoProfile", "-Command", &script])
11765 .output()
11766 .map_err(|e| format!("Patch history query failed: {e}"))?;
11767 out.push_str("=== Recent HotFixes (KBs) ===\n");
11768 out.push_str(&String::from_utf8_lossy(&output.stdout));
11769 }
11770
11771 #[cfg(not(target_os = "windows"))]
11772 {
11773 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11774 }
11775
11776 Ok(out.trim_end().to_string())
11777}
11778
11779fn inspect_ad_user(identity: &str) -> Result<String, String> {
11782 let mut out = String::from("Host inspection: ad_user\n\n");
11783 let ident = identity.trim();
11784 if ident.is_empty() {
11785 out.push_str("Status: No identity specified. Performing self-discovery...\n");
11786 #[cfg(target_os = "windows")]
11787 {
11788 let script = r#"
11789$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11790"USER: " + $u.Name
11791"SID: " + $u.User.Value
11792"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11793"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11794"#;
11795 let output = Command::new("powershell")
11796 .args(["-NoProfile", "-Command", script])
11797 .output()
11798 .ok();
11799 if let Some(o) = output {
11800 out.push_str(&String::from_utf8_lossy(&o.stdout));
11801 }
11802 }
11803 return Ok(out);
11804 }
11805
11806 #[cfg(target_os = "windows")]
11807 {
11808 let script = format!(
11809 r#"
11810try {{
11811 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11812 "NAME: " + $u.Name
11813 "SID: " + $u.SID
11814 "ENABLED: " + $u.Enabled
11815 "EXPIRED: " + $u.PasswordExpired
11816 "LOGON: " + $u.LastLogonDate
11817 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11818}} catch {{
11819 # Fallback to net user if AD module is missing or fails
11820 $net = net user "{ident}" /domain 2>&1
11821 if ($LASTEXITCODE -eq 0) {{
11822 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11823 }} else {{
11824 "ERROR: " + $_.Exception.Message
11825 }}
11826}}"#
11827 );
11828
11829 let output = Command::new("powershell")
11830 .args(["-NoProfile", "-Command", &script])
11831 .output()
11832 .ok();
11833
11834 if let Some(o) = output {
11835 let stdout = String::from_utf8_lossy(&o.stdout);
11836 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11837 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11838 }
11839 out.push_str(&stdout);
11840 }
11841 }
11842
11843 #[cfg(not(target_os = "windows"))]
11844 {
11845 let _ = ident;
11846 out.push_str("(AD User lookup only available on Windows nodes)\n");
11847 }
11848
11849 Ok(out.trim_end().to_string())
11850}
11851
11852fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11855 let mut out = String::from("Host inspection: dns_lookup\n\n");
11856 let target = name.trim();
11857 if target.is_empty() {
11858 return Err("Missing required target name for dns_lookup.".to_string());
11859 }
11860
11861 #[cfg(target_os = "windows")]
11862 {
11863 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11864 let output = Command::new("powershell")
11865 .args(["-NoProfile", "-Command", &script])
11866 .output()
11867 .ok();
11868 if let Some(o) = output {
11869 let stdout = String::from_utf8_lossy(&o.stdout);
11870 if stdout.trim().is_empty() {
11871 out.push_str(&format!("No {record_type} records found for {target}.\n"));
11872 } else {
11873 out.push_str(&stdout);
11874 }
11875 }
11876 }
11877
11878 #[cfg(not(target_os = "windows"))]
11879 {
11880 let output = Command::new("dig")
11881 .args([target, record_type, "+short"])
11882 .output()
11883 .ok();
11884 if let Some(o) = output {
11885 out.push_str(&String::from_utf8_lossy(&o.stdout));
11886 }
11887 }
11888
11889 Ok(out.trim_end().to_string())
11890}
11891
11892#[cfg(target_os = "windows")]
11895fn ps_exec(script: &str) -> String {
11896 Command::new("powershell")
11897 .args(["-NoProfile", "-NonInteractive", "-Command", script])
11898 .output()
11899 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
11900 .unwrap_or_default()
11901}
11902
11903fn inspect_mdm_enrollment() -> Result<String, String> {
11904 #[cfg(target_os = "windows")]
11905 {
11906 let mut out = String::from("Host inspection: mdm_enrollment\n\n");
11907
11908 out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
11910 let ps_dsreg = r#"
11911$raw = dsregcmd /status 2>$null
11912$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
11913 'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
11914foreach ($line in $raw) {
11915 $t = $line.Trim()
11916 foreach ($f in $fields) {
11917 if ($t -like "$f :*") {
11918 $val = ($t -split ':',2)[1].Trim()
11919 "$f`: $val"
11920 }
11921 }
11922}
11923"#;
11924 match run_powershell(ps_dsreg) {
11925 Ok(o) if !o.trim().is_empty() => {
11926 for line in o.lines() {
11927 let l = line.trim();
11928 if !l.is_empty() {
11929 out.push_str(&format!("- {l}\n"));
11930 }
11931 }
11932 }
11933 Ok(_) => out.push_str(
11934 "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
11935 ),
11936 Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
11937 }
11938
11939 out.push_str("\n=== Enrollment accounts (registry) ===\n");
11941 let ps_enroll = r#"
11942$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
11943if (Test-Path $base) {
11944 $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
11945 if ($accounts) {
11946 foreach ($acct in $accounts) {
11947 $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
11948 $upn = if ($p.UPN) { $p.UPN } else { '(none)' }
11949 $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
11950 $type = switch ($p.EnrollmentType) {
11951 6 { 'MDM' }
11952 13 { 'MAM' }
11953 default { "Type=$($p.EnrollmentType)" }
11954 }
11955 $state = switch ($p.EnrollmentState) {
11956 1 { 'Enrolled' }
11957 2 { 'InProgress' }
11958 6 { 'Unenrolled' }
11959 default { "State=$($p.EnrollmentState)" }
11960 }
11961 "Account: $upn | $type | $state | $server"
11962 }
11963 } else { "No enrollment accounts found under $base" }
11964} else { "Enrollment registry key not found — device is not MDM-enrolled" }
11965"#;
11966 match run_powershell(ps_enroll) {
11967 Ok(o) => {
11968 for line in o.lines() {
11969 let l = line.trim();
11970 if !l.is_empty() {
11971 out.push_str(&format!("- {l}\n"));
11972 }
11973 }
11974 }
11975 Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
11976 }
11977
11978 out.push_str("\n=== MDM services ===\n");
11980 let ps_svc = r#"
11981$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
11982foreach ($n in $names) {
11983 $s = Get-Service -Name $n -ErrorAction SilentlyContinue
11984 if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
11985}
11986"#;
11987 match run_powershell(ps_svc) {
11988 Ok(o) if !o.trim().is_empty() => {
11989 for line in o.lines() {
11990 let l = line.trim();
11991 if !l.is_empty() {
11992 out.push_str(&format!("- {l}\n"));
11993 }
11994 }
11995 }
11996 Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
11997 Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
11998 }
11999
12000 out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12002 let ps_evt = r#"
12003$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12004 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12005$cutoff = (Get-Date).AddHours(-24)
12006$found = $false
12007foreach ($log in $logs) {
12008 $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12009 Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12010 foreach ($e in $evts) {
12011 $found = $true
12012 $ts = $e.TimeCreated.ToString('HH:mm')
12013 $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12014 "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12015 }
12016}
12017if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12018"#;
12019 match run_powershell(ps_evt) {
12020 Ok(o) => {
12021 for line in o.lines() {
12022 let l = line.trim();
12023 if !l.is_empty() {
12024 out.push_str(&format!("- {l}\n"));
12025 }
12026 }
12027 }
12028 Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
12029 }
12030
12031 out.push_str("\n=== Findings ===\n");
12033 let body = out.clone();
12034 let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12035 let intune_running = body.contains("IntuneManagementExtension: Running");
12036 let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12037
12038 if !enrolled {
12039 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");
12040 } else {
12041 out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12042 if !intune_running {
12043 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");
12044 }
12045 }
12046 if has_errors {
12047 out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12048 }
12049 if !enrolled && !has_errors {
12050 out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12051 }
12052
12053 Ok(out)
12054 }
12055
12056 #[cfg(not(target_os = "windows"))]
12057 {
12058 Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12059 }
12060}
12061
12062fn inspect_hyperv() -> Result<String, String> {
12063 #[cfg(target_os = "windows")]
12064 {
12065 let mut findings: Vec<String> = Vec::new();
12066 let mut out = String::new();
12067
12068 let ps_role = r#"
12070$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12071$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12072$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12073$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12074"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12075 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12076 $(if ($feature) { $feature.State } else { "Unknown" }),
12077 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12078 $(if ($ram) { $ram } else { "0" })
12079"#;
12080 let role_out = ps_exec(ps_role);
12081 out.push_str("=== Hyper-V role state ===\n");
12082
12083 let mut vmms_running = false;
12084 let mut host_ram_bytes: u64 = 0;
12085
12086 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12087 let kv: std::collections::HashMap<&str, &str> = line
12088 .split('|')
12089 .filter_map(|p| {
12090 let mut it = p.splitn(2, ':');
12091 Some((it.next()?, it.next()?))
12092 })
12093 .collect();
12094 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12095 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12096 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12097 host_ram_bytes = kv
12098 .get("HostRAMBytes")
12099 .and_then(|v| v.parse().ok())
12100 .unwrap_or(0);
12101
12102 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12103 vmms_running = vmms_status.starts_with("Running");
12104
12105 out.push_str(&format!("- Host: {host_name}\n"));
12106 out.push_str(&format!(
12107 "- Hyper-V feature: {}\n",
12108 if hyperv_installed {
12109 "Enabled"
12110 } else {
12111 "Not installed"
12112 }
12113 ));
12114 out.push_str(&format!("- VMMS service: {vmms_status}\n"));
12115 if host_ram_bytes > 0 {
12116 out.push_str(&format!(
12117 "- Host physical RAM: {} GB\n",
12118 host_ram_bytes / 1_073_741_824
12119 ));
12120 }
12121
12122 if !hyperv_installed {
12123 findings.push(
12124 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12125 );
12126 } else if !vmms_running {
12127 findings.push(
12128 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12129 );
12130 }
12131 } else {
12132 out.push_str("- Could not determine Hyper-V role state\n");
12133 findings.push("Hyper-V does not appear to be installed on this machine.".into());
12134 }
12135
12136 out.push_str("\n=== Virtual machines ===\n");
12138 if vmms_running {
12139 let ps_vms = r#"
12140Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12141 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12142 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12143 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12144 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12145 $_.Status, $_.Generation
12146}
12147"#;
12148 let vms_out = ps_exec(ps_vms);
12149 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12150
12151 if vm_lines.is_empty() {
12152 out.push_str("- No virtual machines found on this host\n");
12153 } else {
12154 let mut total_ram_bytes: u64 = 0;
12155 let mut saved_vms: Vec<String> = Vec::new();
12156 for line in &vm_lines {
12157 let kv: std::collections::HashMap<&str, &str> = line
12158 .split('|')
12159 .filter_map(|p| {
12160 let mut it = p.splitn(2, ':');
12161 Some((it.next()?, it.next()?))
12162 })
12163 .collect();
12164 let name = kv.get("VM").copied().unwrap_or("Unknown");
12165 let state = kv.get("State").copied().unwrap_or("Unknown");
12166 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12167 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12168 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12169 let status = kv.get("Status").copied().unwrap_or("");
12170 let gen = kv.get("Generation").copied().unwrap_or("?");
12171
12172 if let Ok(r) = ram.parse::<f64>() {
12173 total_ram_bytes += (r * 1_073_741_824.0) as u64;
12174 }
12175 if state.eq_ignore_ascii_case("Saved") {
12176 saved_vms.push(name.to_string());
12177 }
12178
12179 out.push_str(&format!(
12180 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
12181 ));
12182 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12183 out.push_str(&format!(" Status: {status}\n"));
12184 }
12185 }
12186
12187 out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
12188 if total_ram_bytes > 0 && host_ram_bytes > 0 {
12189 let pct = (total_ram_bytes * 100) / host_ram_bytes;
12190 out.push_str(&format!(
12191 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
12192 total_ram_bytes / 1_073_741_824
12193 ));
12194 if pct > 90 {
12195 findings.push(format!(
12196 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12197 ));
12198 }
12199 }
12200 if !saved_vms.is_empty() {
12201 findings.push(format!(
12202 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12203 saved_vms.join(", ")
12204 ));
12205 }
12206 }
12207 } else {
12208 out.push_str("- VMMS not running — cannot enumerate VMs\n");
12209 }
12210
12211 out.push_str("\n=== VM network switches ===\n");
12213 if vmms_running {
12214 let ps_switches = r#"
12215Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12216 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12217 $_.Name, $_.SwitchType,
12218 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12219}
12220"#;
12221 let sw_out = ps_exec(ps_switches);
12222 let switch_lines: Vec<&str> = sw_out
12223 .lines()
12224 .filter(|l| l.starts_with("Switch:"))
12225 .collect();
12226
12227 if switch_lines.is_empty() {
12228 out.push_str("- No VM switches configured\n");
12229 } else {
12230 for line in &switch_lines {
12231 let kv: std::collections::HashMap<&str, &str> = line
12232 .split('|')
12233 .filter_map(|p| {
12234 let mut it = p.splitn(2, ':');
12235 Some((it.next()?, it.next()?))
12236 })
12237 .collect();
12238 let name = kv.get("Switch").copied().unwrap_or("Unknown");
12239 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12240 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12241 out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
12242 }
12243 }
12244 } else {
12245 out.push_str("- VMMS not running — cannot enumerate switches\n");
12246 }
12247
12248 out.push_str("\n=== VM checkpoints ===\n");
12250 if vmms_running {
12251 let ps_checkpoints = r#"
12252$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12253if ($all) {
12254 $all | ForEach-Object {
12255 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12256 $_.Name, $_.VMName,
12257 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12258 $_.SnapshotType
12259 }
12260} else {
12261 "NONE"
12262}
12263"#;
12264 let cp_out = ps_exec(ps_checkpoints);
12265 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12266 out.push_str("- No checkpoints found\n");
12267 } else {
12268 let cp_lines: Vec<&str> = cp_out
12269 .lines()
12270 .filter(|l| l.starts_with("Checkpoint:"))
12271 .collect();
12272 let mut per_vm: std::collections::HashMap<&str, usize> =
12273 std::collections::HashMap::new();
12274 for line in &cp_lines {
12275 let kv: std::collections::HashMap<&str, &str> = line
12276 .split('|')
12277 .filter_map(|p| {
12278 let mut it = p.splitn(2, ':');
12279 Some((it.next()?, it.next()?))
12280 })
12281 .collect();
12282 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12283 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12284 let created = kv.get("Created").copied().unwrap_or("");
12285 let cp_type = kv.get("Type").copied().unwrap_or("");
12286 out.push_str(&format!(
12287 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
12288 ));
12289 *per_vm.entry(vm_name).or_insert(0) += 1;
12290 }
12291 for (vm, count) in &per_vm {
12292 if *count >= 3 {
12293 findings.push(format!(
12294 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12295 ));
12296 }
12297 }
12298 }
12299 } else {
12300 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12301 }
12302
12303 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12304 if findings.is_empty() {
12305 result.push_str("- No Hyper-V health issues detected.\n");
12306 } else {
12307 for f in &findings {
12308 result.push_str(&format!("- Finding: {f}\n"));
12309 }
12310 }
12311 result.push('\n');
12312 result.push_str(&out);
12313 return Ok(result.trim_end().to_string());
12314 }
12315
12316 #[cfg(not(target_os = "windows"))]
12317 Ok(
12318 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12319 .into(),
12320 )
12321}
12322
12323fn inspect_ip_config() -> Result<String, String> {
12326 let mut out = String::from("Host inspection: ip_config\n\n");
12327
12328 #[cfg(target_os = "windows")]
12329 {
12330 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12331 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12332 '\\n Status: ' + $_.NetAdapter.Status + \
12333 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12334 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12335 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12336 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12337 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12338 }";
12339 let output = Command::new("powershell")
12340 .args(["-NoProfile", "-Command", script])
12341 .output()
12342 .ok();
12343 if let Some(o) = output {
12344 out.push_str(&String::from_utf8_lossy(&o.stdout));
12345 }
12346 }
12347
12348 #[cfg(not(target_os = "windows"))]
12349 {
12350 let output = Command::new("ip").args(["addr", "show"]).output().ok();
12351 if let Some(o) = output {
12352 out.push_str(&String::from_utf8_lossy(&o.stdout));
12353 }
12354 }
12355
12356 Ok(out.trim_end().to_string())
12357}
12358
12359fn inspect_event_query(
12362 event_id: Option<u32>,
12363 log_name: Option<&str>,
12364 source: Option<&str>,
12365 hours: u32,
12366 level: Option<&str>,
12367 max_entries: usize,
12368) -> Result<String, String> {
12369 #[cfg(target_os = "windows")]
12370 {
12371 let mut findings: Vec<String> = Vec::new();
12372
12373 let log = log_name.unwrap_or("*");
12375 let cap = max_entries.min(50);
12376
12377 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12379 Some("error") | Some("errors") => Some(2u8),
12380 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12381 Some("information") | Some("info") => Some(4u8),
12382 _ => None,
12383 };
12384
12385 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12387 if log != "*" {
12388 filter_parts.push(format!("LogName = '{log}'"));
12389 }
12390 if let Some(id) = event_id {
12391 filter_parts.push(format!("Id = {id}"));
12392 }
12393 if let Some(src) = source {
12394 filter_parts.push(format!("ProviderName = '{src}'"));
12395 }
12396 if let Some(lvl) = level_filter {
12397 filter_parts.push(format!("Level = {lvl}"));
12398 }
12399
12400 let filter_ht = filter_parts.join("; ");
12401
12402 let ps = format!(
12403 r#"
12404$filter = @{{ {filter_ht} }}
12405try {{
12406 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12407 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12408 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12409 if ($events) {{
12410 $events | ForEach-Object {{
12411 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12412 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12413 $_.Id, $_.LevelDisplayName, $_.ProviderName,
12414 ($_.Msg -replace '\|','/')
12415 }}
12416 }} else {{
12417 "NONE"
12418 }}
12419}} catch {{
12420 "ERROR:$($_.Exception.Message)"
12421}}
12422"#
12423 );
12424
12425 let raw = ps_exec(&ps);
12426 let lines: Vec<&str> = raw.lines().collect();
12427
12428 let mut query_desc = format!("last {hours}h");
12430 if let Some(id) = event_id {
12431 query_desc.push_str(&format!(", Event ID {id}"));
12432 }
12433 if let Some(src) = source {
12434 query_desc.push_str(&format!(", source '{src}'"));
12435 }
12436 if log != "*" {
12437 query_desc.push_str(&format!(", log '{log}'"));
12438 }
12439 if let Some(l) = level {
12440 query_desc.push_str(&format!(", level '{l}'"));
12441 }
12442
12443 let mut out = format!("=== Event query: {query_desc} ===\n");
12444
12445 if lines
12446 .iter()
12447 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12448 {
12449 out.push_str("- No matching events found.\n");
12450 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12451 let msg = err_line.trim_start_matches("ERROR:").trim();
12452 if is_event_query_no_results_message(msg) {
12453 out.push_str("- No matching events found.\n");
12454 } else {
12455 out.push_str(&format!("- Query error: {msg}\n"));
12456 findings.push(format!("Event query failed: {msg}"));
12457 }
12458 } else {
12459 let event_lines: Vec<&str> = lines
12460 .iter()
12461 .filter(|l| l.starts_with("TIME:"))
12462 .copied()
12463 .collect();
12464 if event_lines.is_empty() {
12465 out.push_str("- No matching events found.\n");
12466 } else {
12467 let mut error_count = 0usize;
12469 let mut warning_count = 0usize;
12470
12471 for line in &event_lines {
12472 let kv: std::collections::HashMap<&str, &str> = line
12473 .split('|')
12474 .filter_map(|p| {
12475 let mut it = p.splitn(2, ':');
12476 Some((it.next()?, it.next()?))
12477 })
12478 .collect();
12479 let time = kv.get("TIME").copied().unwrap_or("?");
12480 let id = kv.get("ID").copied().unwrap_or("?");
12481 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12482 let src = kv.get("SOURCE").copied().unwrap_or("?");
12483 let msg = kv.get("MSG").copied().unwrap_or("").trim();
12484
12485 let msg_display = if msg.len() > 120 {
12487 format!("{}…", &msg[..120])
12488 } else {
12489 msg.to_string()
12490 };
12491
12492 out.push_str(&format!(
12493 "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n"
12494 ));
12495
12496 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12497 error_count += 1;
12498 } else if lvl.eq_ignore_ascii_case("warning") {
12499 warning_count += 1;
12500 }
12501 }
12502
12503 out.push_str(&format!(
12504 "\n- Total shown: {} event(s)\n",
12505 event_lines.len()
12506 ));
12507
12508 if error_count > 0 {
12509 findings.push(format!(
12510 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12511 ));
12512 }
12513 if warning_count > 5 {
12514 findings.push(format!(
12515 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12516 ));
12517 }
12518 }
12519 }
12520
12521 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12522 if findings.is_empty() {
12523 result.push_str("- No actionable findings from this event query.\n");
12524 } else {
12525 for f in &findings {
12526 result.push_str(&format!("- Finding: {f}\n"));
12527 }
12528 }
12529 result.push('\n');
12530 result.push_str(&out);
12531 return Ok(result.trim_end().to_string());
12532 }
12533
12534 #[cfg(not(target_os = "windows"))]
12535 {
12536 let _ = (event_id, log_name, source, hours, level, max_entries);
12537 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12538 }
12539}
12540
12541fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12544 let n = max_entries.clamp(5, 50);
12545 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12546 let mut findings: Vec<String> = Vec::new();
12547 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12548 let mut sections = String::new();
12549
12550 #[cfg(target_os = "windows")]
12551 {
12552 let proc_filter_ps = match process_filter {
12553 Some(proc) => format!(
12554 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12555 proc.replace('\'', "''")
12556 ),
12557 None => String::new(),
12558 };
12559
12560 let ps = format!(
12561 r#"
12562$results = @()
12563try {{
12564 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12565 if ($events) {{
12566 foreach ($e in $events) {{
12567 $msg = $e.Message
12568 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12569 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12570 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12571 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12572 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12573 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12574 }}
12575 $results
12576 }} else {{ 'NONE' }}
12577}} catch {{ 'ERROR:' + $_.Exception.Message }}
12578"#
12579 );
12580
12581 let raw = ps_exec(&ps);
12582 let text = raw.trim();
12583
12584 let wer_ps = r#"
12586$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12587$count = 0
12588if (Test-Path $wer) {
12589 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12590}
12591$count
12592"#;
12593 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12594
12595 if text == "NONE" {
12596 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12597 } else if text.starts_with("ERROR:") {
12598 let msg = text.trim_start_matches("ERROR:").trim();
12599 sections.push_str(&format!(
12600 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12601 ));
12602 } else {
12603 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12604 let crash_count = events
12605 .iter()
12606 .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12607 .count();
12608 let hang_count = events
12609 .iter()
12610 .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12611 .count();
12612
12613 let mut app_counts: std::collections::HashMap<String, usize> =
12615 std::collections::HashMap::new();
12616 for line in &events {
12617 let parts: Vec<&str> = line.splitn(6, '|').collect();
12618 if parts.len() >= 3 {
12619 *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12620 }
12621 }
12622
12623 if crash_count > 0 {
12624 findings.push(format!(
12625 "{crash_count} application crash event(s) — review below for faulting app and exception code."
12626 ));
12627 }
12628 if hang_count > 0 {
12629 findings.push(format!(
12630 "{hang_count} application hang event(s) — process stopped responding."
12631 ));
12632 }
12633 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12634 if count > 1 {
12635 findings.push(format!(
12636 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12637 ));
12638 }
12639 }
12640 if wer_count > 10 {
12641 findings.push(format!(
12642 "{wer_count} WER reports archived — elevated crash history on this machine."
12643 ));
12644 }
12645
12646 let filter_note = match process_filter {
12647 Some(p) => format!(" (filtered: {p})"),
12648 None => String::new(),
12649 };
12650 sections.push_str(&format!(
12651 "=== Application crashes and hangs{filter_note} ===\n"
12652 ));
12653
12654 for line in &events {
12655 let parts: Vec<&str> = line.splitn(6, '|').collect();
12656 if parts.len() >= 6 {
12657 let time = parts[0];
12658 let kind = parts[1];
12659 let app = parts[2];
12660 let ver = parts[3];
12661 let module = parts[4];
12662 let exc = parts[5];
12663 let ver_note = if !ver.is_empty() {
12664 format!(" v{ver}")
12665 } else {
12666 String::new()
12667 };
12668 sections.push_str(&format!(" [{time}] {kind}: {app}{ver_note}\n"));
12669 if !module.is_empty() && module != "?" {
12670 let exc_note = if !exc.is_empty() {
12671 format!(" (exc {exc})")
12672 } else {
12673 String::new()
12674 };
12675 sections.push_str(&format!(" faulting module: {module}{exc_note}\n"));
12676 } else if !exc.is_empty() {
12677 sections.push_str(&format!(" exception: {exc}\n"));
12678 }
12679 }
12680 }
12681 sections.push_str(&format!(
12682 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12683 ));
12684
12685 if wer_count > 0 {
12686 sections.push_str(&format!(
12687 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12688 ));
12689 }
12690 }
12691 }
12692
12693 #[cfg(not(target_os = "windows"))]
12694 {
12695 let _ = (process_filter, n);
12696 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12697 }
12698
12699 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12700 if findings.is_empty() {
12701 result.push_str("- No actionable findings.\n");
12702 } else {
12703 for f in &findings {
12704 result.push_str(&format!("- Finding: {f}\n"));
12705 }
12706 }
12707 result.push('\n');
12708 result.push_str(§ions);
12709 Ok(result.trim_end().to_string())
12710}
12711
12712#[cfg(target_os = "windows")]
12713fn gpu_voltage_telemetry_note() -> String {
12714 let output = Command::new("nvidia-smi")
12715 .args(["--help-query-gpu"])
12716 .output();
12717
12718 match output {
12719 Ok(o) => {
12720 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12721 if text.contains("\"voltage\"") || text.contains("voltage.") {
12722 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12723 } else {
12724 "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()
12725 }
12726 }
12727 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12728 }
12729}
12730
12731#[cfg(target_os = "windows")]
12732fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
12733 if raw == 0 {
12734 return None;
12735 }
12736 if raw & 0x80 != 0 {
12737 let tenths = raw & 0x7f;
12738 return Some(format!(
12739 "{:.1} V (firmware-reported WMI current voltage)",
12740 tenths as f64 / 10.0
12741 ));
12742 }
12743
12744 let legacy = match raw {
12745 1 => Some("5.0 V"),
12746 2 => Some("3.3 V"),
12747 4 => Some("2.9 V"),
12748 _ => None,
12749 }?;
12750 Some(format!(
12751 "{} (legacy WMI voltage capability flag, not live telemetry)",
12752 legacy
12753 ))
12754}
12755
12756async fn inspect_overclocker() -> Result<String, String> {
12757 let mut out = String::from("Host inspection: overclocker\n\n");
12758
12759 #[cfg(target_os = "windows")]
12760 {
12761 out.push_str(
12762 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
12763 );
12764
12765 let nvidia = Command::new("nvidia-smi")
12767 .args([
12768 "--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",
12769 "--format=csv,noheader,nounits",
12770 ])
12771 .output();
12772
12773 if let Ok(o) = nvidia {
12774 let stdout = String::from_utf8_lossy(&o.stdout);
12775 if !stdout.trim().is_empty() {
12776 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
12777 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
12778 if parts.len() >= 10 {
12779 out.push_str(&format!("- Model: {}\n", parts[0]));
12780 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
12781 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
12782 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
12783 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
12784 if !parts[6].eq_ignore_ascii_case("[N/A]") {
12785 out.push_str(&format!("- Power Avg: {} W\n", parts[6]));
12786 }
12787 if !parts[7].eq_ignore_ascii_case("[N/A]") {
12788 out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
12789 }
12790 if !parts[8].eq_ignore_ascii_case("[N/A]") {
12791 out.push_str(&format!("- Power Cap: {} W requested\n", parts[8]));
12792 }
12793 if !parts[9].eq_ignore_ascii_case("[N/A]") {
12794 out.push_str(&format!("- Power Enf: {} W enforced\n", parts[9]));
12795 }
12796 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
12797
12798 if parts.len() > 10 {
12799 let throttle_hex = parts[10];
12800 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
12801 if !reasons.is_empty() {
12802 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
12803 } else {
12804 out.push_str("- Throttling: None (Performance State: Max)\n");
12805 }
12806 }
12807 }
12808 out.push_str("\n");
12809 }
12810 }
12811
12812 out.push_str("=== VOLTAGE TELEMETRY ===\n");
12813 out.push_str(&format!(
12814 "- GPU Voltage: {}\n\n",
12815 gpu_voltage_telemetry_note()
12816 ));
12817
12818 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
12820 let history = gpu_state.history.lock().unwrap();
12821 if history.len() >= 2 {
12822 out.push_str("=== SILICON TRENDS (Session) ===\n");
12823 let first = history.front().unwrap();
12824 let last = history.back().unwrap();
12825
12826 let temp_diff = last.temperature as i32 - first.temperature as i32;
12827 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
12828
12829 let temp_trend = if temp_diff > 1 {
12830 "Rising"
12831 } else if temp_diff < -1 {
12832 "Falling"
12833 } else {
12834 "Stable"
12835 };
12836 let clock_trend = if clock_diff > 10 {
12837 "Increasing"
12838 } else if clock_diff < -10 {
12839 "Decreasing"
12840 } else {
12841 "Stable"
12842 };
12843
12844 out.push_str(&format!(
12845 "- Temperature: {} ({}°C anomaly)\n",
12846 temp_trend, temp_diff
12847 ));
12848 out.push_str(&format!(
12849 "- Core Clock: {} ({} MHz delta)\n",
12850 clock_trend, clock_diff
12851 ));
12852 out.push_str("\n");
12853 }
12854
12855 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))\" }";
12857 let cpu_stats = Command::new("powershell")
12858 .args(["-NoProfile", "-Command", ps_cmd])
12859 .output();
12860
12861 if let Ok(o) = cpu_stats {
12862 let stdout = String::from_utf8_lossy(&o.stdout);
12863 if !stdout.trim().is_empty() {
12864 out.push_str("=== SILICON CORE (CPU) ===\n");
12865 for line in stdout.lines() {
12866 if let Some((path, val)) = line.split_once(':') {
12867 if path.to_lowercase().contains("processor frequency") {
12868 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
12869 } else if path.to_lowercase().contains("% of maximum frequency") {
12870 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
12871 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
12872 if throttle_num < 95.0 {
12873 out.push_str(
12874 " [WARNING] Active downclocking or power-saving detected.\n",
12875 );
12876 }
12877 }
12878 }
12879 }
12880 }
12881 }
12882
12883 let thermal = Command::new("powershell")
12885 .args([
12886 "-NoProfile",
12887 "-Command",
12888 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
12889 ])
12890 .output();
12891 if let Ok(o) = thermal {
12892 let stdout = String::from_utf8_lossy(&o.stdout);
12893 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12894 let temp = if v.is_array() {
12895 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12896 } else {
12897 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12898 };
12899 if temp > 1.0 {
12900 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
12901 }
12902 }
12903 }
12904
12905 let wmi = Command::new("powershell")
12907 .args([
12908 "-NoProfile",
12909 "-Command",
12910 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
12911 ])
12912 .output();
12913
12914 if let Ok(o) = wmi {
12915 let stdout = String::from_utf8_lossy(&o.stdout);
12916 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12917 out.push_str("\n=== HARDWARE DNA ===\n");
12918 out.push_str(&format!(
12919 "- Rated Max: {} MHz\n",
12920 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
12921 ));
12922 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
12923 Some(raw) => {
12924 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
12925 out.push_str(&format!("- CPU Voltage: {}\n", decoded));
12926 } else {
12927 out.push_str(
12928 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
12929 );
12930 }
12931 }
12932 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
12933 }
12934 }
12935 }
12936 }
12937
12938 #[cfg(not(target_os = "windows"))]
12939 {
12940 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
12941 }
12942
12943 Ok(out.trim_end().to_string())
12944}
12945
12946#[cfg(target_os = "windows")]
12948fn decode_nvidia_throttle_reasons(hex: &str) -> String {
12949 let hex = hex.trim().trim_start_matches("0x");
12950 let val = match u64::from_str_radix(hex, 16) {
12951 Ok(v) => v,
12952 Err(_) => return String::new(),
12953 };
12954
12955 if val == 0 {
12956 return String::new();
12957 }
12958
12959 let mut reasons = Vec::new();
12960 if val & 0x01 != 0 {
12961 reasons.push("GPU Idle");
12962 }
12963 if val & 0x02 != 0 {
12964 reasons.push("Applications Clocks Setting");
12965 }
12966 if val & 0x04 != 0 {
12967 reasons.push("SW Power Cap (PL1/PL2)");
12968 }
12969 if val & 0x08 != 0 {
12970 reasons.push("HW Slowdown (Thermal/Power)");
12971 }
12972 if val & 0x10 != 0 {
12973 reasons.push("Sync Boost");
12974 }
12975 if val & 0x20 != 0 {
12976 reasons.push("SW Thermal Slowdown");
12977 }
12978 if val & 0x40 != 0 {
12979 reasons.push("HW Thermal Slowdown");
12980 }
12981 if val & 0x80 != 0 {
12982 reasons.push("HW Power Brake Slowdown");
12983 }
12984 if val & 0x100 != 0 {
12985 reasons.push("Display Clock Setting");
12986 }
12987
12988 reasons.join(", ")
12989}
12990
12991#[cfg(windows)]
12994fn run_powershell(script: &str) -> Result<String, String> {
12995 use std::process::Command;
12996 let out = Command::new("powershell")
12997 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12998 .output()
12999 .map_err(|e| format!("powershell launch failed: {e}"))?;
13000 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13001}
13002
13003#[cfg(windows)]
13006fn inspect_camera(max_entries: usize) -> Result<String, String> {
13007 let mut out = String::from("=== Camera devices ===\n");
13008
13009 let ps_devices = r#"
13011Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13012ForEach-Object {
13013 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13014 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13015}
13016"#;
13017 match run_powershell(ps_devices) {
13018 Ok(o) if !o.trim().is_empty() => {
13019 for line in o.lines().take(max_entries) {
13020 let l = line.trim();
13021 if !l.is_empty() {
13022 out.push_str(&format!("- {l}\n"));
13023 }
13024 }
13025 }
13026 _ => out.push_str("- No camera devices found via PnP\n"),
13027 }
13028
13029 out.push_str("\n=== Windows camera privacy ===\n");
13031 let ps_privacy = r#"
13032$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13033$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13034"Global: $global"
13035$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13036 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13037 ForEach-Object {
13038 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13039 if ($v) { " $($_.PSChildName): $v" }
13040 }
13041$apps
13042"#;
13043 match run_powershell(ps_privacy) {
13044 Ok(o) if !o.trim().is_empty() => {
13045 for line in o.lines().take(max_entries) {
13046 let l = line.trim_end();
13047 if !l.is_empty() {
13048 out.push_str(&format!("{l}\n"));
13049 }
13050 }
13051 }
13052 _ => out.push_str("- Could not read camera privacy registry\n"),
13053 }
13054
13055 out.push_str("\n=== Biometric / Hello camera ===\n");
13057 let ps_bio = r#"
13058Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13059ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13060"#;
13061 match run_powershell(ps_bio) {
13062 Ok(o) if !o.trim().is_empty() => {
13063 for line in o.lines().take(max_entries) {
13064 let l = line.trim();
13065 if !l.is_empty() {
13066 out.push_str(&format!("- {l}\n"));
13067 }
13068 }
13069 }
13070 _ => out.push_str("- No biometric devices found\n"),
13071 }
13072
13073 let mut findings: Vec<String> = Vec::new();
13075 if out.contains("Status: Error") || out.contains("Status: Unknown") {
13076 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13077 }
13078 if out.contains("Global: Deny") {
13079 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());
13080 }
13081
13082 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13083 if findings.is_empty() {
13084 result.push_str("- No obvious camera or privacy gate issue detected.\n");
13085 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13086 } else {
13087 for f in &findings {
13088 result.push_str(&format!("- Finding: {f}\n"));
13089 }
13090 }
13091 result.push('\n');
13092 result.push_str(&out);
13093 Ok(result)
13094}
13095
13096#[cfg(not(windows))]
13097fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13098 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13099}
13100
13101#[cfg(windows)]
13104fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13105 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13106
13107 let ps_hello = r#"
13109$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13110$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13111$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13112"PIN-style logon path: $helloKey"
13113"WbioSrvc start type: $faceConfigured"
13114"FingerPrint key present: $pinConfigured"
13115"#;
13116 match run_powershell(ps_hello) {
13117 Ok(o) => {
13118 for line in o.lines().take(max_entries) {
13119 let l = line.trim();
13120 if !l.is_empty() {
13121 out.push_str(&format!("- {l}\n"));
13122 }
13123 }
13124 }
13125 Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
13126 }
13127
13128 out.push_str("\n=== Biometric service ===\n");
13130 let ps_bio_svc = r#"
13131$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13132if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13133else { "WbioSrvc not found" }
13134"#;
13135 match run_powershell(ps_bio_svc) {
13136 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
13137 Err(_) => out.push_str("- Could not query biometric service\n"),
13138 }
13139
13140 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13142 let ps_events = r#"
13143$cutoff = (Get-Date).AddHours(-24)
13144Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13145ForEach-Object {
13146 $xml = [xml]$_.ToXml()
13147 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13148 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13149 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13150} | Select-Object -First 10
13151"#;
13152 match run_powershell(ps_events) {
13153 Ok(o) if !o.trim().is_empty() => {
13154 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13155 out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
13156 for line in o.lines().take(max_entries) {
13157 let l = line.trim();
13158 if !l.is_empty() {
13159 out.push_str(&format!(" {l}\n"));
13160 }
13161 }
13162 }
13163 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13164 }
13165
13166 out.push_str("\n=== Active credential providers ===\n");
13168 let ps_cp = r#"
13169Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13170ForEach-Object {
13171 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13172 if ($name) { $name }
13173} | Select-Object -First 15
13174"#;
13175 match run_powershell(ps_cp) {
13176 Ok(o) if !o.trim().is_empty() => {
13177 for line in o.lines().take(max_entries) {
13178 let l = line.trim();
13179 if !l.is_empty() {
13180 out.push_str(&format!("- {l}\n"));
13181 }
13182 }
13183 }
13184 _ => out.push_str("- Could not enumerate credential providers\n"),
13185 }
13186
13187 let mut findings: Vec<String> = Vec::new();
13188 if out.contains("WbioSrvc | Status: Stopped") {
13189 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13190 }
13191 if out.contains("recent logon failure") && !out.contains("0 recent") {
13192 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13193 }
13194
13195 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13196 if findings.is_empty() {
13197 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13198 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13199 } else {
13200 for f in &findings {
13201 result.push_str(&format!("- Finding: {f}\n"));
13202 }
13203 }
13204 result.push('\n');
13205 result.push_str(&out);
13206 Ok(result)
13207}
13208
13209#[cfg(not(windows))]
13210fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13211 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13212}
13213
13214#[cfg(windows)]
13217fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13218 let mut out = String::from("=== Installer engines ===\n");
13219
13220 let ps_engines = r#"
13221$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13222foreach ($name in $services) {
13223 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13224 if ($svc) {
13225 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13226 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13227 "$name | Status: $($svc.Status) | StartType: $startType"
13228 } else {
13229 "$name | Not present"
13230 }
13231}
13232if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13233 "msiexec.exe | Present: Yes"
13234} else {
13235 "msiexec.exe | Present: No"
13236}
13237"#;
13238 match run_powershell(ps_engines) {
13239 Ok(o) if !o.trim().is_empty() => {
13240 for line in o.lines().take(max_entries + 6) {
13241 let l = line.trim();
13242 if !l.is_empty() {
13243 out.push_str(&format!("- {l}\n"));
13244 }
13245 }
13246 }
13247 _ => out.push_str("- Could not inspect installer engine services\n"),
13248 }
13249
13250 out.push_str("\n=== winget and App Installer ===\n");
13251 let ps_winget = r#"
13252$cmd = Get-Command winget -ErrorAction SilentlyContinue
13253if ($cmd) {
13254 try {
13255 $v = & winget --version 2>$null
13256 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13257 } catch { "winget | Present but invocation failed" }
13258} else {
13259 "winget | Missing"
13260}
13261$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13262if ($appInstaller) {
13263 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13264} else {
13265 "DesktopAppInstaller | Status: Missing"
13266}
13267"#;
13268 match run_powershell(ps_winget) {
13269 Ok(o) if !o.trim().is_empty() => {
13270 for line in o.lines().take(max_entries) {
13271 let l = line.trim();
13272 if !l.is_empty() {
13273 out.push_str(&format!("- {l}\n"));
13274 }
13275 }
13276 }
13277 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13278 }
13279
13280 out.push_str("\n=== Microsoft Store packages ===\n");
13281 let ps_store = r#"
13282$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13283if ($store) {
13284 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13285} else {
13286 "Microsoft.WindowsStore | Status: Missing"
13287}
13288"#;
13289 match run_powershell(ps_store) {
13290 Ok(o) if !o.trim().is_empty() => {
13291 for line in o.lines().take(max_entries) {
13292 let l = line.trim();
13293 if !l.is_empty() {
13294 out.push_str(&format!("- {l}\n"));
13295 }
13296 }
13297 }
13298 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13299 }
13300
13301 out.push_str("\n=== Reboot and transaction blockers ===\n");
13302 let ps_blockers = r#"
13303$pending = $false
13304if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13305 "RebootPending: CBS"
13306 $pending = $true
13307}
13308if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13309 "RebootPending: WindowsUpdate"
13310 $pending = $true
13311}
13312$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13313if ($rename) {
13314 "PendingFileRenameOperations: Yes"
13315 $pending = $true
13316}
13317if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13318 "InstallerInProgress: Yes"
13319 $pending = $true
13320}
13321if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13322"#;
13323 match run_powershell(ps_blockers) {
13324 Ok(o) if !o.trim().is_empty() => {
13325 for line in o.lines().take(max_entries) {
13326 let l = line.trim();
13327 if !l.is_empty() {
13328 out.push_str(&format!("- {l}\n"));
13329 }
13330 }
13331 }
13332 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13333 }
13334
13335 out.push_str("\n=== Recent installer failures (7d) ===\n");
13336 let ps_failures = r#"
13337$cutoff = (Get-Date).AddDays(-7)
13338$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13339 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13340$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13341 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13342 Select-Object -First 6 |
13343 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13344$all = @($msi) + @($appx)
13345if ($all.Count -eq 0) {
13346 "No recent MSI/AppX installer errors detected"
13347} else {
13348 $all | Select-Object -First 8
13349}
13350"#;
13351 match run_powershell(ps_failures) {
13352 Ok(o) if !o.trim().is_empty() => {
13353 for line in o.lines().take(max_entries + 2) {
13354 let l = line.trim();
13355 if !l.is_empty() {
13356 out.push_str(&format!("- {l}\n"));
13357 }
13358 }
13359 }
13360 _ => out.push_str("- Could not inspect recent installer failure events\n"),
13361 }
13362
13363 let mut findings: Vec<String> = Vec::new();
13364 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13365 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13366 }
13367 if out.contains("msiexec.exe | Present: No") {
13368 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13369 }
13370 if out.contains("winget | Missing") {
13371 findings.push(
13372 "winget is missing - App Installer may not be installed or registered for this user."
13373 .into(),
13374 );
13375 }
13376 if out.contains("DesktopAppInstaller | Status: Missing") {
13377 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13378 }
13379 if out.contains("Microsoft.WindowsStore | Status: Missing") {
13380 findings.push(
13381 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13382 .into(),
13383 );
13384 }
13385 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13386 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13387 }
13388 if out.contains("InstallerInProgress: Yes") {
13389 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13390 }
13391 if out.contains("MSI | ") || out.contains("AppX | ") {
13392 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());
13393 }
13394
13395 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13396 if findings.is_empty() {
13397 result.push_str("- No obvious installer-platform blocker detected.\n");
13398 } else {
13399 for finding in &findings {
13400 result.push_str(&format!("- Finding: {finding}\n"));
13401 }
13402 }
13403 result.push('\n');
13404 result.push_str(&out);
13405 Ok(result)
13406}
13407
13408#[cfg(not(windows))]
13409fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13410 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())
13411}
13412
13413#[cfg(windows)]
13416fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13417 let mut out = String::from("=== OneDrive client ===\n");
13418
13419 let ps_client = r#"
13420$candidatePaths = @(
13421 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13422 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13423 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13424) | Where-Object { $_ -and (Test-Path $_) }
13425$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13426$exe = $candidatePaths | Select-Object -First 1
13427if (-not $exe -and $proc) {
13428 try { $exe = $proc.Path } catch {}
13429}
13430if ($exe) {
13431 "Installed: Yes"
13432 "Executable: $exe"
13433 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13434} else {
13435 "Installed: Unknown"
13436}
13437if ($proc) {
13438 "Process: Running | PID: $($proc.Id)"
13439} else {
13440 "Process: Not running"
13441}
13442"#;
13443 match run_powershell(ps_client) {
13444 Ok(o) if !o.trim().is_empty() => {
13445 for line in o.lines().take(max_entries) {
13446 let l = line.trim();
13447 if !l.is_empty() {
13448 out.push_str(&format!("- {l}\n"));
13449 }
13450 }
13451 }
13452 _ => out.push_str("- Could not inspect OneDrive client state\n"),
13453 }
13454
13455 out.push_str("\n=== OneDrive accounts ===\n");
13456 let ps_accounts = r#"
13457function MaskEmail([string]$Email) {
13458 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13459 $parts = $Email.Split('@', 2)
13460 $local = $parts[0]
13461 $domain = $parts[1]
13462 if ($local.Length -le 1) { return "*@$domain" }
13463 return ($local.Substring(0,1) + "***@" + $domain)
13464}
13465$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13466if (Test-Path $base) {
13467 Get-ChildItem $base -ErrorAction SilentlyContinue |
13468 Sort-Object PSChildName |
13469 Select-Object -First 12 |
13470 ForEach-Object {
13471 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13472 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13473 $mail = MaskEmail ([string]$p.UserEmail)
13474 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13475 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13476 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13477 }
13478} else {
13479 "No OneDrive accounts configured"
13480}
13481"#;
13482 match run_powershell(ps_accounts) {
13483 Ok(o) if !o.trim().is_empty() => {
13484 for line in o.lines().take(max_entries) {
13485 let l = line.trim();
13486 if !l.is_empty() {
13487 out.push_str(&format!("- {l}\n"));
13488 }
13489 }
13490 }
13491 _ => out.push_str("- Could not read OneDrive account registry state\n"),
13492 }
13493
13494 out.push_str("\n=== OneDrive policy overrides ===\n");
13495 let ps_policy = r#"
13496$paths = @(
13497 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13498 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13499)
13500$names = @(
13501 'DisableFileSyncNGSC',
13502 'DisableLibrariesDefaultSaveToOneDrive',
13503 'KFMSilentOptIn',
13504 'KFMBlockOptIn',
13505 'SilentAccountConfig'
13506)
13507$found = $false
13508foreach ($path in $paths) {
13509 if (Test-Path $path) {
13510 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13511 foreach ($name in $names) {
13512 $value = $p.$name
13513 if ($null -ne $value -and [string]$value -ne '') {
13514 "$path | $name=$value"
13515 $found = $true
13516 }
13517 }
13518 }
13519}
13520if (-not $found) { "No OneDrive policy overrides detected" }
13521"#;
13522 match run_powershell(ps_policy) {
13523 Ok(o) if !o.trim().is_empty() => {
13524 for line in o.lines().take(max_entries) {
13525 let l = line.trim();
13526 if !l.is_empty() {
13527 out.push_str(&format!("- {l}\n"));
13528 }
13529 }
13530 }
13531 _ => out.push_str("- Could not read OneDrive policy state\n"),
13532 }
13533
13534 out.push_str("\n=== Known Folder Backup ===\n");
13535 let ps_kfm = r#"
13536$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13537$roots = @()
13538if (Test-Path $base) {
13539 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13540 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13541 if ($p.UserFolder) {
13542 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13543 }
13544 }
13545}
13546$roots = $roots | Select-Object -Unique
13547$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13548if (Test-Path $shell) {
13549 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13550 $folders = @(
13551 @{ Name='Desktop'; Value=$props.Desktop },
13552 @{ Name='Documents'; Value=$props.Personal },
13553 @{ Name='Pictures'; Value=$props.'My Pictures' }
13554 )
13555 foreach ($folder in $folders) {
13556 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13557 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13558 $protected = $false
13559 foreach ($root in $roots) {
13560 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13561 $protected = $true
13562 break
13563 }
13564 }
13565 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13566 }
13567} else {
13568 "Explorer shell folders unavailable"
13569}
13570"#;
13571 match run_powershell(ps_kfm) {
13572 Ok(o) if !o.trim().is_empty() => {
13573 for line in o.lines().take(max_entries) {
13574 let l = line.trim();
13575 if !l.is_empty() {
13576 out.push_str(&format!("- {l}\n"));
13577 }
13578 }
13579 }
13580 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13581 }
13582
13583 let mut findings: Vec<String> = Vec::new();
13584 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13585 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13586 }
13587 if out.contains("No OneDrive accounts configured") {
13588 findings.push(
13589 "No OneDrive accounts are configured - sync cannot start until the user signs in."
13590 .into(),
13591 );
13592 }
13593 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13594 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13595 }
13596 if out.contains("Exists: No") {
13597 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13598 }
13599 if out.contains("DisableFileSyncNGSC=1") {
13600 findings
13601 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13602 }
13603 if out.contains("KFMBlockOptIn=1") {
13604 findings
13605 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13606 }
13607 if out.contains("SyncRoot: C:\\") {
13608 let mut missing_kfm: Vec<&str> = Vec::new();
13609 for folder in ["Desktop", "Documents", "Pictures"] {
13610 if out.lines().any(|line| {
13611 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13612 }) {
13613 missing_kfm.push(folder);
13614 }
13615 }
13616 if !missing_kfm.is_empty() {
13617 findings.push(format!(
13618 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13619 missing_kfm.join(", ")
13620 ));
13621 }
13622 }
13623
13624 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13625 if findings.is_empty() {
13626 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13627 } else {
13628 for finding in &findings {
13629 result.push_str(&format!("- Finding: {finding}\n"));
13630 }
13631 }
13632 result.push('\n');
13633 result.push_str(&out);
13634 Ok(result)
13635}
13636
13637#[cfg(not(windows))]
13638fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13639 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13640}
13641
13642#[cfg(windows)]
13643fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13644 let mut out = String::from("=== Browser inventory ===\n");
13645
13646 let ps_inventory = r#"
13647$browsers = @(
13648 @{ Name='Edge'; Paths=@(
13649 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13650 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13651 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13652 @{ Name='Chrome'; Paths=@(
13653 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13654 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13655 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13656 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13657 @{ Name='Firefox'; Paths=@(
13658 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13659 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13660 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13661)
13662foreach ($browser in $browsers) {
13663 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13664 if ($exe) {
13665 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13666 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13667 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13668 } else {
13669 "$($browser.Name) | Installed: No"
13670 }
13671}
13672$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13673$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13674$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13675"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13676"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13677"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13678"#;
13679 match run_powershell(ps_inventory) {
13680 Ok(o) if !o.trim().is_empty() => {
13681 for line in o.lines().take(max_entries + 6) {
13682 let l = line.trim();
13683 if !l.is_empty() {
13684 out.push_str(&format!("- {l}\n"));
13685 }
13686 }
13687 }
13688 _ => out.push_str("- Could not inspect installed browser inventory\n"),
13689 }
13690
13691 out.push_str("\n=== Runtime state ===\n");
13692 let ps_runtime = r#"
13693$targets = 'msedge','chrome','firefox','msedgewebview2'
13694foreach ($name in $targets) {
13695 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13696 if ($procs) {
13697 $count = @($procs).Count
13698 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13699 "$name | Processes: $count | WorkingSetMB: $wsMb"
13700 } else {
13701 "$name | Processes: 0 | WorkingSetMB: 0"
13702 }
13703}
13704"#;
13705 match run_powershell(ps_runtime) {
13706 Ok(o) if !o.trim().is_empty() => {
13707 for line in o.lines().take(max_entries + 4) {
13708 let l = line.trim();
13709 if !l.is_empty() {
13710 out.push_str(&format!("- {l}\n"));
13711 }
13712 }
13713 }
13714 _ => out.push_str("- Could not inspect browser runtime state\n"),
13715 }
13716
13717 out.push_str("\n=== WebView2 runtime ===\n");
13718 let ps_webview = r#"
13719$paths = @(
13720 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13721 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13722) | Where-Object { $_ -and (Test-Path $_) }
13723$runtimeDir = $paths | ForEach-Object {
13724 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13725 Where-Object { $_.Name -match '^\d+\.' } |
13726 Sort-Object Name -Descending |
13727 Select-Object -First 1
13728} | Select-Object -First 1
13729if ($runtimeDir) {
13730 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13731 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13732 "Installed: Yes"
13733 "Version: $version"
13734 "Executable: $exe"
13735} else {
13736 "Installed: No"
13737}
13738$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
13739"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
13740"#;
13741 match run_powershell(ps_webview) {
13742 Ok(o) if !o.trim().is_empty() => {
13743 for line in o.lines().take(max_entries) {
13744 let l = line.trim();
13745 if !l.is_empty() {
13746 out.push_str(&format!("- {l}\n"));
13747 }
13748 }
13749 }
13750 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13751 }
13752
13753 out.push_str("\n=== Policy and proxy surface ===\n");
13754 let ps_policy = r#"
13755$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
13756$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
13757$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
13758$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
13759$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
13760"UserProxyEnabled: $proxyEnabled"
13761"UserProxyServer: $proxyServer"
13762"UserAutoConfigURL: $autoConfig"
13763"UserAutoDetect: $autoDetect"
13764$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
13765if ($winhttp) {
13766 $normalized = ($winhttp -replace '\s+', ' ').Trim()
13767 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
13768 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
13769 "WinHTTP: $normalized"
13770}
13771$policyTargets = @(
13772 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
13773 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
13774)
13775foreach ($policy in $policyTargets) {
13776 if (Test-Path $policy.Path) {
13777 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
13778 foreach ($key in $policy.Keys) {
13779 $value = $item.$key
13780 if ($null -ne $value -and [string]$value -ne '') {
13781 if ($value -is [array]) {
13782 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
13783 } else {
13784 "$($policy.Name)Policy | $key=$value"
13785 }
13786 }
13787 }
13788 }
13789}
13790"#;
13791 match run_powershell(ps_policy) {
13792 Ok(o) if !o.trim().is_empty() => {
13793 for line in o.lines().take(max_entries + 8) {
13794 let l = line.trim();
13795 if !l.is_empty() {
13796 out.push_str(&format!("- {l}\n"));
13797 }
13798 }
13799 }
13800 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
13801 }
13802
13803 out.push_str("\n=== Profile and cache pressure ===\n");
13804 let ps_profiles = r#"
13805$profiles = @(
13806 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
13807 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
13808 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
13809)
13810foreach ($profile in $profiles) {
13811 if (Test-Path $profile.Root) {
13812 if ($profile.Name -eq 'Firefox') {
13813 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
13814 } else {
13815 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
13816 Where-Object {
13817 $_.Name -eq 'Default' -or
13818 $_.Name -eq 'Guest Profile' -or
13819 $_.Name -eq 'System Profile' -or
13820 $_.Name -like 'Profile *'
13821 }
13822 }
13823 $profileCount = @($dirs).Count
13824 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
13825 if (-not $sizeBytes) { $sizeBytes = 0 }
13826 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
13827 $extCount = 'Unknown'
13828 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
13829 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
13830 }
13831 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
13832 } else {
13833 "$($profile.Name) | ProfileRoot: Missing"
13834 }
13835}
13836"#;
13837 match run_powershell(ps_profiles) {
13838 Ok(o) if !o.trim().is_empty() => {
13839 for line in o.lines().take(max_entries + 4) {
13840 let l = line.trim();
13841 if !l.is_empty() {
13842 out.push_str(&format!("- {l}\n"));
13843 }
13844 }
13845 }
13846 _ => out.push_str("- Could not inspect browser profile pressure\n"),
13847 }
13848
13849 out.push_str("\n=== Recent browser failures (7d) ===\n");
13850 let ps_failures = r#"
13851$cutoff = (Get-Date).AddDays(-7)
13852$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
13853$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
13854 Where-Object {
13855 $msg = [string]$_.Message
13856 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
13857 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
13858 } |
13859 Select-Object -First 6
13860if ($events) {
13861 foreach ($event in $events) {
13862 $msg = ($event.Message -replace '\s+', ' ')
13863 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13864 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13865 }
13866} else {
13867 "No recent browser crash or WER events detected"
13868}
13869"#;
13870 match run_powershell(ps_failures) {
13871 Ok(o) if !o.trim().is_empty() => {
13872 for line in o.lines().take(max_entries + 2) {
13873 let l = line.trim();
13874 if !l.is_empty() {
13875 out.push_str(&format!("- {l}\n"));
13876 }
13877 }
13878 }
13879 _ => out.push_str("- Could not inspect recent browser failure events\n"),
13880 }
13881
13882 let mut findings: Vec<String> = Vec::new();
13883 if out.contains("Edge | Installed: No")
13884 && out.contains("Chrome | Installed: No")
13885 && out.contains("Firefox | Installed: No")
13886 {
13887 findings.push(
13888 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
13889 .into(),
13890 );
13891 }
13892 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
13893 findings.push(
13894 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
13895 .into(),
13896 );
13897 }
13898 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
13899 findings.push(
13900 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
13901 .into(),
13902 );
13903 }
13904 if out.contains("EdgePolicy | Proxy")
13905 || out.contains("ChromePolicy | Proxy")
13906 || out.contains("ExtensionInstallForcelist=")
13907 {
13908 findings.push(
13909 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
13910 .into(),
13911 );
13912 }
13913 for browser in ["msedge", "chrome", "firefox"] {
13914 let process_marker = format!("{browser} | Processes: ");
13915 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
13916 let count = line
13917 .split("| Processes: ")
13918 .nth(1)
13919 .and_then(|rest| rest.split(" |").next())
13920 .and_then(|value| value.trim().parse::<usize>().ok())
13921 .unwrap_or(0);
13922 let ws_mb = line
13923 .split("| WorkingSetMB: ")
13924 .nth(1)
13925 .and_then(|value| value.trim().parse::<f64>().ok())
13926 .unwrap_or(0.0);
13927 if count >= 25 {
13928 findings.push(format!(
13929 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
13930 ));
13931 } else if ws_mb >= 2500.0 {
13932 findings.push(format!(
13933 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
13934 ));
13935 }
13936 }
13937 }
13938 if out.contains("=== WebView2 runtime ===\n- Installed: No")
13939 || (out.contains("=== WebView2 runtime ===")
13940 && out.contains("- Installed: No")
13941 && out.contains("- ProcessCount: 0"))
13942 {
13943 findings.push(
13944 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
13945 .into(),
13946 );
13947 }
13948 for browser in ["Edge", "Chrome", "Firefox"] {
13949 let prefix = format!("{browser} | ProfileRoot:");
13950 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
13951 let size_gb = line
13952 .split("| SizeGB: ")
13953 .nth(1)
13954 .and_then(|rest| rest.split(" |").next())
13955 .and_then(|value| value.trim().parse::<f64>().ok())
13956 .unwrap_or(0.0);
13957 let ext_count = line
13958 .split("| Extensions: ")
13959 .nth(1)
13960 .and_then(|value| value.trim().parse::<usize>().ok())
13961 .unwrap_or(0);
13962 if size_gb >= 2.5 {
13963 findings.push(format!(
13964 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
13965 ));
13966 }
13967 if ext_count >= 20 {
13968 findings.push(format!(
13969 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
13970 ));
13971 }
13972 }
13973 }
13974 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13975 findings.push(
13976 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
13977 .into(),
13978 );
13979 }
13980
13981 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
13982 if findings.is_empty() {
13983 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
13984 } else {
13985 for finding in &findings {
13986 result.push_str(&format!("- Finding: {finding}\n"));
13987 }
13988 }
13989 result.push('\n');
13990 result.push_str(&out);
13991 Ok(result)
13992}
13993
13994#[cfg(not(windows))]
13995fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
13996 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())
13997}
13998
13999#[cfg(windows)]
14000fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14001 let mut out = String::from("=== Outlook install inventory ===\n");
14002
14003 let ps_install = r#"
14004$installPaths = @(
14005 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14006 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14007 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14008 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14009 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14010 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14011)
14012$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14013if ($exe) {
14014 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14015 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14016 "Installed: Yes"
14017 "Executable: $exe"
14018 "Version: $version"
14019 "Product: $productName"
14020} else {
14021 "Installed: No"
14022}
14023$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14024if ($newOutlook) {
14025 "NewOutlook: Installed | Version: $($newOutlook.Version)"
14026} else {
14027 "NewOutlook: Not installed"
14028}
14029"#;
14030 match run_powershell(ps_install) {
14031 Ok(o) if !o.trim().is_empty() => {
14032 for line in o.lines().take(max_entries + 4) {
14033 let l = line.trim();
14034 if !l.is_empty() {
14035 out.push_str(&format!("- {l}\n"));
14036 }
14037 }
14038 }
14039 _ => out.push_str("- Could not inspect Outlook install paths\n"),
14040 }
14041
14042 out.push_str("\n=== Runtime state ===\n");
14043 let ps_runtime = r#"
14044$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14045if ($proc) {
14046 $count = @($proc).Count
14047 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14048 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14049 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14050} else {
14051 "Running: No"
14052}
14053"#;
14054 match run_powershell(ps_runtime) {
14055 Ok(o) if !o.trim().is_empty() => {
14056 for line in o.lines().take(4) {
14057 let l = line.trim();
14058 if !l.is_empty() {
14059 out.push_str(&format!("- {l}\n"));
14060 }
14061 }
14062 }
14063 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14064 }
14065
14066 out.push_str("\n=== Mail profiles ===\n");
14067 let ps_profiles = r#"
14068$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14069if (-not (Test-Path $profileKey)) {
14070 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14071}
14072if (Test-Path $profileKey) {
14073 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14074 $count = @($profiles).Count
14075 "ProfileCount: $count"
14076 foreach ($p in $profiles | Select-Object -First 10) {
14077 "Profile: $($p.PSChildName)"
14078 }
14079} else {
14080 "ProfileCount: 0"
14081 "No Outlook profiles found in registry"
14082}
14083"#;
14084 match run_powershell(ps_profiles) {
14085 Ok(o) if !o.trim().is_empty() => {
14086 for line in o.lines().take(max_entries + 2) {
14087 let l = line.trim();
14088 if !l.is_empty() {
14089 out.push_str(&format!("- {l}\n"));
14090 }
14091 }
14092 }
14093 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14094 }
14095
14096 out.push_str("\n=== OST and PST data files ===\n");
14097 let ps_datafiles = r#"
14098$searchRoots = @(
14099 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14100 (Join-Path $env:USERPROFILE 'Documents'),
14101 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14102) | Where-Object { $_ -and (Test-Path $_) }
14103$files = foreach ($root in $searchRoots) {
14104 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14105 Select-Object FullName,
14106 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14107 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14108 LastWriteTime
14109}
14110if ($files) {
14111 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14112 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14113 }
14114} else {
14115 "No OST or PST files found in standard locations"
14116}
14117"#;
14118 match run_powershell(ps_datafiles) {
14119 Ok(o) if !o.trim().is_empty() => {
14120 for line in o.lines().take(max_entries + 4) {
14121 let l = line.trim();
14122 if !l.is_empty() {
14123 out.push_str(&format!("- {l}\n"));
14124 }
14125 }
14126 }
14127 _ => out.push_str("- Could not inspect OST/PST data files\n"),
14128 }
14129
14130 out.push_str("\n=== Add-in pressure ===\n");
14131 let ps_addins = r#"
14132$addinPaths = @(
14133 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14134 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14135 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14136)
14137$addins = foreach ($path in $addinPaths) {
14138 if (Test-Path $path) {
14139 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14140 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14141 $loadBehavior = $item.LoadBehavior
14142 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14143 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14144 }
14145 }
14146}
14147$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14148$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14149"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14150foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14151 $state = switch ($a.LoadBehavior) {
14152 0 { 'Disabled' }
14153 2 { 'LoadOnStart(inactive)' }
14154 3 { 'ActiveOnStart' }
14155 8 { 'DemandLoad' }
14156 9 { 'ActiveDemand' }
14157 16 { 'ConnectedFirst' }
14158 default { "LoadBehavior=$($a.LoadBehavior)" }
14159 }
14160 "$($a.Name) | $state"
14161}
14162$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14163$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14164if (Test-Path $disabledByResiliency) {
14165 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14166 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14167 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14168}
14169"#;
14170 match run_powershell(ps_addins) {
14171 Ok(o) if !o.trim().is_empty() => {
14172 for line in o.lines().take(max_entries + 8) {
14173 let l = line.trim();
14174 if !l.is_empty() {
14175 out.push_str(&format!("- {l}\n"));
14176 }
14177 }
14178 }
14179 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14180 }
14181
14182 out.push_str("\n=== Authentication and cache friction ===\n");
14183 let ps_auth = r#"
14184$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14185$tokenCount = if (Test-Path $tokenCache) {
14186 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14187} else { 0 }
14188"TokenBrokerCacheFiles: $tokenCount"
14189$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14190$credsCount = @($credentialManager).Count
14191"OfficeCredentialsInVault: $credsCount"
14192$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14193if (Test-Path $samlKey) {
14194 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14195 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14196 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14197 "WAMOverride: $connected"
14198 "SignedInUserId: $signedIn"
14199}
14200$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14201if (Test-Path $outlookReg) {
14202 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14203 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14204}
14205"#;
14206 match run_powershell(ps_auth) {
14207 Ok(o) if !o.trim().is_empty() => {
14208 for line in o.lines().take(max_entries + 4) {
14209 let l = line.trim();
14210 if !l.is_empty() {
14211 out.push_str(&format!("- {l}\n"));
14212 }
14213 }
14214 }
14215 _ => out.push_str("- Could not inspect Outlook auth state\n"),
14216 }
14217
14218 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14219 let ps_events = r#"
14220$cutoff = (Get-Date).AddDays(-7)
14221$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14222 Where-Object {
14223 $msg = [string]$_.Message
14224 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14225 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14226 } |
14227 Select-Object -First 8
14228if ($events) {
14229 foreach ($event in $events) {
14230 $msg = ($event.Message -replace '\s+', ' ')
14231 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14232 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14233 }
14234} else {
14235 "No recent Outlook crash or error events detected in Application log"
14236}
14237"#;
14238 match run_powershell(ps_events) {
14239 Ok(o) if !o.trim().is_empty() => {
14240 for line in o.lines().take(max_entries + 4) {
14241 let l = line.trim();
14242 if !l.is_empty() {
14243 out.push_str(&format!("- {l}\n"));
14244 }
14245 }
14246 }
14247 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14248 }
14249
14250 let mut findings: Vec<String> = Vec::new();
14251
14252 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14253 findings.push(
14254 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14255 .into(),
14256 );
14257 }
14258
14259 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14260 let ws_mb = line
14261 .split("WorkingSetMB: ")
14262 .nth(1)
14263 .and_then(|r| r.split(" |").next())
14264 .and_then(|v| v.trim().parse::<f64>().ok())
14265 .unwrap_or(0.0);
14266 if ws_mb >= 1500.0 {
14267 findings.push(format!(
14268 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14269 ));
14270 }
14271 }
14272
14273 let large_ost: Vec<String> = out
14274 .lines()
14275 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14276 .filter_map(|l| {
14277 let mb = l
14278 .split("SizeMB: ")
14279 .nth(1)
14280 .and_then(|r| r.split(" |").next())
14281 .and_then(|v| v.trim().parse::<f64>().ok())
14282 .unwrap_or(0.0);
14283 if mb >= 10_000.0 {
14284 Some(format!("{mb:.0} MB OST file detected"))
14285 } else {
14286 None
14287 }
14288 })
14289 .collect();
14290 for msg in large_ost {
14291 findings.push(format!(
14292 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14293 ));
14294 }
14295
14296 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14297 let active_count = line
14298 .split("Active: ")
14299 .nth(1)
14300 .and_then(|r| r.split(" |").next())
14301 .and_then(|v| v.trim().parse::<usize>().ok())
14302 .unwrap_or(0);
14303 if active_count >= 8 {
14304 findings.push(format!(
14305 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14306 ));
14307 }
14308 }
14309
14310 if out.contains("ResiliencyDisabledItems:") {
14311 findings.push(
14312 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14313 .into(),
14314 );
14315 }
14316
14317 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14318 findings.push(
14319 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14320 .into(),
14321 );
14322 }
14323
14324 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14325 findings.push(
14326 "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)."
14327 .into(),
14328 );
14329 }
14330
14331 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14332 if findings.is_empty() {
14333 result.push_str("- No obvious Outlook health blocker detected.\n");
14334 } else {
14335 for finding in &findings {
14336 result.push_str(&format!("- Finding: {finding}\n"));
14337 }
14338 }
14339 result.push('\n');
14340 result.push_str(&out);
14341 Ok(result)
14342}
14343
14344#[cfg(not(windows))]
14345fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14346 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14347}
14348
14349#[cfg(windows)]
14350fn inspect_teams(max_entries: usize) -> Result<String, String> {
14351 let mut out = String::from("=== Teams install inventory ===\n");
14352
14353 let ps_install = r#"
14354# Classic Teams (Teams 1.0)
14355$classicExe = @(
14356 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14357 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14358) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14359
14360if ($classicExe) {
14361 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14362 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14363} else {
14364 "ClassicTeams: Not installed"
14365}
14366
14367# New Teams (Teams 2.0 / ms-teams.exe)
14368$newTeamsExe = @(
14369 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14370 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14371) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14372
14373$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14374if ($newTeamsPkg) {
14375 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14376} elseif ($newTeamsExe) {
14377 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14378 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14379} else {
14380 "NewTeams: Not installed"
14381}
14382
14383# Teams Machine-Wide Installer (MSI/per-machine)
14384$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14385 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14386 Select-Object -First 1
14387if ($mwi) {
14388 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14389} else {
14390 "MachineWideInstaller: Not found"
14391}
14392"#;
14393 match run_powershell(ps_install) {
14394 Ok(o) if !o.trim().is_empty() => {
14395 for line in o.lines().take(max_entries + 4) {
14396 let l = line.trim();
14397 if !l.is_empty() {
14398 out.push_str(&format!("- {l}\n"));
14399 }
14400 }
14401 }
14402 _ => out.push_str("- Could not inspect Teams install paths\n"),
14403 }
14404
14405 out.push_str("\n=== Runtime state ===\n");
14406 let ps_runtime = r#"
14407$targets = @('Teams','ms-teams')
14408foreach ($name in $targets) {
14409 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14410 if ($procs) {
14411 $count = @($procs).Count
14412 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14413 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14414 } else {
14415 "$name | Running: No"
14416 }
14417}
14418"#;
14419 match run_powershell(ps_runtime) {
14420 Ok(o) if !o.trim().is_empty() => {
14421 for line in o.lines().take(6) {
14422 let l = line.trim();
14423 if !l.is_empty() {
14424 out.push_str(&format!("- {l}\n"));
14425 }
14426 }
14427 }
14428 _ => out.push_str("- Could not inspect Teams runtime state\n"),
14429 }
14430
14431 out.push_str("\n=== Cache directory sizing ===\n");
14432 let ps_cache = r#"
14433$cachePaths = @(
14434 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14435 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14436 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14437 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14438)
14439foreach ($entry in $cachePaths) {
14440 if (Test-Path $entry.Path) {
14441 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14442 if (-not $sizeBytes) { $sizeBytes = 0 }
14443 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14444 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14445 } else {
14446 "$($entry.Name) | Path: $($entry.Path) | Not found"
14447 }
14448}
14449"#;
14450 match run_powershell(ps_cache) {
14451 Ok(o) if !o.trim().is_empty() => {
14452 for line in o.lines().take(max_entries + 4) {
14453 let l = line.trim();
14454 if !l.is_empty() {
14455 out.push_str(&format!("- {l}\n"));
14456 }
14457 }
14458 }
14459 _ => out.push_str("- Could not inspect Teams cache directories\n"),
14460 }
14461
14462 out.push_str("\n=== WebView2 runtime ===\n");
14463 let ps_webview = r#"
14464$paths = @(
14465 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14466 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14467) | Where-Object { $_ -and (Test-Path $_) }
14468$runtimeDir = $paths | ForEach-Object {
14469 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14470 Where-Object { $_.Name -match '^\d+\.' } |
14471 Sort-Object Name -Descending |
14472 Select-Object -First 1
14473} | Select-Object -First 1
14474if ($runtimeDir) {
14475 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14476 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14477 "Installed: Yes | Version: $version"
14478} else {
14479 "Installed: No -- New Teams and some Office features require WebView2"
14480}
14481"#;
14482 match run_powershell(ps_webview) {
14483 Ok(o) if !o.trim().is_empty() => {
14484 for line in o.lines().take(4) {
14485 let l = line.trim();
14486 if !l.is_empty() {
14487 out.push_str(&format!("- {l}\n"));
14488 }
14489 }
14490 }
14491 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14492 }
14493
14494 out.push_str("\n=== Account and sign-in state ===\n");
14495 let ps_auth = r#"
14496# Classic Teams account registry
14497$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14498if (Test-Path $classicAcct) {
14499 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14500 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14501 "ClassicTeamsAccount: $email"
14502} else {
14503 "ClassicTeamsAccount: Not configured"
14504}
14505# WAM / token broker state for Teams
14506$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14507$tokenCount = if (Test-Path $tokenCache) {
14508 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14509} else { 0 }
14510"TokenBrokerCacheFiles: $tokenCount"
14511# Office identity
14512$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14513if (Test-Path $officeId) {
14514 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14515 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14516 "OfficeSignedInUserId: $signedIn"
14517}
14518# Check if Teams is in startup
14519$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14520$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14521"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14522"#;
14523 match run_powershell(ps_auth) {
14524 Ok(o) if !o.trim().is_empty() => {
14525 for line in o.lines().take(max_entries + 4) {
14526 let l = line.trim();
14527 if !l.is_empty() {
14528 out.push_str(&format!("- {l}\n"));
14529 }
14530 }
14531 }
14532 _ => out.push_str("- Could not inspect Teams account state\n"),
14533 }
14534
14535 out.push_str("\n=== Audio and video device binding ===\n");
14536 let ps_devices = r#"
14537# Teams stores device prefs in the settings file
14538$settingsPaths = @(
14539 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14540 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14541)
14542$found = $false
14543foreach ($sp in $settingsPaths) {
14544 if (Test-Path $sp) {
14545 $found = $true
14546 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14547 if ($raw) {
14548 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14549 if ($json) {
14550 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14551 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14552 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14553 "ConfigFile: $sp"
14554 "Microphone: $mic"
14555 "Speaker: $spk"
14556 "Camera: $cam"
14557 } else {
14558 "ConfigFile: $sp (not parseable as JSON)"
14559 }
14560 } else {
14561 "ConfigFile: $sp (empty)"
14562 }
14563 break
14564 }
14565}
14566if (-not $found) {
14567 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14568}
14569"#;
14570 match run_powershell(ps_devices) {
14571 Ok(o) if !o.trim().is_empty() => {
14572 for line in o.lines().take(max_entries + 4) {
14573 let l = line.trim();
14574 if !l.is_empty() {
14575 out.push_str(&format!("- {l}\n"));
14576 }
14577 }
14578 }
14579 _ => out.push_str("- Could not inspect Teams device binding\n"),
14580 }
14581
14582 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14583 let ps_events = r#"
14584$cutoff = (Get-Date).AddDays(-7)
14585$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14586 Where-Object {
14587 $msg = [string]$_.Message
14588 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14589 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14590 } |
14591 Select-Object -First 8
14592if ($events) {
14593 foreach ($event in $events) {
14594 $msg = ($event.Message -replace '\s+', ' ')
14595 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14596 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14597 }
14598} else {
14599 "No recent Teams crash or error events detected in Application log"
14600}
14601"#;
14602 match run_powershell(ps_events) {
14603 Ok(o) if !o.trim().is_empty() => {
14604 for line in o.lines().take(max_entries + 4) {
14605 let l = line.trim();
14606 if !l.is_empty() {
14607 out.push_str(&format!("- {l}\n"));
14608 }
14609 }
14610 }
14611 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14612 }
14613
14614 let mut findings: Vec<String> = Vec::new();
14615
14616 let classic_installed = out.contains("- ClassicTeams: Installed");
14617 let new_installed = out.contains("- NewTeams: Installed");
14618 if !classic_installed && !new_installed {
14619 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14620 }
14621
14622 for name in ["Teams", "ms-teams"] {
14623 let marker = format!("{name} | Running: Yes | Processes:");
14624 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14625 let ws_mb = line
14626 .split("WorkingSetMB: ")
14627 .nth(1)
14628 .and_then(|v| v.trim().parse::<f64>().ok())
14629 .unwrap_or(0.0);
14630 if ws_mb >= 1000.0 {
14631 findings.push(format!(
14632 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14633 ));
14634 }
14635 }
14636 }
14637
14638 for (label, threshold_mb) in [
14639 ("ClassicTeamsCache", 500.0_f64),
14640 ("ClassicTeamsSquirrel", 2000.0),
14641 ("NewTeamsCache", 500.0),
14642 ("NewTeamsAppData", 3000.0),
14643 ] {
14644 let marker = format!("{label} |");
14645 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14646 let mb = line
14647 .split("SizeMB: ")
14648 .nth(1)
14649 .and_then(|v| v.trim().parse::<f64>().ok())
14650 .unwrap_or(0.0);
14651 if mb >= threshold_mb {
14652 findings.push(format!(
14653 "{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."
14654 ));
14655 }
14656 }
14657 }
14658
14659 if out.contains("- Installed: No -- New Teams") {
14660 findings.push(
14661 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14662 .into(),
14663 );
14664 }
14665
14666 if out.contains("- ClassicTeamsAccount: Not configured")
14667 && out.contains("- OfficeSignedInUserId: None")
14668 {
14669 findings.push(
14670 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14671 .into(),
14672 );
14673 }
14674
14675 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14676 findings.push(
14677 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14678 .into(),
14679 );
14680 }
14681
14682 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14683 if findings.is_empty() {
14684 result.push_str("- No obvious Teams health blocker detected.\n");
14685 } else {
14686 for finding in &findings {
14687 result.push_str(&format!("- Finding: {finding}\n"));
14688 }
14689 }
14690 result.push('\n');
14691 result.push_str(&out);
14692 Ok(result)
14693}
14694
14695#[cfg(not(windows))]
14696fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14697 Ok(
14698 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14699 .into(),
14700 )
14701}
14702
14703#[cfg(windows)]
14704fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14705 let mut out = String::from("=== Identity broker services ===\n");
14706
14707 let ps_services = r#"
14708$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14709foreach ($name in $serviceNames) {
14710 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14711 if ($svc) {
14712 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14713 } else {
14714 "$name | Not found"
14715 }
14716}
14717"#;
14718 match run_powershell(ps_services) {
14719 Ok(o) if !o.trim().is_empty() => {
14720 for line in o.lines().take(max_entries) {
14721 let l = line.trim();
14722 if !l.is_empty() {
14723 out.push_str(&format!("- {l}\n"));
14724 }
14725 }
14726 }
14727 _ => out.push_str("- Could not inspect identity broker services\n"),
14728 }
14729
14730 out.push_str("\n=== Device registration ===\n");
14731 let ps_device = r#"
14732$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
14733if ($dsreg) {
14734 try {
14735 $raw = & $dsreg.Source /status 2>$null
14736 $text = ($raw -join "`n")
14737 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
14738 $seen = $false
14739 foreach ($key in $keys) {
14740 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
14741 if ($match.Success) {
14742 "${key}: $($match.Groups[1].Value.Trim())"
14743 $seen = $true
14744 }
14745 }
14746 if (-not $seen) {
14747 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
14748 }
14749 } catch {
14750 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
14751 }
14752} else {
14753 "DeviceRegistration: dsregcmd unavailable"
14754}
14755"#;
14756 match run_powershell(ps_device) {
14757 Ok(o) if !o.trim().is_empty() => {
14758 for line in o.lines().take(max_entries + 4) {
14759 let l = line.trim();
14760 if !l.is_empty() {
14761 out.push_str(&format!("- {l}\n"));
14762 }
14763 }
14764 }
14765 _ => out.push_str(
14766 "- DeviceRegistration: Could not inspect device registration state in this session\n",
14767 ),
14768 }
14769
14770 out.push_str("\n=== Broker packages and caches ===\n");
14771 let ps_broker = r#"
14772$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
14773if ($pkg) {
14774 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
14775} else {
14776 "AADBrokerPlugin: Not installed"
14777}
14778$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14779$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14780"TokenBrokerCacheFiles: $tokenCount"
14781$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
14782$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14783"IdentityCacheFiles: $identityCount"
14784$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
14785$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14786"OneAuthFiles: $oneAuthCount"
14787"#;
14788 match run_powershell(ps_broker) {
14789 Ok(o) if !o.trim().is_empty() => {
14790 for line in o.lines().take(max_entries + 4) {
14791 let l = line.trim();
14792 if !l.is_empty() {
14793 out.push_str(&format!("- {l}\n"));
14794 }
14795 }
14796 }
14797 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
14798 }
14799
14800 out.push_str("\n=== Microsoft app account signals ===\n");
14801 let ps_accounts = r#"
14802function MaskEmail([string]$Email) {
14803 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14804 $parts = $Email.Split('@', 2)
14805 $local = $parts[0]
14806 $domain = $parts[1]
14807 if ($local.Length -le 1) { return "*@$domain" }
14808 return ($local.Substring(0,1) + "***@" + $domain)
14809}
14810$allAccounts = @()
14811$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14812if (Test-Path $officeId) {
14813 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14814 if ($id.SignedInUserId) {
14815 $allAccounts += [string]$id.SignedInUserId
14816 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
14817 } else {
14818 "OfficeSignedInUserId: None"
14819 }
14820} else {
14821 "OfficeSignedInUserId: Not configured"
14822}
14823$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14824if (Test-Path $teamsAcct) {
14825 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
14826 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
14827 if (-not [string]::IsNullOrWhiteSpace($email)) {
14828 $allAccounts += $email
14829 "TeamsAccount: $(MaskEmail $email)"
14830 } else {
14831 "TeamsAccount: Unknown"
14832 }
14833} else {
14834 "TeamsAccount: Not configured"
14835}
14836$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14837$oneDriveEmails = @()
14838if (Test-Path $oneDriveBase) {
14839 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
14840 ForEach-Object {
14841 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14842 if ($p.UserEmail) { [string]$p.UserEmail }
14843 } |
14844 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
14845 Sort-Object -Unique
14846}
14847$allAccounts += $oneDriveEmails
14848"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
14849if (@($oneDriveEmails).Count -gt 0) {
14850 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14851}
14852$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
14853"DistinctIdentityCount: $($distinct.Count)"
14854if ($distinct.Count -gt 0) {
14855 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14856}
14857"#;
14858 match run_powershell(ps_accounts) {
14859 Ok(o) if !o.trim().is_empty() => {
14860 for line in o.lines().take(max_entries + 6) {
14861 let l = line.trim();
14862 if !l.is_empty() {
14863 out.push_str(&format!("- {l}\n"));
14864 }
14865 }
14866 }
14867 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
14868 }
14869
14870 out.push_str("\n=== WebView2 auth dependency ===\n");
14871 let ps_webview = r#"
14872$paths = @(
14873 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14874 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14875) | Where-Object { $_ -and (Test-Path $_) }
14876$runtimeDir = $paths | ForEach-Object {
14877 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14878 Where-Object { $_.Name -match '^\d+\.' } |
14879 Sort-Object Name -Descending |
14880 Select-Object -First 1
14881} | Select-Object -First 1
14882if ($runtimeDir) {
14883 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14884 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14885 "WebView2: Installed | Version: $version"
14886} else {
14887 "WebView2: Not installed"
14888}
14889"#;
14890 match run_powershell(ps_webview) {
14891 Ok(o) if !o.trim().is_empty() => {
14892 for line in o.lines().take(4) {
14893 let l = line.trim();
14894 if !l.is_empty() {
14895 out.push_str(&format!("- {l}\n"));
14896 }
14897 }
14898 }
14899 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14900 }
14901
14902 out.push_str("\n=== Recent auth-related events (24h) ===\n");
14903 let ps_events = r#"
14904try {
14905 $cutoff = (Get-Date).AddHours(-24)
14906 $events = @()
14907 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
14908 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
14909 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
14910 Select-Object -First 4
14911 }
14912 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
14913 Where-Object {
14914 ($_.LevelDisplayName -in @('Error','Warning')) -and (
14915 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
14916 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
14917 )
14918 } |
14919 Select-Object -First 6
14920 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
14921 "AuthEventCount: $(@($events).Count)"
14922 if ($events) {
14923 foreach ($e in $events) {
14924 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
14925 'No message'
14926 } else {
14927 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
14928 }
14929 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
14930 }
14931 } else {
14932 "No auth-related warning/error events detected"
14933 }
14934} catch {
14935 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
14936}
14937"#;
14938 match run_powershell(ps_events) {
14939 Ok(o) if !o.trim().is_empty() => {
14940 for line in o.lines().take(max_entries + 8) {
14941 let l = line.trim();
14942 if !l.is_empty() {
14943 out.push_str(&format!("- {l}\n"));
14944 }
14945 }
14946 }
14947 _ => out
14948 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
14949 }
14950
14951 let parse_count = |prefix: &str| -> Option<u64> {
14952 out.lines().find_map(|line| {
14953 line.trim()
14954 .strip_prefix(prefix)
14955 .and_then(|value| value.trim().parse::<u64>().ok())
14956 })
14957 };
14958
14959 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
14960 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
14961
14962 let mut findings: Vec<String> = Vec::new();
14963 if out.contains("TokenBroker | Status: Stopped")
14964 || out.contains("wlidsvc | Status: Stopped")
14965 || out.contains("OneAuth | Status: Stopped")
14966 {
14967 findings.push(
14968 "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."
14969 .into(),
14970 );
14971 }
14972 if out.contains("AADBrokerPlugin: Not installed") {
14973 findings.push(
14974 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
14975 .into(),
14976 );
14977 }
14978 if out.contains("WebView2: Not installed") {
14979 findings.push(
14980 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
14981 .into(),
14982 );
14983 }
14984 if distinct_identity_count > 1 {
14985 findings.push(format!(
14986 "{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."
14987 ));
14988 }
14989 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
14990 && distinct_identity_count > 0
14991 {
14992 findings.push(
14993 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
14994 .into(),
14995 );
14996 }
14997 if out.contains("DeviceRegistration: dsregcmd")
14998 || out.contains("DeviceRegistration: Could not inspect device registration state")
14999 {
15000 findings.push(
15001 "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."
15002 .into(),
15003 );
15004 }
15005 if auth_event_count > 0 {
15006 findings.push(format!(
15007 "{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."
15008 ));
15009 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15010 findings.push(
15011 "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."
15012 .into(),
15013 );
15014 }
15015
15016 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15017 if findings.is_empty() {
15018 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15019 } else {
15020 for finding in &findings {
15021 result.push_str(&format!("- Finding: {finding}\n"));
15022 }
15023 }
15024 result.push('\n');
15025 result.push_str(&out);
15026 Ok(result)
15027}
15028
15029#[cfg(not(windows))]
15030fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15031 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())
15032}
15033
15034#[cfg(windows)]
15035fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15036 let mut out = String::from("=== File History ===\n");
15037
15038 let ps_fh = r#"
15039$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15040if ($svc) {
15041 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15042} else {
15043 "FileHistoryService: Not found"
15044}
15045# File History config in registry
15046$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15047$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15048if (Test-Path $fhUser) {
15049 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15050 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15051 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15052 $lastBackup = if ($fh.ProtectedUpToTime) {
15053 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15054 } else { 'Never' }
15055 "Enabled: $enabled"
15056 "BackupDrive: $target"
15057 "LastBackup: $lastBackup"
15058} else {
15059 "Enabled: Not configured"
15060 "BackupDrive: Not configured"
15061 "LastBackup: Never"
15062}
15063"#;
15064 match run_powershell(ps_fh) {
15065 Ok(o) if !o.trim().is_empty() => {
15066 for line in o.lines().take(6) {
15067 let l = line.trim();
15068 if !l.is_empty() {
15069 out.push_str(&format!("- {l}\n"));
15070 }
15071 }
15072 }
15073 _ => out.push_str("- Could not inspect File History state\n"),
15074 }
15075
15076 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15077 let ps_wbadmin = r#"
15078$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15079"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15080# Last backup from wbadmin
15081$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15082if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15083 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15084 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15085 if ($lastDate) { $lastDate.Trim() }
15086 if ($lastTarget) { $lastTarget.Trim() }
15087} else {
15088 "LastWbadminBackup: No backup versions found"
15089}
15090# Task-based backup
15091$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15092foreach ($t in $task) {
15093 "BackupTask: $($t.TaskName) | State: $($t.State)"
15094}
15095"#;
15096 match run_powershell(ps_wbadmin) {
15097 Ok(o) if !o.trim().is_empty() => {
15098 for line in o.lines().take(8) {
15099 let l = line.trim();
15100 if !l.is_empty() {
15101 out.push_str(&format!("- {l}\n"));
15102 }
15103 }
15104 }
15105 _ => out.push_str("- Could not inspect Windows Backup state\n"),
15106 }
15107
15108 out.push_str("\n=== System Restore ===\n");
15109 let ps_sr = r#"
15110$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15111 Select-Object -ExpandProperty DeviceID
15112foreach ($drive in $drives) {
15113 $protection = try {
15114 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15115 } catch { $null }
15116 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15117 $rpConf = try {
15118 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15119 } catch { $null }
15120 # Check if SR is disabled for this drive
15121 $disabled = $false
15122 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15123 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15124}
15125# Most recent restore point
15126$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15127if ($points) {
15128 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15129 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15130 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15131} else {
15132 "MostRecentRestorePoint: None found"
15133}
15134$srEnabled = try {
15135 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15136 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15137} catch { 'Unknown' }
15138"SystemRestoreState: $srEnabled"
15139"#;
15140 match run_powershell(ps_sr) {
15141 Ok(o) if !o.trim().is_empty() => {
15142 for line in o.lines().take(8) {
15143 let l = line.trim();
15144 if !l.is_empty() {
15145 out.push_str(&format!("- {l}\n"));
15146 }
15147 }
15148 }
15149 _ => out.push_str("- Could not inspect System Restore state\n"),
15150 }
15151
15152 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15153 let ps_kfm = r#"
15154$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15155if (Test-Path $kfmKey) {
15156 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15157 foreach ($acct in $accounts | Select-Object -First 3) {
15158 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15159 $email = $props.UserEmail
15160 $kfmDesktop = $props.'KFMSilentOptInDesktop'
15161 $kfmDocs = $props.'KFMSilentOptInDocuments'
15162 $kfmPics = $props.'KFMSilentOptInPictures'
15163 "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' })"
15164 }
15165} else {
15166 "OneDriveKFM: No OneDrive accounts found"
15167}
15168"#;
15169 match run_powershell(ps_kfm) {
15170 Ok(o) if !o.trim().is_empty() => {
15171 for line in o.lines().take(6) {
15172 let l = line.trim();
15173 if !l.is_empty() {
15174 out.push_str(&format!("- {l}\n"));
15175 }
15176 }
15177 }
15178 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15179 }
15180
15181 out.push_str("\n=== Recent backup failure events (7d) ===\n");
15182 let ps_events = r#"
15183$cutoff = (Get-Date).AddDays(-7)
15184$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15185 Where-Object {
15186 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15187 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15188 } |
15189 Where-Object { $_.Level -le 3 } |
15190 Select-Object -First 6
15191if ($events) {
15192 foreach ($event in $events) {
15193 $msg = ($event.Message -replace '\s+', ' ')
15194 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15195 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15196 }
15197} else {
15198 "No recent backup failure events detected"
15199}
15200"#;
15201 match run_powershell(ps_events) {
15202 Ok(o) if !o.trim().is_empty() => {
15203 for line in o.lines().take(8) {
15204 let l = line.trim();
15205 if !l.is_empty() {
15206 out.push_str(&format!("- {l}\n"));
15207 }
15208 }
15209 }
15210 _ => out.push_str("- Could not inspect backup failure events\n"),
15211 }
15212
15213 let mut findings: Vec<String> = Vec::new();
15214
15215 let fh_enabled = out.contains("- Enabled: Enabled");
15216 let fh_never =
15217 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15218 let no_wbadmin = out.contains("No backup versions found");
15219 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15220
15221 if !fh_enabled && no_wbadmin {
15222 findings.push(
15223 "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(),
15224 );
15225 } else if fh_enabled && fh_never {
15226 findings.push(
15227 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15228 );
15229 }
15230
15231 if no_restore_point {
15232 findings.push(
15233 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15234 );
15235 }
15236
15237 if out.contains("- FileHistoryService: Stopped")
15238 || out.contains("- FileHistoryService: Not found")
15239 {
15240 findings.push(
15241 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15242 );
15243 }
15244
15245 if out.contains("Application Error |")
15246 || out.contains("Microsoft-Windows-Backup |")
15247 || out.contains("wbengine |")
15248 {
15249 findings.push(
15250 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15251 );
15252 }
15253
15254 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15255 if findings.is_empty() {
15256 result.push_str("- No obvious backup health blocker detected.\n");
15257 } else {
15258 for finding in &findings {
15259 result.push_str(&format!("- Finding: {finding}\n"));
15260 }
15261 }
15262 result.push('\n');
15263 result.push_str(&out);
15264 Ok(result)
15265}
15266
15267#[cfg(not(windows))]
15268fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15269 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15270}
15271
15272#[cfg(windows)]
15273fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15274 let mut out = String::from("=== Windows Search service ===\n");
15275
15276 let ps_svc = r#"
15278$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15279if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15280else { "WSearch service not found" }
15281"#;
15282 match run_powershell(ps_svc) {
15283 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15284 Err(_) => out.push_str("- Could not query WSearch service\n"),
15285 }
15286
15287 out.push_str("\n=== Indexer state ===\n");
15289 let ps_idx = r#"
15290$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15291$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15292if ($props) {
15293 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15294 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15295 "DataDirectory: $($props.DataDirectory)"
15296} else { "Registry key not found" }
15297"#;
15298 match run_powershell(ps_idx) {
15299 Ok(o) => {
15300 for line in o.lines() {
15301 let l = line.trim();
15302 if !l.is_empty() {
15303 out.push_str(&format!("- {l}\n"));
15304 }
15305 }
15306 }
15307 Err(_) => out.push_str("- Could not read indexer registry\n"),
15308 }
15309
15310 out.push_str("\n=== Indexed locations ===\n");
15312 let ps_locs = r#"
15313$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15314if ($comObj) {
15315 $catalog = $comObj.GetCatalog('SystemIndex')
15316 $manager = $catalog.GetCrawlScopeManager()
15317 $rules = $manager.EnumerateRoots()
15318 while ($true) {
15319 try {
15320 $root = $rules.Next(1)
15321 if ($root.Count -eq 0) { break }
15322 $r = $root[0]
15323 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15324 } catch { break }
15325 }
15326} else { " COM admin interface not available (normal on non-admin sessions)" }
15327"#;
15328 match run_powershell(ps_locs) {
15329 Ok(o) if !o.trim().is_empty() => {
15330 for line in o.lines() {
15331 let l = line.trim_end();
15332 if !l.is_empty() {
15333 out.push_str(&format!("{l}\n"));
15334 }
15335 }
15336 }
15337 _ => {
15338 let ps_reg = r#"
15340Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15341ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
15342"#;
15343 match run_powershell(ps_reg) {
15344 Ok(o) if !o.trim().is_empty() => {
15345 for line in o.lines() {
15346 let l = line.trim_end();
15347 if !l.is_empty() {
15348 out.push_str(&format!("{l}\n"));
15349 }
15350 }
15351 }
15352 _ => out.push_str(" - Could not enumerate indexed locations\n"),
15353 }
15354 }
15355 }
15356
15357 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15359 let ps_evts = r#"
15360Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15361Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15362ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15363"#;
15364 match run_powershell(ps_evts) {
15365 Ok(o) if !o.trim().is_empty() => {
15366 for line in o.lines() {
15367 let l = line.trim();
15368 if !l.is_empty() {
15369 out.push_str(&format!("- {l}\n"));
15370 }
15371 }
15372 }
15373 _ => out.push_str("- No recent indexer errors found\n"),
15374 }
15375
15376 let mut findings: Vec<String> = Vec::new();
15377 if out.contains("Status: Stopped") {
15378 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15379 }
15380 if out.contains("IsContentIndexingEnabled: 0")
15381 || out.contains("IsContentIndexingEnabled: False")
15382 {
15383 findings.push(
15384 "Content indexing is disabled — file content won't be searchable, only filenames."
15385 .into(),
15386 );
15387 }
15388 if out.contains("SetupCompletedSuccessfully: 0")
15389 || out.contains("SetupCompletedSuccessfully: False")
15390 {
15391 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15392 }
15393
15394 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15395 if findings.is_empty() {
15396 result.push_str("- Windows Search service and indexer appear healthy.\n");
15397 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15398 } else {
15399 for f in &findings {
15400 result.push_str(&format!("- Finding: {f}\n"));
15401 }
15402 }
15403 result.push('\n');
15404 result.push_str(&out);
15405 Ok(result)
15406}
15407
15408#[cfg(not(windows))]
15409fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15410 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15411}
15412
15413#[cfg(windows)]
15416fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15417 let mut out = String::new();
15418
15419 out.push_str("=== Active displays ===\n");
15421 let ps_displays = r#"
15422Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15423Select-Object -First 20 |
15424ForEach-Object {
15425 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15426}
15427"#;
15428 match run_powershell(ps_displays) {
15429 Ok(o) if !o.trim().is_empty() => {
15430 for line in o.lines().take(max_entries) {
15431 let l = line.trim();
15432 if !l.is_empty() {
15433 out.push_str(&format!("- {l}\n"));
15434 }
15435 }
15436 }
15437 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15438 }
15439
15440 out.push_str("\n=== Video adapters ===\n");
15442 let ps_gpu = r#"
15443Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15444ForEach-Object {
15445 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15446 $hz = "$($_.CurrentRefreshRate) Hz"
15447 $bits = "$($_.CurrentBitsPerPixel) bpp"
15448 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15449}
15450"#;
15451 match run_powershell(ps_gpu) {
15452 Ok(o) if !o.trim().is_empty() => {
15453 for line in o.lines().take(max_entries) {
15454 let l = line.trim();
15455 if !l.is_empty() {
15456 out.push_str(&format!("- {l}\n"));
15457 }
15458 }
15459 }
15460 _ => out.push_str("- Could not query video adapter info\n"),
15461 }
15462
15463 out.push_str("\n=== Connected monitors ===\n");
15465 let ps_monitors = r#"
15466Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15467ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15468"#;
15469 match run_powershell(ps_monitors) {
15470 Ok(o) if !o.trim().is_empty() => {
15471 for line in o.lines().take(max_entries) {
15472 let l = line.trim();
15473 if !l.is_empty() {
15474 out.push_str(&format!("- {l}\n"));
15475 }
15476 }
15477 }
15478 _ => out.push_str("- No monitor info available via WMI\n"),
15479 }
15480
15481 out.push_str("\n=== DPI / scaling ===\n");
15483 let ps_dpi = r#"
15484Add-Type -TypeDefinition @'
15485using System; using System.Runtime.InteropServices;
15486public class DPI {
15487 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15488 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15489 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15490}
15491'@ -ErrorAction SilentlyContinue
15492try {
15493 $hdc = [DPI]::GetDC([IntPtr]::Zero)
15494 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15495 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15496 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15497 $scale = [Math]::Round($dpiX / 96.0 * 100)
15498 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15499} catch { "DPI query unavailable" }
15500"#;
15501 match run_powershell(ps_dpi) {
15502 Ok(o) if !o.trim().is_empty() => {
15503 out.push_str(&format!("- {}\n", o.trim()));
15504 }
15505 _ => out.push_str("- DPI info unavailable\n"),
15506 }
15507
15508 let mut findings: Vec<String> = Vec::new();
15509 if out.contains("0x0") || out.contains("@ 0 Hz") {
15510 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15511 }
15512
15513 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15514 if findings.is_empty() {
15515 result.push_str("- Display configuration appears normal.\n");
15516 } else {
15517 for f in &findings {
15518 result.push_str(&format!("- Finding: {f}\n"));
15519 }
15520 }
15521 result.push('\n');
15522 result.push_str(&out);
15523 Ok(result)
15524}
15525
15526#[cfg(not(windows))]
15527fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15528 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15529}
15530
15531#[cfg(windows)]
15534fn inspect_ntp() -> Result<String, String> {
15535 let mut out = String::new();
15536
15537 out.push_str("=== Windows Time service ===\n");
15539 let ps_svc = r#"
15540$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15541if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15542else { "W32Time service not found" }
15543"#;
15544 match run_powershell(ps_svc) {
15545 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15546 Err(_) => out.push_str("- Could not query W32Time service\n"),
15547 }
15548
15549 out.push_str("\n=== NTP source and sync status ===\n");
15551 let ps_sync = r#"
15552$q = w32tm /query /status 2>$null
15553if ($q) { $q } else { "w32tm query unavailable" }
15554"#;
15555 match run_powershell(ps_sync) {
15556 Ok(o) if !o.trim().is_empty() => {
15557 for line in o.lines() {
15558 let l = line.trim();
15559 if !l.is_empty() {
15560 out.push_str(&format!(" {l}\n"));
15561 }
15562 }
15563 }
15564 _ => out.push_str(" - Could not query w32tm status\n"),
15565 }
15566
15567 out.push_str("\n=== Configured NTP servers ===\n");
15569 let ps_peers = r#"
15570w32tm /query /peers 2>$null | Select-Object -First 10
15571"#;
15572 match run_powershell(ps_peers) {
15573 Ok(o) if !o.trim().is_empty() => {
15574 for line in o.lines() {
15575 let l = line.trim();
15576 if !l.is_empty() {
15577 out.push_str(&format!(" {l}\n"));
15578 }
15579 }
15580 }
15581 _ => {
15582 let ps_reg = r#"
15584(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15585"#;
15586 match run_powershell(ps_reg) {
15587 Ok(o) if !o.trim().is_empty() => {
15588 out.push_str(&format!(" NtpServer (registry): {}\n", o.trim()));
15589 }
15590 _ => out.push_str(" - Could not enumerate NTP peers\n"),
15591 }
15592 }
15593 }
15594
15595 let mut findings: Vec<String> = Vec::new();
15596 if out.contains("W32Time | Status: Stopped") {
15597 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15598 }
15599 if out.contains("The computer did not resync") || out.contains("Error") {
15600 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15601 }
15602
15603 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15604 if findings.is_empty() {
15605 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15606 } else {
15607 for f in &findings {
15608 result.push_str(&format!("- Finding: {f}\n"));
15609 }
15610 }
15611 result.push('\n');
15612 result.push_str(&out);
15613 Ok(result)
15614}
15615
15616#[cfg(not(windows))]
15617fn inspect_ntp() -> Result<String, String> {
15618 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15620
15621 let timedatectl = std::process::Command::new("timedatectl")
15622 .arg("status")
15623 .output();
15624
15625 if let Ok(o) = timedatectl {
15626 let text = String::from_utf8_lossy(&o.stdout);
15627 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15628 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15629 } else {
15630 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15631 }
15632 for line in text.lines() {
15633 let l = line.trim();
15634 if !l.is_empty() {
15635 out.push_str(&format!(" {l}\n"));
15636 }
15637 }
15638 return Ok(out);
15639 }
15640
15641 let sntp = std::process::Command::new("sntp")
15643 .args(["-d", "time.apple.com"])
15644 .output();
15645 if let Ok(o) = sntp {
15646 out.push_str("- NTP check via sntp:\n");
15647 out.push_str(&String::from_utf8_lossy(&o.stdout));
15648 return Ok(out);
15649 }
15650
15651 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15652 Ok(out)
15653}
15654
15655#[cfg(windows)]
15658fn inspect_cpu_power() -> Result<String, String> {
15659 let mut out = String::new();
15660
15661 out.push_str("=== Active power plan ===\n");
15663 let ps_plan = r#"
15664$plan = powercfg /getactivescheme 2>$null
15665if ($plan) { $plan } else { "Could not query power scheme" }
15666"#;
15667 match run_powershell(ps_plan) {
15668 Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15669 _ => out.push_str("- Could not read active power plan\n"),
15670 }
15671
15672 out.push_str("\n=== Processor performance policy ===\n");
15674 let ps_proc = r#"
15675$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15676$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15677$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15678$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15679if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15680if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15681if ($boost) {
15682 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15683 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15684 "Turbo boost mode: $bname"
15685}
15686"#;
15687 match run_powershell(ps_proc) {
15688 Ok(o) if !o.trim().is_empty() => {
15689 for line in o.lines() {
15690 let l = line.trim();
15691 if !l.is_empty() {
15692 out.push_str(&format!("- {l}\n"));
15693 }
15694 }
15695 }
15696 _ => out.push_str("- Could not query processor performance settings\n"),
15697 }
15698
15699 out.push_str("\n=== CPU frequency ===\n");
15701 let ps_freq = r#"
15702Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15703ForEach-Object {
15704 $cur = $_.CurrentClockSpeed
15705 $max = $_.MaxClockSpeed
15706 $load = $_.LoadPercentage
15707 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15708}
15709"#;
15710 match run_powershell(ps_freq) {
15711 Ok(o) if !o.trim().is_empty() => {
15712 for line in o.lines() {
15713 let l = line.trim();
15714 if !l.is_empty() {
15715 out.push_str(&format!("- {l}\n"));
15716 }
15717 }
15718 }
15719 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15720 }
15721
15722 out.push_str("\n=== Throttling indicators ===\n");
15724 let ps_throttle = r#"
15725$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15726if ($pwr) {
15727 $pwr | Select-Object -First 4 | ForEach-Object {
15728 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15729 "Thermal zone $($_.InstanceName): ${c}°C"
15730 }
15731} else { "Thermal zone WMI not available (normal on consumer hardware)" }
15732"#;
15733 match run_powershell(ps_throttle) {
15734 Ok(o) if !o.trim().is_empty() => {
15735 for line in o.lines() {
15736 let l = line.trim();
15737 if !l.is_empty() {
15738 out.push_str(&format!("- {l}\n"));
15739 }
15740 }
15741 }
15742 _ => out.push_str("- Thermal zone info unavailable\n"),
15743 }
15744
15745 let mut findings: Vec<String> = Vec::new();
15746 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
15747 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
15748 }
15749 if out.contains("Turbo boost mode: Disabled") {
15750 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
15751 }
15752 if out.contains("Min processor state: 100%") {
15753 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
15754 }
15755
15756 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
15757 if findings.is_empty() {
15758 result.push_str("- CPU power and frequency settings appear normal.\n");
15759 } else {
15760 for f in &findings {
15761 result.push_str(&format!("- Finding: {f}\n"));
15762 }
15763 }
15764 result.push('\n');
15765 result.push_str(&out);
15766 Ok(result)
15767}
15768
15769#[cfg(windows)]
15770fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15771 let mut out = String::new();
15772
15773 out.push_str("=== Credential vault summary ===\n");
15774 let ps_summary = r#"
15775$raw = cmdkey /list 2>&1
15776$lines = $raw -split "`n"
15777$total = ($lines | Where-Object { $_ -match "Target:" }).Count
15778"Total stored credentials: $total"
15779$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
15780$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
15781$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
15782" Windows credentials: $windows"
15783" Generic credentials: $generic"
15784" Certificate-based: $cert"
15785"#;
15786 match run_powershell(ps_summary) {
15787 Ok(o) => {
15788 for line in o.lines() {
15789 let l = line.trim();
15790 if !l.is_empty() {
15791 out.push_str(&format!("- {l}\n"));
15792 }
15793 }
15794 }
15795 Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
15796 }
15797
15798 out.push_str("\n=== Credential targets (up to 20) ===\n");
15799 let ps_list = r#"
15800$raw = cmdkey /list 2>&1
15801$entries = @(); $cur = @{}
15802foreach ($line in ($raw -split "`n")) {
15803 $l = $line.Trim()
15804 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
15805 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
15806 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
15807}
15808$entries | Select-Object -Last 20 | ForEach-Object {
15809 "[$($_.Type)] $($_.Target) (user: $($_.User))"
15810}
15811"#;
15812 match run_powershell(ps_list) {
15813 Ok(o) => {
15814 let lines: Vec<&str> = o
15815 .lines()
15816 .map(|l| l.trim())
15817 .filter(|l| !l.is_empty())
15818 .collect();
15819 if lines.is_empty() {
15820 out.push_str("- No credential entries found\n");
15821 } else {
15822 for l in &lines {
15823 out.push_str(&format!("- {l}\n"));
15824 }
15825 }
15826 }
15827 Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
15828 }
15829
15830 let total_creds: usize = {
15831 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
15832 run_powershell(ps_count)
15833 .ok()
15834 .and_then(|s| s.trim().parse().ok())
15835 .unwrap_or(0)
15836 };
15837
15838 let mut findings: Vec<String> = Vec::new();
15839 if total_creds > 30 {
15840 findings.push(format!(
15841 "{total_creds} stored credentials found — consider auditing for stale entries."
15842 ));
15843 }
15844
15845 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
15846 if findings.is_empty() {
15847 result.push_str("- Credential store looks normal.\n");
15848 } else {
15849 for f in &findings {
15850 result.push_str(&format!("- Finding: {f}\n"));
15851 }
15852 }
15853 result.push('\n');
15854 result.push_str(&out);
15855 Ok(result)
15856}
15857
15858#[cfg(not(windows))]
15859fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15860 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
15861}
15862
15863#[cfg(windows)]
15864fn inspect_tpm() -> Result<String, String> {
15865 let mut out = String::new();
15866
15867 out.push_str("=== TPM state ===\n");
15868 let ps_tpm = r#"
15869function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
15870 $text = if ($null -eq $Value) { "" } else { [string]$Value }
15871 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
15872 "$Name$text"
15873}
15874$t = Get-Tpm -ErrorAction SilentlyContinue
15875if ($t) {
15876 Emit-Field "TpmPresent: " $t.TpmPresent
15877 Emit-Field "TpmReady: " $t.TpmReady
15878 Emit-Field "TpmEnabled: " $t.TpmEnabled
15879 Emit-Field "TpmOwned: " $t.TpmOwned
15880 Emit-Field "RestartPending: " $t.RestartPending
15881 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
15882 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
15883} else { "TPM module unavailable" }
15884"#;
15885 match run_powershell(ps_tpm) {
15886 Ok(o) => {
15887 for line in o.lines() {
15888 let l = line.trim();
15889 if !l.is_empty() {
15890 out.push_str(&format!("- {l}\n"));
15891 }
15892 }
15893 }
15894 Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
15895 }
15896
15897 out.push_str("\n=== TPM spec version (WMI) ===\n");
15898 let ps_spec = r#"
15899$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
15900if ($wmi) {
15901 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
15902 "SpecVersion: $spec"
15903 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
15904 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
15905 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
15906} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
15907"#;
15908 match run_powershell(ps_spec) {
15909 Ok(o) => {
15910 for line in o.lines() {
15911 let l = line.trim();
15912 if !l.is_empty() {
15913 out.push_str(&format!("- {l}\n"));
15914 }
15915 }
15916 }
15917 Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
15918 }
15919
15920 out.push_str("\n=== Secure Boot state ===\n");
15921 let ps_sb = r#"
15922try {
15923 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
15924 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
15925} catch {
15926 $msg = $_.Exception.Message
15927 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
15928 "Secure Boot: Unknown (administrator privileges required)"
15929 } elseif ($msg -match "Cmdlet not supported on this platform") {
15930 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
15931 } else {
15932 "Secure Boot: N/A ($msg)"
15933 }
15934}
15935"#;
15936 match run_powershell(ps_sb) {
15937 Ok(o) => {
15938 for line in o.lines() {
15939 let l = line.trim();
15940 if !l.is_empty() {
15941 out.push_str(&format!("- {l}\n"));
15942 }
15943 }
15944 }
15945 Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
15946 }
15947
15948 out.push_str("\n=== Firmware type ===\n");
15949 let ps_fw = r#"
15950$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
15951switch ($fw) {
15952 1 { "Firmware type: BIOS (Legacy)" }
15953 2 { "Firmware type: UEFI" }
15954 default {
15955 $bcd = bcdedit /enum firmware 2>$null
15956 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
15957 else { "Firmware type: Unknown or not set" }
15958 }
15959}
15960"#;
15961 match run_powershell(ps_fw) {
15962 Ok(o) => {
15963 for line in o.lines() {
15964 let l = line.trim();
15965 if !l.is_empty() {
15966 out.push_str(&format!("- {l}\n"));
15967 }
15968 }
15969 }
15970 Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
15971 }
15972
15973 let mut findings: Vec<String> = Vec::new();
15974 let mut indeterminate = false;
15975 if out.contains("TpmPresent: False") {
15976 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
15977 }
15978 if out.contains("TpmReady: False") {
15979 findings.push(
15980 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
15981 );
15982 }
15983 if out.contains("SpecVersion: 1.2") {
15984 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
15985 }
15986 if out.contains("Secure Boot: DISABLED") {
15987 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
15988 }
15989 if out.contains("Firmware type: BIOS (Legacy)") {
15990 findings.push(
15991 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
15992 );
15993 }
15994
15995 if out.contains("TPM module unavailable")
15996 || out.contains("Win32_Tpm WMI class unavailable")
15997 || out.contains("Secure Boot: N/A")
15998 || out.contains("Secure Boot: Unknown")
15999 || out.contains("Firmware type: Unknown or not set")
16000 || out.contains("TpmPresent: Unknown")
16001 || out.contains("TpmReady: Unknown")
16002 || out.contains("TpmEnabled: Unknown")
16003 {
16004 indeterminate = true;
16005 }
16006 if indeterminate {
16007 findings.push(
16008 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16009 .into(),
16010 );
16011 }
16012
16013 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16014 if findings.is_empty() {
16015 result.push_str("- TPM and Secure Boot appear healthy.\n");
16016 } else {
16017 for f in &findings {
16018 result.push_str(&format!("- Finding: {f}\n"));
16019 }
16020 }
16021 result.push('\n');
16022 result.push_str(&out);
16023 Ok(result)
16024}
16025
16026#[cfg(not(windows))]
16027fn inspect_tpm() -> Result<String, String> {
16028 Ok(
16029 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16030 .into(),
16031 )
16032}
16033
16034#[cfg(windows)]
16035fn inspect_latency() -> Result<String, String> {
16036 let mut out = String::new();
16037
16038 let ps_gw = r#"
16040$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16041 Sort-Object RouteMetric | Select-Object -First 1).NextHop
16042if ($gw) { $gw } else { "" }
16043"#;
16044 let gateway = run_powershell(ps_gw)
16045 .ok()
16046 .map(|s| s.trim().to_string())
16047 .filter(|s| !s.is_empty());
16048
16049 let targets: Vec<(&str, String)> = {
16050 let mut t = Vec::new();
16051 if let Some(ref gw) = gateway {
16052 t.push(("Default gateway", gw.clone()));
16053 }
16054 t.push(("Cloudflare DNS", "1.1.1.1".into()));
16055 t.push(("Google DNS", "8.8.8.8".into()));
16056 t
16057 };
16058
16059 let mut findings: Vec<String> = Vec::new();
16060
16061 for (label, host) in &targets {
16062 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16063 let ps_ping = format!(
16065 r#"
16066$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16067if ($r) {{
16068 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16069 $min = ($rtts | Measure-Object -Minimum).Minimum
16070 $max = ($rtts | Measure-Object -Maximum).Maximum
16071 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16072 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16073 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16074 "Packet loss: ${{loss}}%"
16075 "Sent: 4 Received: $($r.Count)"
16076}} else {{
16077 "UNREACHABLE — 100% packet loss"
16078}}
16079"#
16080 );
16081 match run_powershell(&ps_ping) {
16082 Ok(o) => {
16083 let body = o.trim().to_string();
16084 for line in body.lines() {
16085 let l = line.trim();
16086 if !l.is_empty() {
16087 out.push_str(&format!("- {l}\n"));
16088 }
16089 }
16090 if body.contains("UNREACHABLE") {
16091 findings.push(format!(
16092 "{label} ({host}) is unreachable — possible routing or firewall issue."
16093 ));
16094 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16095 let pct: u32 = loss_line
16096 .chars()
16097 .filter(|c| c.is_ascii_digit())
16098 .collect::<String>()
16099 .parse()
16100 .unwrap_or(0);
16101 if pct >= 25 {
16102 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16103 }
16104 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16106 let parts: Vec<&str> = rtt_line.split('/').collect();
16108 if parts.len() >= 2 {
16109 let avg_str: String =
16110 parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
16111 let avg: u32 = avg_str.parse().unwrap_or(0);
16112 if avg > 150 {
16113 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16114 }
16115 }
16116 }
16117 }
16118 }
16119 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16120 }
16121 }
16122
16123 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16124 if findings.is_empty() {
16125 result.push_str("- Latency and reachability look normal.\n");
16126 } else {
16127 for f in &findings {
16128 result.push_str(&format!("- Finding: {f}\n"));
16129 }
16130 }
16131 result.push('\n');
16132 result.push_str(&out);
16133 Ok(result)
16134}
16135
16136#[cfg(not(windows))]
16137fn inspect_latency() -> Result<String, String> {
16138 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16139 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16140 let mut findings: Vec<String> = Vec::new();
16141
16142 for (label, host) in &targets {
16143 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16144 let ping = std::process::Command::new("ping")
16145 .args(["-c", "4", "-W", "2", host])
16146 .output();
16147 match ping {
16148 Ok(o) => {
16149 let body = String::from_utf8_lossy(&o.stdout).into_owned();
16150 for line in body.lines() {
16151 let l = line.trim();
16152 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16153 out.push_str(&format!("- {l}\n"));
16154 }
16155 }
16156 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16157 findings.push(format!("{label} ({host}) is unreachable."));
16158 }
16159 }
16160 Err(e) => out.push_str(&format!("- ping error: {e}\n")),
16161 }
16162 }
16163
16164 if findings.is_empty() {
16165 out.insert_str(
16166 "Host inspection: latency\n\n=== Findings ===\n".len(),
16167 "- Latency and reachability look normal.\n",
16168 );
16169 } else {
16170 let mut prefix = String::new();
16171 for f in &findings {
16172 prefix.push_str(&format!("- Finding: {f}\n"));
16173 }
16174 out.insert_str(
16175 "Host inspection: latency\n\n=== Findings ===\n".len(),
16176 &prefix,
16177 );
16178 }
16179 Ok(out)
16180}
16181
16182#[cfg(windows)]
16183fn inspect_network_adapter() -> Result<String, String> {
16184 let mut out = String::new();
16185
16186 out.push_str("=== Network adapters ===\n");
16187 let ps_adapters = r#"
16188Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16189 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16190 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16191}
16192"#;
16193 match run_powershell(ps_adapters) {
16194 Ok(o) => {
16195 for line in o.lines() {
16196 let l = line.trim();
16197 if !l.is_empty() {
16198 out.push_str(&format!("- {l}\n"));
16199 }
16200 }
16201 }
16202 Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
16203 }
16204
16205 out.push_str("\n=== Duplex and negotiated speed ===\n");
16206 let ps_duplex = r#"
16207Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16208 $name = $_.Name
16209 $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16210 Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16211 Select-Object DisplayName, DisplayValue
16212 if ($duplex) {
16213 "--- $name ---"
16214 $duplex | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16215 } else {
16216 "--- $name --- (no duplex/speed property exposed by driver)"
16217 }
16218}
16219"#;
16220 match run_powershell(ps_duplex) {
16221 Ok(o) => {
16222 let lines: Vec<&str> = o
16223 .lines()
16224 .map(|l| l.trim())
16225 .filter(|l| !l.is_empty())
16226 .collect();
16227 for l in &lines {
16228 out.push_str(&format!("- {l}\n"));
16229 }
16230 }
16231 Err(e) => out.push_str(&format!("- Duplex query error: {e}\n")),
16232 }
16233
16234 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16235 let ps_offload = r#"
16236Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16237 $name = $_.Name
16238 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16239 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16240 Select-Object DisplayName, DisplayValue
16241 if ($props) {
16242 "--- $name ---"
16243 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16244 }
16245}
16246"#;
16247 match run_powershell(ps_offload) {
16248 Ok(o) => {
16249 let lines: Vec<&str> = o
16250 .lines()
16251 .map(|l| l.trim())
16252 .filter(|l| !l.is_empty())
16253 .collect();
16254 if lines.is_empty() {
16255 out.push_str(
16256 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16257 );
16258 } else {
16259 for l in &lines {
16260 out.push_str(&format!("- {l}\n"));
16261 }
16262 }
16263 }
16264 Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
16265 }
16266
16267 out.push_str("\n=== Adapter error counters ===\n");
16268 let ps_errors = r#"
16269Get-NetAdapterStatistics | ForEach-Object {
16270 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16271 if ($errs -gt 0) {
16272 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16273 }
16274}
16275"#;
16276 match run_powershell(ps_errors) {
16277 Ok(o) => {
16278 let lines: Vec<&str> = o
16279 .lines()
16280 .map(|l| l.trim())
16281 .filter(|l| !l.is_empty())
16282 .collect();
16283 if lines.is_empty() {
16284 out.push_str("- No adapter errors or discards detected.\n");
16285 } else {
16286 for l in &lines {
16287 out.push_str(&format!("- {l}\n"));
16288 }
16289 }
16290 }
16291 Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
16292 }
16293
16294 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16295 let ps_wol = r#"
16296Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16297 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16298 if ($wol) {
16299 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16300 }
16301}
16302"#;
16303 match run_powershell(ps_wol) {
16304 Ok(o) => {
16305 let lines: Vec<&str> = o
16306 .lines()
16307 .map(|l| l.trim())
16308 .filter(|l| !l.is_empty())
16309 .collect();
16310 if lines.is_empty() {
16311 out.push_str("- Power management data unavailable for active adapters.\n");
16312 } else {
16313 for l in &lines {
16314 out.push_str(&format!("- {l}\n"));
16315 }
16316 }
16317 }
16318 Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
16319 }
16320
16321 let mut findings: Vec<String> = Vec::new();
16322 if out.contains("RX errors:") || out.contains("TX errors:") {
16324 findings
16325 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16326 }
16327 if out.contains("Half") {
16329 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16330 }
16331
16332 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16333 if findings.is_empty() {
16334 result.push_str("- Network adapter configuration looks normal.\n");
16335 } else {
16336 for f in &findings {
16337 result.push_str(&format!("- Finding: {f}\n"));
16338 }
16339 }
16340 result.push('\n');
16341 result.push_str(&out);
16342 Ok(result)
16343}
16344
16345#[cfg(not(windows))]
16346fn inspect_network_adapter() -> Result<String, String> {
16347 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16348
16349 out.push_str("=== Network adapters (ip link) ===\n");
16350 let ip_link = std::process::Command::new("ip")
16351 .args(["link", "show"])
16352 .output();
16353 if let Ok(o) = ip_link {
16354 for line in String::from_utf8_lossy(&o.stdout).lines() {
16355 let l = line.trim();
16356 if !l.is_empty() {
16357 out.push_str(&format!("- {l}\n"));
16358 }
16359 }
16360 }
16361
16362 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16363 let ip_stats = std::process::Command::new("ip")
16364 .args(["-s", "link", "show"])
16365 .output();
16366 if let Ok(o) = ip_stats {
16367 for line in String::from_utf8_lossy(&o.stdout).lines() {
16368 let l = line.trim();
16369 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16370 {
16371 out.push_str(&format!("- {l}\n"));
16372 }
16373 }
16374 }
16375 Ok(out)
16376}
16377
16378#[cfg(windows)]
16379fn inspect_dhcp() -> Result<String, String> {
16380 let mut out = String::new();
16381
16382 out.push_str("=== DHCP lease details (per adapter) ===\n");
16383 let ps_dhcp = r#"
16384$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16385 Where-Object { $_.IPEnabled -eq $true }
16386foreach ($a in $adapters) {
16387 "--- $($a.Description) ---"
16388 " DHCP Enabled: $($a.DHCPEnabled)"
16389 if ($a.DHCPEnabled) {
16390 " DHCP Server: $($a.DHCPServer)"
16391 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16392 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
16393 " Lease Obtained: $obtained"
16394 " Lease Expires: $expires"
16395 }
16396 " IP Address: $($a.IPAddress -join ', ')"
16397 " Subnet Mask: $($a.IPSubnet -join ', ')"
16398 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
16399 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
16400 " MAC Address: $($a.MACAddress)"
16401 ""
16402}
16403"#;
16404 match run_powershell(ps_dhcp) {
16405 Ok(o) => {
16406 for line in o.lines() {
16407 let l = line.trim_end();
16408 if !l.is_empty() {
16409 out.push_str(&format!("{l}\n"));
16410 }
16411 }
16412 }
16413 Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16414 }
16415
16416 let mut findings: Vec<String> = Vec::new();
16418 let ps_expiry = r#"
16419$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16420foreach ($a in $adapters) {
16421 try {
16422 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16423 $now = Get-Date
16424 $hrs = ($exp - $now).TotalHours
16425 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16426 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16427 } catch {}
16428}
16429"#;
16430 if let Ok(o) = run_powershell(ps_expiry) {
16431 for line in o.lines() {
16432 let l = line.trim();
16433 if !l.is_empty() {
16434 if l.contains("EXPIRED") {
16435 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16436 } else if l.contains("expires in") {
16437 findings.push(format!("DHCP lease expiring soon — {l}"));
16438 }
16439 }
16440 }
16441 }
16442
16443 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16444 if findings.is_empty() {
16445 result.push_str("- DHCP leases look healthy.\n");
16446 } else {
16447 for f in &findings {
16448 result.push_str(&format!("- Finding: {f}\n"));
16449 }
16450 }
16451 result.push('\n');
16452 result.push_str(&out);
16453 Ok(result)
16454}
16455
16456#[cfg(not(windows))]
16457fn inspect_dhcp() -> Result<String, String> {
16458 let mut out = String::from(
16459 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16460 );
16461 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16462 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16463 if std::path::Path::new(path).exists() {
16464 let cat = std::process::Command::new("cat").arg(path).output();
16465 if let Ok(o) = cat {
16466 let text = String::from_utf8_lossy(&o.stdout);
16467 for line in text.lines().take(40) {
16468 let l = line.trim();
16469 if l.contains("lease")
16470 || l.contains("expire")
16471 || l.contains("server")
16472 || l.contains("address")
16473 {
16474 out.push_str(&format!("- {l}\n"));
16475 }
16476 }
16477 }
16478 }
16479 }
16480 let ip = std::process::Command::new("ip")
16482 .args(["addr", "show"])
16483 .output();
16484 if let Ok(o) = ip {
16485 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16486 for line in String::from_utf8_lossy(&o.stdout).lines() {
16487 let l = line.trim();
16488 if l.starts_with("inet") || l.contains("dynamic") {
16489 out.push_str(&format!("- {l}\n"));
16490 }
16491 }
16492 }
16493 Ok(out)
16494}
16495
16496#[cfg(windows)]
16497fn inspect_mtu() -> Result<String, String> {
16498 let mut out = String::new();
16499
16500 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16501 let ps_mtu = r#"
16502Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16503 Sort-Object ConnectionState, InterfaceAlias |
16504 ForEach-Object {
16505 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16506 }
16507"#;
16508 match run_powershell(ps_mtu) {
16509 Ok(o) => {
16510 for line in o.lines() {
16511 let l = line.trim();
16512 if !l.is_empty() {
16513 out.push_str(&format!("- {l}\n"));
16514 }
16515 }
16516 }
16517 Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16518 }
16519
16520 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16521 let ps_mtu6 = r#"
16522Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16523 Sort-Object ConnectionState, InterfaceAlias |
16524 ForEach-Object {
16525 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16526 }
16527"#;
16528 match run_powershell(ps_mtu6) {
16529 Ok(o) => {
16530 for line in o.lines() {
16531 let l = line.trim();
16532 if !l.is_empty() {
16533 out.push_str(&format!("- {l}\n"));
16534 }
16535 }
16536 }
16537 Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16538 }
16539
16540 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16541 let ps_pmtu = r#"
16543$sizes = @(1472, 1400, 1280, 576)
16544$result = $null
16545foreach ($s in $sizes) {
16546 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16547 if ($r) { $result = $s; break }
16548}
16549if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16550else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16551"#;
16552 match run_powershell(ps_pmtu) {
16553 Ok(o) => {
16554 for line in o.lines() {
16555 let l = line.trim();
16556 if !l.is_empty() {
16557 out.push_str(&format!("- {l}\n"));
16558 }
16559 }
16560 }
16561 Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16562 }
16563
16564 let mut findings: Vec<String> = Vec::new();
16565 if out.contains("MTU: 576 bytes") {
16566 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16567 }
16568 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16569 findings.push(
16570 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16571 .into(),
16572 );
16573 }
16574 if out.contains("All test sizes failed") {
16575 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16576 }
16577
16578 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16579 if findings.is_empty() {
16580 result.push_str("- MTU configuration looks normal.\n");
16581 } else {
16582 for f in &findings {
16583 result.push_str(&format!("- Finding: {f}\n"));
16584 }
16585 }
16586 result.push('\n');
16587 result.push_str(&out);
16588 Ok(result)
16589}
16590
16591#[cfg(not(windows))]
16592fn inspect_mtu() -> Result<String, String> {
16593 let mut out = String::from(
16594 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16595 );
16596
16597 out.push_str("=== Per-interface MTU (ip link) ===\n");
16598 let ip = std::process::Command::new("ip")
16599 .args(["link", "show"])
16600 .output();
16601 if let Ok(o) = ip {
16602 for line in String::from_utf8_lossy(&o.stdout).lines() {
16603 let l = line.trim();
16604 if l.contains("mtu") || l.starts_with("\\d") {
16605 out.push_str(&format!("- {l}\n"));
16606 }
16607 }
16608 }
16609
16610 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16611 let ping = std::process::Command::new("ping")
16612 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16613 .output();
16614 match ping {
16615 Ok(o) => {
16616 let body = String::from_utf8_lossy(&o.stdout);
16617 for line in body.lines() {
16618 let l = line.trim();
16619 if !l.is_empty() {
16620 out.push_str(&format!("- {l}\n"));
16621 }
16622 }
16623 }
16624 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16625 }
16626 Ok(out)
16627}
16628
16629#[cfg(not(windows))]
16630fn inspect_cpu_power() -> Result<String, String> {
16631 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16632
16633 out.push_str("=== CPU frequency (Linux) ===\n");
16635 let cat_scaling = std::process::Command::new("cat")
16636 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16637 .output();
16638 if let Ok(o) = cat_scaling {
16639 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16640 .trim()
16641 .parse()
16642 .unwrap_or(0);
16643 if khz > 0 {
16644 out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16645 }
16646 }
16647 let cat_max = std::process::Command::new("cat")
16648 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16649 .output();
16650 if let Ok(o) = cat_max {
16651 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16652 .trim()
16653 .parse()
16654 .unwrap_or(0);
16655 if khz > 0 {
16656 out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16657 }
16658 }
16659 let governor = std::process::Command::new("cat")
16660 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16661 .output();
16662 if let Ok(o) = governor {
16663 let g = String::from_utf8_lossy(&o.stdout);
16664 let g = g.trim();
16665 if !g.is_empty() {
16666 out.push_str(&format!("- Governor: {g}\n"));
16667 }
16668 }
16669 Ok(out)
16670}
16671
16672#[cfg(windows)]
16675fn inspect_ipv6() -> Result<String, String> {
16676 let script = r#"
16677$result = [System.Text.StringBuilder]::new()
16678
16679# Per-adapter IPv6 addresses
16680$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16681$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16682 Where-Object { $_.IPAddress -notmatch '^::1$' } |
16683 Sort-Object InterfaceAlias
16684foreach ($a in $adapters) {
16685 $prefix = $a.PrefixOrigin
16686 $suffix = $a.SuffixOrigin
16687 $scope = $a.AddressState
16688 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
16689}
16690if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
16691
16692# Default gateway IPv6
16693$result.AppendLine("") | Out-Null
16694$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16695$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16696if ($gw6) {
16697 foreach ($g in $gw6) {
16698 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
16699 }
16700} else {
16701 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
16702}
16703
16704# DHCPv6 lease info
16705$result.AppendLine("") | Out-Null
16706$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16707$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16708 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16709if ($dhcpv6) {
16710 foreach ($d in $dhcpv6) {
16711 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16712 }
16713} else {
16714 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16715}
16716
16717# Privacy extensions
16718$result.AppendLine("") | Out-Null
16719$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16720try {
16721 $priv = netsh interface ipv6 show privacy
16722 $result.AppendLine(($priv -join "`n")) | Out-Null
16723} catch {
16724 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
16725}
16726
16727# Tunnel adapters
16728$result.AppendLine("") | Out-Null
16729$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16730$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
16731if ($tunnels) {
16732 foreach ($t in $tunnels) {
16733 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
16734 }
16735} else {
16736 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
16737}
16738
16739# Findings
16740$findings = [System.Collections.Generic.List[string]]::new()
16741$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16742 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
16743if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
16744$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
16745if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
16746
16747$result.AppendLine("") | Out-Null
16748$result.AppendLine("=== Findings ===") | Out-Null
16749if ($findings.Count -eq 0) {
16750 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
16751} else {
16752 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16753}
16754
16755Write-Output $result.ToString()
16756"#;
16757 let out = run_powershell(script)?;
16758 Ok(format!("Host inspection: ipv6\n\n{out}"))
16759}
16760
16761#[cfg(not(windows))]
16762fn inspect_ipv6() -> Result<String, String> {
16763 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
16764 if let Ok(o) = std::process::Command::new("ip")
16765 .args(["-6", "addr", "show"])
16766 .output()
16767 {
16768 out.push_str(&String::from_utf8_lossy(&o.stdout));
16769 }
16770 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
16771 if let Ok(o) = std::process::Command::new("ip")
16772 .args(["-6", "route"])
16773 .output()
16774 {
16775 out.push_str(&String::from_utf8_lossy(&o.stdout));
16776 }
16777 Ok(out)
16778}
16779
16780#[cfg(windows)]
16783fn inspect_tcp_params() -> Result<String, String> {
16784 let script = r#"
16785$result = [System.Text.StringBuilder]::new()
16786
16787# Autotuning and global TCP settings
16788$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
16789try {
16790 $global = netsh interface tcp show global
16791 foreach ($line in $global) {
16792 $l = $line.Trim()
16793 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
16794 $result.AppendLine(" $l") | Out-Null
16795 }
16796 }
16797} catch {
16798 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
16799}
16800
16801# Supplemental params via Get-NetTCPSetting
16802$result.AppendLine("") | Out-Null
16803$result.AppendLine("=== TCP settings profiles ===") | Out-Null
16804try {
16805 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
16806 foreach ($s in $tcpSettings) {
16807 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
16808 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
16809 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
16810 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
16811 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
16812 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
16813 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
16814 $result.AppendLine("") | Out-Null
16815 }
16816} catch {
16817 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
16818}
16819
16820# Chimney offload state
16821$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
16822try {
16823 $chimney = netsh interface tcp show chimney
16824 $result.AppendLine(($chimney -join "`n ")) | Out-Null
16825} catch {
16826 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
16827}
16828
16829# ECN state
16830$result.AppendLine("") | Out-Null
16831$result.AppendLine("=== ECN capability ===") | Out-Null
16832try {
16833 $ecn = netsh interface tcp show ecncapability
16834 $result.AppendLine(($ecn -join "`n ")) | Out-Null
16835} catch {
16836 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
16837}
16838
16839# Findings
16840$findings = [System.Collections.Generic.List[string]]::new()
16841try {
16842 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
16843 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
16844 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
16845 }
16846 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
16847 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
16848 }
16849} catch {}
16850
16851$result.AppendLine("") | Out-Null
16852$result.AppendLine("=== Findings ===") | Out-Null
16853if ($findings.Count -eq 0) {
16854 $result.AppendLine("- TCP parameters look normal.") | Out-Null
16855} else {
16856 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16857}
16858
16859Write-Output $result.ToString()
16860"#;
16861 let out = run_powershell(script)?;
16862 Ok(format!("Host inspection: tcp_params\n\n{out}"))
16863}
16864
16865#[cfg(not(windows))]
16866fn inspect_tcp_params() -> Result<String, String> {
16867 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
16868 for key in &[
16869 "net.ipv4.tcp_congestion_control",
16870 "net.ipv4.tcp_rmem",
16871 "net.ipv4.tcp_wmem",
16872 "net.ipv4.tcp_window_scaling",
16873 "net.ipv4.tcp_ecn",
16874 "net.ipv4.tcp_timestamps",
16875 ] {
16876 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
16877 out.push_str(&format!(
16878 " {}\n",
16879 String::from_utf8_lossy(&o.stdout).trim()
16880 ));
16881 }
16882 }
16883 Ok(out)
16884}
16885
16886#[cfg(windows)]
16889fn inspect_wlan_profiles() -> Result<String, String> {
16890 let script = r#"
16891$result = [System.Text.StringBuilder]::new()
16892
16893# List all saved profiles
16894$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
16895try {
16896 $profilesRaw = netsh wlan show profiles
16897 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16898 $_.Matches[0].Groups[1].Value.Trim()
16899 }
16900
16901 if (-not $profiles) {
16902 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
16903 } else {
16904 foreach ($p in $profiles) {
16905 $result.AppendLine("") | Out-Null
16906 $result.AppendLine(" Profile: $p") | Out-Null
16907 # Get detail for each profile
16908 $detail = netsh wlan show profile name="$p" key=clear 2>$null
16909 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16910 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
16911 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
16912 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
16913 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16914 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16915 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16916 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16917 }
16918 }
16919} catch {
16920 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
16921}
16922
16923# Currently connected SSID
16924$result.AppendLine("") | Out-Null
16925$result.AppendLine("=== Currently connected ===") | Out-Null
16926try {
16927 $conn = netsh wlan show interfaces
16928 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
16929 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
16930 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
16931 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
16932 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16933 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16934 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16935 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16936 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
16937} catch {
16938 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
16939}
16940
16941# Findings
16942$findings = [System.Collections.Generic.List[string]]::new()
16943try {
16944 $allDetail = netsh wlan show profiles 2>$null
16945 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16946 $_.Matches[0].Groups[1].Value.Trim()
16947 }
16948 foreach ($pn in $profileNames) {
16949 $det = netsh wlan show profile name="$pn" key=clear 2>$null
16950 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16951 if ($authLine) {
16952 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
16953 if ($authVal -match 'Open|WEP|None') {
16954 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
16955 }
16956 }
16957 }
16958} catch {}
16959
16960$result.AppendLine("") | Out-Null
16961$result.AppendLine("=== Findings ===") | Out-Null
16962if ($findings.Count -eq 0) {
16963 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
16964} else {
16965 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16966}
16967
16968Write-Output $result.ToString()
16969"#;
16970 let out = run_powershell(script)?;
16971 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
16972}
16973
16974#[cfg(not(windows))]
16975fn inspect_wlan_profiles() -> Result<String, String> {
16976 let mut out =
16977 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
16978 if let Ok(o) = std::process::Command::new("nmcli")
16980 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
16981 .output()
16982 {
16983 for line in String::from_utf8_lossy(&o.stdout).lines() {
16984 if line.contains("wireless") || line.contains("wifi") {
16985 out.push_str(&format!(" {line}\n"));
16986 }
16987 }
16988 } else {
16989 out.push_str(" nmcli not available.\n");
16990 }
16991 Ok(out)
16992}
16993
16994#[cfg(windows)]
16997fn inspect_ipsec() -> Result<String, String> {
16998 let script = r#"
16999$result = [System.Text.StringBuilder]::new()
17000
17001# IPSec rules (firewall-integrated)
17002$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17003try {
17004 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17005 if ($rules) {
17006 foreach ($r in $rules) {
17007 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
17008 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
17009 $result.AppendLine(" Action: $($r.Action)") | Out-Null
17010 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
17011 }
17012 } else {
17013 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
17014 }
17015} catch {
17016 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
17017}
17018
17019# Active main-mode SAs
17020$result.AppendLine("") | Out-Null
17021$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17022try {
17023 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17024 if ($mmSAs) {
17025 foreach ($sa in $mmSAs) {
17026 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17027 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
17028 }
17029 } else {
17030 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
17031 }
17032} catch {
17033 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
17034}
17035
17036# Active quick-mode SAs
17037$result.AppendLine("") | Out-Null
17038$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17039try {
17040 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17041 if ($qmSAs) {
17042 foreach ($sa in $qmSAs) {
17043 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17044 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17045 }
17046 } else {
17047 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
17048 }
17049} catch {
17050 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17051}
17052
17053# IKE service state
17054$result.AppendLine("") | Out-Null
17055$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17056$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17057if ($ikeAgentSvc) {
17058 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17059} else {
17060 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
17061}
17062
17063# Findings
17064$findings = [System.Collections.Generic.List[string]]::new()
17065$mmSACount = 0
17066try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17067if ($mmSACount -gt 0) {
17068 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17069}
17070
17071$result.AppendLine("") | Out-Null
17072$result.AppendLine("=== Findings ===") | Out-Null
17073if ($findings.Count -eq 0) {
17074 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17075} else {
17076 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17077}
17078
17079Write-Output $result.ToString()
17080"#;
17081 let out = run_powershell(script)?;
17082 Ok(format!("Host inspection: ipsec\n\n{out}"))
17083}
17084
17085#[cfg(not(windows))]
17086fn inspect_ipsec() -> Result<String, String> {
17087 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17088 if let Ok(o) = std::process::Command::new("ip")
17089 .args(["xfrm", "state"])
17090 .output()
17091 {
17092 let body = String::from_utf8_lossy(&o.stdout);
17093 if body.trim().is_empty() {
17094 out.push_str(" No active IPSec SAs.\n");
17095 } else {
17096 out.push_str(&body);
17097 }
17098 }
17099 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17100 if let Ok(o) = std::process::Command::new("ip")
17101 .args(["xfrm", "policy"])
17102 .output()
17103 {
17104 let body = String::from_utf8_lossy(&o.stdout);
17105 if body.trim().is_empty() {
17106 out.push_str(" No IPSec policies.\n");
17107 } else {
17108 out.push_str(&body);
17109 }
17110 }
17111 Ok(out)
17112}
17113
17114#[cfg(windows)]
17117fn inspect_netbios() -> Result<String, String> {
17118 let script = r#"
17119$result = [System.Text.StringBuilder]::new()
17120
17121# NetBIOS node type and WINS per adapter
17122$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17123try {
17124 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17125 Where-Object { $_.IPEnabled -eq $true }
17126 foreach ($a in $adapters) {
17127 $nodeType = switch ($a.TcpipNetbiosOptions) {
17128 0 { "EnableNetBIOSViaDHCP" }
17129 1 { "Enabled" }
17130 2 { "Disabled" }
17131 default { "Unknown ($($a.TcpipNetbiosOptions))" }
17132 }
17133 $result.AppendLine(" [$($a.Description)]") | Out-Null
17134 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
17135 if ($a.WINSPrimaryServer) {
17136 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
17137 }
17138 if ($a.WINSSecondaryServer) {
17139 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
17140 }
17141 }
17142} catch {
17143 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
17144}
17145
17146# nbtstat -n — registered local NetBIOS names
17147$result.AppendLine("") | Out-Null
17148$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17149try {
17150 $nbt = nbtstat -n 2>$null
17151 foreach ($line in $nbt) {
17152 $l = $line.Trim()
17153 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17154 $result.AppendLine(" $l") | Out-Null
17155 }
17156 }
17157} catch {
17158 $result.AppendLine(" nbtstat not available.") | Out-Null
17159}
17160
17161# NetBIOS session table
17162$result.AppendLine("") | Out-Null
17163$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17164try {
17165 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17166 if ($sessions) {
17167 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
17168 } else {
17169 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
17170 }
17171} catch {
17172 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
17173}
17174
17175# Findings
17176$findings = [System.Collections.Generic.List[string]]::new()
17177try {
17178 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17179 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17180 if ($enabled) {
17181 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17182 }
17183 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17184 Where-Object { $_.WINSPrimaryServer }
17185 if ($wins) {
17186 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17187 }
17188} catch {}
17189
17190$result.AppendLine("") | Out-Null
17191$result.AppendLine("=== Findings ===") | Out-Null
17192if ($findings.Count -eq 0) {
17193 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17194} else {
17195 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17196}
17197
17198Write-Output $result.ToString()
17199"#;
17200 let out = run_powershell(script)?;
17201 Ok(format!("Host inspection: netbios\n\n{out}"))
17202}
17203
17204#[cfg(not(windows))]
17205fn inspect_netbios() -> Result<String, String> {
17206 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17207 if let Ok(o) = std::process::Command::new("nmblookup")
17208 .arg("-A")
17209 .arg("localhost")
17210 .output()
17211 {
17212 out.push_str(&String::from_utf8_lossy(&o.stdout));
17213 } else {
17214 out.push_str(" nmblookup not available (Samba not installed).\n");
17215 }
17216 Ok(out)
17217}
17218
17219#[cfg(windows)]
17222fn inspect_nic_teaming() -> Result<String, String> {
17223 let script = r#"
17224$result = [System.Text.StringBuilder]::new()
17225
17226# Team inventory
17227$result.AppendLine("=== NIC teams ===") | Out-Null
17228try {
17229 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17230 if ($teams) {
17231 foreach ($t in $teams) {
17232 $result.AppendLine(" Team: $($t.Name)") | Out-Null
17233 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
17234 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
17235 $result.AppendLine(" Status: $($t.Status)") | Out-Null
17236 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
17237 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17238 }
17239 } else {
17240 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
17241 }
17242} catch {
17243 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17244}
17245
17246# Team members detail
17247$result.AppendLine("") | Out-Null
17248$result.AppendLine("=== Team member detail ===") | Out-Null
17249try {
17250 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17251 if ($members) {
17252 foreach ($m in $members) {
17253 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
17254 }
17255 } else {
17256 $result.AppendLine(" No team members found.") | Out-Null
17257 }
17258} catch {
17259 $result.AppendLine(" Could not query team members.") | Out-Null
17260}
17261
17262# Findings
17263$findings = [System.Collections.Generic.List[string]]::new()
17264try {
17265 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17266 if ($degraded) {
17267 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17268 }
17269 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17270 if ($downMembers) {
17271 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17272 }
17273} catch {}
17274
17275$result.AppendLine("") | Out-Null
17276$result.AppendLine("=== Findings ===") | Out-Null
17277if ($findings.Count -eq 0) {
17278 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17279} else {
17280 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17281}
17282
17283Write-Output $result.ToString()
17284"#;
17285 let out = run_powershell(script)?;
17286 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17287}
17288
17289#[cfg(not(windows))]
17290fn inspect_nic_teaming() -> Result<String, String> {
17291 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17292 if let Ok(o) = std::process::Command::new("cat")
17293 .arg("/proc/net/bonding/bond0")
17294 .output()
17295 {
17296 if o.status.success() {
17297 out.push_str(&String::from_utf8_lossy(&o.stdout));
17298 } else {
17299 out.push_str(" No bond0 interface found.\n");
17300 }
17301 }
17302 if let Ok(o) = std::process::Command::new("ip")
17303 .args(["link", "show", "type", "bond"])
17304 .output()
17305 {
17306 let body = String::from_utf8_lossy(&o.stdout);
17307 if !body.trim().is_empty() {
17308 out.push_str("\n=== Bond links (ip link) ===\n");
17309 out.push_str(&body);
17310 }
17311 }
17312 Ok(out)
17313}
17314
17315#[cfg(windows)]
17318fn inspect_snmp() -> Result<String, String> {
17319 let script = r#"
17320$result = [System.Text.StringBuilder]::new()
17321
17322# SNMP service state
17323$result.AppendLine("=== SNMP service state ===") | Out-Null
17324$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17325if ($svc) {
17326 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17327} else {
17328 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
17329}
17330
17331$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17332if ($svcTrap) {
17333 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17334}
17335
17336# Community strings (presence only — values redacted)
17337$result.AppendLine("") | Out-Null
17338$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17339try {
17340 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17341 if ($communities) {
17342 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17343 if ($names) {
17344 foreach ($n in $names) {
17345 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
17346 }
17347 } else {
17348 $result.AppendLine(" No community strings configured.") | Out-Null
17349 }
17350 } else {
17351 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
17352 }
17353} catch {
17354 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
17355}
17356
17357# Permitted managers
17358$result.AppendLine("") | Out-Null
17359$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17360try {
17361 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17362 if ($managers) {
17363 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17364 if ($mgrs) {
17365 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
17366 } else {
17367 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
17368 }
17369 } else {
17370 $result.AppendLine(" No manager restrictions configured.") | Out-Null
17371 }
17372} catch {
17373 $result.AppendLine(" Could not read permitted managers.") | Out-Null
17374}
17375
17376# Findings
17377$findings = [System.Collections.Generic.List[string]]::new()
17378$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17379if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17380 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17381 try {
17382 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17383 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17384 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17385 } catch {}
17386}
17387
17388$result.AppendLine("") | Out-Null
17389$result.AppendLine("=== Findings ===") | Out-Null
17390if ($findings.Count -eq 0) {
17391 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17392} else {
17393 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17394}
17395
17396Write-Output $result.ToString()
17397"#;
17398 let out = run_powershell(script)?;
17399 Ok(format!("Host inspection: snmp\n\n{out}"))
17400}
17401
17402#[cfg(not(windows))]
17403fn inspect_snmp() -> Result<String, String> {
17404 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17405 for svc in &["snmpd", "snmp"] {
17406 if let Ok(o) = std::process::Command::new("systemctl")
17407 .args(["is-active", svc])
17408 .output()
17409 {
17410 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17411 out.push_str(&format!(" {svc}: {status}\n"));
17412 }
17413 }
17414 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17415 if let Ok(o) = std::process::Command::new("grep")
17416 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17417 .output()
17418 {
17419 if o.status.success() {
17420 for line in String::from_utf8_lossy(&o.stdout).lines() {
17421 out.push_str(&format!(" {line}\n"));
17422 }
17423 } else {
17424 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
17425 }
17426 }
17427 Ok(out)
17428}
17429
17430#[cfg(windows)]
17433fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17434 let target_host = host.unwrap_or("8.8.8.8");
17435 let target_port = port.unwrap_or(443);
17436
17437 let script = format!(
17438 r#"
17439$result = [System.Text.StringBuilder]::new()
17440$result.AppendLine("=== Port reachability test ===") | Out-Null
17441$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
17442$result.AppendLine("") | Out-Null
17443
17444try {{
17445 $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17446 if ($test) {{
17447 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17448 $result.AppendLine(" Result: $status") | Out-Null
17449 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
17450 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
17451 if ($test.PingSucceeded) {{
17452 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17453 }} else {{
17454 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
17455 }}
17456 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
17457 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
17458
17459 $result.AppendLine("") | Out-Null
17460 $result.AppendLine("=== Findings ===") | Out-Null
17461 if ($test.TcpTestSucceeded) {{
17462 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17463 }} else {{
17464 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17465 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17466 }}
17467 }}
17468}} catch {{
17469 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17470}}
17471
17472Write-Output $result.ToString()
17473"#
17474 );
17475 let out = run_powershell(&script)?;
17476 Ok(format!("Host inspection: port_test\n\n{out}"))
17477}
17478
17479#[cfg(not(windows))]
17480fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17481 let target_host = host.unwrap_or("8.8.8.8");
17482 let target_port = port.unwrap_or(443);
17483 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
17484 let nc = std::process::Command::new("nc")
17486 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17487 .output();
17488 match nc {
17489 Ok(o) => {
17490 let stderr = String::from_utf8_lossy(&o.stderr);
17491 let stdout = String::from_utf8_lossy(&o.stdout);
17492 let body = if !stdout.trim().is_empty() {
17493 stdout.as_ref()
17494 } else {
17495 stderr.as_ref()
17496 };
17497 out.push_str(&format!(" {}\n", body.trim()));
17498 out.push_str("\n=== Findings ===\n");
17499 if o.status.success() {
17500 out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17501 } else {
17502 out.push_str(&format!(
17503 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17504 ));
17505 }
17506 }
17507 Err(e) => out.push_str(&format!(" nc not available: {e}\n")),
17508 }
17509 Ok(out)
17510}
17511
17512#[cfg(windows)]
17515fn inspect_network_profile() -> Result<String, String> {
17516 let script = r#"
17517$result = [System.Text.StringBuilder]::new()
17518
17519$result.AppendLine("=== Network location profiles ===") | Out-Null
17520try {
17521 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17522 if ($profiles) {
17523 foreach ($p in $profiles) {
17524 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
17525 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
17526 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
17527 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
17528 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
17529 $result.AppendLine("") | Out-Null
17530 }
17531 } else {
17532 $result.AppendLine(" No network connection profiles found.") | Out-Null
17533 }
17534} catch {
17535 $result.AppendLine(" Could not query network profiles.") | Out-Null
17536}
17537
17538# Findings
17539$findings = [System.Collections.Generic.List[string]]::new()
17540try {
17541 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17542 if ($pub) {
17543 foreach ($p in $pub) {
17544 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17545 }
17546 }
17547 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17548 if ($domain) {
17549 foreach ($d in $domain) {
17550 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17551 }
17552 }
17553} catch {}
17554
17555$result.AppendLine("=== Findings ===") | Out-Null
17556if ($findings.Count -eq 0) {
17557 $result.AppendLine("- Network profiles look normal.") | Out-Null
17558} else {
17559 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17560}
17561
17562Write-Output $result.ToString()
17563"#;
17564 let out = run_powershell(script)?;
17565 Ok(format!("Host inspection: network_profile\n\n{out}"))
17566}
17567
17568#[cfg(not(windows))]
17569fn inspect_network_profile() -> Result<String, String> {
17570 let mut out = String::from(
17571 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17572 );
17573 if let Ok(o) = std::process::Command::new("nmcli")
17574 .args([
17575 "-t",
17576 "-f",
17577 "NAME,TYPE,STATE,DEVICE",
17578 "connection",
17579 "show",
17580 "--active",
17581 ])
17582 .output()
17583 {
17584 out.push_str(&String::from_utf8_lossy(&o.stdout));
17585 } else {
17586 out.push_str(" nmcli not available.\n");
17587 }
17588 Ok(out)
17589}
17590
17591#[cfg(windows)]
17594fn inspect_storage_spaces() -> Result<String, String> {
17595 let script = r#"
17596$result = [System.Text.StringBuilder]::new()
17597
17598# Storage Pools
17599try {
17600 $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
17601 if ($pools) {
17602 $result.AppendLine("=== Storage Pools ===") | Out-Null
17603 foreach ($pool in $pools) {
17604 $health = $pool.HealthStatus
17605 $oper = $pool.OperationalStatus
17606 $sizGB = [math]::Round($pool.Size / 1GB, 1)
17607 $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
17608 $result.AppendLine(" Pool: $($pool.FriendlyName) Size: ${sizGB}GB Allocated: ${allocGB}GB Health: $health Status: $oper") | Out-Null
17609 }
17610 $result.AppendLine("") | Out-Null
17611 } else {
17612 $result.AppendLine("=== Storage Pools ===") | Out-Null
17613 $result.AppendLine(" No Storage Spaces pools configured.") | Out-Null
17614 $result.AppendLine("") | Out-Null
17615 }
17616} catch {
17617 $result.AppendLine("=== Storage Pools ===") | Out-Null
17618 $result.AppendLine(" Unable to query storage pools (may require elevation).") | Out-Null
17619 $result.AppendLine("") | Out-Null
17620}
17621
17622# Virtual Disks
17623try {
17624 $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
17625 if ($vdisks) {
17626 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17627 foreach ($vd in $vdisks) {
17628 $health = $vd.HealthStatus
17629 $oper = $vd.OperationalStatus
17630 $layout = $vd.ResiliencySettingName
17631 $sizGB = [math]::Round($vd.Size / 1GB, 1)
17632 $result.AppendLine(" VDisk: $($vd.FriendlyName) Layout: $layout Size: ${sizGB}GB Health: $health Status: $oper") | Out-Null
17633 }
17634 $result.AppendLine("") | Out-Null
17635 } else {
17636 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17637 $result.AppendLine(" No Storage Spaces virtual disks configured.") | Out-Null
17638 $result.AppendLine("") | Out-Null
17639 }
17640} catch {
17641 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17642 $result.AppendLine(" Unable to query virtual disks.") | Out-Null
17643 $result.AppendLine("") | Out-Null
17644}
17645
17646# Physical Disks in pools
17647try {
17648 $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
17649 if ($pdisks) {
17650 $result.AppendLine("=== Physical Disks ===") | Out-Null
17651 foreach ($pd in $pdisks) {
17652 $sizGB = [math]::Round($pd.Size / 1GB, 1)
17653 $health = $pd.HealthStatus
17654 $usage = $pd.Usage
17655 $media = $pd.MediaType
17656 $result.AppendLine(" $($pd.FriendlyName) ${sizGB}GB $media Usage: $usage Health: $health") | Out-Null
17657 }
17658 $result.AppendLine("") | Out-Null
17659 }
17660} catch {}
17661
17662# Findings
17663$findings = @()
17664try {
17665 $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17666 foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
17667 $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17668 foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
17669 $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
17670 foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
17671} catch {}
17672
17673if ($findings.Count -gt 0) {
17674 $result.AppendLine("=== Findings ===") | Out-Null
17675 foreach ($f in $findings) { $result.AppendLine(" $f") | Out-Null }
17676} else {
17677 $result.AppendLine("=== Findings ===") | Out-Null
17678 $result.AppendLine(" All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
17679}
17680
17681Write-Output $result.ToString().TrimEnd()
17682"#;
17683 let out = run_powershell(script)?;
17684 Ok(format!("Host inspection: storage_spaces\n\n{out}"))
17685}
17686
17687#[cfg(not(windows))]
17688fn inspect_storage_spaces() -> Result<String, String> {
17689 let mut out = String::from("Host inspection: storage_spaces\n\n");
17690 let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
17692 if !mdstat.is_empty() {
17693 out.push_str("=== Software RAID (/proc/mdstat) ===\n");
17694 out.push_str(&mdstat);
17695 } else {
17696 out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
17697 }
17698 if let Ok(o) = Command::new("lvs")
17700 .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
17701 .output()
17702 {
17703 let lvs = String::from_utf8_lossy(&o.stdout).to_string();
17704 if !lvs.trim().is_empty() {
17705 out.push_str("\n=== LVM Logical Volumes ===\n");
17706 out.push_str(&lvs);
17707 }
17708 }
17709 Ok(out)
17710}
17711
17712#[cfg(windows)]
17715fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
17716 let limit = max_entries.min(50);
17717 let script = format!(
17718 r#"
17719$result = [System.Text.StringBuilder]::new()
17720
17721# Current threat detections (active + quarantined)
17722try {{
17723 $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
17724 if ($threats) {{
17725 $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
17726 foreach ($t in $threats) {{
17727 $name = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
17728 if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
17729 $time = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
17730 $action = $t.ActionSuccess
17731 $status = $t.CurrentThreatExecutionStatusID
17732 $result.AppendLine(" [$time] $name ActionSuccess:$action Status:$status") | Out-Null
17733 }}
17734 $result.AppendLine("") | Out-Null
17735 }} else {{
17736 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
17737 $result.AppendLine(" No threat detections on record — Defender history is clean.") | Out-Null
17738 $result.AppendLine("") | Out-Null
17739 }}
17740}} catch {{
17741 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
17742 $result.AppendLine(" Unable to query threat detections: $_") | Out-Null
17743 $result.AppendLine("") | Out-Null
17744}}
17745
17746# Quarantine items
17747try {{
17748 $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
17749 if ($quarantine) {{
17750 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
17751 foreach ($q in $quarantine) {{
17752 $result.AppendLine(" $($q.ThreatName) Severity:$($q.SeverityID) Category:$($q.CategoryID) Active:$($q.IsActive)") | Out-Null
17753 }}
17754 $result.AppendLine("") | Out-Null
17755 }} else {{
17756 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
17757 $result.AppendLine(" No quarantined threats found.") | Out-Null
17758 $result.AppendLine("") | Out-Null
17759 }}
17760}} catch {{
17761 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
17762 $result.AppendLine(" Unable to query quarantine list: $_") | Out-Null
17763 $result.AppendLine("") | Out-Null
17764}}
17765
17766# Defender scan stats
17767try {{
17768 $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
17769 if ($status) {{
17770 $lastScan = $status.QuickScanStartTime
17771 $lastFull = $status.FullScanStartTime
17772 $sigDate = $status.AntivirusSignatureLastUpdated
17773 $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
17774 $result.AppendLine(" Last quick scan : $lastScan") | Out-Null
17775 $result.AppendLine(" Last full scan : $lastFull") | Out-Null
17776 $result.AppendLine(" Signature date : $sigDate") | Out-Null
17777 }}
17778}} catch {{}}
17779
17780Write-Output $result.ToString().TrimEnd()
17781"#,
17782 limit = limit
17783 );
17784 let out = run_powershell(&script)?;
17785 Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
17786}
17787
17788#[cfg(windows)]
17791fn inspect_domain_health() -> Result<String, String> {
17792 let script = r#"
17793$result = [System.Text.StringBuilder]::new()
17794
17795# Domain membership
17796try {
17797 $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
17798 $joined = $cs.PartOfDomain
17799 $domain = $cs.Domain
17800 $result.AppendLine("=== Domain Membership ===") | Out-Null
17801 $result.AppendLine(" Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
17802 if ($joined) { $result.AppendLine(" Domain : $domain") | Out-Null }
17803 $result.AppendLine(" Computer : $($cs.Name)") | Out-Null
17804} catch {
17805 $result.AppendLine(" Domain membership check failed: $_") | Out-Null
17806}
17807
17808# dsregcmd device registration state
17809try {
17810 $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
17811 if ($dsreg) {
17812 $result.AppendLine("") | Out-Null
17813 $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
17814 foreach ($line in $dsreg) { $result.AppendLine(" $($line.Trim())") | Out-Null }
17815 }
17816} catch {}
17817
17818# DC discovery via nltest
17819$result.AppendLine("") | Out-Null
17820$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
17821try {
17822 $nl = nltest /dsgetdc:. 2>&1
17823 $dc_name = $null
17824 foreach ($line in $nl) {
17825 if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
17826 $result.AppendLine(" $($line.Trim())") | Out-Null
17827 }
17828 if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
17829 }
17830 if ($dc_name) {
17831 $result.AppendLine("") | Out-Null
17832 $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
17833 foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
17834 try {
17835 $tcp = New-Object System.Net.Sockets.TcpClient
17836 $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
17837 $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
17838 $tcp.Close()
17839 $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
17840 } catch { $status = 'FAILED' }
17841 $result.AppendLine(" Port $($entry.p) ($($entry.n)): $status") | Out-Null
17842 }
17843 }
17844} catch {
17845 $result.AppendLine(" nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
17846}
17847
17848# Last GPO machine refresh time
17849try {
17850 $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
17851 if (Test-Path $gpoKey) {
17852 $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
17853 $result.AppendLine("") | Out-Null
17854 $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
17855 $result.AppendLine(" Machine GPO last applied: $($gpo.EndTime)") | Out-Null
17856 }
17857} catch {}
17858
17859Write-Output $result.ToString().TrimEnd()
17860"#;
17861 let out = run_powershell(script)?;
17862 Ok(format!("Host inspection: domain_health\n\n{out}"))
17863}
17864
17865#[cfg(not(windows))]
17866fn inspect_domain_health() -> Result<String, String> {
17867 let mut out = String::from("Host inspection: domain_health\n\n");
17868 for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
17869 if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
17870 let s = String::from_utf8_lossy(&o.stdout);
17871 if !s.trim().is_empty() {
17872 out.push_str(&format!("$ {}\n{}\n", cmd_args.join(" "), s.trim_end()));
17873 }
17874 }
17875 }
17876 if out.trim_end().ends_with("domain_health") {
17877 out.push_str("Not domain-joined or realm/sssd not installed.\n");
17878 }
17879 Ok(out)
17880}
17881
17882#[cfg(windows)]
17885fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
17886 let limit = max_entries.min(60);
17887 let script = format!(
17888 r#"
17889$result = [System.Text.StringBuilder]::new()
17890$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
17891$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
17892$result.AppendLine("") | Out-Null
17893$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
17894foreach ($s in $svc) {{
17895 $req = if ($s.RequiredServices.Count -gt 0) {{ "requires: $($s.RequiredServices.Name -join ', ')" }} else {{ "" }}
17896 $dep = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
17897 $parts = @($req, $dep) | Where-Object {{ $_ }}
17898 if ($parts) {{
17899 $result.AppendLine(" [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
17900 }}
17901}}
17902Write-Output $result.ToString().TrimEnd()
17903"#,
17904 limit = limit
17905 );
17906 let out = run_powershell(&script)?;
17907 Ok(format!("Host inspection: service_dependencies\n\n{out}"))
17908}
17909
17910#[cfg(not(windows))]
17911fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
17912 let out = Command::new("systemctl")
17913 .args(["list-dependencies", "--no-pager", "--plain"])
17914 .output()
17915 .ok()
17916 .and_then(|o| String::from_utf8(o.stdout).ok())
17917 .unwrap_or_else(|| "systemctl not available.\n".to_string());
17918 Ok(format!(
17919 "Host inspection: service_dependencies\n\n{}",
17920 out.trim_end()
17921 ))
17922}
17923
17924#[cfg(windows)]
17927fn inspect_wmi_health() -> Result<String, String> {
17928 let script = r#"
17929$result = [System.Text.StringBuilder]::new()
17930$result.AppendLine("=== WMI Repository Health ===") | Out-Null
17931
17932# Basic WMI query test
17933try {
17934 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
17935 $result.AppendLine(" Query (Win32_OperatingSystem): OK") | Out-Null
17936 $result.AppendLine(" OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
17937} catch {
17938 $result.AppendLine(" Query FAILED: $_") | Out-Null
17939 $result.AppendLine(" FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
17940}
17941
17942# Repository integrity
17943try {
17944 $verify = & winmgmt /verifyrepository 2>&1
17945 $result.AppendLine(" winmgmt /verifyrepository: $verify") | Out-Null
17946} catch {
17947 $result.AppendLine(" winmgmt check unavailable: $_") | Out-Null
17948}
17949
17950# WMI service state
17951$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
17952if ($svc) {
17953 $result.AppendLine(" Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
17954}
17955
17956# Repository folder size
17957$repPath = "$env:SystemRoot\System32\wbem\Repository"
17958if (Test-Path $repPath) {
17959 $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
17960 $mb = [math]::Round($bytes / 1MB, 1)
17961 $result.AppendLine(" Repository size: $mb MB ($repPath)") | Out-Null
17962 if ($mb -gt 200) {
17963 $result.AppendLine(" FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
17964 }
17965}
17966
17967$result.AppendLine("") | Out-Null
17968$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
17969$result.AppendLine(" 1. net stop winmgmt") | Out-Null
17970$result.AppendLine(" 2. winmgmt /salvagerepository (try first)") | Out-Null
17971$result.AppendLine(" 3. winmgmt /resetrepository (last resort — loses custom namespaces)") | Out-Null
17972$result.AppendLine(" 4. net start winmgmt") | Out-Null
17973
17974Write-Output $result.ToString().TrimEnd()
17975"#;
17976 let out = run_powershell(script)?;
17977 Ok(format!("Host inspection: wmi_health\n\n{out}"))
17978}
17979
17980#[cfg(not(windows))]
17981fn inspect_wmi_health() -> Result<String, String> {
17982 Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
17983}
17984
17985#[cfg(windows)]
17988fn inspect_local_security_policy() -> Result<String, String> {
17989 let script = r#"
17990$result = [System.Text.StringBuilder]::new()
17991$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
17992$na = net accounts 2>&1
17993foreach ($line in $na) {
17994 if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
17995 $result.AppendLine(" $($line.Trim())") | Out-Null
17996 }
17997}
17998
17999$result.AppendLine("") | Out-Null
18000$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18001try {
18002 $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18003 if ($null -eq $lmLevel) { $lmLevel = 3 }
18004 $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'}
18005 $result.AppendLine(" LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18006 if ($lmLevel -lt 3) {
18007 $result.AppendLine(" FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18008 }
18009} catch {}
18010
18011$result.AppendLine("") | Out-Null
18012$result.AppendLine("=== UAC Settings ===") | Out-Null
18013try {
18014 $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18015 if ($uac) {
18016 $result.AppendLine(" UAC Enabled : $($uac.EnableLUA) (1=on, 0=disabled)") | Out-Null
18017 $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18018 $bval = $uac.ConsentPromptBehaviorAdmin
18019 $result.AppendLine(" Admin Prompt Behavior : $bval — $($behavMap[$bval])") | Out-Null
18020 if ($uac.EnableLUA -eq 0) {
18021 $result.AppendLine(" FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18022 }
18023 }
18024} catch {}
18025
18026Write-Output $result.ToString().TrimEnd()
18027"#;
18028 let out = run_powershell(script)?;
18029 Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18030}
18031
18032#[cfg(not(windows))]
18033fn inspect_local_security_policy() -> Result<String, String> {
18034 let mut out = String::from("Host inspection: local_security_policy\n\n");
18035 if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18036 out.push_str("=== /etc/login.defs ===\n");
18037 for line in content.lines() {
18038 let t = line.trim();
18039 if !t.is_empty() && !t.starts_with('#') {
18040 out.push_str(&format!(" {t}\n"));
18041 }
18042 }
18043 }
18044 Ok(out)
18045}
18046
18047#[cfg(windows)]
18050fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18051 let limit = max_entries.min(50);
18052 let script = format!(
18053 r#"
18054$result = [System.Text.StringBuilder]::new()
18055$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18056$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18057if (Test-Path $usbPath) {{
18058 $count = 0
18059 $seen = @{{}}
18060 $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18061 foreach ($class in $classes) {{
18062 $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18063 foreach ($inst in $instances) {{
18064 if ($count -ge {limit}) {{ break }}
18065 try {{
18066 $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18067 $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18068 if (-not $seen[$fn]) {{
18069 $seen[$fn] = $true
18070 $result.AppendLine(" $fn") | Out-Null
18071 $count++
18072 }}
18073 }} catch {{}}
18074 }}
18075 }}
18076 if ($count -eq 0) {{
18077 $result.AppendLine(" No USB storage devices found in registry.") | Out-Null
18078 }} else {{
18079 $result.AppendLine("") | Out-Null
18080 $result.AppendLine(" ($count unique devices; requires elevation for full history)") | Out-Null
18081 }}
18082}} else {{
18083 $result.AppendLine(" USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18084}}
18085Write-Output $result.ToString().TrimEnd()
18086"#,
18087 limit = limit
18088 );
18089 let out = run_powershell(&script)?;
18090 Ok(format!("Host inspection: usb_history\n\n{out}"))
18091}
18092
18093#[cfg(not(windows))]
18094fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18095 let mut out = String::from("Host inspection: usb_history\n\n");
18096 if let Ok(o) = Command::new("journalctl")
18097 .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18098 .output()
18099 {
18100 let s = String::from_utf8_lossy(&o.stdout);
18101 let usb_lines: Vec<&str> = s
18102 .lines()
18103 .filter(|l| l.to_ascii_lowercase().contains("usb"))
18104 .take(30)
18105 .collect();
18106 if !usb_lines.is_empty() {
18107 out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18108 for line in usb_lines {
18109 out.push_str(&format!(" {line}\n"));
18110 }
18111 }
18112 } else {
18113 out.push_str("USB history via journalctl not available.\n");
18114 }
18115 Ok(out)
18116}
18117
18118#[cfg(windows)]
18121fn inspect_print_spooler() -> Result<String, String> {
18122 let script = r#"
18123$result = [System.Text.StringBuilder]::new()
18124
18125$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18126$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18127if ($svc) {
18128 $result.AppendLine(" Status : $($svc.Status)") | Out-Null
18129 $result.AppendLine(" Start Type : $($svc.StartType)") | Out-Null
18130} else {
18131 $result.AppendLine(" Spooler service not found.") | Out-Null
18132}
18133
18134# PrintNightmare mitigations (CVE-2021-34527)
18135$result.AppendLine("") | Out-Null
18136$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18137try {
18138 $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18139 if ($val -eq 1) {
18140 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18141 } else {
18142 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18143 $result.AppendLine(" FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18144 }
18145} catch { $result.AppendLine(" Mitigation key not readable: $_") | Out-Null }
18146
18147try {
18148 $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18149 if (Test-Path $pnpPath) {
18150 $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18151 $result.AppendLine(" RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18152 $result.AppendLine(" NoWarningNoElevationOnInstall : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18153 if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18154 $result.AppendLine(" FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18155 }
18156 } else {
18157 $result.AppendLine(" No Point and Print policy (using Windows defaults).") | Out-Null
18158 }
18159} catch {}
18160
18161# Pending print jobs
18162$result.AppendLine("") | Out-Null
18163$result.AppendLine("=== Print Queue ===") | Out-Null
18164try {
18165 $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18166 if ($jobs) {
18167 foreach ($j in $jobs | Select-Object -First 5) {
18168 $result.AppendLine(" $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18169 }
18170 } else {
18171 $result.AppendLine(" No pending print jobs.") | Out-Null
18172 }
18173} catch {
18174 $result.AppendLine(" Print queue check requires elevation.") | Out-Null
18175}
18176
18177Write-Output $result.ToString().TrimEnd()
18178"#;
18179 let out = run_powershell(script)?;
18180 Ok(format!("Host inspection: print_spooler\n\n{out}"))
18181}
18182
18183#[cfg(not(windows))]
18184fn inspect_print_spooler() -> Result<String, String> {
18185 let mut out = String::from("Host inspection: print_spooler\n\n");
18186 if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18187 let s = String::from_utf8_lossy(&o.stdout);
18188 if !s.trim().is_empty() {
18189 out.push_str("=== CUPS Status (lpstat -s) ===\n");
18190 out.push_str(s.trim_end());
18191 out.push('\n');
18192 }
18193 } else {
18194 out.push_str("CUPS not detected (lpstat not found).\n");
18195 }
18196 Ok(out)
18197}
18198
18199#[cfg(not(windows))]
18200fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18201 let mut out = String::from("Host inspection: defender_quarantine\n\n");
18202 out.push_str("Windows Defender is Windows-only.\n");
18203 if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18205 if o.status.success() {
18206 out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18207 if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18208 out.push_str("\n=== ClamAV Recent Log ===\n");
18209 for line in log.lines().rev().take(20) {
18210 out.push_str(&format!(" {line}\n"));
18211 }
18212 }
18213 }
18214 } else {
18215 out.push_str("No AV tool detected (ClamAV not found).\n");
18216 }
18217 Ok(out)
18218}