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 "public_ip" | "external_ip" | "myip" => inspect_public_ip().await,
99 "ssl_cert" | "tls_cert" | "cert_audit" | "website_cert" => {
100 let host = args.get("host").and_then(|v| v.as_str()).unwrap_or("google.com");
101 inspect_ssl_cert(host)
102 }
103 "proxy" | "proxy_settings" => inspect_proxy(),
104 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
105 "traceroute" | "tracert" | "trace_route" | "trace" => {
106 let host = args
107 .get("host")
108 .and_then(|v| v.as_str())
109 .unwrap_or("8.8.8.8")
110 .to_string();
111 inspect_traceroute(&host, max_entries)
112 }
113 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
114 "arp" | "arp_table" => inspect_arp(),
115 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
116 "os_config" | "system_config" => inspect_os_config(),
117 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
118 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
119 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
120 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
121 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
122 inspect_docker_filesystems(max_entries)
123 }
124 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
125 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
126 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
127 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
128 "git_config" | "git_global" => inspect_git_config(),
129 "databases" | "database" | "db_services" | "db" => inspect_databases(),
130 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
131 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
132 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
133 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
134 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
135 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
136 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
137 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
138 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
139 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
140 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
141 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
142 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
143 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
144 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
145 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
146 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
147 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
148 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
149 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
150 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
151 "data_audit" | "csv_audit" | "file_audit" => {
152 let path = resolve_optional_path(args)?;
153 inspect_data_audit(path, max_entries).await
154 }
155 "repo_doctor" => {
156 let path = resolve_optional_path(args)?;
157 inspect_repo_doctor(path, max_entries)
158 }
159 "directory" => {
160 let raw_path = args
161 .get("path")
162 .and_then(|v| v.as_str())
163 .ok_or_else(|| {
164 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
165 .to_string()
166 })?;
167 let resolved = resolve_path(raw_path)?;
168 inspect_directory("Directory", resolved, max_entries).await
169 }
170 "disk_benchmark" | "stress_test" | "io_intensity" => {
171 let path = resolve_optional_path(args)?;
172 inspect_disk_benchmark(path).await
173 }
174 "permissions" | "acl" | "access_control" => {
175 let path = resolve_optional_path(args)?;
176 inspect_permissions(path, max_entries)
177 }
178 "login_history" | "logon_history" | "user_logins" => {
179 inspect_login_history(max_entries)
180 }
181 "share_access" | "unc_access" | "remote_share" => {
182 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
183 inspect_share_access(path)
184 }
185 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
186 "thermal" | "throttling" | "overheating" => inspect_thermal(),
187 "activation" | "license_status" | "slmgr" => inspect_activation(),
188 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
189 "ad_user" | "ad" | "domain_user" => {
190 let identity = parse_name_filter(args).unwrap_or_default();
191 inspect_ad_user(&identity)
192 }
193 "dns_lookup" | "dig" | "nslookup" => {
194 let name = parse_name_filter(args).unwrap_or_default();
195 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
196 inspect_dns_lookup(&name, record_type)
197 }
198 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
199 "ip_config" | "ip_detail" => inspect_ip_config(),
200 "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
201 "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
202 "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
203 "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
204 "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
205 "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
206 "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
207 "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
208 "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
209 "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
210 let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
211 let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
212 inspect_port_test(pt_host.as_deref(), pt_port)
213 }
214 "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
215 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
216 "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
217 inspect_display_config(max_entries)
218 }
219 "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
220 inspect_ntp()
221 }
222 "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
223 inspect_cpu_power()
224 }
225 "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
226 inspect_credentials(max_entries)
227 }
228 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
229 inspect_tpm()
230 }
231 "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
232 inspect_latency()
233 }
234 "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
235 inspect_network_adapter()
236 }
237 "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
238 let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
239 let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
240 let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
241 let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
242 let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
243 inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
244 }
245 "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
246 let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
247 inspect_app_crashes(process_filter.as_deref(), max_entries)
248 }
249 "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
250 inspect_mdm_enrollment()
251 }
252 "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
253 inspect_storage_spaces()
254 }
255 "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
256 inspect_defender_quarantine(max_entries)
257 }
258 "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
259 inspect_domain_health()
260 }
261 "service_dependencies" | "svc_deps" | "service_deps" => {
262 inspect_service_dependencies(max_entries)
263 }
264 "wmi_health" | "wmi_repository" | "wmi_status" => {
265 inspect_wmi_health()
266 }
267 "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
268 inspect_local_security_policy()
269 }
270 "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
271 inspect_usb_history(max_entries)
272 }
273 "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
274 inspect_print_spooler()
275 }
276 other => Err(format!(
277 "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, public_ip, ssl_cert, data_audit, network_profile, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, app_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, docker_filesystems, wsl, wsl_filesystems, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, domain_health, device_health, drivers, peripherals, sessions, permissions, login_history, share_access, registry_audit, thermal, activation, patch_history, ad_user, dns_lookup, hyperv, ip_config, overclocker, event_query, mdm_enrollment, storage_spaces, defender_quarantine, service_dependencies, wmi_health, local_security_policy, usb_history, print_spooler.",
278 other
279 )),
280
281 };
282
283 result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
284}
285
286fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
287 let Some(scope) = admin_sensitive_topic_scope(topic) else {
288 return body;
289 };
290 let lower = body.to_lowercase();
291 let privilege_limited = lower.contains("access denied")
292 || lower.contains("administrator privilege is required")
293 || lower.contains("administrator privileges required")
294 || lower.contains("requires administrator")
295 || lower.contains("requires elevation")
296 || lower.contains("non-admin session")
297 || lower.contains("could not be fully determined from this session");
298 if !privilege_limited || lower.contains("=== elevation note ===") {
299 return body;
300 }
301
302 let mut annotated = body;
303 annotated.push_str("\n=== Elevation note ===\n");
304 annotated.push_str("- Hematite should stay non-admin by default.\n");
305 annotated.push_str(
306 "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
307 );
308 annotated.push_str(&format!(
309 "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
310 ));
311 annotated
312}
313
314fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
315 match topic {
316 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
317 Some("TPM / Secure Boot / firmware")
318 }
319 "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
320 "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
321 "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
322 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
323 "windows_features" | "optional_features" | "installed_features" | "features" => {
324 Some("Windows Features")
325 }
326 "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
327 _ => None,
328 }
329}
330
331#[cfg(test)]
332mod privilege_hint_tests {
333 use super::annotate_privilege_limited_output;
334
335 #[test]
336 fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
337 let body = "Host inspection: network\nError: Access denied.\n".to_string();
338 let annotated = annotate_privilege_limited_output("network", body.clone());
339 assert_eq!(annotated, body);
340 }
341
342 #[test]
343 fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
344 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();
345 let annotated = annotate_privilege_limited_output("tpm", body);
346 assert!(annotated.contains("=== Elevation note ==="));
347 assert!(annotated.contains("stay non-admin by default"));
348 assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
349 }
350}
351
352#[cfg(test)]
353mod event_query_tests {
354 use super::is_event_query_no_results_message;
355
356 #[cfg(target_os = "windows")]
357 #[test]
358 fn treats_windows_no_results_message_as_empty_query() {
359 assert!(is_event_query_no_results_message(
360 "No events were found that match the specified selection criteria."
361 ));
362 }
363
364 #[cfg(target_os = "windows")]
365 #[test]
366 fn does_not_treat_real_errors_as_empty_query() {
367 assert!(!is_event_query_no_results_message("Access is denied."));
368 }
369}
370
371fn parse_max_entries(args: &Value) -> usize {
372 args.get("max_entries")
373 .and_then(|v| v.as_u64())
374 .map(|n| n as usize)
375 .unwrap_or(DEFAULT_MAX_ENTRIES)
376 .clamp(1, MAX_ENTRIES_CAP)
377}
378
379fn parse_port_filter(args: &Value) -> Option<u16> {
380 args.get("port")
381 .and_then(|v| v.as_u64())
382 .and_then(|n| u16::try_from(n).ok())
383}
384
385fn parse_name_filter(args: &Value) -> Option<String> {
386 args.get("name")
387 .and_then(|v| v.as_str())
388 .map(str::trim)
389 .filter(|value| !value.is_empty())
390 .map(|value| value.to_string())
391}
392
393fn parse_lookback_hours(args: &Value) -> Option<u32> {
394 args.get("lookback_hours")
395 .and_then(|v| v.as_u64())
396 .map(|n| n as u32)
397}
398
399fn parse_issue_text(args: &Value) -> Option<String> {
400 args.get("issue")
401 .and_then(|v| v.as_str())
402 .map(str::trim)
403 .filter(|value| !value.is_empty())
404 .map(|value| value.to_string())
405}
406
407#[cfg(target_os = "windows")]
408fn is_event_query_no_results_message(message: &str) -> bool {
409 let lower = message.to_ascii_lowercase();
410 lower.contains("no events were found")
411 || lower.contains("no events match the specified selection criteria")
412}
413
414fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
415 match args.get("path").and_then(|v| v.as_str()) {
416 Some(raw_path) => resolve_path(raw_path),
417 None => {
418 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
419 }
420 }
421}
422
423fn inspect_summary(max_entries: usize) -> Result<String, String> {
424 let current_dir =
425 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
426 let workspace_root = crate::tools::file_ops::workspace_root();
427 let workspace_mode = workspace_mode_label(&workspace_root);
428 let path_stats = analyze_path_env();
429 let toolchains = collect_toolchains();
430
431 let mut out = String::from("Host inspection: summary\n\n");
432 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
433 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
434 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
435 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
436 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
437 out.push_str(&format!(
438 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
439 path_stats.total_entries,
440 path_stats.unique_entries,
441 path_stats.duplicate_entries.len(),
442 path_stats.missing_entries.len()
443 ));
444
445 if toolchains.found.is_empty() {
446 out.push_str(
447 "- Toolchains found: none of the common developer tools were detected on PATH\n",
448 );
449 } else {
450 out.push_str("- Toolchains found:\n");
451 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
452 out.push_str(&format!(" - {}: {}\n", label, version));
453 }
454 if toolchains.found.len() > max_entries.min(8) {
455 out.push_str(&format!(
456 " - ... {} more found tools omitted\n",
457 toolchains.found.len() - max_entries.min(8)
458 ));
459 }
460 }
461
462 if !toolchains.missing.is_empty() {
463 out.push_str(&format!(
464 "- Common tools not detected on PATH: {}\n",
465 toolchains.missing.join(", ")
466 ));
467 }
468
469 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
470 match path {
471 Some(path) if path.exists() => match count_top_level_items(&path) {
472 Ok(count) => out.push_str(&format!(
473 "- {}: {} top-level items at {}\n",
474 label,
475 count,
476 path.display()
477 )),
478 Err(e) => out.push_str(&format!(
479 "- {}: exists at {} but could not inspect ({})\n",
480 label,
481 path.display(),
482 e
483 )),
484 },
485 Some(path) => out.push_str(&format!(
486 "- {}: expected at {} but not found\n",
487 label,
488 path.display()
489 )),
490 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
491 }
492 }
493
494 Ok(out.trim_end().to_string())
495}
496
497fn inspect_toolchains() -> Result<String, String> {
498 let report = collect_toolchains();
499 let mut out = String::from("Host inspection: toolchains\n\n");
500
501 if report.found.is_empty() {
502 out.push_str("- No common developer tools were detected on PATH.");
503 } else {
504 out.push_str("Detected developer tools:\n");
505 for (label, version) in report.found {
506 out.push_str(&format!("- {}: {}\n", label, version));
507 }
508 }
509
510 if !report.missing.is_empty() {
511 out.push_str("\nNot detected on PATH:\n");
512 for label in report.missing {
513 out.push_str(&format!("- {}\n", label));
514 }
515 }
516
517 Ok(out.trim_end().to_string())
518}
519
520fn inspect_path(max_entries: usize) -> Result<String, String> {
521 let path_stats = analyze_path_env();
522 let mut out = String::from("Host inspection: PATH\n\n");
523 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
524 out.push_str(&format!(
525 "- Unique entries: {}\n",
526 path_stats.unique_entries
527 ));
528 out.push_str(&format!(
529 "- Duplicate entries: {}\n",
530 path_stats.duplicate_entries.len()
531 ));
532 out.push_str(&format!(
533 "- Missing paths: {}\n",
534 path_stats.missing_entries.len()
535 ));
536
537 out.push_str("\nPATH entries:\n");
538 for entry in path_stats.entries.iter().take(max_entries) {
539 out.push_str(&format!("- {}\n", entry));
540 }
541 if path_stats.entries.len() > max_entries {
542 out.push_str(&format!(
543 "- ... {} more entries omitted\n",
544 path_stats.entries.len() - max_entries
545 ));
546 }
547
548 if !path_stats.duplicate_entries.is_empty() {
549 out.push_str("\nDuplicate entries:\n");
550 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
551 out.push_str(&format!("- {}\n", entry));
552 }
553 if path_stats.duplicate_entries.len() > max_entries {
554 out.push_str(&format!(
555 "- ... {} more duplicates omitted\n",
556 path_stats.duplicate_entries.len() - max_entries
557 ));
558 }
559 }
560
561 if !path_stats.missing_entries.is_empty() {
562 out.push_str("\nMissing directories:\n");
563 for entry in path_stats.missing_entries.iter().take(max_entries) {
564 out.push_str(&format!("- {}\n", entry));
565 }
566 if path_stats.missing_entries.len() > max_entries {
567 out.push_str(&format!(
568 "- ... {} more missing entries omitted\n",
569 path_stats.missing_entries.len() - max_entries
570 ));
571 }
572 }
573
574 Ok(out.trim_end().to_string())
575}
576
577fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
578 let path_stats = analyze_path_env();
579 let toolchains = collect_toolchains();
580 let package_managers = collect_package_managers();
581 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
582
583 let mut out = String::from("Host inspection: env_doctor\n\n");
584 out.push_str(&format!(
585 "- PATH health: {} duplicates, {} missing entries\n",
586 path_stats.duplicate_entries.len(),
587 path_stats.missing_entries.len()
588 ));
589 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
590 out.push_str(&format!(
591 "- Package managers found: {}\n",
592 package_managers.found.len()
593 ));
594
595 if !package_managers.found.is_empty() {
596 out.push_str("\nPackage managers:\n");
597 for (label, version) in package_managers.found.iter().take(max_entries) {
598 out.push_str(&format!("- {}: {}\n", label, version));
599 }
600 if package_managers.found.len() > max_entries {
601 out.push_str(&format!(
602 "- ... {} more package managers omitted\n",
603 package_managers.found.len() - max_entries
604 ));
605 }
606 }
607
608 if !path_stats.duplicate_entries.is_empty() {
609 out.push_str("\nDuplicate PATH entries:\n");
610 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
611 out.push_str(&format!("- {}\n", entry));
612 }
613 if path_stats.duplicate_entries.len() > max_entries.min(5) {
614 out.push_str(&format!(
615 "- ... {} more duplicate entries omitted\n",
616 path_stats.duplicate_entries.len() - max_entries.min(5)
617 ));
618 }
619 }
620
621 if !path_stats.missing_entries.is_empty() {
622 out.push_str("\nMissing PATH entries:\n");
623 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
624 out.push_str(&format!("- {}\n", entry));
625 }
626 if path_stats.missing_entries.len() > max_entries.min(5) {
627 out.push_str(&format!(
628 "- ... {} more missing entries omitted\n",
629 path_stats.missing_entries.len() - max_entries.min(5)
630 ));
631 }
632 }
633
634 if !findings.is_empty() {
635 out.push_str("\nFindings:\n");
636 for finding in findings.iter().take(max_entries.max(5)) {
637 out.push_str(&format!("- {}\n", finding));
638 }
639 if findings.len() > max_entries.max(5) {
640 out.push_str(&format!(
641 "- ... {} more findings omitted\n",
642 findings.len() - max_entries.max(5)
643 ));
644 }
645 } else {
646 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
647 }
648
649 out.push_str(
650 "\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.",
651 );
652
653 Ok(out.trim_end().to_string())
654}
655
656#[derive(Clone, Copy, Debug, Eq, PartialEq)]
657enum FixPlanKind {
658 EnvPath,
659 PortConflict,
660 LmStudio,
661 DriverInstall,
662 GroupPolicy,
663 FirewallRule,
664 SshKey,
665 WslSetup,
666 ServiceConfig,
667 WindowsActivation,
668 RegistryEdit,
669 ScheduledTaskCreate,
670 DiskCleanup,
671 DnsResolution,
672 Generic,
673}
674
675async fn inspect_fix_plan(
676 issue: Option<String>,
677 port_filter: Option<u16>,
678 max_entries: usize,
679) -> Result<String, String> {
680 let issue = issue.unwrap_or_else(|| {
681 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
682 .to_string()
683 });
684 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
685 match plan_kind {
686 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
687 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
688 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
689 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
690 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
691 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
692 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
693 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
694 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
695 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
696 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
697 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
698 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
699 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
700 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
701 }
702}
703
704fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
705 let lower = issue.to_ascii_lowercase();
706 if lower.contains("firewall rule")
709 || lower.contains("inbound rule")
710 || lower.contains("outbound rule")
711 || (lower.contains("firewall")
712 && (lower.contains("allow")
713 || lower.contains("block")
714 || lower.contains("create")
715 || lower.contains("open")))
716 {
717 FixPlanKind::FirewallRule
718 } else if port_filter.is_some()
719 || lower.contains("port ")
720 || lower.contains("address already in use")
721 || lower.contains("already in use")
722 || lower.contains("what owns port")
723 || lower.contains("listening on port")
724 {
725 FixPlanKind::PortConflict
726 } else if lower.contains("lm studio")
727 || lower.contains("localhost:1234")
728 || lower.contains("/v1/models")
729 || lower.contains("no coding model loaded")
730 || lower.contains("embedding model")
731 || lower.contains("server on port 1234")
732 || lower.contains("runtime refresh")
733 {
734 FixPlanKind::LmStudio
735 } else if lower.contains("driver")
736 || lower.contains("gpu driver")
737 || lower.contains("nvidia driver")
738 || lower.contains("amd driver")
739 || lower.contains("install driver")
740 || lower.contains("update driver")
741 {
742 FixPlanKind::DriverInstall
743 } else if lower.contains("group policy")
744 || lower.contains("gpedit")
745 || lower.contains("local policy")
746 || lower.contains("secpol")
747 || lower.contains("administrative template")
748 {
749 FixPlanKind::GroupPolicy
750 } else if lower.contains("ssh key")
751 || lower.contains("ssh-keygen")
752 || lower.contains("generate ssh")
753 || lower.contains("authorized_keys")
754 || lower.contains("id_rsa")
755 || lower.contains("id_ed25519")
756 {
757 FixPlanKind::SshKey
758 } else if lower.contains("wsl")
759 || lower.contains("windows subsystem for linux")
760 || lower.contains("install ubuntu")
761 || lower.contains("install linux on windows")
762 || lower.contains("wsl2")
763 {
764 FixPlanKind::WslSetup
765 } else if lower.contains("service")
766 && (lower.contains("start ")
767 || lower.contains("stop ")
768 || lower.contains("restart ")
769 || lower.contains("enable ")
770 || lower.contains("disable ")
771 || lower.contains("configure service"))
772 {
773 FixPlanKind::ServiceConfig
774 } else if lower.contains("activate windows")
775 || lower.contains("windows activation")
776 || lower.contains("product key")
777 || lower.contains("kms")
778 || lower.contains("not activated")
779 {
780 FixPlanKind::WindowsActivation
781 } else if lower.contains("registry")
782 || lower.contains("regedit")
783 || lower.contains("hklm")
784 || lower.contains("hkcu")
785 || lower.contains("reg add")
786 || lower.contains("reg delete")
787 || lower.contains("registry key")
788 {
789 FixPlanKind::RegistryEdit
790 } else if lower.contains("scheduled task")
791 || lower.contains("task scheduler")
792 || lower.contains("schtasks")
793 || lower.contains("create task")
794 || lower.contains("run on startup")
795 || lower.contains("run on schedule")
796 || lower.contains("cron")
797 {
798 FixPlanKind::ScheduledTaskCreate
799 } else if lower.contains("disk cleanup")
800 || lower.contains("free up disk")
801 || lower.contains("free up space")
802 || lower.contains("clear cache")
803 || lower.contains("disk full")
804 || lower.contains("low disk space")
805 || lower.contains("reclaim space")
806 {
807 FixPlanKind::DiskCleanup
808 } else if lower.contains("cargo")
809 || lower.contains("rustc")
810 || lower.contains("path")
811 || lower.contains("package manager")
812 || lower.contains("package managers")
813 || lower.contains("toolchain")
814 || lower.contains("winget")
815 || lower.contains("choco")
816 || lower.contains("scoop")
817 || lower.contains("python")
818 || lower.contains("node")
819 {
820 FixPlanKind::EnvPath
821 } else if lower.contains("dns ")
822 || lower.contains("nameserver")
823 || lower.contains("cannot resolve")
824 || lower.contains("nslookup")
825 || lower.contains("flushdns")
826 {
827 FixPlanKind::DnsResolution
828 } else {
829 FixPlanKind::Generic
830 }
831}
832
833fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
834 let path_stats = analyze_path_env();
835 let toolchains = collect_toolchains();
836 let package_managers = collect_package_managers();
837 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
838 let found_tools = toolchains
839 .found
840 .iter()
841 .map(|(label, _)| label.as_str())
842 .collect::<HashSet<_>>();
843 let found_managers = package_managers
844 .found
845 .iter()
846 .map(|(label, _)| label.as_str())
847 .collect::<HashSet<_>>();
848
849 let mut out = String::from("Host inspection: fix_plan\n\n");
850 out.push_str(&format!("- Requested issue: {}\n", issue));
851 out.push_str("- Fix-plan type: environment/path\n");
852 out.push_str(&format!(
853 "- PATH health: {} duplicates, {} missing entries\n",
854 path_stats.duplicate_entries.len(),
855 path_stats.missing_entries.len()
856 ));
857 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
858 out.push_str(&format!(
859 "- Package managers found: {}\n",
860 package_managers.found.len()
861 ));
862
863 out.push_str("\nLikely causes:\n");
864 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
865 out.push_str(
866 "- 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",
867 );
868 }
869 if path_stats.duplicate_entries.is_empty()
870 && path_stats.missing_entries.is_empty()
871 && !findings.is_empty()
872 {
873 for finding in findings.iter().take(max_entries.max(4)) {
874 out.push_str(&format!("- {}\n", finding));
875 }
876 } else {
877 if !path_stats.duplicate_entries.is_empty() {
878 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
879 }
880 if !path_stats.missing_entries.is_empty() {
881 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
882 }
883 }
884 if found_tools.contains("node")
885 && !found_managers.contains("npm")
886 && !found_managers.contains("pnpm")
887 {
888 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
889 }
890 if found_tools.contains("python")
891 && !found_managers.contains("pip")
892 && !found_managers.contains("uv")
893 && !found_managers.contains("pipx")
894 {
895 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
896 }
897
898 out.push_str("\nFix plan:\n");
899 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");
900 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
901 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");
902 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
903 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");
904 }
905 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
906 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
907 }
908 if found_tools.contains("node")
909 && !found_managers.contains("npm")
910 && !found_managers.contains("pnpm")
911 {
912 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");
913 }
914 if found_tools.contains("python")
915 && !found_managers.contains("pip")
916 && !found_managers.contains("uv")
917 && !found_managers.contains("pipx")
918 {
919 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");
920 }
921
922 if !path_stats.duplicate_entries.is_empty() {
923 out.push_str("\nExample duplicate PATH rows:\n");
924 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
925 out.push_str(&format!("- {}\n", entry));
926 }
927 }
928 if !path_stats.missing_entries.is_empty() {
929 out.push_str("\nExample missing PATH rows:\n");
930 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
931 out.push_str(&format!("- {}\n", entry));
932 }
933 }
934
935 out.push_str(
936 "\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.",
937 );
938 Ok(out.trim_end().to_string())
939}
940
941fn inspect_port_fix_plan(
942 issue: &str,
943 port_filter: Option<u16>,
944 max_entries: usize,
945) -> Result<String, String> {
946 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
947 let listeners = collect_listening_ports().unwrap_or_default();
948 let mut matching = listeners;
949 if let Some(port) = requested_port {
950 matching.retain(|entry| entry.port == port);
951 }
952 let processes = collect_processes().unwrap_or_default();
953
954 let mut out = String::from("Host inspection: fix_plan\n\n");
955 out.push_str(&format!("- Requested issue: {}\n", issue));
956 out.push_str("- Fix-plan type: port_conflict\n");
957 if let Some(port) = requested_port {
958 out.push_str(&format!("- Requested port: {}\n", port));
959 } else {
960 out.push_str("- Requested port: not parsed from the issue text\n");
961 }
962 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
963
964 if !matching.is_empty() {
965 out.push_str("\nCurrent listeners:\n");
966 for entry in matching.iter().take(max_entries.min(5)) {
967 let process_name = entry
968 .pid
969 .as_deref()
970 .and_then(|pid| pid.parse::<u32>().ok())
971 .and_then(|pid| {
972 processes
973 .iter()
974 .find(|process| process.pid == pid)
975 .map(|process| process.name.as_str())
976 })
977 .unwrap_or("unknown");
978 let pid = entry.pid.as_deref().unwrap_or("unknown");
979 out.push_str(&format!(
980 "- {} {} ({}) pid {} process {}\n",
981 entry.protocol, entry.local, entry.state, pid, process_name
982 ));
983 }
984 }
985
986 out.push_str("\nFix plan:\n");
987 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");
988 if !matching.is_empty() {
989 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");
990 } else {
991 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");
992 }
993 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
994 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");
995 out.push_str(
996 "\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.",
997 );
998 Ok(out.trim_end().to_string())
999}
1000
1001async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
1002 let config = crate::agent::config::load_config();
1003 let configured_api = config
1004 .api_url
1005 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
1006 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
1007 let reachability = probe_http_endpoint(&models_url).await;
1008 let embed_model = detect_loaded_embed_model(&configured_api).await;
1009
1010 let mut out = String::from("Host inspection: fix_plan\n\n");
1011 out.push_str(&format!("- Requested issue: {}\n", issue));
1012 out.push_str("- Fix-plan type: lm_studio\n");
1013 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
1014 out.push_str(&format!("- Probe URL: {}\n", models_url));
1015 match &reachability {
1016 EndpointProbe::Reachable(status) => {
1017 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
1018 }
1019 EndpointProbe::Unreachable(detail) => {
1020 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
1021 }
1022 }
1023 out.push_str(&format!(
1024 "- Embedding model loaded: {}\n",
1025 embed_model.as_deref().unwrap_or("none detected")
1026 ));
1027
1028 out.push_str("\nFix plan:\n");
1029 match reachability {
1030 EndpointProbe::Reachable(_) => {
1031 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");
1032 }
1033 EndpointProbe::Unreachable(_) => {
1034 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");
1035 }
1036 }
1037 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");
1038 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");
1039 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");
1040 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");
1041 if let Some(model) = embed_model {
1042 out.push_str(&format!(
1043 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1044 model
1045 ));
1046 }
1047 if max_entries > 0 {
1048 out.push_str(
1049 "\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.",
1050 );
1051 }
1052 Ok(out.trim_end().to_string())
1053}
1054
1055fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1056 #[cfg(target_os = "windows")]
1058 let gpu_info = {
1059 let out = Command::new("powershell")
1060 .args([
1061 "-NoProfile",
1062 "-NonInteractive",
1063 "-Command",
1064 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1065 ])
1066 .output()
1067 .ok()
1068 .and_then(|o| String::from_utf8(o.stdout).ok())
1069 .unwrap_or_default();
1070 out.trim().to_string()
1071 };
1072 #[cfg(not(target_os = "windows"))]
1073 let gpu_info = String::from("(GPU detection not available on this platform)");
1074
1075 let mut out = String::from("Host inspection: fix_plan\n\n");
1076 out.push_str(&format!("- Requested issue: {}\n", issue));
1077 out.push_str("- Fix-plan type: driver_install\n");
1078 if !gpu_info.is_empty() {
1079 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1080 }
1081 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1082 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1083 out.push_str(
1084 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1085 );
1086 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1087 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1088 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1089 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
1090 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1091 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");
1092 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1093 out.push_str("\nVerification:\n");
1094 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1095 out.push_str("- The DriverVersion should match what you installed.\n");
1096 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.");
1097 Ok(out.trim_end().to_string())
1098}
1099
1100fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1101 #[cfg(target_os = "windows")]
1103 let edition = {
1104 Command::new("powershell")
1105 .args([
1106 "-NoProfile",
1107 "-NonInteractive",
1108 "-Command",
1109 "(Get-CimInstance Win32_OperatingSystem).Caption",
1110 ])
1111 .output()
1112 .ok()
1113 .and_then(|o| String::from_utf8(o.stdout).ok())
1114 .unwrap_or_default()
1115 .trim()
1116 .to_string()
1117 };
1118 #[cfg(not(target_os = "windows"))]
1119 let edition = String::from("(Windows edition detection not available)");
1120
1121 let is_home = edition.to_lowercase().contains("home");
1122
1123 let mut out = String::from("Host inspection: fix_plan\n\n");
1124 out.push_str(&format!("- Requested issue: {}\n", issue));
1125 out.push_str("- Fix-plan type: group_policy\n");
1126 out.push_str(&format!(
1127 "- Windows edition detected: {}\n",
1128 if edition.is_empty() {
1129 "unknown".to_string()
1130 } else {
1131 edition.clone()
1132 }
1133 ));
1134
1135 if is_home {
1136 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1137 out.push_str("Options on Home edition:\n");
1138 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");
1139 out.push_str(
1140 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1141 );
1142 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1143 } else {
1144 out.push_str("\nFix plan — Editing Local Group Policy:\n");
1145 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1146 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1147 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1148 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1149 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1150 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
1151 }
1152 out.push_str("\nVerification:\n");
1153 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1154 out.push_str(
1155 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1156 );
1157 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.");
1158 Ok(out.trim_end().to_string())
1159}
1160
1161fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1162 #[cfg(target_os = "windows")]
1163 let profile_state = {
1164 Command::new("powershell")
1165 .args([
1166 "-NoProfile",
1167 "-NonInteractive",
1168 "-Command",
1169 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1170 ])
1171 .output()
1172 .ok()
1173 .and_then(|o| String::from_utf8(o.stdout).ok())
1174 .unwrap_or_default()
1175 .trim()
1176 .to_string()
1177 };
1178 #[cfg(not(target_os = "windows"))]
1179 let profile_state = String::new();
1180
1181 let mut out = String::from("Host inspection: fix_plan\n\n");
1182 out.push_str(&format!("- Requested issue: {}\n", issue));
1183 out.push_str("- Fix-plan type: firewall_rule\n");
1184 if !profile_state.is_empty() {
1185 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1186 }
1187 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1188 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1189 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1190 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1191 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1192 out.push_str("\nTo ALLOW an application through the firewall:\n");
1193 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1194 out.push_str("\nTo REMOVE a rule you created:\n");
1195 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1196 out.push_str("\nTo see existing custom rules:\n");
1197 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1198 out.push_str("\nVerification:\n");
1199 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1200 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.");
1201 Ok(out.trim_end().to_string())
1202}
1203
1204fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1205 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1206 let ssh_dir = home.join(".ssh");
1207 let has_ssh_dir = ssh_dir.exists();
1208 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1209 let has_rsa = ssh_dir.join("id_rsa").exists();
1210 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1211
1212 let mut out = String::from("Host inspection: fix_plan\n\n");
1213 out.push_str(&format!("- Requested issue: {}\n", issue));
1214 out.push_str("- Fix-plan type: ssh_key\n");
1215 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1216 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1217 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1218 out.push_str(&format!(
1219 "- authorized_keys found: {}\n",
1220 has_authorized_keys
1221 ));
1222
1223 if has_ed25519 {
1224 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1225 }
1226
1227 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1228 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1229 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1230 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1231 out.push_str(
1232 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1233 );
1234 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1235 out.push_str("3. Start the SSH agent and add your key:\n");
1236 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1237 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1238 out.push_str(" Start-Service ssh-agent\n");
1239 out.push_str(" # Then add the key (normal PowerShell):\n");
1240 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1241 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1242 out.push_str(" # Print your public key:\n");
1243 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1244 out.push_str(" # On the target server, append it:\n");
1245 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1246 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1247 out.push_str("5. Test the connection:\n");
1248 out.push_str(" ssh user@server-address\n");
1249 out.push_str("\nFor GitHub/GitLab:\n");
1250 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1251 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1252 out.push_str("- Test: ssh -T git@github.com\n");
1253 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.");
1254 Ok(out.trim_end().to_string())
1255}
1256
1257fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1258 #[cfg(target_os = "windows")]
1259 let wsl_status = {
1260 let out = Command::new("wsl")
1261 .args(["--status"])
1262 .output()
1263 .ok()
1264 .and_then(|o| {
1265 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1266 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1267 Some(format!("{}{}", stdout, stderr))
1268 })
1269 .unwrap_or_default();
1270 out.trim().to_string()
1271 };
1272 #[cfg(not(target_os = "windows"))]
1273 let wsl_status = String::new();
1274
1275 let wsl_installed =
1276 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1277
1278 let mut out = String::from("Host inspection: fix_plan\n\n");
1279 out.push_str(&format!("- Requested issue: {}\n", issue));
1280 out.push_str("- Fix-plan type: wsl_setup\n");
1281 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1282 if !wsl_status.is_empty() {
1283 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1284 }
1285
1286 if wsl_installed {
1287 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1288 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1289 out.push_str(" Available distros: wsl --list --online\n");
1290 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1291 out.push_str("3. Create your Linux username and password when prompted.\n");
1292 } else {
1293 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1294 out.push_str("1. Open PowerShell as Administrator.\n");
1295 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1296 out.push_str(" wsl --install\n");
1297 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1298 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1299 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1300 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1301 out.push_str(" wsl --set-default-version 2\n");
1302 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1303 out.push_str(" wsl --install -d Debian\n");
1304 out.push_str(" wsl --list --online # to see all available distros\n");
1305 }
1306 out.push_str("\nVerification:\n");
1307 out.push_str("- Run: wsl --list --verbose\n");
1308 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1309 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.");
1310 Ok(out.trim_end().to_string())
1311}
1312
1313fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1314 let lower = issue.to_ascii_lowercase();
1315 let service_hint = if lower.contains("ssh") {
1317 Some("sshd")
1318 } else if lower.contains("mysql") {
1319 Some("MySQL80")
1320 } else if lower.contains("postgres") || lower.contains("postgresql") {
1321 Some("postgresql")
1322 } else if lower.contains("redis") {
1323 Some("Redis")
1324 } else if lower.contains("nginx") {
1325 Some("nginx")
1326 } else if lower.contains("apache") {
1327 Some("Apache2.4")
1328 } else {
1329 None
1330 };
1331
1332 #[cfg(target_os = "windows")]
1333 let service_state = if let Some(svc) = service_hint {
1334 Command::new("powershell")
1335 .args([
1336 "-NoProfile",
1337 "-NonInteractive",
1338 "-Command",
1339 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1340 ])
1341 .output()
1342 .ok()
1343 .and_then(|o| String::from_utf8(o.stdout).ok())
1344 .unwrap_or_default()
1345 .trim()
1346 .to_string()
1347 } else {
1348 String::new()
1349 };
1350 #[cfg(not(target_os = "windows"))]
1351 let service_state = String::new();
1352
1353 let mut out = String::from("Host inspection: fix_plan\n\n");
1354 out.push_str(&format!("- Requested issue: {}\n", issue));
1355 out.push_str("- Fix-plan type: service_config\n");
1356 if let Some(svc) = service_hint {
1357 out.push_str(&format!("- Service detected in request: {}\n", svc));
1358 }
1359 if !service_state.is_empty() {
1360 out.push_str(&format!("- Current state: {}\n", service_state));
1361 }
1362
1363 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1364 out.push_str("\nStart a service:\n");
1365 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1366 out.push_str("\nStop a service:\n");
1367 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1368 out.push_str("\nRestart a service:\n");
1369 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1370 out.push_str("\nEnable a service to start automatically:\n");
1371 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1372 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1373 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1374 out.push_str("\nFind the exact service name:\n");
1375 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1376 out.push_str("\nVerification:\n");
1377 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1378 if let Some(svc) = service_hint {
1379 out.push_str(&format!(
1380 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1381 svc, svc
1382 ));
1383 }
1384 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.");
1385 Ok(out.trim_end().to_string())
1386}
1387
1388fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1389 #[cfg(target_os = "windows")]
1390 let activation_status = {
1391 Command::new("powershell")
1392 .args([
1393 "-NoProfile",
1394 "-NonInteractive",
1395 "-Command",
1396 "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 + ')' })\" }",
1397 ])
1398 .output()
1399 .ok()
1400 .and_then(|o| String::from_utf8(o.stdout).ok())
1401 .unwrap_or_default()
1402 .trim()
1403 .to_string()
1404 };
1405 #[cfg(not(target_os = "windows"))]
1406 let activation_status = String::new();
1407
1408 let activation_lower = activation_status.to_lowercase();
1409 let is_licensed =
1410 activation_lower.contains("licensed") && !activation_lower.contains("not licensed");
1411
1412 let mut out = String::from("Host inspection: fix_plan\n\n");
1413 out.push_str(&format!("- Requested issue: {}\n", issue));
1414 out.push_str("- Fix-plan type: windows_activation\n");
1415 if !activation_status.is_empty() {
1416 out.push_str(&format!(
1417 "- Current activation state:\n{}\n",
1418 activation_status
1419 ));
1420 }
1421
1422 if is_licensed {
1423 out.push_str(
1424 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1425 );
1426 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1427 out.push_str(" (Forces an online activation attempt)\n");
1428 out.push_str("2. Check activation details: slmgr /dli\n");
1429 } else {
1430 out.push_str("\nFix plan — Activating Windows:\n");
1431 out.push_str("1. Check your current status first:\n");
1432 out.push_str(" slmgr /dli (basic info)\n");
1433 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1434 out.push_str("\n2. If you have a retail product key:\n");
1435 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1436 out.push_str(" slmgr /ato (activate online)\n");
1437 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1438 out.push_str(" - Go to Settings → System → Activation\n");
1439 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1440 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1441 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1442 out.push_str(" - Contact your IT department for the KMS server address\n");
1443 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1444 out.push_str(" - Activate: slmgr /ato\n");
1445 }
1446 out.push_str("\nVerification:\n");
1447 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1448 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1449 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.");
1450 Ok(out.trim_end().to_string())
1451}
1452
1453fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1454 let mut out = String::from("Host inspection: fix_plan\n\n");
1455 out.push_str(&format!("- Requested issue: {}\n", issue));
1456 out.push_str("- Fix-plan type: registry_edit\n");
1457 out.push_str(
1458 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1459 );
1460 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1461 out.push_str("\n1. Back up before you touch anything:\n");
1462 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1463 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1464 out.push_str(" # Or export the whole registry (takes a while):\n");
1465 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1466 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1467 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1468 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1469 out.push_str(
1470 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1471 );
1472 out.push_str("\n4. Create a new key:\n");
1473 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1474 out.push_str("\n5. Delete a value:\n");
1475 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1476 out.push_str("\n6. Restore from backup if something breaks:\n");
1477 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1478 out.push_str("\nCommon registry hives:\n");
1479 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1480 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1481 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1482 out.push_str("\nVerification:\n");
1483 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1484 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.");
1485 Ok(out.trim_end().to_string())
1486}
1487
1488fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1489 let mut out = String::from("Host inspection: fix_plan\n\n");
1490 out.push_str(&format!("- Requested issue: {}\n", issue));
1491 out.push_str("- Fix-plan type: scheduled_task_create\n");
1492 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1493 out.push_str("\nExample: Run a script at 9 AM every day\n");
1494 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1495 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1496 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1497 out.push_str("\nExample: Run at Windows startup\n");
1498 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1499 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1500 out.push_str("\nExample: Run at user logon\n");
1501 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1502 out.push_str(
1503 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1504 );
1505 out.push_str("\nExample: Run every 30 minutes\n");
1506 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1507 out.push_str("\nView all tasks:\n");
1508 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1509 out.push_str("\nDelete a task:\n");
1510 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1511 out.push_str("\nRun a task immediately:\n");
1512 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1513 out.push_str("\nVerification:\n");
1514 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1515 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.");
1516 Ok(out.trim_end().to_string())
1517}
1518
1519fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1520 #[cfg(target_os = "windows")]
1521 let disk_info = {
1522 Command::new("powershell")
1523 .args([
1524 "-NoProfile",
1525 "-NonInteractive",
1526 "-Command",
1527 "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\" }",
1528 ])
1529 .output()
1530 .ok()
1531 .and_then(|o| String::from_utf8(o.stdout).ok())
1532 .unwrap_or_default()
1533 .trim()
1534 .to_string()
1535 };
1536 #[cfg(not(target_os = "windows"))]
1537 let disk_info = String::new();
1538
1539 let mut out = String::from("Host inspection: fix_plan\n\n");
1540 out.push_str(&format!("- Requested issue: {}\n", issue));
1541 out.push_str("- Fix-plan type: disk_cleanup\n");
1542 if !disk_info.is_empty() {
1543 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1544 }
1545 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1546 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1547 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1548 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1549 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1550 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1551 out.push_str(" Stop-Service wuauserv\n");
1552 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1553 out.push_str(" Start-Service wuauserv\n");
1554 out.push_str("\n3. Clear Windows Temp folder:\n");
1555 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1556 out.push_str(
1557 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1558 );
1559 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1560 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1561 out.push_str(" - npm cache: npm cache clean --force\n");
1562 out.push_str(" - pip cache: pip cache purge\n");
1563 out.push_str(
1564 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1565 );
1566 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1567 out.push_str("\n5. Check for large files:\n");
1568 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");
1569 out.push_str("\nVerification:\n");
1570 out.push_str(
1571 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1572 );
1573 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.");
1574 Ok(out.trim_end().to_string())
1575}
1576
1577fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1578 let mut out = String::from("Host inspection: fix_plan\n\n");
1579 out.push_str(&format!("- Requested issue: {}\n", issue));
1580 out.push_str("- Fix-plan type: generic\n");
1581 out.push_str(
1582 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1583 Structured lanes available:\n\
1584 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1585 - Port conflict (address already in use, what owns port)\n\
1586 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1587 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1588 - Group Policy (gpedit, local policy, administrative template)\n\
1589 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1590 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1591 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1592 - Service config (start/stop/restart/enable/disable a service)\n\
1593 - Windows activation (product key, not activated, kms)\n\
1594 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1595 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1596 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1597 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1598 );
1599 Ok(out.trim_end().to_string())
1600}
1601
1602fn inspect_resource_load() -> Result<String, String> {
1603 #[cfg(target_os = "windows")]
1604 {
1605 let output = Command::new("powershell")
1606 .args([
1607 "-NoProfile",
1608 "-Command",
1609 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1610 ])
1611 .output()
1612 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1613
1614 let text = String::from_utf8_lossy(&output.stdout);
1615 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1616
1617 let cpu_load = lines
1618 .next()
1619 .and_then(|l| l.parse::<u32>().ok())
1620 .unwrap_or(0);
1621 let mem_json = lines.collect::<Vec<_>>().join("");
1622 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1623
1624 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1625 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1626 let used_kb = total_kb.saturating_sub(free_kb);
1627 let mem_percent = if total_kb > 0 {
1628 (used_kb * 100) / total_kb
1629 } else {
1630 0
1631 };
1632
1633 let mut out = String::from("Host inspection: resource_load\n\n");
1634 out.push_str("**System Performance Summary:**\n");
1635 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1636 out.push_str(&format!(
1637 "- Memory Usage: {} / {} ({}%)\n",
1638 human_bytes(used_kb * 1024),
1639 human_bytes(total_kb * 1024),
1640 mem_percent
1641 ));
1642
1643 if cpu_load > 85 {
1644 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1645 }
1646 if mem_percent > 90 {
1647 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1648 }
1649
1650 Ok(out)
1651 }
1652 #[cfg(not(target_os = "windows"))]
1653 {
1654 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1655 }
1656}
1657
1658#[derive(Debug)]
1659enum EndpointProbe {
1660 Reachable(u16),
1661 Unreachable(String),
1662}
1663
1664async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1665 let client = match reqwest::Client::builder()
1666 .timeout(std::time::Duration::from_secs(3))
1667 .build()
1668 {
1669 Ok(client) => client,
1670 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1671 };
1672
1673 match client.get(url).send().await {
1674 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1675 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1676 }
1677}
1678
1679async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1680 if configured_api.contains("11434") {
1681 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1682 let url = format!("{}/api/ps", base);
1683 let client = reqwest::Client::builder()
1684 .timeout(std::time::Duration::from_secs(3))
1685 .build()
1686 .ok()?;
1687 let response = client.get(url).send().await.ok()?;
1688 let body = response.json::<serde_json::Value>().await.ok()?;
1689 let entries = body["models"].as_array()?;
1690 for entry in entries {
1691 let name = entry["name"]
1692 .as_str()
1693 .or_else(|| entry["model"].as_str())
1694 .unwrap_or_default();
1695 let lower = name.to_ascii_lowercase();
1696 if lower.contains("embed")
1697 || lower.contains("embedding")
1698 || lower.contains("minilm")
1699 || lower.contains("bge")
1700 || lower.contains("e5")
1701 {
1702 return Some(name.to_string());
1703 }
1704 }
1705 return None;
1706 }
1707
1708 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1709 let url = format!("{}/api/v0/models", base);
1710 let client = reqwest::Client::builder()
1711 .timeout(std::time::Duration::from_secs(3))
1712 .build()
1713 .ok()?;
1714
1715 #[derive(serde::Deserialize)]
1716 struct ModelList {
1717 data: Vec<ModelEntry>,
1718 }
1719 #[derive(serde::Deserialize)]
1720 struct ModelEntry {
1721 id: String,
1722 #[serde(rename = "type", default)]
1723 model_type: String,
1724 #[serde(default)]
1725 state: String,
1726 }
1727
1728 let response = client.get(url).send().await.ok()?;
1729 let models = response.json::<ModelList>().await.ok()?;
1730 models
1731 .data
1732 .into_iter()
1733 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1734 .map(|model| model.id)
1735}
1736
1737fn first_port_in_text(text: &str) -> Option<u16> {
1738 text.split(|c: char| !c.is_ascii_digit())
1739 .find(|fragment| !fragment.is_empty())
1740 .and_then(|fragment| fragment.parse::<u16>().ok())
1741}
1742
1743fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1744 let mut processes = collect_processes()?;
1745 if let Some(filter) = name_filter.as_deref() {
1746 let lowered = filter.to_ascii_lowercase();
1747 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1748 }
1749 processes.sort_by(|a, b| {
1750 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1751 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1752 b_cpu
1753 .partial_cmp(&a_cpu)
1754 .unwrap_or(std::cmp::Ordering::Equal)
1755 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1756 .then_with(|| a.name.cmp(&b.name))
1757 .then_with(|| a.pid.cmp(&b.pid))
1758 });
1759
1760 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1761
1762 let mut out = String::from("Host inspection: processes\n\n");
1763 if let Some(filter) = name_filter.as_deref() {
1764 out.push_str(&format!("- Filter name: {}\n", filter));
1765 }
1766 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1767 out.push_str(&format!(
1768 "- Total reported working set: {}\n",
1769 human_bytes(total_memory)
1770 ));
1771
1772 if processes.is_empty() {
1773 out.push_str("\nNo running processes matched.");
1774 return Ok(out);
1775 }
1776
1777 out.push_str("\nTop processes by resource usage:\n");
1778 for entry in processes.iter().take(max_entries) {
1779 let cpu_str = entry
1780 .cpu_percent
1781 .map(|p| format!(" [CPU: {:.1}%]", p))
1782 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1783 .unwrap_or_default();
1784 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1785 format!(" [I/O R:{}/W:{}]", r, w)
1786 } else {
1787 " [I/O unknown]".to_string()
1788 };
1789 out.push_str(&format!(
1790 "- {} (pid {}) - {}{}{}{}\n",
1791 entry.name,
1792 entry.pid,
1793 human_bytes(entry.memory_bytes),
1794 cpu_str,
1795 io_str,
1796 entry
1797 .detail
1798 .as_deref()
1799 .map(|detail| format!(" [{}]", detail))
1800 .unwrap_or_default()
1801 ));
1802 }
1803 if processes.len() > max_entries {
1804 out.push_str(&format!(
1805 "- ... {} more processes omitted\n",
1806 processes.len() - max_entries
1807 ));
1808 }
1809
1810 Ok(out.trim_end().to_string())
1811}
1812
1813fn inspect_network(max_entries: usize) -> Result<String, String> {
1814 let adapters = collect_network_adapters()?;
1815 let active_count = adapters
1816 .iter()
1817 .filter(|adapter| adapter.is_active())
1818 .count();
1819 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1820
1821 let mut out = String::from("Host inspection: network\n\n");
1822 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1823 out.push_str(&format!("- Active adapters: {}\n", active_count));
1824 out.push_str(&format!(
1825 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1826 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1827 ));
1828
1829 if adapters.is_empty() {
1830 out.push_str("\nNo adapter details were detected.");
1831 return Ok(out);
1832 }
1833
1834 out.push_str("\nAdapter summary:\n");
1835 for adapter in adapters.iter().take(max_entries) {
1836 let status = if adapter.is_active() {
1837 "active"
1838 } else if adapter.disconnected {
1839 "disconnected"
1840 } else {
1841 "idle"
1842 };
1843 let mut details = vec![status.to_string()];
1844 if !adapter.ipv4.is_empty() {
1845 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1846 }
1847 if !adapter.ipv6.is_empty() {
1848 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1849 }
1850 if !adapter.gateways.is_empty() {
1851 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1852 }
1853 if !adapter.dns_servers.is_empty() {
1854 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1855 }
1856 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1857 }
1858 if adapters.len() > max_entries {
1859 out.push_str(&format!(
1860 "- ... {} more adapters omitted\n",
1861 adapters.len() - max_entries
1862 ));
1863 }
1864
1865 Ok(out.trim_end().to_string())
1866}
1867
1868fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1869 let mut out = String::from("Host inspection: lan_discovery\n\n");
1870
1871 #[cfg(target_os = "windows")]
1872 {
1873 let n = max_entries.clamp(5, 20);
1874 let adapters = collect_network_adapters()?;
1875 let services = collect_services().unwrap_or_default();
1876 let active_adapters: Vec<&NetworkAdapter> = adapters
1877 .iter()
1878 .filter(|adapter| adapter.is_active())
1879 .collect();
1880 let gateways: Vec<String> = active_adapters
1881 .iter()
1882 .flat_map(|adapter| adapter.gateways.clone())
1883 .collect::<HashSet<_>>()
1884 .into_iter()
1885 .collect();
1886
1887 let neighbor_script = r#"
1888$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1889 Where-Object {
1890 $_.IPAddress -notlike '127.*' -and
1891 $_.IPAddress -notlike '169.254*' -and
1892 $_.State -notin @('Unreachable','Invalid')
1893 } |
1894 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1895$neighbors | ConvertTo-Json -Compress
1896"#;
1897 let neighbor_text = Command::new("powershell")
1898 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1899 .output()
1900 .ok()
1901 .and_then(|o| String::from_utf8(o.stdout).ok())
1902 .unwrap_or_default();
1903 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1904 .into_iter()
1905 .take(n)
1906 .collect();
1907
1908 let listener_script = r#"
1909Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1910 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1911 Select-Object LocalAddress, LocalPort, OwningProcess |
1912 ForEach-Object {
1913 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1914 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1915 }
1916"#;
1917 let listener_text = Command::new("powershell")
1918 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1919 .output()
1920 .ok()
1921 .and_then(|o| String::from_utf8(o.stdout).ok())
1922 .unwrap_or_default();
1923 let listeners: Vec<(String, u16, String, String)> = listener_text
1924 .lines()
1925 .filter_map(|line| {
1926 let parts: Vec<&str> = line.trim().split('|').collect();
1927 if parts.len() < 4 {
1928 return None;
1929 }
1930 Some((
1931 parts[0].to_string(),
1932 parts[1].parse::<u16>().ok()?,
1933 parts[2].to_string(),
1934 parts[3].to_string(),
1935 ))
1936 })
1937 .take(n)
1938 .collect();
1939
1940 let smb_mapping_script = r#"
1941Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1942 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1943"#;
1944 let smb_mappings: Vec<String> = Command::new("powershell")
1945 .args([
1946 "-NoProfile",
1947 "-NonInteractive",
1948 "-Command",
1949 smb_mapping_script,
1950 ])
1951 .output()
1952 .ok()
1953 .and_then(|o| String::from_utf8(o.stdout).ok())
1954 .unwrap_or_default()
1955 .lines()
1956 .take(n)
1957 .map(|line| line.trim().to_string())
1958 .filter(|line| !line.is_empty())
1959 .collect();
1960
1961 let smb_connections_script = r#"
1962Get-SmbConnection -ErrorAction SilentlyContinue |
1963 Select-Object ServerName, ShareName, NumOpens |
1964 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1965"#;
1966 let smb_connections: Vec<String> = Command::new("powershell")
1967 .args([
1968 "-NoProfile",
1969 "-NonInteractive",
1970 "-Command",
1971 smb_connections_script,
1972 ])
1973 .output()
1974 .ok()
1975 .and_then(|o| String::from_utf8(o.stdout).ok())
1976 .unwrap_or_default()
1977 .lines()
1978 .take(n)
1979 .map(|line| line.trim().to_string())
1980 .filter(|line| !line.is_empty())
1981 .collect();
1982
1983 let discovery_service_names = [
1984 "FDResPub",
1985 "fdPHost",
1986 "SSDPSRV",
1987 "upnphost",
1988 "LanmanServer",
1989 "LanmanWorkstation",
1990 "lmhosts",
1991 ];
1992 let discovery_services: Vec<&ServiceEntry> = services
1993 .iter()
1994 .filter(|entry| {
1995 discovery_service_names
1996 .iter()
1997 .any(|name| entry.name.eq_ignore_ascii_case(name))
1998 })
1999 .collect();
2000
2001 let mut findings = Vec::new();
2002 if active_adapters.is_empty() {
2003 findings.push(AuditFinding {
2004 finding: "No active LAN adapters were detected.".to_string(),
2005 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2006 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(),
2007 });
2008 }
2009
2010 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2011 .iter()
2012 .copied()
2013 .filter(|entry| {
2014 !entry.status.eq_ignore_ascii_case("running")
2015 && !entry.status.eq_ignore_ascii_case("active")
2016 })
2017 .collect();
2018 if !stopped_discovery_services.is_empty() {
2019 let names = stopped_discovery_services
2020 .iter()
2021 .map(|entry| entry.name.as_str())
2022 .collect::<Vec<_>>()
2023 .join(", ");
2024 findings.push(AuditFinding {
2025 finding: format!("Discovery-related services are not running: {names}"),
2026 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2027 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(),
2028 });
2029 }
2030
2031 if listeners.is_empty() {
2032 findings.push(AuditFinding {
2033 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2034 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2035 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(),
2036 });
2037 }
2038
2039 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2040 findings.push(AuditFinding {
2041 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2042 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2043 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(),
2044 });
2045 }
2046
2047 out.push_str("=== Findings ===\n");
2048 if findings.is_empty() {
2049 out.push_str(
2050 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2051 );
2052 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2053 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");
2054 } else {
2055 for finding in &findings {
2056 out.push_str(&format!("- Finding: {}\n", finding.finding));
2057 out.push_str(&format!(" Impact: {}\n", finding.impact));
2058 out.push_str(&format!(" Fix: {}\n", finding.fix));
2059 }
2060 }
2061
2062 out.push_str("\n=== Active adapter and gateway summary ===\n");
2063 if active_adapters.is_empty() {
2064 out.push_str("- No active adapters detected.\n");
2065 } else {
2066 for adapter in active_adapters.iter().take(n) {
2067 let ipv4 = if adapter.ipv4.is_empty() {
2068 "no IPv4".to_string()
2069 } else {
2070 adapter.ipv4.join(", ")
2071 };
2072 let gateway = if adapter.gateways.is_empty() {
2073 "no gateway".to_string()
2074 } else {
2075 adapter.gateways.join(", ")
2076 };
2077 out.push_str(&format!(
2078 "- {} | IPv4: {} | Gateway: {}\n",
2079 adapter.name, ipv4, gateway
2080 ));
2081 }
2082 }
2083
2084 out.push_str("\n=== Neighborhood evidence ===\n");
2085 out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2086 out.push_str(&format!(
2087 "- Neighbor entries observed: {}\n",
2088 neighbors.len()
2089 ));
2090 if neighbors.is_empty() {
2091 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2092 } else {
2093 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2094 out.push_str(&format!(
2095 "- {} on {} | MAC: {} | State: {}\n",
2096 ip, iface, mac, state
2097 ));
2098 }
2099 }
2100
2101 out.push_str("\n=== Discovery services ===\n");
2102 if discovery_services.is_empty() {
2103 out.push_str("- Discovery service status unavailable.\n");
2104 } else {
2105 for entry in discovery_services.iter().take(n) {
2106 let startup = entry.startup.as_deref().unwrap_or("unknown");
2107 out.push_str(&format!(
2108 "- {} | Status: {} | Startup: {}\n",
2109 entry.name, entry.status, startup
2110 ));
2111 }
2112 }
2113
2114 out.push_str("\n=== Discovery listener surface ===\n");
2115 if listeners.is_empty() {
2116 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2117 } else {
2118 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2119 let label = match *port {
2120 137 => "NetBIOS Name Service",
2121 138 => "NetBIOS Datagram",
2122 1900 => "SSDP/UPnP",
2123 5353 => "mDNS",
2124 5355 => "LLMNR",
2125 _ => "Discovery",
2126 };
2127 let proc_label = if proc_name.is_empty() {
2128 "unknown".to_string()
2129 } else {
2130 proc_name.clone()
2131 };
2132 out.push_str(&format!(
2133 "- {}:{} | {} | PID {} ({})\n",
2134 addr, port, label, pid, proc_label
2135 ));
2136 }
2137 }
2138
2139 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2140 if smb_mappings.is_empty() && smb_connections.is_empty() {
2141 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2142 } else {
2143 if !smb_mappings.is_empty() {
2144 out.push_str("- Mapped drives:\n");
2145 for mapping in smb_mappings.iter().take(n) {
2146 let parts: Vec<&str> = mapping.split('|').collect();
2147 if parts.len() >= 2 {
2148 out.push_str(&format!(" - {} -> {}\n", parts[0], parts[1]));
2149 }
2150 }
2151 }
2152 if !smb_connections.is_empty() {
2153 out.push_str("- Active SMB connections:\n");
2154 for connection in smb_connections.iter().take(n) {
2155 let parts: Vec<&str> = connection.split('|').collect();
2156 if parts.len() >= 3 {
2157 out.push_str(&format!(
2158 " - {}\\{} | Opens: {}\n",
2159 parts[0], parts[1], parts[2]
2160 ));
2161 }
2162 }
2163 }
2164 }
2165 }
2166
2167 #[cfg(not(target_os = "windows"))]
2168 {
2169 let n = max_entries.clamp(5, 20);
2170 let adapters = collect_network_adapters()?;
2171 let arp_output = Command::new("ip")
2172 .args(["neigh"])
2173 .output()
2174 .ok()
2175 .and_then(|o| String::from_utf8(o.stdout).ok())
2176 .unwrap_or_default();
2177 let neighbors: Vec<&str> = arp_output
2178 .lines()
2179 .filter(|line| !line.trim().is_empty())
2180 .take(n)
2181 .collect();
2182
2183 out.push_str("=== Findings ===\n");
2184 if adapters.iter().any(|adapter| adapter.is_active()) {
2185 out.push_str(
2186 "- Finding: LAN discovery support is partially available on this platform.\n",
2187 );
2188 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2189 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2190 } else {
2191 out.push_str("- Finding: No active LAN adapters were detected.\n");
2192 out.push_str(
2193 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2194 );
2195 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2196 }
2197
2198 out.push_str("\n=== Active adapter and gateway summary ===\n");
2199 if adapters.is_empty() {
2200 out.push_str("- No adapters detected.\n");
2201 } else {
2202 for adapter in adapters.iter().take(n) {
2203 let ipv4 = if adapter.ipv4.is_empty() {
2204 "no IPv4".to_string()
2205 } else {
2206 adapter.ipv4.join(", ")
2207 };
2208 let gateway = if adapter.gateways.is_empty() {
2209 "no gateway".to_string()
2210 } else {
2211 adapter.gateways.join(", ")
2212 };
2213 out.push_str(&format!(
2214 "- {} | IPv4: {} | Gateway: {}\n",
2215 adapter.name, ipv4, gateway
2216 ));
2217 }
2218 }
2219
2220 out.push_str("\n=== Neighborhood evidence ===\n");
2221 if neighbors.is_empty() {
2222 out.push_str("- No neighbor entries detected.\n");
2223 } else {
2224 for line in neighbors {
2225 out.push_str(&format!("- {}\n", line.trim()));
2226 }
2227 }
2228 }
2229
2230 Ok(out.trim_end().to_string())
2231}
2232
2233fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2234 let mut services = collect_services()?;
2235 if let Some(filter) = name_filter.as_deref() {
2236 let lowered = filter.to_ascii_lowercase();
2237 services.retain(|entry| {
2238 entry.name.to_ascii_lowercase().contains(&lowered)
2239 || entry
2240 .display_name
2241 .as_deref()
2242 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2243 .unwrap_or(false)
2244 });
2245 }
2246
2247 services.sort_by(|a, b| {
2248 let a_running =
2249 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2250 let b_running =
2251 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2252 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2253 });
2254
2255 let running = services
2256 .iter()
2257 .filter(|entry| {
2258 entry.status.eq_ignore_ascii_case("running")
2259 || entry.status.eq_ignore_ascii_case("active")
2260 })
2261 .count();
2262 let failed = services
2263 .iter()
2264 .filter(|entry| {
2265 entry.status.eq_ignore_ascii_case("failed")
2266 || entry.status.eq_ignore_ascii_case("error")
2267 || entry.status.eq_ignore_ascii_case("stopped")
2268 })
2269 .count();
2270
2271 let mut out = String::from("Host inspection: services\n\n");
2272 if let Some(filter) = name_filter.as_deref() {
2273 out.push_str(&format!("- Filter name: {}\n", filter));
2274 }
2275 out.push_str(&format!("- Services found: {}\n", services.len()));
2276 out.push_str(&format!("- Running/active: {}\n", running));
2277 out.push_str(&format!("- Failed/stopped: {}\n", failed));
2278
2279 if services.is_empty() {
2280 out.push_str("\nNo services matched.");
2281 return Ok(out);
2282 }
2283
2284 let per_section = (max_entries / 2).max(5);
2286
2287 let running_services: Vec<_> = services
2288 .iter()
2289 .filter(|e| {
2290 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2291 })
2292 .collect();
2293 let stopped_services: Vec<_> = services
2294 .iter()
2295 .filter(|e| {
2296 e.status.eq_ignore_ascii_case("stopped")
2297 || e.status.eq_ignore_ascii_case("failed")
2298 || e.status.eq_ignore_ascii_case("error")
2299 })
2300 .collect();
2301
2302 let fmt_entry = |entry: &&ServiceEntry| {
2303 let startup = entry
2304 .startup
2305 .as_deref()
2306 .map(|v| format!(" | startup {}", v))
2307 .unwrap_or_default();
2308 let logon = entry
2309 .start_name
2310 .as_deref()
2311 .map(|v| format!(" | LogOn: {}", v))
2312 .unwrap_or_default();
2313 let display = entry
2314 .display_name
2315 .as_deref()
2316 .filter(|v| *v != &entry.name)
2317 .map(|v| format!(" [{}]", v))
2318 .unwrap_or_default();
2319 format!(
2320 "- {}{} - {}{}{}\n",
2321 entry.name, display, entry.status, startup, logon
2322 )
2323 };
2324
2325 out.push_str(&format!(
2326 "\nRunning services ({} total, showing up to {}):\n",
2327 running_services.len(),
2328 per_section
2329 ));
2330 for entry in running_services.iter().take(per_section) {
2331 out.push_str(&fmt_entry(entry));
2332 }
2333 if running_services.len() > per_section {
2334 out.push_str(&format!(
2335 "- ... {} more running services omitted\n",
2336 running_services.len() - per_section
2337 ));
2338 }
2339
2340 out.push_str(&format!(
2341 "\nStopped/failed services ({} total, showing up to {}):\n",
2342 stopped_services.len(),
2343 per_section
2344 ));
2345 for entry in stopped_services.iter().take(per_section) {
2346 out.push_str(&fmt_entry(entry));
2347 }
2348 if stopped_services.len() > per_section {
2349 out.push_str(&format!(
2350 "- ... {} more stopped services omitted\n",
2351 stopped_services.len() - per_section
2352 ));
2353 }
2354
2355 Ok(out.trim_end().to_string())
2356}
2357
2358async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2359 inspect_directory("Disk", path, max_entries).await
2360}
2361
2362fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2363 let mut listeners = collect_listening_ports()?;
2364 if let Some(port) = port_filter {
2365 listeners.retain(|entry| entry.port == port);
2366 }
2367 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2368
2369 let mut out = String::from("Host inspection: ports\n\n");
2370 if let Some(port) = port_filter {
2371 out.push_str(&format!("- Filter port: {}\n", port));
2372 }
2373 out.push_str(&format!(
2374 "- Listening endpoints found: {}\n",
2375 listeners.len()
2376 ));
2377
2378 if listeners.is_empty() {
2379 out.push_str("\nNo listening endpoints matched.");
2380 return Ok(out);
2381 }
2382
2383 out.push_str("\nListening endpoints:\n");
2384 for entry in listeners.iter().take(max_entries) {
2385 let pid_str = entry
2386 .pid
2387 .as_deref()
2388 .map(|p| format!(" pid {}", p))
2389 .unwrap_or_default();
2390 let name_str = entry
2391 .process_name
2392 .as_deref()
2393 .map(|n| format!(" [{}]", n))
2394 .unwrap_or_default();
2395 out.push_str(&format!(
2396 "- {} {} ({}){}{}\n",
2397 entry.protocol, entry.local, entry.state, pid_str, name_str
2398 ));
2399 }
2400 if listeners.len() > max_entries {
2401 out.push_str(&format!(
2402 "- ... {} more listening endpoints omitted\n",
2403 listeners.len() - max_entries
2404 ));
2405 }
2406
2407 Ok(out.trim_end().to_string())
2408}
2409
2410fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2411 if !path.exists() {
2412 return Err(format!("Path does not exist: {}", path.display()));
2413 }
2414 if !path.is_dir() {
2415 return Err(format!("Path is not a directory: {}", path.display()));
2416 }
2417
2418 let markers = collect_project_markers(&path);
2419 let hematite_state = collect_hematite_state(&path);
2420 let git_state = inspect_git_state(&path);
2421 let release_state = inspect_release_artifacts(&path);
2422
2423 let mut out = String::from("Host inspection: repo_doctor\n\n");
2424 out.push_str(&format!("- Path: {}\n", path.display()));
2425 out.push_str(&format!(
2426 "- Workspace mode: {}\n",
2427 workspace_mode_for_path(&path)
2428 ));
2429
2430 if markers.is_empty() {
2431 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");
2432 } else {
2433 out.push_str("- Project markers:\n");
2434 for marker in markers.iter().take(max_entries) {
2435 out.push_str(&format!(" - {}\n", marker));
2436 }
2437 }
2438
2439 match git_state {
2440 Some(git) => {
2441 out.push_str(&format!("- Git root: {}\n", git.root.display()));
2442 out.push_str(&format!("- Git branch: {}\n", git.branch));
2443 out.push_str(&format!("- Git status: {}\n", git.status_label()));
2444 }
2445 None => out.push_str("- Git: not inside a detected work tree\n"),
2446 }
2447
2448 out.push_str(&format!(
2449 "- Hematite docs/imports/reports: {}/{}/{}\n",
2450 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2451 ));
2452 if hematite_state.workspace_profile {
2453 out.push_str("- Workspace profile: present\n");
2454 } else {
2455 out.push_str("- Workspace profile: absent\n");
2456 }
2457
2458 if let Some(release) = release_state {
2459 out.push_str(&format!("- Cargo version: {}\n", release.version));
2460 out.push_str(&format!(
2461 "- Windows artifacts for current version: {}/{}/{}\n",
2462 bool_label(release.portable_dir),
2463 bool_label(release.portable_zip),
2464 bool_label(release.setup_exe)
2465 ));
2466 }
2467
2468 Ok(out.trim_end().to_string())
2469}
2470
2471async fn inspect_known_directory(
2472 label: &str,
2473 path: Option<PathBuf>,
2474 max_entries: usize,
2475) -> Result<String, String> {
2476 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2477 inspect_directory(label, path, max_entries).await
2478}
2479
2480async fn inspect_directory(
2481 label: &str,
2482 path: PathBuf,
2483 max_entries: usize,
2484) -> Result<String, String> {
2485 let label = label.to_string();
2486 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2487 .await
2488 .map_err(|e| format!("inspect_host task failed: {e}"))?
2489}
2490
2491fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2492 if !path.exists() {
2493 return Err(format!("Path does not exist: {}", path.display()));
2494 }
2495 if !path.is_dir() {
2496 return Err(format!("Path is not a directory: {}", path.display()));
2497 }
2498
2499 let mut top_level_entries = Vec::new();
2500 for entry in fs::read_dir(path)
2501 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2502 {
2503 match entry {
2504 Ok(entry) => top_level_entries.push(entry),
2505 Err(_) => continue,
2506 }
2507 }
2508 top_level_entries.sort_by_key(|entry| entry.file_name());
2509
2510 let top_level_count = top_level_entries.len();
2511 let mut sample_names = Vec::new();
2512 let mut largest_entries = Vec::new();
2513 let mut aggregate = PathAggregate::default();
2514 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2515
2516 for entry in top_level_entries {
2517 let name = entry.file_name().to_string_lossy().to_string();
2518 if sample_names.len() < max_entries {
2519 sample_names.push(name.clone());
2520 }
2521 let kind = match entry.file_type() {
2522 Ok(ft) if ft.is_dir() => "dir",
2523 Ok(ft) if ft.is_symlink() => "symlink",
2524 _ => "file",
2525 };
2526 let stats = measure_path(&entry.path(), &mut budget);
2527 aggregate.merge(&stats);
2528 largest_entries.push(LargestEntry {
2529 name,
2530 kind,
2531 bytes: stats.total_bytes,
2532 });
2533 }
2534
2535 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2536
2537 let mut out = format!("Directory inspection: {}\n\n", label);
2538 out.push_str(&format!("- Path: {}\n", path.display()));
2539 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2540 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2541 out.push_str(&format!(
2542 "- Recursive directories: {}\n",
2543 aggregate.dir_count
2544 ));
2545 out.push_str(&format!(
2546 "- Total size: {}{}\n",
2547 human_bytes(aggregate.total_bytes),
2548 if aggregate.partial {
2549 " (partial scan)"
2550 } else {
2551 ""
2552 }
2553 ));
2554 if aggregate.skipped_entries > 0 {
2555 out.push_str(&format!(
2556 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2557 aggregate.skipped_entries
2558 ));
2559 }
2560
2561 if !largest_entries.is_empty() {
2562 out.push_str("\nLargest top-level entries:\n");
2563 for entry in largest_entries.iter().take(max_entries) {
2564 out.push_str(&format!(
2565 "- {} [{}] - {}\n",
2566 entry.name,
2567 entry.kind,
2568 human_bytes(entry.bytes)
2569 ));
2570 }
2571 }
2572
2573 if !sample_names.is_empty() {
2574 out.push_str("\nSample names:\n");
2575 for name in sample_names {
2576 out.push_str(&format!("- {}\n", name));
2577 }
2578 }
2579
2580 Ok(out.trim_end().to_string())
2581}
2582
2583fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2584 let trimmed = raw.trim();
2585 if trimmed.is_empty() {
2586 return Err("Path must not be empty.".to_string());
2587 }
2588
2589 if let Some(rest) = trimmed
2590 .strip_prefix("~/")
2591 .or_else(|| trimmed.strip_prefix("~\\"))
2592 {
2593 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2594 return Ok(home.join(rest));
2595 }
2596
2597 let path = PathBuf::from(trimmed);
2598 if path.is_absolute() {
2599 Ok(path)
2600 } else {
2601 let cwd =
2602 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2603 let full_path = cwd.join(&path);
2604
2605 if !full_path.exists()
2608 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2609 {
2610 if let Some(home) = home::home_dir() {
2611 let home_path = home.join(trimmed);
2612 if home_path.exists() {
2613 return Ok(home_path);
2614 }
2615 }
2616 }
2617
2618 Ok(full_path)
2619 }
2620}
2621
2622fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2623 workspace_mode_for_path(workspace_root)
2624}
2625
2626fn workspace_mode_for_path(path: &Path) -> &'static str {
2627 if is_project_marker_path(path) {
2628 "project"
2629 } else if path.join(".hematite").join("docs").exists()
2630 || path.join(".hematite").join("imports").exists()
2631 || path.join(".hematite").join("reports").exists()
2632 {
2633 "docs-only"
2634 } else {
2635 "general directory"
2636 }
2637}
2638
2639fn is_project_marker_path(path: &Path) -> bool {
2640 [
2641 "Cargo.toml",
2642 "package.json",
2643 "pyproject.toml",
2644 "go.mod",
2645 "composer.json",
2646 "requirements.txt",
2647 "Makefile",
2648 "justfile",
2649 ]
2650 .iter()
2651 .any(|name| path.join(name).exists())
2652 || path.join(".git").exists()
2653}
2654
2655fn preferred_shell_label() -> &'static str {
2656 #[cfg(target_os = "windows")]
2657 {
2658 "PowerShell"
2659 }
2660 #[cfg(not(target_os = "windows"))]
2661 {
2662 "sh"
2663 }
2664}
2665
2666fn desktop_dir() -> Option<PathBuf> {
2667 home::home_dir().map(|home| home.join("Desktop"))
2668}
2669
2670fn downloads_dir() -> Option<PathBuf> {
2671 home::home_dir().map(|home| home.join("Downloads"))
2672}
2673
2674fn count_top_level_items(path: &Path) -> Result<usize, String> {
2675 let mut count = 0usize;
2676 for entry in
2677 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2678 {
2679 if entry.is_ok() {
2680 count += 1;
2681 }
2682 }
2683 Ok(count)
2684}
2685
2686#[derive(Default)]
2687struct PathAggregate {
2688 total_bytes: u64,
2689 file_count: u64,
2690 dir_count: u64,
2691 skipped_entries: u64,
2692 partial: bool,
2693}
2694
2695impl PathAggregate {
2696 fn merge(&mut self, other: &PathAggregate) {
2697 self.total_bytes += other.total_bytes;
2698 self.file_count += other.file_count;
2699 self.dir_count += other.dir_count;
2700 self.skipped_entries += other.skipped_entries;
2701 self.partial |= other.partial;
2702 }
2703}
2704
2705struct LargestEntry {
2706 name: String,
2707 kind: &'static str,
2708 bytes: u64,
2709}
2710
2711fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2712 if *budget == 0 {
2713 return PathAggregate {
2714 partial: true,
2715 skipped_entries: 1,
2716 ..PathAggregate::default()
2717 };
2718 }
2719 *budget -= 1;
2720
2721 let metadata = match fs::symlink_metadata(path) {
2722 Ok(metadata) => metadata,
2723 Err(_) => {
2724 return PathAggregate {
2725 skipped_entries: 1,
2726 ..PathAggregate::default()
2727 }
2728 }
2729 };
2730
2731 let file_type = metadata.file_type();
2732 if file_type.is_symlink() {
2733 return PathAggregate {
2734 skipped_entries: 1,
2735 ..PathAggregate::default()
2736 };
2737 }
2738
2739 if metadata.is_file() {
2740 return PathAggregate {
2741 total_bytes: metadata.len(),
2742 file_count: 1,
2743 ..PathAggregate::default()
2744 };
2745 }
2746
2747 if !metadata.is_dir() {
2748 return PathAggregate::default();
2749 }
2750
2751 let mut aggregate = PathAggregate {
2752 dir_count: 1,
2753 ..PathAggregate::default()
2754 };
2755
2756 let read_dir = match fs::read_dir(path) {
2757 Ok(read_dir) => read_dir,
2758 Err(_) => {
2759 aggregate.skipped_entries += 1;
2760 return aggregate;
2761 }
2762 };
2763
2764 for child in read_dir {
2765 match child {
2766 Ok(child) => {
2767 let child_stats = measure_path(&child.path(), budget);
2768 aggregate.merge(&child_stats);
2769 }
2770 Err(_) => aggregate.skipped_entries += 1,
2771 }
2772 }
2773
2774 aggregate
2775}
2776
2777struct PathAnalysis {
2778 total_entries: usize,
2779 unique_entries: usize,
2780 entries: Vec<String>,
2781 duplicate_entries: Vec<String>,
2782 missing_entries: Vec<String>,
2783}
2784
2785fn analyze_path_env() -> PathAnalysis {
2786 let mut entries = Vec::new();
2787 let mut duplicate_entries = Vec::new();
2788 let mut missing_entries = Vec::new();
2789 let mut seen = HashSet::new();
2790
2791 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2792 for path in std::env::split_paths(&raw_path) {
2793 let display = path.display().to_string();
2794 if display.trim().is_empty() {
2795 continue;
2796 }
2797
2798 let normalized = normalize_path_entry(&display);
2799 if !seen.insert(normalized) {
2800 duplicate_entries.push(display.clone());
2801 }
2802 if !path.exists() {
2803 missing_entries.push(display.clone());
2804 }
2805 entries.push(display);
2806 }
2807
2808 let total_entries = entries.len();
2809 let unique_entries = seen.len();
2810
2811 PathAnalysis {
2812 total_entries,
2813 unique_entries,
2814 entries,
2815 duplicate_entries,
2816 missing_entries,
2817 }
2818}
2819
2820fn normalize_path_entry(value: &str) -> String {
2821 #[cfg(target_os = "windows")]
2822 {
2823 value
2824 .replace('/', "\\")
2825 .trim_end_matches(['\\', '/'])
2826 .to_ascii_lowercase()
2827 }
2828 #[cfg(not(target_os = "windows"))]
2829 {
2830 value.trim_end_matches('/').to_string()
2831 }
2832}
2833
2834struct ToolchainReport {
2835 found: Vec<(String, String)>,
2836 missing: Vec<String>,
2837}
2838
2839struct PackageManagerReport {
2840 found: Vec<(String, String)>,
2841}
2842
2843#[derive(Debug, Clone)]
2844struct ProcessEntry {
2845 name: String,
2846 pid: u32,
2847 memory_bytes: u64,
2848 cpu_seconds: Option<f64>,
2849 cpu_percent: Option<f64>,
2850 read_ops: Option<u64>,
2851 write_ops: Option<u64>,
2852 detail: Option<String>,
2853}
2854
2855#[derive(Debug, Clone)]
2856struct ServiceEntry {
2857 name: String,
2858 status: String,
2859 startup: Option<String>,
2860 display_name: Option<String>,
2861 start_name: Option<String>,
2862}
2863
2864#[derive(Debug, Clone, Default)]
2865struct NetworkAdapter {
2866 name: String,
2867 ipv4: Vec<String>,
2868 ipv6: Vec<String>,
2869 gateways: Vec<String>,
2870 dns_servers: Vec<String>,
2871 disconnected: bool,
2872}
2873
2874impl NetworkAdapter {
2875 fn is_active(&self) -> bool {
2876 !self.disconnected
2877 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2878 }
2879}
2880
2881#[derive(Debug, Clone, Copy, Default)]
2882struct ListenerExposureSummary {
2883 loopback_only: usize,
2884 wildcard_public: usize,
2885 specific_bind: usize,
2886}
2887
2888#[derive(Debug, Clone)]
2889struct ListeningPort {
2890 protocol: String,
2891 local: String,
2892 port: u16,
2893 state: String,
2894 pid: Option<String>,
2895 process_name: Option<String>,
2896}
2897
2898fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2899 #[cfg(target_os = "windows")]
2900 {
2901 collect_windows_listening_ports()
2902 }
2903 #[cfg(not(target_os = "windows"))]
2904 {
2905 collect_unix_listening_ports()
2906 }
2907}
2908
2909fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2910 #[cfg(target_os = "windows")]
2911 {
2912 collect_windows_network_adapters()
2913 }
2914 #[cfg(not(target_os = "windows"))]
2915 {
2916 collect_unix_network_adapters()
2917 }
2918}
2919
2920fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2921 #[cfg(target_os = "windows")]
2922 {
2923 collect_windows_services()
2924 }
2925 #[cfg(not(target_os = "windows"))]
2926 {
2927 collect_unix_services()
2928 }
2929}
2930
2931#[cfg(target_os = "windows")]
2932fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2933 let output = Command::new("netstat")
2934 .args(["-ano", "-p", "tcp"])
2935 .output()
2936 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2937 if !output.status.success() {
2938 return Err("netstat returned a non-success status.".to_string());
2939 }
2940
2941 let text = String::from_utf8_lossy(&output.stdout);
2942 let mut listeners = Vec::new();
2943 for line in text.lines() {
2944 let trimmed = line.trim();
2945 if !trimmed.starts_with("TCP") {
2946 continue;
2947 }
2948 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2949 if cols.len() < 5 || cols[3] != "LISTENING" {
2950 continue;
2951 }
2952 let Some(port) = extract_port_from_socket(cols[1]) else {
2953 continue;
2954 };
2955 listeners.push(ListeningPort {
2956 protocol: cols[0].to_string(),
2957 local: cols[1].to_string(),
2958 port,
2959 state: cols[3].to_string(),
2960 pid: Some(cols[4].to_string()),
2961 process_name: None,
2962 });
2963 }
2964
2965 let unique_pids: Vec<String> = listeners
2968 .iter()
2969 .filter_map(|l| l.pid.clone())
2970 .collect::<HashSet<_>>()
2971 .into_iter()
2972 .collect();
2973
2974 if !unique_pids.is_empty() {
2975 let pid_list = unique_pids.join(",");
2976 let ps_cmd = format!(
2977 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2978 pid_list
2979 );
2980 if let Ok(ps_out) = Command::new("powershell")
2981 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2982 .output()
2983 {
2984 let mut pid_map = std::collections::HashMap::<String, String>::new();
2985 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2986 for line in ps_text.lines() {
2987 let parts: Vec<&str> = line.split_whitespace().collect();
2988 if parts.len() >= 2 {
2989 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2990 }
2991 }
2992 for listener in &mut listeners {
2993 if let Some(pid) = &listener.pid {
2994 listener.process_name = pid_map.get(pid).cloned();
2995 }
2996 }
2997 }
2998 }
2999
3000 Ok(listeners)
3001}
3002
3003#[cfg(not(target_os = "windows"))]
3004fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3005 let output = Command::new("ss")
3006 .args(["-ltn"])
3007 .output()
3008 .map_err(|e| format!("Failed to run ss: {e}"))?;
3009 if !output.status.success() {
3010 return Err("ss returned a non-success status.".to_string());
3011 }
3012
3013 let text = String::from_utf8_lossy(&output.stdout);
3014 let mut listeners = Vec::new();
3015 for line in text.lines().skip(1) {
3016 let cols: Vec<&str> = line.split_whitespace().collect();
3017 if cols.len() < 4 {
3018 continue;
3019 }
3020 let Some(port) = extract_port_from_socket(cols[3]) else {
3021 continue;
3022 };
3023 listeners.push(ListeningPort {
3024 protocol: "tcp".to_string(),
3025 local: cols[3].to_string(),
3026 port,
3027 state: cols[0].to_string(),
3028 pid: None,
3029 process_name: None,
3030 });
3031 }
3032
3033 Ok(listeners)
3034}
3035
3036fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3037 #[cfg(target_os = "windows")]
3038 {
3039 collect_windows_processes()
3040 }
3041 #[cfg(not(target_os = "windows"))]
3042 {
3043 collect_unix_processes()
3044 }
3045}
3046
3047#[cfg(target_os = "windows")]
3048fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3049 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3050 let output = Command::new("powershell")
3051 .args(["-NoProfile", "-Command", command])
3052 .output()
3053 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3054 if !output.status.success() {
3055 return Err("PowerShell service inspection returned a non-success status.".to_string());
3056 }
3057
3058 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3059}
3060
3061#[cfg(not(target_os = "windows"))]
3062fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3063 let status_output = Command::new("systemctl")
3064 .args([
3065 "list-units",
3066 "--type=service",
3067 "--all",
3068 "--no-pager",
3069 "--no-legend",
3070 "--plain",
3071 ])
3072 .output()
3073 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3074 if !status_output.status.success() {
3075 return Err("systemctl list-units returned a non-success status.".to_string());
3076 }
3077
3078 let startup_output = Command::new("systemctl")
3079 .args([
3080 "list-unit-files",
3081 "--type=service",
3082 "--no-legend",
3083 "--no-pager",
3084 "--plain",
3085 ])
3086 .output()
3087 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3088 if !startup_output.status.success() {
3089 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3090 }
3091
3092 Ok(parse_unix_services(
3093 &String::from_utf8_lossy(&status_output.stdout),
3094 &String::from_utf8_lossy(&startup_output.stdout),
3095 ))
3096}
3097
3098#[cfg(target_os = "windows")]
3099fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3100 let output = Command::new("ipconfig")
3101 .args(["/all"])
3102 .output()
3103 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3104 if !output.status.success() {
3105 return Err("ipconfig returned a non-success status.".to_string());
3106 }
3107
3108 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3109 &output.stdout,
3110 )))
3111}
3112
3113#[cfg(not(target_os = "windows"))]
3114fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3115 let addr_output = Command::new("ip")
3116 .args(["-o", "addr", "show", "up"])
3117 .output()
3118 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3119 if !addr_output.status.success() {
3120 return Err("ip addr returned a non-success status.".to_string());
3121 }
3122
3123 let route_output = Command::new("ip")
3124 .args(["route", "show", "default"])
3125 .output()
3126 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3127 if !route_output.status.success() {
3128 return Err("ip route returned a non-success status.".to_string());
3129 }
3130
3131 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3132 apply_unix_default_routes(
3133 &mut adapters,
3134 &String::from_utf8_lossy(&route_output.stdout),
3135 );
3136 apply_unix_dns_servers(&mut adapters);
3137 Ok(adapters)
3138}
3139
3140#[cfg(target_os = "windows")]
3141fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3142 let script = r#"
3144 $s1 = Get-Process | Select-Object Id, CPU
3145 Start-Sleep -Milliseconds 250
3146 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3147 $s2 | ForEach-Object {
3148 $p2 = $_
3149 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3150 $pct = 0.0
3151 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3152 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3153 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3154 # Standard Task Manager style is (delta / interval) * 100.
3155 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3156 }
3157 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3158 }
3159 "#;
3160
3161 let output = Command::new("powershell")
3162 .args(["-NoProfile", "-Command", script])
3163 .output()
3164 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3165
3166 let text = String::from_utf8_lossy(&output.stdout);
3167 let mut out = Vec::new();
3168 for line in text.lines() {
3169 let parts: Vec<&str> = line.trim().split('|').collect();
3170 if parts.len() < 5 {
3171 continue;
3172 }
3173 let mut entry = ProcessEntry {
3174 name: "unknown".to_string(),
3175 pid: 0,
3176 memory_bytes: 0,
3177 cpu_seconds: None,
3178 cpu_percent: None,
3179 read_ops: None,
3180 write_ops: None,
3181 detail: None,
3182 };
3183 for p in parts {
3184 if let Some((k, v)) = p.split_once(':') {
3185 match k {
3186 "PID" => entry.pid = v.parse().unwrap_or(0),
3187 "NAME" => entry.name = v.to_string(),
3188 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3189 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3190 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3191 "READ" => entry.read_ops = v.parse().ok(),
3192 "WRITE" => entry.write_ops = v.parse().ok(),
3193 _ => {}
3194 }
3195 }
3196 }
3197 out.push(entry);
3198 }
3199 Ok(out)
3200}
3201
3202#[cfg(not(target_os = "windows"))]
3203fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3204 let output = Command::new("ps")
3205 .args(["-eo", "pid=,rss=,comm="])
3206 .output()
3207 .map_err(|e| format!("Failed to run ps: {e}"))?;
3208 if !output.status.success() {
3209 return Err("ps returned a non-success status.".to_string());
3210 }
3211
3212 let text = String::from_utf8_lossy(&output.stdout);
3213 let mut processes = Vec::new();
3214 for line in text.lines() {
3215 let cols: Vec<&str> = line.split_whitespace().collect();
3216 if cols.len() < 3 {
3217 continue;
3218 }
3219 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3220 else {
3221 continue;
3222 };
3223 processes.push(ProcessEntry {
3224 name: cols[2..].join(" "),
3225 pid,
3226 memory_bytes: rss_kib * 1024,
3227 cpu_seconds: None,
3228 cpu_percent: None,
3229 read_ops: None,
3230 write_ops: None,
3231 detail: None,
3232 });
3233 }
3234
3235 Ok(processes)
3236}
3237
3238fn extract_port_from_socket(value: &str) -> Option<u16> {
3239 let cleaned = value.trim().trim_matches(['[', ']']);
3240 let port_str = cleaned.rsplit(':').next()?;
3241 port_str.parse::<u16>().ok()
3242}
3243
3244fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3245 let mut summary = ListenerExposureSummary::default();
3246 for entry in listeners {
3247 let local = entry.local.to_ascii_lowercase();
3248 if is_loopback_listener(&local) {
3249 summary.loopback_only += 1;
3250 } else if is_wildcard_listener(&local) {
3251 summary.wildcard_public += 1;
3252 } else {
3253 summary.specific_bind += 1;
3254 }
3255 }
3256 summary
3257}
3258
3259fn is_loopback_listener(local: &str) -> bool {
3260 local.starts_with("127.")
3261 || local.starts_with("[::1]")
3262 || local.starts_with("::1")
3263 || local.starts_with("localhost:")
3264}
3265
3266fn is_wildcard_listener(local: &str) -> bool {
3267 local.starts_with("0.0.0.0:")
3268 || local.starts_with("[::]:")
3269 || local.starts_with(":::")
3270 || local == "*:*"
3271}
3272
3273struct GitState {
3274 root: PathBuf,
3275 branch: String,
3276 dirty_entries: usize,
3277}
3278
3279impl GitState {
3280 fn status_label(&self) -> String {
3281 if self.dirty_entries == 0 {
3282 "clean".to_string()
3283 } else {
3284 format!("dirty ({} changed path(s))", self.dirty_entries)
3285 }
3286 }
3287}
3288
3289fn inspect_git_state(path: &Path) -> Option<GitState> {
3290 let root = capture_first_line(
3291 "git",
3292 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3293 )?;
3294 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3295 .unwrap_or_else(|| "detached".to_string());
3296 let output = Command::new("git")
3297 .args(["-C", path.to_str()?, "status", "--short"])
3298 .output()
3299 .ok()?;
3300 if !output.status.success() {
3301 return None;
3302 }
3303 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3304 Some(GitState {
3305 root: PathBuf::from(root),
3306 branch,
3307 dirty_entries,
3308 })
3309}
3310
3311struct HematiteState {
3312 docs_count: usize,
3313 import_count: usize,
3314 report_count: usize,
3315 workspace_profile: bool,
3316}
3317
3318fn collect_hematite_state(path: &Path) -> HematiteState {
3319 let root = path.join(".hematite");
3320 HematiteState {
3321 docs_count: count_entries_if_exists(&root.join("docs")),
3322 import_count: count_entries_if_exists(&root.join("imports")),
3323 report_count: count_entries_if_exists(&root.join("reports")),
3324 workspace_profile: root.join("workspace_profile.json").exists(),
3325 }
3326}
3327
3328fn count_entries_if_exists(path: &Path) -> usize {
3329 if !path.exists() || !path.is_dir() {
3330 return 0;
3331 }
3332 fs::read_dir(path)
3333 .ok()
3334 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3335 .unwrap_or(0)
3336}
3337
3338fn collect_project_markers(path: &Path) -> Vec<String> {
3339 [
3340 "Cargo.toml",
3341 "package.json",
3342 "pyproject.toml",
3343 "go.mod",
3344 "justfile",
3345 "Makefile",
3346 ".git",
3347 ]
3348 .iter()
3349 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3350 .collect()
3351}
3352
3353struct ReleaseArtifactState {
3354 version: String,
3355 portable_dir: bool,
3356 portable_zip: bool,
3357 setup_exe: bool,
3358}
3359
3360fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3361 let cargo_toml = path.join("Cargo.toml");
3362 if !cargo_toml.exists() {
3363 return None;
3364 }
3365 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3366 let version = [regex_line_capture(
3367 &cargo_text,
3368 r#"(?m)^version\s*=\s*"([^"]+)""#,
3369 )?]
3370 .concat();
3371 let dist_windows = path.join("dist").join("windows");
3372 let prefix = format!("Hematite-{}", version);
3373 Some(ReleaseArtifactState {
3374 version,
3375 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3376 portable_zip: dist_windows
3377 .join(format!("{}-portable.zip", prefix))
3378 .exists(),
3379 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3380 })
3381}
3382
3383fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3384 let regex = regex::Regex::new(pattern).ok()?;
3385 let captures = regex.captures(text)?;
3386 captures.get(1).map(|m| m.as_str().to_string())
3387}
3388
3389fn bool_label(value: bool) -> &'static str {
3390 if value {
3391 "yes"
3392 } else {
3393 "no"
3394 }
3395}
3396
3397fn collect_toolchains() -> ToolchainReport {
3398 let config = crate::agent::config::load_config();
3399 let mut python_probes = Vec::new();
3400 let _ = if let Some(ref path) = config.python_path {
3401 python_probes.push(CommandProbe::new(path, &["--version"]));
3402 } else {
3403 };
3404
3405 python_probes.extend([
3406 CommandProbe::new("python3", &["--version"]),
3407 CommandProbe::new("python", &["--version"]),
3408 CommandProbe::new("py", &["-3", "--version"]),
3409 CommandProbe::new("py", &["--version"]),
3410 ]);
3411
3412 let checks = [
3413 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3414 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3415 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3416 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3417 ToolCheck::new(
3418 "npm",
3419 &[
3420 CommandProbe::new("npm", &["--version"]),
3421 CommandProbe::new("npm.cmd", &["--version"]),
3422 ],
3423 ),
3424 ToolCheck::new(
3425 "pnpm",
3426 &[
3427 CommandProbe::new("pnpm", &["--version"]),
3428 CommandProbe::new("pnpm.cmd", &["--version"]),
3429 ],
3430 ),
3431 ToolCheck::new("python", &python_probes),
3432 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3433 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3434 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3435 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3436 ];
3437
3438 let mut found = Vec::new();
3439 let mut missing = Vec::new();
3440
3441 for check in checks {
3442 match check.detect() {
3443 Some(version) => found.push((check.label.to_string(), version)),
3444 None => missing.push(check.label.to_string()),
3445 }
3446 }
3447
3448 ToolchainReport { found, missing }
3449}
3450
3451fn collect_package_managers() -> PackageManagerReport {
3452 let config = crate::agent::config::load_config();
3453 let mut pip_probes = Vec::new();
3454 if let Some(ref path) = config.python_path {
3455 pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3456 }
3457 pip_probes.extend([
3458 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3459 CommandProbe::new("python", &["-m", "pip", "--version"]),
3460 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3461 CommandProbe::new("py", &["-m", "pip", "--version"]),
3462 CommandProbe::new("pip", &["--version"]),
3463 ]);
3464
3465 let checks = [
3466 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3467 ToolCheck::new(
3468 "npm",
3469 &[
3470 CommandProbe::new("npm", &["--version"]),
3471 CommandProbe::new("npm.cmd", &["--version"]),
3472 ],
3473 ),
3474 ToolCheck::new(
3475 "pnpm",
3476 &[
3477 CommandProbe::new("pnpm", &["--version"]),
3478 CommandProbe::new("pnpm.cmd", &["--version"]),
3479 ],
3480 ),
3481 ToolCheck::new("pip", &pip_probes),
3482 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3483 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3484 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3485 ToolCheck::new(
3486 "choco",
3487 &[
3488 CommandProbe::new("choco", &["--version"]),
3489 CommandProbe::new("choco.exe", &["--version"]),
3490 ],
3491 ),
3492 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3493 ];
3494
3495 let mut found = Vec::new();
3496 for check in checks {
3497 match check.detect() {
3498 Some(version) => found.push((check.label.to_string(), version)),
3499 None => {}
3500 }
3501 }
3502
3503 PackageManagerReport { found }
3504}
3505
3506#[derive(Clone)]
3507struct ToolCheck {
3508 label: &'static str,
3509 probes: Vec<CommandProbe>,
3510}
3511
3512impl ToolCheck {
3513 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3514 Self {
3515 label,
3516 probes: probes.to_vec(),
3517 }
3518 }
3519
3520 fn detect(&self) -> Option<String> {
3521 for probe in &self.probes {
3522 if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3523 return Some(output);
3524 }
3525 }
3526 None
3527 }
3528}
3529
3530#[derive(Clone)]
3531struct CommandProbe {
3532 program: String,
3533 args: Vec<String>,
3534}
3535
3536impl CommandProbe {
3537 fn new(program: &str, args: &[&str]) -> Self {
3538 Self {
3539 program: program.to_string(),
3540 args: args.iter().map(|s| s.to_string()).collect(),
3541 }
3542 }
3543}
3544
3545fn build_env_doctor_findings(
3546 toolchains: &ToolchainReport,
3547 package_managers: &PackageManagerReport,
3548 path_stats: &PathAnalysis,
3549) -> Vec<String> {
3550 let found_tools = toolchains
3551 .found
3552 .iter()
3553 .map(|(label, _)| label.as_str())
3554 .collect::<HashSet<_>>();
3555 let found_managers = package_managers
3556 .found
3557 .iter()
3558 .map(|(label, _)| label.as_str())
3559 .collect::<HashSet<_>>();
3560
3561 let mut findings = Vec::new();
3562
3563 if path_stats.duplicate_entries.len() > 0 {
3564 findings.push(format!(
3565 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3566 path_stats.duplicate_entries.len()
3567 ));
3568 }
3569 if path_stats.missing_entries.len() > 0 {
3570 findings.push(format!(
3571 "PATH contains {} entries that do not exist on disk.",
3572 path_stats.missing_entries.len()
3573 ));
3574 }
3575 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3576 findings.push(
3577 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3578 .to_string(),
3579 );
3580 }
3581 if found_tools.contains("node")
3582 && !found_managers.contains("npm")
3583 && !found_managers.contains("pnpm")
3584 {
3585 findings.push(
3586 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3587 .to_string(),
3588 );
3589 }
3590 if found_tools.contains("python")
3591 && !found_managers.contains("pip")
3592 && !found_managers.contains("uv")
3593 && !found_managers.contains("pipx")
3594 {
3595 findings.push(
3596 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3597 .to_string(),
3598 );
3599 }
3600 let windows_manager_count = ["winget", "choco", "scoop"]
3601 .iter()
3602 .filter(|label| found_managers.contains(**label))
3603 .count();
3604 if windows_manager_count > 1 {
3605 findings.push(
3606 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3607 .to_string(),
3608 );
3609 }
3610 if findings.is_empty() && !found_managers.is_empty() {
3611 findings.push(
3612 "Core package-manager coverage looks healthy for a normal developer workstation."
3613 .to_string(),
3614 );
3615 }
3616
3617 findings
3618}
3619
3620fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3621 let output = std::process::Command::new(program)
3622 .args(args.iter().map(|s| s.as_ref()))
3623 .output()
3624 .ok()?;
3625 if !output.status.success() {
3626 return None;
3627 }
3628
3629 let stdout = if output.stdout.is_empty() {
3630 String::from_utf8_lossy(&output.stderr).into_owned()
3631 } else {
3632 String::from_utf8_lossy(&output.stdout).into_owned()
3633 };
3634
3635 stdout
3636 .lines()
3637 .map(str::trim)
3638 .find(|line| !line.is_empty())
3639 .map(|line| line.to_string())
3640}
3641
3642fn human_bytes(bytes: u64) -> String {
3643 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3644 let mut value = bytes as f64;
3645 let mut unit_index = 0usize;
3646
3647 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3648 value /= 1024.0;
3649 unit_index += 1;
3650 }
3651
3652 if unit_index == 0 {
3653 format!("{} {}", bytes, UNITS[unit_index])
3654 } else {
3655 format!("{value:.1} {}", UNITS[unit_index])
3656 }
3657}
3658
3659#[cfg(target_os = "windows")]
3660fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3661 let mut adapters = Vec::new();
3662 let mut current: Option<NetworkAdapter> = None;
3663 let mut pending_dns = false;
3664
3665 for raw_line in text.lines() {
3666 let line = raw_line.trim_end();
3667 let trimmed = line.trim();
3668 if trimmed.is_empty() {
3669 pending_dns = false;
3670 continue;
3671 }
3672
3673 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3674 if let Some(adapter) = current.take() {
3675 adapters.push(adapter);
3676 }
3677 current = Some(NetworkAdapter {
3678 name: trimmed.trim_end_matches(':').to_string(),
3679 ..NetworkAdapter::default()
3680 });
3681 pending_dns = false;
3682 continue;
3683 }
3684
3685 let Some(adapter) = current.as_mut() else {
3686 continue;
3687 };
3688
3689 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3690 adapter.disconnected = true;
3691 }
3692
3693 if let Some(value) = value_after_colon(trimmed) {
3694 let normalized = normalize_ipconfig_value(value);
3695 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3696 adapter.ipv4.push(normalized);
3697 pending_dns = false;
3698 } else if trimmed.starts_with("IPv6 Address")
3699 || trimmed.starts_with("Temporary IPv6 Address")
3700 || trimmed.starts_with("Link-local IPv6 Address")
3701 {
3702 if !normalized.is_empty() {
3703 adapter.ipv6.push(normalized);
3704 }
3705 pending_dns = false;
3706 } else if trimmed.starts_with("Default Gateway") {
3707 if !normalized.is_empty() {
3708 adapter.gateways.push(normalized);
3709 }
3710 pending_dns = false;
3711 } else if trimmed.starts_with("DNS Servers") {
3712 if !normalized.is_empty() {
3713 adapter.dns_servers.push(normalized);
3714 }
3715 pending_dns = true;
3716 } else {
3717 pending_dns = false;
3718 }
3719 } else if pending_dns {
3720 let normalized = normalize_ipconfig_value(trimmed);
3721 if !normalized.is_empty() {
3722 adapter.dns_servers.push(normalized);
3723 }
3724 }
3725 }
3726
3727 if let Some(adapter) = current.take() {
3728 adapters.push(adapter);
3729 }
3730
3731 for adapter in &mut adapters {
3732 dedup_vec(&mut adapter.ipv4);
3733 dedup_vec(&mut adapter.ipv6);
3734 dedup_vec(&mut adapter.gateways);
3735 dedup_vec(&mut adapter.dns_servers);
3736 }
3737
3738 adapters
3739}
3740
3741#[cfg(not(target_os = "windows"))]
3742fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3743 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3744
3745 for line in text.lines() {
3746 let cols: Vec<&str> = line.split_whitespace().collect();
3747 if cols.len() < 4 {
3748 continue;
3749 }
3750 let name = cols[1].trim_end_matches(':').to_string();
3751 let family = cols[2];
3752 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3753 let entry = adapters
3754 .entry(name.clone())
3755 .or_insert_with(|| NetworkAdapter {
3756 name,
3757 ..NetworkAdapter::default()
3758 });
3759 match family {
3760 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3761 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3762 _ => {}
3763 }
3764 }
3765
3766 adapters.into_values().collect()
3767}
3768
3769#[cfg(not(target_os = "windows"))]
3770fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3771 for line in text.lines() {
3772 let cols: Vec<&str> = line.split_whitespace().collect();
3773 if cols.len() < 5 {
3774 continue;
3775 }
3776 let gateway = cols
3777 .windows(2)
3778 .find(|pair| pair[0] == "via")
3779 .map(|pair| pair[1].to_string());
3780 let dev = cols
3781 .windows(2)
3782 .find(|pair| pair[0] == "dev")
3783 .map(|pair| pair[1]);
3784 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3785 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3786 adapter.gateways.push(gateway);
3787 }
3788 }
3789 }
3790
3791 for adapter in adapters {
3792 dedup_vec(&mut adapter.gateways);
3793 }
3794}
3795
3796#[cfg(not(target_os = "windows"))]
3797fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3798 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3799 return;
3800 };
3801 let mut dns_servers = text
3802 .lines()
3803 .filter_map(|line| line.strip_prefix("nameserver "))
3804 .map(str::trim)
3805 .filter(|value| !value.is_empty())
3806 .map(|value| value.to_string())
3807 .collect::<Vec<_>>();
3808 dedup_vec(&mut dns_servers);
3809 if dns_servers.is_empty() {
3810 return;
3811 }
3812 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3813 adapter.dns_servers = dns_servers.clone();
3814 }
3815}
3816
3817#[cfg(target_os = "windows")]
3818fn value_after_colon(line: &str) -> Option<&str> {
3819 line.split_once(':').map(|(_, value)| value.trim())
3820}
3821
3822#[cfg(target_os = "windows")]
3823fn normalize_ipconfig_value(value: &str) -> String {
3824 value
3825 .trim()
3826 .trim_end_matches("(Preferred)")
3827 .trim_end_matches("(Deprecated)")
3828 .trim()
3829 .trim_matches(['(', ')'])
3830 .trim()
3831 .to_string()
3832}
3833
3834#[cfg(target_os = "windows")]
3835fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3836 let mac_upper = mac.to_ascii_uppercase();
3837 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3838 return true;
3839 }
3840
3841 ip == "255.255.255.255"
3842 || ip.starts_with("224.")
3843 || ip.starts_with("225.")
3844 || ip.starts_with("226.")
3845 || ip.starts_with("227.")
3846 || ip.starts_with("228.")
3847 || ip.starts_with("229.")
3848 || ip.starts_with("230.")
3849 || ip.starts_with("231.")
3850 || ip.starts_with("232.")
3851 || ip.starts_with("233.")
3852 || ip.starts_with("234.")
3853 || ip.starts_with("235.")
3854 || ip.starts_with("236.")
3855 || ip.starts_with("237.")
3856 || ip.starts_with("238.")
3857 || ip.starts_with("239.")
3858}
3859
3860fn dedup_vec(values: &mut Vec<String>) {
3861 let mut seen = HashSet::new();
3862 values.retain(|value| seen.insert(value.clone()));
3863}
3864
3865#[cfg(target_os = "windows")]
3866fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3867 let trimmed = text.trim();
3868 if trimmed.is_empty() {
3869 return Vec::new();
3870 }
3871
3872 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3873 return Vec::new();
3874 };
3875 let entries = match value {
3876 Value::Array(items) => items,
3877 other => vec![other],
3878 };
3879
3880 let mut neighbors = Vec::new();
3881 for entry in entries {
3882 let ip = entry
3883 .get("IPAddress")
3884 .and_then(|v| v.as_str())
3885 .unwrap_or("")
3886 .to_string();
3887 if ip.is_empty() {
3888 continue;
3889 }
3890 let mac = entry
3891 .get("LinkLayerAddress")
3892 .and_then(|v| v.as_str())
3893 .unwrap_or("unknown")
3894 .to_string();
3895 let state = entry
3896 .get("State")
3897 .and_then(|v| v.as_str())
3898 .unwrap_or("unknown")
3899 .to_string();
3900 let iface = entry
3901 .get("InterfaceAlias")
3902 .and_then(|v| v.as_str())
3903 .unwrap_or("unknown")
3904 .to_string();
3905 if is_noise_lan_neighbor(&ip, &mac) {
3906 continue;
3907 }
3908 neighbors.push((ip, mac, state, iface));
3909 }
3910
3911 neighbors
3912}
3913
3914#[cfg(target_os = "windows")]
3915fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3916 let trimmed = text.trim();
3917 if trimmed.is_empty() {
3918 return Ok(Vec::new());
3919 }
3920
3921 let value: Value = serde_json::from_str(trimmed)
3922 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3923 let entries = match value {
3924 Value::Array(items) => items,
3925 other => vec![other],
3926 };
3927
3928 let mut services = Vec::new();
3929 for entry in entries {
3930 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3931 continue;
3932 };
3933 services.push(ServiceEntry {
3934 name: name.to_string(),
3935 status: entry
3936 .get("State")
3937 .and_then(|v| v.as_str())
3938 .unwrap_or("unknown")
3939 .to_string(),
3940 startup: entry
3941 .get("StartMode")
3942 .and_then(|v| v.as_str())
3943 .map(|v| v.to_string()),
3944 display_name: entry
3945 .get("DisplayName")
3946 .and_then(|v| v.as_str())
3947 .map(|v| v.to_string()),
3948 start_name: entry
3949 .get("StartName")
3950 .and_then(|v| v.as_str())
3951 .map(|v| v.to_string()),
3952 });
3953 }
3954
3955 Ok(services)
3956}
3957
3958#[cfg(target_os = "windows")]
3959fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3960 match node.cloned() {
3961 Some(Value::Array(items)) => items,
3962 Some(other) => vec![other],
3963 None => Vec::new(),
3964 }
3965}
3966
3967#[cfg(target_os = "windows")]
3968fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3969 windows_json_entries(node)
3970 .into_iter()
3971 .filter_map(|entry| {
3972 let name = entry
3973 .get("FriendlyName")
3974 .and_then(|v| v.as_str())
3975 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3976 .unwrap_or("")
3977 .trim()
3978 .to_string();
3979 if name.is_empty() {
3980 return None;
3981 }
3982 Some(WindowsPnpDevice {
3983 name,
3984 status: entry
3985 .get("Status")
3986 .and_then(|v| v.as_str())
3987 .unwrap_or("Unknown")
3988 .trim()
3989 .to_string(),
3990 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3991 entry
3992 .get("Problem")
3993 .and_then(|v| v.as_i64())
3994 .map(|v| v as u64)
3995 }),
3996 class_name: entry
3997 .get("Class")
3998 .and_then(|v| v.as_str())
3999 .map(|v| v.trim().to_string()),
4000 instance_id: entry
4001 .get("InstanceId")
4002 .and_then(|v| v.as_str())
4003 .map(|v| v.trim().to_string()),
4004 })
4005 })
4006 .collect()
4007}
4008
4009#[cfg(target_os = "windows")]
4010fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4011 windows_json_entries(node)
4012 .into_iter()
4013 .filter_map(|entry| {
4014 let name = entry
4015 .get("Name")
4016 .and_then(|v| v.as_str())
4017 .unwrap_or("")
4018 .trim()
4019 .to_string();
4020 if name.is_empty() {
4021 return None;
4022 }
4023 Some(WindowsSoundDevice {
4024 name,
4025 status: entry
4026 .get("Status")
4027 .and_then(|v| v.as_str())
4028 .unwrap_or("Unknown")
4029 .trim()
4030 .to_string(),
4031 manufacturer: entry
4032 .get("Manufacturer")
4033 .and_then(|v| v.as_str())
4034 .map(|v| v.trim().to_string()),
4035 })
4036 })
4037 .collect()
4038}
4039
4040#[cfg(target_os = "windows")]
4041fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4042 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4043 || device.problem.unwrap_or(0) != 0
4044}
4045
4046#[cfg(target_os = "windows")]
4047fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4048 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4049}
4050
4051#[cfg(target_os = "windows")]
4052fn is_microphone_like_name(name: &str) -> bool {
4053 let lower = name.to_ascii_lowercase();
4054 lower.contains("microphone")
4055 || lower.contains("mic")
4056 || lower.contains("input")
4057 || lower.contains("array")
4058 || lower.contains("capture")
4059 || lower.contains("record")
4060}
4061
4062#[cfg(target_os = "windows")]
4063fn is_bluetooth_like_name(name: &str) -> bool {
4064 let lower = name.to_ascii_lowercase();
4065 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4066}
4067
4068#[cfg(target_os = "windows")]
4069fn service_is_running(service: &ServiceEntry) -> bool {
4070 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4071}
4072
4073#[cfg(not(target_os = "windows"))]
4074fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4075 let mut startup_modes = std::collections::HashMap::<String, String>::new();
4076 for line in startup_text.lines() {
4077 let cols: Vec<&str> = line.split_whitespace().collect();
4078 if cols.len() < 2 {
4079 continue;
4080 }
4081 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4082 }
4083
4084 let mut services = Vec::new();
4085 for line in status_text.lines() {
4086 let cols: Vec<&str> = line.split_whitespace().collect();
4087 if cols.len() < 4 {
4088 continue;
4089 }
4090 let unit = cols[0];
4091 let load = cols[1];
4092 let active = cols[2];
4093 let sub = cols[3];
4094 let description = if cols.len() > 4 {
4095 Some(cols[4..].join(" "))
4096 } else {
4097 None
4098 };
4099 services.push(ServiceEntry {
4100 name: unit.to_string(),
4101 status: format!("{}/{}", active, sub),
4102 startup: startup_modes
4103 .get(unit)
4104 .cloned()
4105 .or_else(|| Some(load.to_string())),
4106 display_name: description,
4107 start_name: None,
4108 });
4109 }
4110
4111 services
4112}
4113
4114fn inspect_health_report() -> Result<String, String> {
4120 let mut needs_fix: Vec<String> = Vec::new();
4121 let mut watch: Vec<String> = Vec::new();
4122 let mut good: Vec<String> = Vec::new();
4123 let mut tips: Vec<String> = Vec::new();
4124
4125 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4126 health_check_memory(&mut watch, &mut good);
4127 health_check_network(&mut needs_fix, &mut watch, &mut good);
4128 health_check_pending_reboot(&mut watch, &mut good);
4129 health_check_services(&mut needs_fix, &mut watch, &mut good);
4130 health_check_thermal(&mut watch, &mut good);
4131 health_check_tools(&mut watch, &mut good, &mut tips);
4132 health_check_recent_errors(&mut watch, &mut tips);
4133
4134 let overall = if !needs_fix.is_empty() {
4135 "ACTION REQUIRED"
4136 } else if !watch.is_empty() {
4137 "WORTH A LOOK"
4138 } else {
4139 "ALL GOOD"
4140 };
4141
4142 let mut out = format!("System Health Report — {overall}\n\n");
4143
4144 if !needs_fix.is_empty() {
4145 out.push_str("Needs fixing:\n");
4146 for item in &needs_fix {
4147 out.push_str(&format!(" [!] {item}\n"));
4148 }
4149 out.push('\n');
4150 }
4151 if !watch.is_empty() {
4152 out.push_str("Worth watching:\n");
4153 for item in &watch {
4154 out.push_str(&format!(" [-] {item}\n"));
4155 }
4156 out.push('\n');
4157 }
4158 if !good.is_empty() {
4159 out.push_str("Looking good:\n");
4160 for item in &good {
4161 out.push_str(&format!(" [+] {item}\n"));
4162 }
4163 out.push('\n');
4164 }
4165 if !tips.is_empty() {
4166 out.push_str("To dig deeper:\n");
4167 for tip in &tips {
4168 out.push_str(&format!(" {tip}\n"));
4169 }
4170 }
4171
4172 Ok(out.trim_end().to_string())
4173}
4174
4175fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4176 #[cfg(target_os = "windows")]
4177 {
4178 let script = r#"try {
4179 $d = Get-PSDrive C -ErrorAction Stop
4180 "$($d.Free)|$($d.Used)"
4181} catch { "ERR" }"#;
4182 if let Ok(out) = Command::new("powershell")
4183 .args(["-NoProfile", "-Command", script])
4184 .output()
4185 {
4186 let text = String::from_utf8_lossy(&out.stdout);
4187 let text = text.trim();
4188 if !text.starts_with("ERR") {
4189 let parts: Vec<&str> = text.split('|').collect();
4190 if parts.len() == 2 {
4191 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4192 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4193 let total = free_bytes + used_bytes;
4194 let free_gb = free_bytes / 1_073_741_824;
4195 let pct_free = if total > 0 {
4196 (free_bytes as f64 / total as f64 * 100.0) as u64
4197 } else {
4198 0
4199 };
4200 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4201 if free_gb < 5 {
4202 needs_fix.push(format!(
4203 "{msg} — very low. Free up space or your system may slow down or stop working."
4204 ));
4205 } else if free_gb < 15 {
4206 watch.push(format!("{msg} — getting low, consider cleaning up."));
4207 } else {
4208 good.push(msg);
4209 }
4210 return;
4211 }
4212 }
4213 }
4214 watch.push("Disk: could not read free space from C: drive.".to_string());
4215 }
4216
4217 #[cfg(not(target_os = "windows"))]
4218 {
4219 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4220 let text = String::from_utf8_lossy(&out.stdout);
4221 for line in text.lines().skip(1) {
4222 let cols: Vec<&str> = line.split_whitespace().collect();
4223 if cols.len() >= 5 {
4224 let avail_str = cols[3].trim_end_matches('G');
4225 let use_pct = cols[4].trim_end_matches('%');
4226 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4227 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4228 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4229 if avail_gb < 5 {
4230 needs_fix.push(format!(
4231 "{msg} — very low. Free up space to prevent system issues."
4232 ));
4233 } else if avail_gb < 15 {
4234 watch.push(format!("{msg} — getting low."));
4235 } else {
4236 good.push(msg);
4237 }
4238 return;
4239 }
4240 }
4241 }
4242 watch.push("Disk: could not determine free space.".to_string());
4243 }
4244}
4245
4246fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4247 #[cfg(target_os = "windows")]
4248 {
4249 let script = r#"try {
4250 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4251 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4252} catch { "ERR" }"#;
4253 if let Ok(out) = Command::new("powershell")
4254 .args(["-NoProfile", "-Command", script])
4255 .output()
4256 {
4257 let text = String::from_utf8_lossy(&out.stdout);
4258 let text = text.trim();
4259 if !text.starts_with("ERR") {
4260 let parts: Vec<&str> = text.split('|').collect();
4261 if parts.len() == 2 {
4262 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4263 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4264 if total_kb > 0 {
4265 let free_gb = free_kb / 1_048_576;
4266 let total_gb = total_kb / 1_048_576;
4267 let free_pct = free_kb * 100 / total_kb;
4268 let msg = format!(
4269 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4270 );
4271 if free_pct < 10 {
4272 watch.push(format!(
4273 "{msg} — very low. Close unused apps to free up memory."
4274 ));
4275 } else if free_pct < 25 {
4276 watch.push(format!("{msg} — running a bit low."));
4277 } else {
4278 good.push(msg);
4279 }
4280 return;
4281 }
4282 }
4283 }
4284 }
4285 }
4286
4287 #[cfg(not(target_os = "windows"))]
4288 {
4289 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4290 let mut total_kb = 0u64;
4291 let mut avail_kb = 0u64;
4292 for line in content.lines() {
4293 if line.starts_with("MemTotal:") {
4294 total_kb = line
4295 .split_whitespace()
4296 .nth(1)
4297 .and_then(|v| v.parse().ok())
4298 .unwrap_or(0);
4299 } else if line.starts_with("MemAvailable:") {
4300 avail_kb = line
4301 .split_whitespace()
4302 .nth(1)
4303 .and_then(|v| v.parse().ok())
4304 .unwrap_or(0);
4305 }
4306 }
4307 if total_kb > 0 {
4308 let free_gb = avail_kb / 1_048_576;
4309 let total_gb = total_kb / 1_048_576;
4310 let free_pct = avail_kb * 100 / total_kb;
4311 let msg =
4312 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4313 if free_pct < 10 {
4314 watch.push(format!("{msg} — very low. Close unused apps."));
4315 } else if free_pct < 25 {
4316 watch.push(format!("{msg} — running a bit low."));
4317 } else {
4318 good.push(msg);
4319 }
4320 }
4321 }
4322 }
4323}
4324
4325fn probe_tool(cmd: &str, arg: &str) -> bool {
4329 if Command::new(cmd)
4330 .arg(arg)
4331 .stdout(std::process::Stdio::null())
4332 .stderr(std::process::Stdio::null())
4333 .status()
4334 .map(|s| s.success())
4335 .unwrap_or(false)
4336 {
4337 return true;
4338 }
4339 #[cfg(windows)]
4341 {
4342 let home = std::env::var("USERPROFILE").unwrap_or_default();
4343 let fallback: Option<String> = match cmd {
4344 "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4345 "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4346 "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4347 _ => None,
4348 };
4349 if let Some(path) = fallback {
4350 return Command::new(&path)
4351 .arg(arg)
4352 .stdout(std::process::Stdio::null())
4353 .stderr(std::process::Stdio::null())
4354 .status()
4355 .map(|s| s.success())
4356 .unwrap_or(false);
4357 }
4358 }
4359 false
4360}
4361
4362fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4363 let tool_checks: &[(&str, &str, &str)] = &[
4364 ("git", "--version", "Git"),
4365 ("cargo", "--version", "Rust / Cargo"),
4366 ("node", "--version", "Node.js"),
4367 ("python", "--version", "Python"),
4368 ("python3", "--version", "Python 3"),
4369 ("npm", "--version", "npm"),
4370 ];
4371
4372 let mut found: Vec<String> = Vec::new();
4373 let mut missing: Vec<String> = Vec::new();
4374 let mut python_found = false;
4375
4376 for (cmd, arg, label) in tool_checks {
4377 if cmd.starts_with("python") && python_found {
4378 continue;
4379 }
4380 let ok = probe_tool(cmd, arg);
4381 if ok {
4382 found.push((*label).to_string());
4383 if cmd.starts_with("python") {
4384 python_found = true;
4385 }
4386 } else if !cmd.starts_with("python") || !python_found {
4387 missing.push((*label).to_string());
4388 }
4389 }
4390
4391 if !found.is_empty() {
4392 good.push(format!("Dev tools found: {}", found.join(", ")));
4393 }
4394 if !missing.is_empty() {
4395 watch.push(format!(
4396 "Not installed (or not on PATH): {} — only matters if you need them",
4397 missing.join(", ")
4398 ));
4399 tips.push(
4400 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4401 .to_string(),
4402 );
4403 }
4404}
4405
4406fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4407 #[cfg(target_os = "windows")]
4408 {
4409 let script = r#"try {
4410 $cutoff = (Get-Date).AddHours(-24)
4411 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4412 $count
4413} catch { "0" }"#;
4414 if let Ok(out) = Command::new("powershell")
4415 .args(["-NoProfile", "-Command", script])
4416 .output()
4417 {
4418 let text = String::from_utf8_lossy(&out.stdout);
4419 let count: u64 = text.trim().parse().unwrap_or(0);
4420 if count > 0 {
4421 watch.push(format!(
4422 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4423 if count == 1 { "" } else { "s" }
4424 ));
4425 tips.push(
4426 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4427 .to_string(),
4428 );
4429 }
4430 }
4431 }
4432
4433 #[cfg(not(target_os = "windows"))]
4434 {
4435 if let Ok(out) = Command::new("journalctl")
4436 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4437 .output()
4438 {
4439 let text = String::from_utf8_lossy(&out.stdout);
4440 if !text.trim().is_empty() {
4441 watch.push("Critical/error entries found in the system journal.".to_string());
4442 tips.push(
4443 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4444 );
4445 }
4446 }
4447 }
4448}
4449
4450fn health_check_network(
4451 needs_fix: &mut Vec<String>,
4452 watch: &mut Vec<String>,
4453 good: &mut Vec<String>,
4454) {
4455 #[cfg(target_os = "windows")]
4456 {
4457 let script = r#"try {
4459 $ping = New-Object System.Net.NetworkInformation.Ping
4460 $r = $ping.Send("1.1.1.1", 2000)
4461 if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4462} catch { "FAIL" }"#;
4463 if let Ok(out) = Command::new("powershell")
4464 .args(["-NoProfile", "-Command", script])
4465 .output()
4466 {
4467 let text = String::from_utf8_lossy(&out.stdout);
4468 let text = text.trim();
4469 if text.starts_with("OK") {
4470 let latency = text.split('|').nth(1).unwrap_or("?");
4471 let latency_ms: u64 = latency.parse().unwrap_or(0);
4472 let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4473 if latency_ms > 300 {
4474 watch.push(format!("{msg} — high latency, may indicate network issue."));
4475 } else {
4476 good.push(msg);
4477 }
4478 } else {
4479 needs_fix.push(
4480 "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4481 Check adapter, gateway, or DNS."
4482 .to_string(),
4483 );
4484 }
4485 return;
4486 }
4487 watch.push("Network: could not run connectivity check.".to_string());
4488 }
4489
4490 #[cfg(not(target_os = "windows"))]
4491 {
4492 let _ = watch;
4493 let ok = Command::new("ping")
4494 .args(["-c", "1", "-W", "2", "1.1.1.1"])
4495 .stdout(std::process::Stdio::null())
4496 .stderr(std::process::Stdio::null())
4497 .status()
4498 .map(|s| s.success())
4499 .unwrap_or(false);
4500 if ok {
4501 good.push("Internet connectivity: reachable.".to_string());
4502 } else {
4503 needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4504 }
4505 }
4506}
4507
4508fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4509 #[cfg(target_os = "windows")]
4510 {
4511 let script = r#"try {
4512 $pending = $false
4513 $reasons = @()
4514 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4515 $pending = $true; $reasons += 'CBS/component update'
4516 }
4517 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4518 $pending = $true; $reasons += 'Windows Update'
4519 }
4520 $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4521 if ($pfr -and $pfr.PendingFileRenameOperations) {
4522 $pending = $true; $reasons += 'file rename ops'
4523 }
4524 if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4525} catch { "OK" }"#;
4526 if let Ok(out) = Command::new("powershell")
4527 .args(["-NoProfile", "-Command", script])
4528 .output()
4529 {
4530 let text = String::from_utf8_lossy(&out.stdout);
4531 let text = text.trim();
4532 if text.starts_with("PENDING") {
4533 let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4534 watch.push(format!(
4535 "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4536 ));
4537 } else {
4538 good.push("No pending reboot.".to_string());
4539 }
4540 }
4541 }
4542
4543 #[cfg(not(target_os = "windows"))]
4544 {
4545 if std::path::Path::new("/var/run/reboot-required").exists() {
4547 watch.push(
4548 "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4549 );
4550 } else {
4551 good.push("No pending reboot.".to_string());
4552 }
4553 }
4554}
4555
4556fn health_check_services(
4557 needs_fix: &mut Vec<String>,
4558 watch: &mut Vec<String>,
4559 good: &mut Vec<String>,
4560) {
4561 #[cfg(not(target_os = "windows"))]
4562 let _ = (&needs_fix, &good);
4563 #[cfg(target_os = "windows")]
4564 let _ = &watch;
4565
4566 #[cfg(target_os = "windows")]
4567 {
4568 let script = r#"try {
4570 $names = @('EventLog','WinDefend','Dnscache')
4571 $stopped = @()
4572 foreach ($n in $names) {
4573 $s = Get-Service $n -ErrorAction SilentlyContinue
4574 if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4575 }
4576 if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4577} catch { "OK" }"#;
4578 if let Ok(out) = Command::new("powershell")
4579 .args(["-NoProfile", "-Command", script])
4580 .output()
4581 {
4582 let text = String::from_utf8_lossy(&out.stdout);
4583 let text = text.trim();
4584 if text.starts_with("STOPPED") {
4585 let names = text.split('|').nth(1).unwrap_or("unknown");
4586 needs_fix.push(format!(
4587 "Critical service(s) not running: {names} — these should always be active."
4588 ));
4589 } else {
4590 good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4591 }
4592 }
4593 }
4594
4595 #[cfg(not(target_os = "windows"))]
4596 {
4597 if let Ok(out) = Command::new("systemctl")
4599 .args(["--failed", "--no-legend", "--plain"])
4600 .output()
4601 {
4602 let text = String::from_utf8_lossy(&out.stdout);
4603 let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4604 if !failed.is_empty() {
4605 watch.push(format!(
4606 "{} failed systemd unit(s): {}",
4607 failed.len(),
4608 failed.join(", ")
4609 ));
4610 } else {
4611 good.push("No failed systemd units.".to_string());
4612 }
4613 }
4614 }
4615}
4616
4617fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4618 #[cfg(target_os = "windows")]
4619 {
4620 let script = r#"try {
4622 $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4623 $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4624 $max = ($temps | Measure-Object -Maximum).Maximum
4625 "$max"
4626} catch { "NA" }"#;
4627 if let Ok(out) = Command::new("powershell")
4628 .args(["-NoProfile", "-Command", script])
4629 .output()
4630 {
4631 let text = String::from_utf8_lossy(&out.stdout);
4632 let text = text.trim();
4633 if text != "NA" && !text.is_empty() {
4634 if let Ok(temp) = text.parse::<f64>() {
4635 let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4636 if temp >= 90.0 {
4637 watch.push(format!("{msg} — very high, check cooling and airflow."));
4638 } else if temp >= 75.0 {
4639 watch.push(format!(
4640 "{msg} — elevated under load, monitor for throttling."
4641 ));
4642 } else {
4643 good.push(format!("{msg} — normal."));
4644 }
4645 }
4646 }
4647 }
4649 }
4650
4651 #[cfg(not(target_os = "windows"))]
4652 {
4653 let paths = [
4655 "/sys/class/thermal/thermal_zone0/temp",
4656 "/sys/class/hwmon/hwmon0/temp1_input",
4657 ];
4658 for path in &paths {
4659 if let Ok(content) = std::fs::read_to_string(path) {
4660 if let Ok(raw) = content.trim().parse::<u64>() {
4661 let temp_c = raw / 1000;
4662 let msg = format!("CPU thermal: {temp_c}°C");
4663 if temp_c >= 90 {
4664 watch.push(format!("{msg} — very high, check cooling."));
4665 } else if temp_c >= 75 {
4666 watch.push(format!("{msg} — elevated under load."));
4667 } else {
4668 good.push(format!("{msg} — normal."));
4669 }
4670 return;
4671 }
4672 }
4673 }
4674 }
4675}
4676
4677fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4680 let mut out = String::from("Host inspection: log_check\n\n");
4681
4682 #[cfg(target_os = "windows")]
4683 {
4684 let hours = lookback_hours.unwrap_or(24);
4686 out.push_str(&format!(
4687 "Checking System/Application logs from the last {} hours...\n\n",
4688 hours
4689 ));
4690
4691 let n = max_entries.clamp(1, 50);
4692 let script = format!(
4693 r#"try {{
4694 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4695 if (-not $events) {{ "NO_EVENTS"; exit }}
4696 $events | Select-Object -First {n} | ForEach-Object {{
4697 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4698 $line
4699 }}
4700}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4701 hours = hours,
4702 n = n
4703 );
4704 let output = Command::new("powershell")
4705 .args(["-NoProfile", "-Command", &script])
4706 .output()
4707 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4708
4709 let raw = String::from_utf8_lossy(&output.stdout);
4710 let text = raw.trim();
4711
4712 if text.is_empty() || text == "NO_EVENTS" {
4713 out.push_str("No critical or error events found in Application/System logs.\n");
4714 return Ok(out.trim_end().to_string());
4715 }
4716 if text.starts_with("ERROR:") {
4717 out.push_str(&format!("Warning: event log query returned: {text}\n"));
4718 return Ok(out.trim_end().to_string());
4719 }
4720
4721 let mut count = 0usize;
4722 for line in text.lines() {
4723 let parts: Vec<&str> = line.splitn(4, '|').collect();
4724 if parts.len() == 4 {
4725 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4726 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4727 count += 1;
4728 }
4729 }
4730 out.push_str(&format!(
4731 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4732 ));
4733 }
4734
4735 #[cfg(not(target_os = "windows"))]
4736 {
4737 let _ = lookback_hours;
4738 let n = max_entries.clamp(1, 50).to_string();
4740 let output = Command::new("journalctl")
4741 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4742 .output();
4743
4744 match output {
4745 Ok(o) if o.status.success() => {
4746 let text = String::from_utf8_lossy(&o.stdout);
4747 let trimmed = text.trim();
4748 if trimmed.is_empty() || trimmed.contains("No entries") {
4749 out.push_str("No critical or error entries found in the system journal.\n");
4750 } else {
4751 out.push_str(trimmed);
4752 out.push('\n');
4753 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4754 }
4755 }
4756 _ => {
4757 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4759 let mut found = false;
4760 for log_path in &log_paths {
4761 if let Ok(content) = std::fs::read_to_string(log_path) {
4762 let lines: Vec<&str> = content.lines().collect();
4763 let tail: Vec<&str> = lines
4764 .iter()
4765 .rev()
4766 .filter(|l| {
4767 let l_lower = l.to_ascii_lowercase();
4768 l_lower.contains("error") || l_lower.contains("crit")
4769 })
4770 .take(max_entries)
4771 .copied()
4772 .collect::<Vec<_>>()
4773 .into_iter()
4774 .rev()
4775 .collect();
4776 if !tail.is_empty() {
4777 out.push_str(&format!("Source: {log_path}\n"));
4778 for l in &tail {
4779 out.push_str(l);
4780 out.push('\n');
4781 }
4782 found = true;
4783 break;
4784 }
4785 }
4786 }
4787 if !found {
4788 out.push_str(
4789 "journalctl not found and no readable syslog detected on this system.\n",
4790 );
4791 }
4792 }
4793 }
4794 }
4795
4796 Ok(out.trim_end().to_string())
4797}
4798
4799fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4802 let mut out = String::from("Host inspection: startup_items\n\n");
4803
4804 #[cfg(target_os = "windows")]
4805 {
4806 let script = r#"
4808$hives = @(
4809 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4810 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4811 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4812)
4813foreach ($h in $hives) {
4814 try {
4815 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4816 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4817 "$($h.Hive)|$($_.Name)|$($_.Value)"
4818 }
4819 } catch {}
4820}
4821"#;
4822 let output = Command::new("powershell")
4823 .args(["-NoProfile", "-Command", script])
4824 .output()
4825 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4826
4827 let raw = String::from_utf8_lossy(&output.stdout);
4828 let text = raw.trim();
4829
4830 let entries: Vec<(String, String, String)> = text
4831 .lines()
4832 .filter_map(|l| {
4833 let parts: Vec<&str> = l.splitn(3, '|').collect();
4834 if parts.len() == 3 {
4835 Some((
4836 parts[0].to_string(),
4837 parts[1].to_string(),
4838 parts[2].to_string(),
4839 ))
4840 } else {
4841 None
4842 }
4843 })
4844 .take(max_entries)
4845 .collect();
4846
4847 if entries.is_empty() {
4848 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4849 } else {
4850 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4851 let mut last_hive = String::new();
4852 for (hive, name, value) in &entries {
4853 if *hive != last_hive {
4854 out.push_str(&format!("[{}]\n", hive));
4855 last_hive = hive.clone();
4856 }
4857 let display = if value.len() > 100 {
4859 format!("{}…", &value[..100])
4860 } else {
4861 value.clone()
4862 };
4863 out.push_str(&format!(" {name}: {display}\n"));
4864 }
4865 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4866 }
4867
4868 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4870 if let Ok(unified_out) = Command::new("powershell")
4871 .args(["-NoProfile", "-Command", unified_script])
4872 .output()
4873 {
4874 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4875 let trimmed = unified_text.trim();
4876 if !trimmed.is_empty() {
4877 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4878 out.push_str(trimmed);
4879 out.push('\n');
4880 }
4881 }
4882 }
4883
4884 #[cfg(not(target_os = "windows"))]
4885 {
4886 let output = Command::new("systemctl")
4888 .args([
4889 "list-unit-files",
4890 "--type=service",
4891 "--state=enabled",
4892 "--no-legend",
4893 "--no-pager",
4894 "--plain",
4895 ])
4896 .output();
4897
4898 match output {
4899 Ok(o) if o.status.success() => {
4900 let text = String::from_utf8_lossy(&o.stdout);
4901 let services: Vec<&str> = text
4902 .lines()
4903 .filter(|l| !l.trim().is_empty())
4904 .take(max_entries)
4905 .collect();
4906 if services.is_empty() {
4907 out.push_str("No enabled systemd services found.\n");
4908 } else {
4909 out.push_str("Enabled systemd services (run at boot):\n\n");
4910 for s in &services {
4911 out.push_str(&format!(" {s}\n"));
4912 }
4913 out.push_str(&format!(
4914 "\nShowing {} of enabled services.\n",
4915 services.len()
4916 ));
4917 }
4918 }
4919 _ => {
4920 out.push_str(
4921 "systemctl not found on this system. Cannot enumerate startup services.\n",
4922 );
4923 }
4924 }
4925
4926 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4928 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4929 let reboot_entries: Vec<&str> = cron_text
4930 .lines()
4931 .filter(|l| l.trim_start().starts_with("@reboot"))
4932 .collect();
4933 if !reboot_entries.is_empty() {
4934 out.push_str("\nCron @reboot entries:\n");
4935 for e in reboot_entries {
4936 out.push_str(&format!(" {e}\n"));
4937 }
4938 }
4939 }
4940 }
4941
4942 Ok(out.trim_end().to_string())
4943}
4944
4945fn inspect_os_config() -> Result<String, String> {
4946 let mut out = String::from("Host inspection: OS Configuration\n\n");
4947
4948 #[cfg(target_os = "windows")]
4949 {
4950 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4952 let power_str = String::from_utf8_lossy(&power_out.stdout);
4953 out.push_str("=== Power Plan ===\n");
4954 out.push_str(power_str.trim());
4955 out.push_str("\n\n");
4956 }
4957
4958 let fw_script =
4960 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4961 if let Ok(fw_out) = Command::new("powershell")
4962 .args(["-NoProfile", "-Command", fw_script])
4963 .output()
4964 {
4965 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4966 out.push_str("=== Firewall Profiles ===\n");
4967 out.push_str(fw_str.trim());
4968 out.push_str("\n\n");
4969 }
4970
4971 let uptime_script =
4973 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4974 if let Ok(uptime_out) = Command::new("powershell")
4975 .args(["-NoProfile", "-Command", uptime_script])
4976 .output()
4977 {
4978 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4979 out.push_str("=== System Uptime (Last Boot) ===\n");
4980 out.push_str(uptime_str.trim());
4981 out.push_str("\n\n");
4982 }
4983 }
4984
4985 #[cfg(not(target_os = "windows"))]
4986 {
4987 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4989 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4990 out.push_str("=== System Uptime ===\n");
4991 out.push_str(uptime_str.trim());
4992 out.push_str("\n\n");
4993 }
4994
4995 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4997 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4998 if !ufw_str.trim().is_empty() {
4999 out.push_str("=== Firewall (UFW) ===\n");
5000 out.push_str(ufw_str.trim());
5001 out.push_str("\n\n");
5002 }
5003 }
5004 }
5005 Ok(out.trim_end().to_string())
5006}
5007
5008pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5009 let action = args
5010 .get("action")
5011 .and_then(|v| v.as_str())
5012 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5013
5014 let target = args
5015 .get("target")
5016 .and_then(|v| v.as_str())
5017 .unwrap_or("")
5018 .trim();
5019
5020 if target.is_empty() && action != "clear_temp" {
5021 return Err("Missing required argument: 'target' for this action".to_string());
5022 }
5023
5024 match action {
5025 "install_package" => {
5026 #[cfg(target_os = "windows")]
5027 {
5028 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5029 match Command::new("powershell")
5030 .args(["-NoProfile", "-Command", &cmd])
5031 .output()
5032 {
5033 Ok(out) => Ok(format!(
5034 "Executed remediation (winget install):\n{}",
5035 String::from_utf8_lossy(&out.stdout)
5036 )),
5037 Err(e) => Err(format!("Failed to run winget: {}", e)),
5038 }
5039 }
5040 #[cfg(not(target_os = "windows"))]
5041 {
5042 Err(
5043 "install_package via wrapper is only supported on Windows currently (winget)"
5044 .to_string(),
5045 )
5046 }
5047 }
5048 "restart_service" => {
5049 #[cfg(target_os = "windows")]
5050 {
5051 let cmd = format!("Restart-Service -Name {} -Force", target);
5052 match Command::new("powershell")
5053 .args(["-NoProfile", "-Command", &cmd])
5054 .output()
5055 {
5056 Ok(out) => {
5057 let err_str = String::from_utf8_lossy(&out.stderr);
5058 if !err_str.is_empty() {
5059 return Err(format!("Error restarting service:\n{}", err_str));
5060 }
5061 Ok(format!("Successfully restarted service: {}", target))
5062 }
5063 Err(e) => Err(format!("Failed to restart service: {}", e)),
5064 }
5065 }
5066 #[cfg(not(target_os = "windows"))]
5067 {
5068 Err(
5069 "restart_service via wrapper is only supported on Windows currently"
5070 .to_string(),
5071 )
5072 }
5073 }
5074 "clear_temp" => {
5075 #[cfg(target_os = "windows")]
5076 {
5077 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5078 match Command::new("powershell")
5079 .args(["-NoProfile", "-Command", cmd])
5080 .output()
5081 {
5082 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5083 Err(e) => Err(format!("Failed to clear temp: {}", e)),
5084 }
5085 }
5086 #[cfg(not(target_os = "windows"))]
5087 {
5088 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5089 }
5090 }
5091 other => Err(format!("Unknown remediation action: {}", other)),
5092 }
5093}
5094
5095fn inspect_storage(max_entries: usize) -> Result<String, String> {
5098 let mut out = String::from("Host inspection: storage\n\n");
5099 let _ = max_entries; out.push_str("Drives:\n");
5103
5104 #[cfg(target_os = "windows")]
5105 {
5106 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5107 $free = $_.Free
5108 $used = $_.Used
5109 if ($free -eq $null) { $free = 0 }
5110 if ($used -eq $null) { $used = 0 }
5111 $total = $free + $used
5112 "$($_.Name)|$free|$used|$total"
5113}"#;
5114 match Command::new("powershell")
5115 .args(["-NoProfile", "-Command", script])
5116 .output()
5117 {
5118 Ok(o) => {
5119 let text = String::from_utf8_lossy(&o.stdout);
5120 let mut drive_count = 0usize;
5121 for line in text.lines() {
5122 let parts: Vec<&str> = line.trim().split('|').collect();
5123 if parts.len() == 4 {
5124 let name = parts[0];
5125 let free: u64 = parts[1].parse().unwrap_or(0);
5126 let total: u64 = parts[3].parse().unwrap_or(0);
5127 if total == 0 {
5128 continue;
5129 }
5130 let free_gb = free / 1_073_741_824;
5131 let total_gb = total / 1_073_741_824;
5132 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5133 let bar_len = 20usize;
5134 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5135 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5136 let warn = if free_gb < 5 {
5137 " [!] CRITICALLY LOW"
5138 } else if free_gb < 15 {
5139 " [-] LOW"
5140 } else {
5141 ""
5142 };
5143 out.push_str(&format!(
5144 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
5145 ));
5146 drive_count += 1;
5147 }
5148 }
5149 if drive_count == 0 {
5150 out.push_str(" (could not enumerate drives)\n");
5151 }
5152 }
5153 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
5154 }
5155
5156 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5158 match Command::new("powershell")
5159 .args(["-NoProfile", "-Command", latency_script])
5160 .output()
5161 {
5162 Ok(o) => {
5163 out.push_str("\nReal-time Disk Intensity:\n");
5164 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5165 if !text.is_empty() {
5166 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
5167 if let Ok(q) = text.parse::<f64>() {
5168 if q > 2.0 {
5169 out.push_str(
5170 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5171 );
5172 } else {
5173 out.push_str(" [~] Disk latency is within healthy bounds.\n");
5174 }
5175 }
5176 } else {
5177 out.push_str(" Average Disk Queue Length: unavailable\n");
5178 }
5179 }
5180 Err(_) => {
5181 out.push_str("\nReal-time Disk Intensity:\n");
5182 out.push_str(" Average Disk Queue Length: unavailable\n");
5183 }
5184 }
5185 }
5186
5187 #[cfg(not(target_os = "windows"))]
5188 {
5189 match Command::new("df")
5190 .args(["-h", "--output=target,size,avail,pcent"])
5191 .output()
5192 {
5193 Ok(o) => {
5194 let text = String::from_utf8_lossy(&o.stdout);
5195 let mut count = 0usize;
5196 for line in text.lines().skip(1) {
5197 let cols: Vec<&str> = line.split_whitespace().collect();
5198 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
5199 out.push_str(&format!(
5200 " {} size: {} avail: {} used: {}\n",
5201 cols[0], cols[1], cols[2], cols[3]
5202 ));
5203 count += 1;
5204 if count >= max_entries {
5205 break;
5206 }
5207 }
5208 }
5209 }
5210 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
5211 }
5212 }
5213
5214 out.push_str("\nLarge developer cache directories (if present):\n");
5216
5217 #[cfg(target_os = "windows")]
5218 {
5219 let home = std::env::var("USERPROFILE").unwrap_or_default();
5220 let check_dirs: &[(&str, &str)] = &[
5221 ("Temp", r"AppData\Local\Temp"),
5222 ("npm cache", r"AppData\Roaming\npm-cache"),
5223 ("Cargo registry", r".cargo\registry"),
5224 ("Cargo git", r".cargo\git"),
5225 ("pip cache", r"AppData\Local\pip\cache"),
5226 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5227 (".rustup toolchains", r".rustup\toolchains"),
5228 ("node_modules (home)", r"node_modules"),
5229 ];
5230
5231 let mut found_any = false;
5232 for (label, rel) in check_dirs {
5233 let full = format!(r"{}\{}", home, rel);
5234 let path = std::path::Path::new(&full);
5235 if path.exists() {
5236 let size_script = format!(
5238 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5239 full.replace('\'', "''")
5240 );
5241 let size_mb = Command::new("powershell")
5242 .args(["-NoProfile", "-Command", &size_script])
5243 .output()
5244 .ok()
5245 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5246 .unwrap_or_else(|| "?".to_string());
5247 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
5248 found_any = true;
5249 }
5250 }
5251 if !found_any {
5252 out.push_str(" (none of the common cache directories found)\n");
5253 }
5254
5255 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5256 }
5257
5258 #[cfg(not(target_os = "windows"))]
5259 {
5260 let home = std::env::var("HOME").unwrap_or_default();
5261 let check_dirs: &[(&str, &str)] = &[
5262 ("npm cache", ".npm"),
5263 ("Cargo registry", ".cargo/registry"),
5264 ("pip cache", ".cache/pip"),
5265 (".rustup toolchains", ".rustup/toolchains"),
5266 ("Yarn cache", ".cache/yarn"),
5267 ];
5268 let mut found_any = false;
5269 for (label, rel) in check_dirs {
5270 let full = format!("{}/{}", home, rel);
5271 if std::path::Path::new(&full).exists() {
5272 let size = Command::new("du")
5273 .args(["-sh", &full])
5274 .output()
5275 .ok()
5276 .map(|o| {
5277 let s = String::from_utf8_lossy(&o.stdout);
5278 s.split_whitespace().next().unwrap_or("?").to_string()
5279 })
5280 .unwrap_or_else(|| "?".to_string());
5281 out.push_str(&format!(" {label}: {size} ({full})\n"));
5282 found_any = true;
5283 }
5284 }
5285 if !found_any {
5286 out.push_str(" (none of the common cache directories found)\n");
5287 }
5288 }
5289
5290 Ok(out.trim_end().to_string())
5291}
5292
5293fn inspect_hardware() -> Result<String, String> {
5296 let mut out = String::from("Host inspection: hardware\n\n");
5297
5298 #[cfg(target_os = "windows")]
5299 {
5300 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5302 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5303} | Select-Object -First 1"#;
5304 if let Ok(o) = Command::new("powershell")
5305 .args(["-NoProfile", "-Command", cpu_script])
5306 .output()
5307 {
5308 let text = String::from_utf8_lossy(&o.stdout);
5309 let text = text.trim();
5310 let parts: Vec<&str> = text.split('|').collect();
5311 if parts.len() == 4 {
5312 out.push_str(&format!(
5313 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
5314 parts[0],
5315 parts[1],
5316 parts[2],
5317 parts[3].parse::<f32>().unwrap_or(0.0)
5318 ));
5319 } else {
5320 out.push_str(&format!("CPU: {text}\n\n"));
5321 }
5322 }
5323
5324 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5326$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5327$speed = ($sticks | Select-Object -First 1).Speed
5328"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5329 if let Ok(o) = Command::new("powershell")
5330 .args(["-NoProfile", "-Command", ram_script])
5331 .output()
5332 {
5333 let text = String::from_utf8_lossy(&o.stdout);
5334 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5335 }
5336
5337 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5339 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5340}"#;
5341 if let Ok(o) = Command::new("powershell")
5342 .args(["-NoProfile", "-Command", gpu_script])
5343 .output()
5344 {
5345 let text = String::from_utf8_lossy(&o.stdout);
5346 let lines: Vec<&str> = text.lines().collect();
5347 if !lines.is_empty() {
5348 out.push_str("GPU(s):\n");
5349 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5350 let parts: Vec<&str> = line.trim().split('|').collect();
5351 if parts.len() == 3 {
5352 let res = if parts[2] == "x" || parts[2].starts_with('0') {
5353 String::new()
5354 } else {
5355 format!(" — {}@display", parts[2])
5356 };
5357 out.push_str(&format!(
5358 " {}\n Driver: {}{}\n",
5359 parts[0], parts[1], res
5360 ));
5361 } else {
5362 out.push_str(&format!(" {}\n", line.trim()));
5363 }
5364 }
5365 out.push('\n');
5366 }
5367 }
5368
5369 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5371$bios = Get-CimInstance Win32_BIOS
5372$cs = Get-CimInstance Win32_ComputerSystem
5373$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5374$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5375"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5376 if let Ok(o) = Command::new("powershell")
5377 .args(["-NoProfile", "-Command", mb_script])
5378 .output()
5379 {
5380 let text = String::from_utf8_lossy(&o.stdout);
5381 let text = text.trim().trim_matches('"');
5382 let parts: Vec<&str> = text.split('|').collect();
5383 if parts.len() == 4 {
5384 out.push_str(&format!(
5385 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5386 parts[0].trim(),
5387 parts[1].trim(),
5388 parts[2].trim(),
5389 parts[3].trim()
5390 ));
5391 }
5392 }
5393
5394 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5396 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5397}"#;
5398 if let Ok(o) = Command::new("powershell")
5399 .args(["-NoProfile", "-Command", disp_script])
5400 .output()
5401 {
5402 let text = String::from_utf8_lossy(&o.stdout);
5403 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5404 if !lines.is_empty() {
5405 out.push_str("Display(s):\n");
5406 for line in &lines {
5407 let parts: Vec<&str> = line.trim().split('|').collect();
5408 if parts.len() == 2 {
5409 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
5410 }
5411 }
5412 }
5413 }
5414 }
5415
5416 #[cfg(not(target_os = "windows"))]
5417 {
5418 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5420 let model = content
5421 .lines()
5422 .find(|l| l.starts_with("model name"))
5423 .and_then(|l| l.split(':').nth(1))
5424 .map(str::trim)
5425 .unwrap_or("unknown");
5426 let cores = content
5427 .lines()
5428 .filter(|l| l.starts_with("processor"))
5429 .count();
5430 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
5431 }
5432
5433 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5435 let total_kb: u64 = content
5436 .lines()
5437 .find(|l| l.starts_with("MemTotal:"))
5438 .and_then(|l| l.split_whitespace().nth(1))
5439 .and_then(|v| v.parse().ok())
5440 .unwrap_or(0);
5441 let total_gb = total_kb / 1_048_576;
5442 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5443 }
5444
5445 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5447 let text = String::from_utf8_lossy(&o.stdout);
5448 let gpu_lines: Vec<&str> = text
5449 .lines()
5450 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5451 .collect();
5452 if !gpu_lines.is_empty() {
5453 out.push_str("GPU(s):\n");
5454 for l in gpu_lines {
5455 out.push_str(&format!(" {l}\n"));
5456 }
5457 out.push('\n');
5458 }
5459 }
5460
5461 if let Ok(o) = Command::new("dmidecode")
5463 .args(["-t", "baseboard", "-t", "bios"])
5464 .output()
5465 {
5466 let text = String::from_utf8_lossy(&o.stdout);
5467 out.push_str("Motherboard/BIOS:\n");
5468 for line in text
5469 .lines()
5470 .filter(|l| {
5471 l.contains("Manufacturer:")
5472 || l.contains("Product Name:")
5473 || l.contains("Version:")
5474 })
5475 .take(6)
5476 {
5477 out.push_str(&format!(" {}\n", line.trim()));
5478 }
5479 }
5480 }
5481
5482 Ok(out.trim_end().to_string())
5483}
5484
5485fn inspect_updates() -> Result<String, String> {
5488 let mut out = String::from("Host inspection: updates\n\n");
5489
5490 #[cfg(target_os = "windows")]
5491 {
5492 let script = r#"
5494try {
5495 $sess = New-Object -ComObject Microsoft.Update.Session
5496 $searcher = $sess.CreateUpdateSearcher()
5497 $count = $searcher.GetTotalHistoryCount()
5498 if ($count -gt 0) {
5499 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5500 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5501 } else { "NONE|LAST_INSTALL" }
5502} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5503"#;
5504 if let Ok(o) = Command::new("powershell")
5505 .args(["-NoProfile", "-Command", script])
5506 .output()
5507 {
5508 let raw = String::from_utf8_lossy(&o.stdout);
5509 let text = raw.trim();
5510 if text.starts_with("ERROR:") {
5511 out.push_str("Last update install: (unable to query)\n");
5512 } else if text.contains("NONE") {
5513 out.push_str("Last update install: No update history found\n");
5514 } else {
5515 let date = text.replace("|LAST_INSTALL", "");
5516 out.push_str(&format!("Last update install: {date}\n"));
5517 }
5518 }
5519
5520 let pending_script = r#"
5522try {
5523 $sess = New-Object -ComObject Microsoft.Update.Session
5524 $searcher = $sess.CreateUpdateSearcher()
5525 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5526 $results.Updates.Count.ToString() + "|PENDING"
5527} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5528"#;
5529 if let Ok(o) = Command::new("powershell")
5530 .args(["-NoProfile", "-Command", pending_script])
5531 .output()
5532 {
5533 let raw = String::from_utf8_lossy(&o.stdout);
5534 let text = raw.trim();
5535 if text.starts_with("ERROR:") {
5536 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5537 } else {
5538 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5539 if count == 0 {
5540 out.push_str("Pending updates: Up to date — no updates waiting\n");
5541 } else if count > 0 {
5542 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5543 out.push_str(
5544 " → Open Windows Update (Settings > Windows Update) to install\n",
5545 );
5546 }
5547 }
5548 }
5549
5550 let svc_script = r#"
5552$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5553if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5554"#;
5555 if let Ok(o) = Command::new("powershell")
5556 .args(["-NoProfile", "-Command", svc_script])
5557 .output()
5558 {
5559 let raw = String::from_utf8_lossy(&o.stdout);
5560 let status = raw.trim();
5561 out.push_str(&format!("Windows Update service: {status}\n"));
5562 }
5563 }
5564
5565 #[cfg(not(target_os = "windows"))]
5566 {
5567 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5568 let mut found = false;
5569 if let Ok(o) = apt_out {
5570 let text = String::from_utf8_lossy(&o.stdout);
5571 let lines: Vec<&str> = text
5572 .lines()
5573 .filter(|l| l.contains('/') && !l.contains("Listing"))
5574 .collect();
5575 if !lines.is_empty() {
5576 out.push_str(&format!(
5577 "{} package(s) can be upgraded (apt)\n",
5578 lines.len()
5579 ));
5580 out.push_str(" → Run: sudo apt upgrade\n");
5581 found = true;
5582 }
5583 }
5584 if !found {
5585 if let Ok(o) = Command::new("dnf")
5586 .args(["check-update", "--quiet"])
5587 .output()
5588 {
5589 let text = String::from_utf8_lossy(&o.stdout);
5590 let count = text
5591 .lines()
5592 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5593 .count();
5594 if count > 0 {
5595 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5596 out.push_str(" → Run: sudo dnf upgrade\n");
5597 } else {
5598 out.push_str("System is up to date.\n");
5599 }
5600 } else {
5601 out.push_str("Could not query package manager for updates.\n");
5602 }
5603 }
5604 }
5605
5606 Ok(out.trim_end().to_string())
5607}
5608
5609fn inspect_security() -> Result<String, String> {
5612 let mut out = String::from("Host inspection: security\n\n");
5613
5614 #[cfg(target_os = "windows")]
5615 {
5616 let defender_script = r#"
5618try {
5619 $status = Get-MpComputerStatus -ErrorAction Stop
5620 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5621} catch { "ERROR:" + $_.Exception.Message }
5622"#;
5623 if let Ok(o) = Command::new("powershell")
5624 .args(["-NoProfile", "-Command", defender_script])
5625 .output()
5626 {
5627 let raw = String::from_utf8_lossy(&o.stdout);
5628 let text = raw.trim();
5629 if text.starts_with("ERROR:") {
5630 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5631 } else {
5632 let get = |key: &str| -> String {
5633 text.split('|')
5634 .find(|s| s.starts_with(key))
5635 .and_then(|s| s.splitn(2, ':').nth(1))
5636 .unwrap_or("unknown")
5637 .to_string()
5638 };
5639 let rtp = get("RTP");
5640 let last_scan = {
5641 text.split('|')
5643 .find(|s| s.starts_with("SCAN:"))
5644 .and_then(|s| s.get(5..))
5645 .unwrap_or("unknown")
5646 .to_string()
5647 };
5648 let def_ver = get("VER");
5649 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5650
5651 let rtp_label = if rtp == "True" {
5652 "ENABLED"
5653 } else {
5654 "DISABLED [!]"
5655 };
5656 out.push_str(&format!(
5657 "Windows Defender real-time protection: {rtp_label}\n"
5658 ));
5659 out.push_str(&format!("Last quick scan: {last_scan}\n"));
5660 out.push_str(&format!("Signature version: {def_ver}\n"));
5661 if age_days >= 0 {
5662 let freshness = if age_days == 0 {
5663 "up to date".to_string()
5664 } else if age_days <= 3 {
5665 format!("{age_days} day(s) old — OK")
5666 } else if age_days <= 7 {
5667 format!("{age_days} day(s) old — consider updating")
5668 } else {
5669 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5670 };
5671 out.push_str(&format!("Signature age: {freshness}\n"));
5672 }
5673 if rtp != "True" {
5674 out.push_str(
5675 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5676 );
5677 out.push_str(
5678 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5679 );
5680 }
5681 }
5682 }
5683
5684 out.push('\n');
5685
5686 let fw_script = r#"
5688try {
5689 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5690} catch { "ERROR:" + $_.Exception.Message }
5691"#;
5692 if let Ok(o) = Command::new("powershell")
5693 .args(["-NoProfile", "-Command", fw_script])
5694 .output()
5695 {
5696 let raw = String::from_utf8_lossy(&o.stdout);
5697 let text = raw.trim();
5698 if !text.starts_with("ERROR:") && !text.is_empty() {
5699 out.push_str("Windows Firewall:\n");
5700 for line in text.lines() {
5701 if let Some((name, enabled)) = line.split_once(':') {
5702 let state = if enabled.trim() == "True" {
5703 "ON"
5704 } else {
5705 "OFF [!]"
5706 };
5707 out.push_str(&format!(" {name}: {state}\n"));
5708 }
5709 }
5710 out.push('\n');
5711 }
5712 }
5713
5714 let act_script = r#"
5716try {
5717 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5718 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5719} catch { "UNKNOWN" }
5720"#;
5721 if let Ok(o) = Command::new("powershell")
5722 .args(["-NoProfile", "-Command", act_script])
5723 .output()
5724 {
5725 let raw = String::from_utf8_lossy(&o.stdout);
5726 match raw.trim() {
5727 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5728 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5729 _ => out.push_str("Windows activation: Unable to determine\n"),
5730 }
5731 }
5732
5733 let uac_script = r#"
5735$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5736if ($val -eq 1) { "ON" } else { "OFF" }
5737"#;
5738 if let Ok(o) = Command::new("powershell")
5739 .args(["-NoProfile", "-Command", uac_script])
5740 .output()
5741 {
5742 let raw = String::from_utf8_lossy(&o.stdout);
5743 let state = raw.trim();
5744 let label = if state == "ON" {
5745 "Enabled"
5746 } else {
5747 "DISABLED [!] — recommended to re-enable via secpol.msc"
5748 };
5749 out.push_str(&format!("UAC (User Account Control): {label}\n"));
5750 }
5751 }
5752
5753 #[cfg(not(target_os = "windows"))]
5754 {
5755 if let Ok(o) = Command::new("ufw").arg("status").output() {
5756 let text = String::from_utf8_lossy(&o.stdout);
5757 out.push_str(&format!(
5758 "UFW: {}\n",
5759 text.lines().next().unwrap_or("unknown")
5760 ));
5761 }
5762 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5763 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5764 out.push_str(&format!("{line}\n"));
5765 }
5766 }
5767 }
5768
5769 Ok(out.trim_end().to_string())
5770}
5771
5772fn inspect_pending_reboot() -> Result<String, String> {
5775 let mut out = String::from("Host inspection: pending_reboot\n\n");
5776
5777 #[cfg(target_os = "windows")]
5778 {
5779 let script = r#"
5780$reasons = @()
5781if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5782 $reasons += "Windows Update requires a restart"
5783}
5784if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5785 $reasons += "Windows component install/update requires a restart"
5786}
5787$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5788if ($pfro -and $pfro.PendingFileRenameOperations) {
5789 $reasons += "Pending file rename operations (driver or system file replacement)"
5790}
5791if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5792"#;
5793 let output = Command::new("powershell")
5794 .args(["-NoProfile", "-Command", script])
5795 .output()
5796 .map_err(|e| format!("pending_reboot: {e}"))?;
5797
5798 let raw = String::from_utf8_lossy(&output.stdout);
5799 let text = raw.trim();
5800
5801 if text == "NO_REBOOT_NEEDED" {
5802 out.push_str("No restart required — system is up to date and stable.\n");
5803 } else if text.is_empty() {
5804 out.push_str("Could not determine reboot status.\n");
5805 } else {
5806 out.push_str("[!] A system restart is pending:\n\n");
5807 for reason in text.split("|REASON|") {
5808 out.push_str(&format!(" • {}\n", reason.trim()));
5809 }
5810 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5811 }
5812 }
5813
5814 #[cfg(not(target_os = "windows"))]
5815 {
5816 if std::path::Path::new("/var/run/reboot-required").exists() {
5817 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5818 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5819 out.push_str("Packages requiring restart:\n");
5820 for p in pkgs.lines().take(10) {
5821 out.push_str(&format!(" • {p}\n"));
5822 }
5823 }
5824 } else {
5825 out.push_str("No restart required.\n");
5826 }
5827 }
5828
5829 Ok(out.trim_end().to_string())
5830}
5831
5832fn inspect_disk_health() -> Result<String, String> {
5835 let mut out = String::from("Host inspection: disk_health\n\n");
5836
5837 #[cfg(target_os = "windows")]
5838 {
5839 let script = r#"
5840try {
5841 $disks = Get-PhysicalDisk -ErrorAction Stop
5842 foreach ($d in $disks) {
5843 $size_gb = [math]::Round($d.Size / 1GB, 0)
5844 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5845 }
5846} catch { "ERROR:" + $_.Exception.Message }
5847"#;
5848 let output = Command::new("powershell")
5849 .args(["-NoProfile", "-Command", script])
5850 .output()
5851 .map_err(|e| format!("disk_health: {e}"))?;
5852
5853 let raw = String::from_utf8_lossy(&output.stdout);
5854 let text = raw.trim();
5855
5856 if text.starts_with("ERROR:") {
5857 out.push_str(&format!("Unable to query disk health: {text}\n"));
5858 out.push_str("This may require running as administrator.\n");
5859 } else if text.is_empty() {
5860 out.push_str("No physical disks found.\n");
5861 } else {
5862 out.push_str("Physical Drive Health:\n\n");
5863 for line in text.lines() {
5864 let parts: Vec<&str> = line.splitn(5, '|').collect();
5865 if parts.len() >= 4 {
5866 let name = parts[0];
5867 let media = parts[1];
5868 let size = parts[2];
5869 let health = parts[3];
5870 let op_status = parts.get(4).unwrap_or(&"");
5871 let health_label = match health.trim() {
5872 "Healthy" => "OK",
5873 "Warning" => "[!] WARNING",
5874 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5875 other => other,
5876 };
5877 out.push_str(&format!(" {name}\n"));
5878 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
5879 out.push_str(&format!(" Health: {health_label}\n"));
5880 if !op_status.is_empty() {
5881 out.push_str(&format!(" Status: {op_status}\n"));
5882 }
5883 out.push('\n');
5884 }
5885 }
5886 }
5887
5888 let smart_script = r#"
5890try {
5891 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5892 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5893} catch { "" }
5894"#;
5895 if let Ok(o) = Command::new("powershell")
5896 .args(["-NoProfile", "-Command", smart_script])
5897 .output()
5898 {
5899 let raw2 = String::from_utf8_lossy(&o.stdout);
5900 let text2 = raw2.trim();
5901 if !text2.is_empty() {
5902 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5903 if failures.is_empty() {
5904 out.push_str("SMART failure prediction: No failures predicted\n");
5905 } else {
5906 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5907 for f in failures {
5908 let name = f.split('|').next().unwrap_or(f);
5909 out.push_str(&format!(" • {name}\n"));
5910 }
5911 out.push_str(
5912 "\nBack up your data immediately and replace the failing drive.\n",
5913 );
5914 }
5915 }
5916 }
5917 }
5918
5919 #[cfg(not(target_os = "windows"))]
5920 {
5921 if let Ok(o) = Command::new("lsblk")
5922 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5923 .output()
5924 {
5925 let text = String::from_utf8_lossy(&o.stdout);
5926 out.push_str("Block devices:\n");
5927 out.push_str(text.trim());
5928 out.push('\n');
5929 }
5930 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5931 let devices = String::from_utf8_lossy(&scan.stdout);
5932 for dev_line in devices.lines().take(4) {
5933 let dev = dev_line.split_whitespace().next().unwrap_or("");
5934 if dev.is_empty() {
5935 continue;
5936 }
5937 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5938 let health = String::from_utf8_lossy(&o.stdout);
5939 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5940 {
5941 out.push_str(&format!("{dev}: {}\n", line.trim()));
5942 }
5943 }
5944 }
5945 } else {
5946 out.push_str("(install smartmontools for SMART health data)\n");
5947 }
5948 }
5949
5950 Ok(out.trim_end().to_string())
5951}
5952
5953fn inspect_battery() -> Result<String, String> {
5956 let mut out = String::from("Host inspection: battery\n\n");
5957
5958 #[cfg(target_os = "windows")]
5959 {
5960 let script = r#"
5961try {
5962 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5963 if (-not $bats) { "NO_BATTERY"; exit }
5964
5965 # Modern Battery Health (Cycle count + Capacity health)
5966 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5967 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
5968 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5969
5970 foreach ($b in $bats) {
5971 $state = switch ($b.BatteryStatus) {
5972 1 { "Discharging" }
5973 2 { "AC Power (Fully Charged)" }
5974 3 { "AC Power (Charging)" }
5975 default { "Status $($b.BatteryStatus)" }
5976 }
5977
5978 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5979 $health = if ($static -and $full) {
5980 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5981 } else { "unknown" }
5982
5983 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5984 }
5985} catch { "ERROR:" + $_.Exception.Message }
5986"#;
5987 let output = Command::new("powershell")
5988 .args(["-NoProfile", "-Command", script])
5989 .output()
5990 .map_err(|e| format!("battery: {e}"))?;
5991
5992 let raw = String::from_utf8_lossy(&output.stdout);
5993 let text = raw.trim();
5994
5995 if text == "NO_BATTERY" {
5996 out.push_str("No battery detected — desktop or AC-only system.\n");
5997 return Ok(out.trim_end().to_string());
5998 }
5999 if text.starts_with("ERROR:") {
6000 out.push_str(&format!("Unable to query battery: {text}\n"));
6001 return Ok(out.trim_end().to_string());
6002 }
6003
6004 for line in text.lines() {
6005 let parts: Vec<&str> = line.split('|').collect();
6006 if parts.len() == 5 {
6007 let name = parts[0];
6008 let charge: i64 = parts[1].parse().unwrap_or(-1);
6009 let state = parts[2];
6010 let cycles = parts[3];
6011 let health = parts[4];
6012
6013 out.push_str(&format!("Battery: {name}\n"));
6014 if charge >= 0 {
6015 let bar_filled = (charge as usize * 20) / 100;
6016 out.push_str(&format!(
6017 " Charge: [{}{}] {}%\n",
6018 "#".repeat(bar_filled),
6019 ".".repeat(20 - bar_filled),
6020 charge
6021 ));
6022 }
6023 out.push_str(&format!(" Status: {state}\n"));
6024 out.push_str(&format!(" Cycles: {cycles}\n"));
6025 out.push_str(&format!(
6026 " Health: {health}% (Actual vs Design Capacity)\n\n"
6027 ));
6028 }
6029 }
6030 }
6031
6032 #[cfg(not(target_os = "windows"))]
6033 {
6034 let power_path = std::path::Path::new("/sys/class/power_supply");
6035 let mut found = false;
6036 if power_path.exists() {
6037 if let Ok(entries) = std::fs::read_dir(power_path) {
6038 for entry in entries.flatten() {
6039 let p = entry.path();
6040 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6041 if t.trim() == "Battery" {
6042 found = true;
6043 let name = p
6044 .file_name()
6045 .unwrap_or_default()
6046 .to_string_lossy()
6047 .to_string();
6048 out.push_str(&format!("Battery: {name}\n"));
6049 let read = |f: &str| {
6050 std::fs::read_to_string(p.join(f))
6051 .ok()
6052 .map(|s| s.trim().to_string())
6053 };
6054 if let Some(cap) = read("capacity") {
6055 out.push_str(&format!(" Charge: {cap}%\n"));
6056 }
6057 if let Some(status) = read("status") {
6058 out.push_str(&format!(" Status: {status}\n"));
6059 }
6060 if let (Some(full), Some(design)) =
6061 (read("energy_full"), read("energy_full_design"))
6062 {
6063 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6064 {
6065 if d > 0.0 {
6066 out.push_str(&format!(
6067 " Wear level: {:.1}% of design capacity\n",
6068 (f / d) * 100.0
6069 ));
6070 }
6071 }
6072 }
6073 }
6074 }
6075 }
6076 }
6077 }
6078 if !found {
6079 out.push_str("No battery found.\n");
6080 }
6081 }
6082
6083 Ok(out.trim_end().to_string())
6084}
6085
6086fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6089 let mut out = String::from("Host inspection: recent_crashes\n\n");
6090 let n = max_entries.clamp(1, 30);
6091
6092 #[cfg(target_os = "windows")]
6093 {
6094 let bsod_script = format!(
6096 r#"
6097try {{
6098 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6099 if ($events) {{
6100 $events | ForEach-Object {{
6101 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6102 }}
6103 }} else {{ "NO_BSOD" }}
6104}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6105 );
6106
6107 if let Ok(o) = Command::new("powershell")
6108 .args(["-NoProfile", "-Command", &bsod_script])
6109 .output()
6110 {
6111 let raw = String::from_utf8_lossy(&o.stdout);
6112 let text = raw.trim();
6113 if text == "NO_BSOD" {
6114 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6115 } else if text.starts_with("ERROR:") {
6116 out.push_str("System crashes: unable to query\n");
6117 } else {
6118 out.push_str("System crashes / unexpected shutdowns:\n");
6119 for line in text.lines() {
6120 let parts: Vec<&str> = line.splitn(3, '|').collect();
6121 if parts.len() >= 3 {
6122 let time = parts[0];
6123 let id = parts[1];
6124 let msg = parts[2];
6125 let label = if id == "41" {
6126 "Unexpected shutdown"
6127 } else {
6128 "BSOD (BugCheck)"
6129 };
6130 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
6131 }
6132 }
6133 out.push('\n');
6134 }
6135 }
6136
6137 let app_script = format!(
6139 r#"
6140try {{
6141 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6142 if ($crashes) {{
6143 $crashes | ForEach-Object {{
6144 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6145 }}
6146 }} else {{ "NO_CRASHES" }}
6147}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6148 );
6149
6150 if let Ok(o) = Command::new("powershell")
6151 .args(["-NoProfile", "-Command", &app_script])
6152 .output()
6153 {
6154 let raw = String::from_utf8_lossy(&o.stdout);
6155 let text = raw.trim();
6156 if text == "NO_CRASHES" {
6157 out.push_str("Application crashes: None in recent history\n");
6158 } else if text.starts_with("ERROR_APP:") {
6159 out.push_str("Application crashes: unable to query\n");
6160 } else {
6161 out.push_str("Application crashes:\n");
6162 for line in text.lines().take(n) {
6163 let parts: Vec<&str> = line.splitn(2, '|').collect();
6164 if parts.len() >= 2 {
6165 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
6166 }
6167 }
6168 }
6169 }
6170 }
6171
6172 #[cfg(not(target_os = "windows"))]
6173 {
6174 let n_str = n.to_string();
6175 if let Ok(o) = Command::new("journalctl")
6176 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6177 .output()
6178 {
6179 let text = String::from_utf8_lossy(&o.stdout);
6180 let trimmed = text.trim();
6181 if trimmed.is_empty() || trimmed.contains("No entries") {
6182 out.push_str("No kernel panics or critical crashes found.\n");
6183 } else {
6184 out.push_str("Kernel critical events:\n");
6185 out.push_str(trimmed);
6186 out.push('\n');
6187 }
6188 }
6189 if let Ok(o) = Command::new("coredumpctl")
6190 .args(["list", "--no-pager"])
6191 .output()
6192 {
6193 let text = String::from_utf8_lossy(&o.stdout);
6194 let count = text
6195 .lines()
6196 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6197 .count();
6198 if count > 0 {
6199 out.push_str(&format!(
6200 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
6201 ));
6202 }
6203 }
6204 }
6205
6206 Ok(out.trim_end().to_string())
6207}
6208
6209fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6212 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6213 let n = max_entries.clamp(1, 30);
6214
6215 #[cfg(target_os = "windows")]
6216 {
6217 let script = format!(
6218 r#"
6219try {{
6220 $tasks = Get-ScheduledTask -ErrorAction Stop |
6221 Where-Object {{ $_.State -ne 'Disabled' }} |
6222 ForEach-Object {{
6223 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6224 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6225 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6226 }} else {{ "never" }}
6227 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6228 $exec = ($_.Actions | Select-Object -First 1).Execute
6229 if (-not $exec) {{ $exec = "(no exec)" }}
6230 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6231 }}
6232 $tasks | Select-Object -First {n}
6233}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6234 );
6235
6236 let output = Command::new("powershell")
6237 .args(["-NoProfile", "-Command", &script])
6238 .output()
6239 .map_err(|e| format!("scheduled_tasks: {e}"))?;
6240
6241 let raw = String::from_utf8_lossy(&output.stdout);
6242 let text = raw.trim();
6243
6244 if text.starts_with("ERROR:") {
6245 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
6246 } else if text.is_empty() {
6247 out.push_str("No active scheduled tasks found.\n");
6248 } else {
6249 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
6250 for line in text.lines() {
6251 let parts: Vec<&str> = line.splitn(6, '|').collect();
6252 if parts.len() >= 5 {
6253 let name = parts[0];
6254 let path = parts[1];
6255 let state = parts[2];
6256 let last = parts[3];
6257 let res = parts[4];
6258 let exec = parts.get(5).unwrap_or(&"").trim();
6259 let display_path = path.trim_matches('\\');
6260 let display_path = if display_path.is_empty() {
6261 "Root"
6262 } else {
6263 display_path
6264 };
6265 out.push_str(&format!(" {name} [{display_path}]\n"));
6266 out.push_str(&format!(
6267 " State: {state} | Last run: {last} | Result: {res}\n"
6268 ));
6269 if !exec.is_empty() && exec != "(no exec)" {
6270 let short = if exec.len() > 80 { &exec[..80] } else { exec };
6271 out.push_str(&format!(" Runs: {short}\n"));
6272 }
6273 }
6274 }
6275 }
6276 }
6277
6278 #[cfg(not(target_os = "windows"))]
6279 {
6280 if let Ok(o) = Command::new("systemctl")
6281 .args(["list-timers", "--no-pager", "--all"])
6282 .output()
6283 {
6284 let text = String::from_utf8_lossy(&o.stdout);
6285 out.push_str("Systemd timers:\n");
6286 for l in text
6287 .lines()
6288 .filter(|l| {
6289 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6290 })
6291 .take(n)
6292 {
6293 out.push_str(&format!(" {l}\n"));
6294 }
6295 out.push('\n');
6296 }
6297 if let Ok(o) = Command::new("crontab").arg("-l").output() {
6298 let text = String::from_utf8_lossy(&o.stdout);
6299 let jobs: Vec<&str> = text
6300 .lines()
6301 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6302 .collect();
6303 if !jobs.is_empty() {
6304 out.push_str("User crontab:\n");
6305 for j in jobs.iter().take(n) {
6306 out.push_str(&format!(" {j}\n"));
6307 }
6308 }
6309 }
6310 }
6311
6312 Ok(out.trim_end().to_string())
6313}
6314
6315fn inspect_dev_conflicts() -> Result<String, String> {
6318 let mut out = String::from("Host inspection: dev_conflicts\n\n");
6319 let mut conflicts: Vec<String> = Vec::new();
6320 let mut notes: Vec<String> = Vec::new();
6321
6322 {
6324 let node_ver = Command::new("node")
6325 .arg("--version")
6326 .output()
6327 .ok()
6328 .and_then(|o| String::from_utf8(o.stdout).ok())
6329 .map(|s| s.trim().to_string());
6330 let nvm_active = Command::new("nvm")
6331 .arg("current")
6332 .output()
6333 .ok()
6334 .and_then(|o| String::from_utf8(o.stdout).ok())
6335 .map(|s| s.trim().to_string())
6336 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6337 let fnm_active = Command::new("fnm")
6338 .arg("current")
6339 .output()
6340 .ok()
6341 .and_then(|o| String::from_utf8(o.stdout).ok())
6342 .map(|s| s.trim().to_string())
6343 .filter(|s| !s.is_empty() && !s.contains("none"));
6344 let volta_active = Command::new("volta")
6345 .args(["which", "node"])
6346 .output()
6347 .ok()
6348 .and_then(|o| String::from_utf8(o.stdout).ok())
6349 .map(|s| s.trim().to_string())
6350 .filter(|s| !s.is_empty());
6351
6352 out.push_str("Node.js:\n");
6353 if let Some(ref v) = node_ver {
6354 out.push_str(&format!(" Active: {v}\n"));
6355 } else {
6356 out.push_str(" Not installed\n");
6357 }
6358 let managers: Vec<&str> = [
6359 nvm_active.as_deref(),
6360 fnm_active.as_deref(),
6361 volta_active.as_deref(),
6362 ]
6363 .iter()
6364 .filter_map(|x| *x)
6365 .collect();
6366 if managers.len() > 1 {
6367 conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6368 } else if !managers.is_empty() {
6369 out.push_str(&format!(" Version manager: {}\n", managers[0]));
6370 }
6371 out.push('\n');
6372 }
6373
6374 {
6376 let py3 = Command::new("python3")
6377 .arg("--version")
6378 .output()
6379 .ok()
6380 .and_then(|o| {
6381 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6382 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6383 let v = if stdout.is_empty() { stderr } else { stdout };
6384 if v.is_empty() {
6385 None
6386 } else {
6387 Some(v)
6388 }
6389 });
6390 let py = Command::new("python")
6391 .arg("--version")
6392 .output()
6393 .ok()
6394 .and_then(|o| {
6395 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6396 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6397 let v = if stdout.is_empty() { stderr } else { stdout };
6398 if v.is_empty() {
6399 None
6400 } else {
6401 Some(v)
6402 }
6403 });
6404 let pyenv = Command::new("pyenv")
6405 .arg("version")
6406 .output()
6407 .ok()
6408 .and_then(|o| String::from_utf8(o.stdout).ok())
6409 .map(|s| s.trim().to_string())
6410 .filter(|s| !s.is_empty());
6411 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6412
6413 out.push_str("Python:\n");
6414 match (&py3, &py) {
6415 (Some(v3), Some(v)) if v3 != v => {
6416 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
6417 if v.contains("2.") {
6418 conflicts.push(
6419 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6420 );
6421 } else {
6422 notes.push(
6423 "python and python3 resolve to different minor versions.".to_string(),
6424 );
6425 }
6426 }
6427 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
6428 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
6429 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
6430 (None, None) => out.push_str(" Not installed\n"),
6431 }
6432 if let Some(ref pe) = pyenv {
6433 out.push_str(&format!(" pyenv: {pe}\n"));
6434 }
6435 if let Some(env) = conda_env {
6436 if env == "base" {
6437 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6438 } else {
6439 out.push_str(&format!(" conda env: {env}\n"));
6440 }
6441 }
6442 out.push('\n');
6443 }
6444
6445 {
6447 let toolchain = Command::new("rustup")
6448 .args(["show", "active-toolchain"])
6449 .output()
6450 .ok()
6451 .and_then(|o| String::from_utf8(o.stdout).ok())
6452 .map(|s| s.trim().to_string())
6453 .filter(|s| !s.is_empty());
6454 let cargo_ver = Command::new("cargo")
6455 .arg("--version")
6456 .output()
6457 .ok()
6458 .and_then(|o| String::from_utf8(o.stdout).ok())
6459 .map(|s| s.trim().to_string());
6460 let rustc_ver = Command::new("rustc")
6461 .arg("--version")
6462 .output()
6463 .ok()
6464 .and_then(|o| String::from_utf8(o.stdout).ok())
6465 .map(|s| s.trim().to_string());
6466
6467 out.push_str("Rust:\n");
6468 if let Some(ref t) = toolchain {
6469 out.push_str(&format!(" Active toolchain: {t}\n"));
6470 }
6471 if let Some(ref c) = cargo_ver {
6472 out.push_str(&format!(" {c}\n"));
6473 }
6474 if let Some(ref r) = rustc_ver {
6475 out.push_str(&format!(" {r}\n"));
6476 }
6477 if cargo_ver.is_none() && rustc_ver.is_none() {
6478 out.push_str(" Not installed\n");
6479 }
6480
6481 #[cfg(not(target_os = "windows"))]
6483 if let Ok(o) = Command::new("which").arg("rustc").output() {
6484 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6485 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6486 conflicts.push(format!(
6487 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6488 ));
6489 }
6490 }
6491 out.push('\n');
6492 }
6493
6494 {
6496 let git_ver = Command::new("git")
6497 .arg("--version")
6498 .output()
6499 .ok()
6500 .and_then(|o| String::from_utf8(o.stdout).ok())
6501 .map(|s| s.trim().to_string());
6502 out.push_str("Git:\n");
6503 if let Some(ref v) = git_ver {
6504 out.push_str(&format!(" {v}\n"));
6505 let email = Command::new("git")
6506 .args(["config", "--global", "user.email"])
6507 .output()
6508 .ok()
6509 .and_then(|o| String::from_utf8(o.stdout).ok())
6510 .map(|s| s.trim().to_string());
6511 if let Some(ref e) = email {
6512 if e.is_empty() {
6513 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6514 } else {
6515 out.push_str(&format!(" user.email: {e}\n"));
6516 }
6517 }
6518 let gpg_sign = Command::new("git")
6519 .args(["config", "--global", "commit.gpgsign"])
6520 .output()
6521 .ok()
6522 .and_then(|o| String::from_utf8(o.stdout).ok())
6523 .map(|s| s.trim().to_string());
6524 if gpg_sign.as_deref() == Some("true") {
6525 let key = Command::new("git")
6526 .args(["config", "--global", "user.signingkey"])
6527 .output()
6528 .ok()
6529 .and_then(|o| String::from_utf8(o.stdout).ok())
6530 .map(|s| s.trim().to_string());
6531 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6532 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6533 }
6534 }
6535 } else {
6536 out.push_str(" Not installed\n");
6537 }
6538 out.push('\n');
6539 }
6540
6541 {
6543 let path_env = std::env::var("PATH").unwrap_or_default();
6544 let sep = if cfg!(windows) { ';' } else { ':' };
6545 let mut seen = HashSet::new();
6546 let mut dupes: Vec<String> = Vec::new();
6547 for p in path_env.split(sep) {
6548 let norm = p.trim().to_lowercase();
6549 if !norm.is_empty() && !seen.insert(norm) {
6550 dupes.push(p.to_string());
6551 }
6552 }
6553 if !dupes.is_empty() {
6554 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6555 notes.push(format!(
6556 "Duplicate PATH entries: {} {}",
6557 shown.join(", "),
6558 if dupes.len() > 3 {
6559 format!("+{} more", dupes.len() - 3)
6560 } else {
6561 String::new()
6562 }
6563 ));
6564 }
6565 }
6566
6567 if conflicts.is_empty() && notes.is_empty() {
6569 out.push_str("No conflicts detected — dev environment looks clean.\n");
6570 } else {
6571 if !conflicts.is_empty() {
6572 out.push_str("CONFLICTS:\n");
6573 for c in &conflicts {
6574 out.push_str(&format!(" [!] {c}\n"));
6575 }
6576 out.push('\n');
6577 }
6578 if !notes.is_empty() {
6579 out.push_str("NOTES:\n");
6580 for n in ¬es {
6581 out.push_str(&format!(" [-] {n}\n"));
6582 }
6583 }
6584 }
6585
6586 Ok(out.trim_end().to_string())
6587}
6588
6589async fn inspect_public_ip() -> Result<String, String> {
6592 let mut out = String::from("Host inspection: public_ip\n\n");
6593
6594 let client = reqwest::Client::builder()
6595 .timeout(std::time::Duration::from_secs(5))
6596 .build()
6597 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
6598
6599 match client.get("https://api.ipify.org?format=json").send().await {
6600 Ok(resp) => {
6601 if let Ok(json) = resp.json::<serde_json::Value>().await {
6602 let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
6603 out.push_str(&format!("Public IP: {}\n", ip));
6604
6605 if let Ok(geo_resp) = client
6607 .get(format!("http://ip-api.com/json/{}", ip))
6608 .send()
6609 .await
6610 {
6611 if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
6612 if let (Some(city), Some(region), Some(country), Some(isp)) = (
6613 geo_json.get("city").and_then(|v| v.as_str()),
6614 geo_json.get("regionName").and_then(|v| v.as_str()),
6615 geo_json.get("country").and_then(|v| v.as_str()),
6616 geo_json.get("isp").and_then(|v| v.as_str()),
6617 ) {
6618 out.push_str(&format!(
6619 "Location: {}, {} ({})\n",
6620 city, region, country
6621 ));
6622 out.push_str(&format!("ISP: {}\n", isp));
6623 }
6624 }
6625 }
6626 } else {
6627 out.push_str("Error: Failed to parse public IP response.\n");
6628 }
6629 }
6630 Err(e) => {
6631 out.push_str(&format!(
6632 "Error: Failed to fetch public IP ({}). Check internet connectivity.\n",
6633 e
6634 ));
6635 }
6636 }
6637
6638 Ok(out)
6639}
6640
6641fn inspect_ssl_cert(host: &str) -> Result<String, String> {
6642 let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
6643
6644 #[cfg(target_os = "windows")]
6645 {
6646 use std::process::Command;
6647 let script = format!(
6648 r#"$domain = "{host}"
6649try {{
6650 $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
6651 $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
6652 $sslStream.AuthenticateAsClient($domain)
6653 $cert = $sslStream.RemoteCertificate
6654 $tcpClient.Close()
6655 if ($cert) {{
6656 $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
6657 $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
6658 }} else {{
6659 "null"
6660 }}
6661}} catch {{
6662 "ERROR:" + $_.Exception.Message
6663}}"#
6664 );
6665
6666 let ps_out = Command::new("powershell")
6667 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
6668 .output()
6669 .map_err(|e| format!("powershell launch failed: {e}"))?;
6670
6671 let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
6672 if text.starts_with("ERROR:") {
6673 out.push_str(&format!("Error: {}\n", text.trim_start_matches("ERROR:")));
6674 } else if text == "null" || text.is_empty() {
6675 out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
6676 } else {
6677 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
6678 if let Some(obj) = json.as_object() {
6679 for (k, v) in obj {
6680 let val_str = v.as_str().unwrap_or("");
6681 out.push_str(&format!("{:<12}: {}\n", k, val_str));
6682 }
6683
6684 if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
6685 if not_after_raw.starts_with("/Date(") {
6686 let ts = not_after_raw
6687 .trim_start_matches("/Date(")
6688 .trim_end_matches(")/")
6689 .parse::<i64>()
6690 .unwrap_or(0);
6691 let expiry =
6692 chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
6693 let now = chrono::Utc::now();
6694 let days_left = expiry.signed_duration_since(now).num_days();
6695 if days_left < 0 {
6696 out.push_str("\nSTATUS: [!!] EXPIRED\n");
6697 } else if days_left < 30 {
6698 out.push_str(&format!(
6699 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6700 days_left
6701 ));
6702 } else {
6703 out.push_str(&format!(
6704 "\nSTATUS: Valid ({} days left)\n",
6705 days_left
6706 ));
6707 }
6708 } else {
6709 if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw)
6710 {
6711 let now = chrono::Utc::now();
6712 let days_left = expiry.signed_duration_since(now).num_days();
6713 if days_left < 0 {
6714 out.push_str("\nSTATUS: [!!] EXPIRED\n");
6715 } else if days_left < 30 {
6716 out.push_str(&format!(
6717 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6718 days_left
6719 ));
6720 } else {
6721 out.push_str(&format!(
6722 "\nSTATUS: Valid ({} days left)\n",
6723 days_left
6724 ));
6725 }
6726 }
6727 }
6728 }
6729 }
6730 } else {
6731 out.push_str(&format!("Raw Output: {}\n", text));
6732 }
6733 }
6734 }
6735
6736 #[cfg(not(target_os = "windows"))]
6737 {
6738 out.push_str(
6739 "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
6740 );
6741 }
6742
6743 Ok(out)
6744}
6745
6746async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
6747 let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
6748
6749 if !path.exists() {
6750 return Err(format!("File not found: {:?}", path));
6751 }
6752 if !path.is_file() {
6753 return Err(format!("Not a file: {:?}", path));
6754 }
6755
6756 let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6757 out.push_str(&format!(
6758 "File Size: {} bytes ({:.2} MB)\n",
6759 file_size,
6760 file_size as f64 / 1_048_576.0
6761 ));
6762
6763 let ext = path
6764 .extension()
6765 .and_then(|s| s.to_str())
6766 .unwrap_or("")
6767 .to_lowercase();
6768 out.push_str(&format!("Format: {}\n\n", ext.to_uppercase()));
6769
6770 match ext.as_str() {
6771 "csv" | "tsv" | "txt" | "log" => {
6772 let content = std::fs::read_to_string(&path)
6773 .map_err(|e| format!("Failed to read file: {}", e))?;
6774 let lines: Vec<&str> = content.lines().collect();
6775 out.push_str(&format!("Row Count: {} (total lines)\n", lines.len()));
6776
6777 if let Some(header) = lines.get(0) {
6778 out.push_str("Columns (Guessed from header):\n");
6779 let delimiter = if ext == "tsv" {
6780 "\t"
6781 } else if header.contains(',') {
6782 ","
6783 } else {
6784 " "
6785 };
6786 let cols: Vec<&str> = header.split(delimiter).map(|s| s.trim()).collect();
6787 for (i, col) in cols.iter().enumerate() {
6788 out.push_str(&format!(" {}. {}\n", i + 1, col));
6789 }
6790 }
6791
6792 out.push_str("\nSample Data (First 5 rows):\n");
6793 for line in lines.iter().take(6) {
6794 out.push_str(&format!(" {}\n", line));
6795 }
6796 }
6797 "json" => {
6798 let content = std::fs::read_to_string(&path)
6799 .map_err(|e| format!("Failed to read file: {}", e))?;
6800 if let Ok(json) = serde_json::from_str::<Value>(&content) {
6801 if let Some(arr) = json.as_array() {
6802 out.push_str(&format!("Record Count: {}\n", arr.len()));
6803 if let Some(first) = arr.get(0) {
6804 if let Some(obj) = first.as_object() {
6805 out.push_str("Fields (from first record):\n");
6806 for k in obj.keys() {
6807 out.push_str(&format!(" - {}\n", k));
6808 }
6809 }
6810 }
6811 out.push_str("\nSample Record:\n");
6812 out.push_str(&serde_json::to_string_pretty(&arr.get(0)).unwrap_or_default());
6813 } else if let Some(obj) = json.as_object() {
6814 out.push_str("Top-level Keys:\n");
6815 for k in obj.keys() {
6816 out.push_str(&format!(" - {}\n", k));
6817 }
6818 }
6819 } else {
6820 out.push_str("Error: Failed to parse as JSON.\n");
6821 }
6822 }
6823 "db" | "sqlite" | "sqlite3" => {
6824 out.push_str("SQLite Database detected.\n");
6825 out.push_str("Use `query_data` to execute SQL against this database.\n");
6826 }
6827 _ => {
6828 out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
6829 let content = std::fs::read_to_string(&path)
6830 .map_err(|e| format!("Failed to read file: {}", e))?;
6831 for line in content.lines().take(10) {
6832 out.push_str(&format!(" {}\n", line));
6833 }
6834 }
6835 }
6836
6837 Ok(out)
6838}
6839
6840fn inspect_connectivity() -> Result<String, String> {
6841 let mut out = String::from("Host inspection: connectivity\n\n");
6842
6843 #[cfg(target_os = "windows")]
6844 {
6845 let inet_script = r#"
6846try {
6847 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6848 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6849} catch { "ERROR:" + $_.Exception.Message }
6850"#;
6851 if let Ok(o) = Command::new("powershell")
6852 .args(["-NoProfile", "-Command", inet_script])
6853 .output()
6854 {
6855 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6856 match text.as_str() {
6857 "REACHABLE" => out.push_str("Internet: reachable\n"),
6858 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6859 _ => out.push_str(&format!(
6860 "Internet: {}\n",
6861 text.trim_start_matches("ERROR:").trim()
6862 )),
6863 }
6864 }
6865
6866 let dns_script = r#"
6867try {
6868 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6869 "DNS:ok"
6870} catch { "DNS:fail:" + $_.Exception.Message }
6871"#;
6872 if let Ok(o) = Command::new("powershell")
6873 .args(["-NoProfile", "-Command", dns_script])
6874 .output()
6875 {
6876 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6877 if text == "DNS:ok" {
6878 out.push_str("DNS: resolving correctly\n");
6879 } else {
6880 let detail = text.trim_start_matches("DNS:fail:").trim();
6881 out.push_str(&format!("DNS: failed — {}\n", detail));
6882 }
6883 }
6884
6885 let gw_script = r#"
6886(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6887"#;
6888 if let Ok(o) = Command::new("powershell")
6889 .args(["-NoProfile", "-Command", gw_script])
6890 .output()
6891 {
6892 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6893 if !gw.is_empty() && gw != "0.0.0.0" {
6894 out.push_str(&format!("Default gateway: {}\n", gw));
6895 }
6896 }
6897 }
6898
6899 #[cfg(not(target_os = "windows"))]
6900 {
6901 let reachable = Command::new("ping")
6902 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6903 .output()
6904 .map(|o| o.status.success())
6905 .unwrap_or(false);
6906 out.push_str(if reachable {
6907 "Internet: reachable\n"
6908 } else {
6909 "Internet: unreachable\n"
6910 });
6911 let dns_ok = Command::new("getent")
6912 .args(["hosts", "dns.google"])
6913 .output()
6914 .map(|o| o.status.success())
6915 .unwrap_or(false);
6916 out.push_str(if dns_ok {
6917 "DNS: resolving correctly\n"
6918 } else {
6919 "DNS: failed\n"
6920 });
6921 if let Ok(o) = Command::new("ip")
6922 .args(["route", "show", "default"])
6923 .output()
6924 {
6925 let text = String::from_utf8_lossy(&o.stdout);
6926 if let Some(line) = text.lines().next() {
6927 out.push_str(&format!("Default gateway: {}\n", line.trim()));
6928 }
6929 }
6930 }
6931
6932 Ok(out.trim_end().to_string())
6933}
6934
6935fn inspect_wifi() -> Result<String, String> {
6938 let mut out = String::from("Host inspection: wifi\n\n");
6939
6940 #[cfg(target_os = "windows")]
6941 {
6942 let output = Command::new("netsh")
6943 .args(["wlan", "show", "interfaces"])
6944 .output()
6945 .map_err(|e| format!("wifi: {e}"))?;
6946 let text = String::from_utf8_lossy(&output.stdout).to_string();
6947
6948 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6949 out.push_str("No wireless interface detected on this machine.\n");
6950 return Ok(out.trim_end().to_string());
6951 }
6952
6953 let fields = [
6954 ("SSID", "SSID"),
6955 ("State", "State"),
6956 ("Signal", "Signal"),
6957 ("Radio type", "Radio type"),
6958 ("Channel", "Channel"),
6959 ("Receive rate (Mbps)", "Download speed (Mbps)"),
6960 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6961 ("Authentication", "Authentication"),
6962 ("Network type", "Network type"),
6963 ];
6964
6965 let mut any = false;
6966 for line in text.lines() {
6967 let trimmed = line.trim();
6968 for (key, label) in &fields {
6969 if trimmed.starts_with(key) && trimmed.contains(':') {
6970 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6971 if !val.is_empty() {
6972 out.push_str(&format!(" {label}: {val}\n"));
6973 any = true;
6974 }
6975 }
6976 }
6977 }
6978 if !any {
6979 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
6980 }
6981 }
6982
6983 #[cfg(not(target_os = "windows"))]
6984 {
6985 if let Ok(o) = Command::new("nmcli")
6986 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6987 .output()
6988 {
6989 let text = String::from_utf8_lossy(&o.stdout).to_string();
6990 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6991 if lines.is_empty() {
6992 out.push_str("No Wi-Fi devices found.\n");
6993 } else {
6994 for l in lines {
6995 out.push_str(&format!(" {l}\n"));
6996 }
6997 }
6998 } else if let Ok(o) = Command::new("iwconfig").output() {
6999 let text = String::from_utf8_lossy(&o.stdout).to_string();
7000 if !text.trim().is_empty() {
7001 out.push_str(text.trim());
7002 out.push('\n');
7003 }
7004 } else {
7005 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7006 }
7007 }
7008
7009 Ok(out.trim_end().to_string())
7010}
7011
7012fn inspect_connections(max_entries: usize) -> Result<String, String> {
7015 let mut out = String::from("Host inspection: connections\n\n");
7016 let n = max_entries.clamp(1, 25);
7017
7018 #[cfg(target_os = "windows")]
7019 {
7020 let script = format!(
7021 r#"
7022try {{
7023 $procs = @{{}}
7024 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7025 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7026 Sort-Object OwningProcess
7027 "TOTAL:" + $all.Count
7028 $all | Select-Object -First {n} | ForEach-Object {{
7029 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7030 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7031 }}
7032}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7033 );
7034
7035 let output = Command::new("powershell")
7036 .args(["-NoProfile", "-Command", &script])
7037 .output()
7038 .map_err(|e| format!("connections: {e}"))?;
7039
7040 let raw = String::from_utf8_lossy(&output.stdout);
7041 let text = raw.trim();
7042
7043 if text.starts_with("ERROR:") {
7044 out.push_str(&format!("Unable to query connections: {text}\n"));
7045 } else {
7046 let mut total = 0usize;
7047 let mut rows = Vec::new();
7048 for line in text.lines() {
7049 if let Some(rest) = line.strip_prefix("TOTAL:") {
7050 total = rest.trim().parse().unwrap_or(0);
7051 } else {
7052 rows.push(line);
7053 }
7054 }
7055 out.push_str(&format!("Established TCP connections: {total}\n\n"));
7056 for row in &rows {
7057 let parts: Vec<&str> = row.splitn(4, '|').collect();
7058 if parts.len() == 4 {
7059 out.push_str(&format!(
7060 " {:<15} (pid {:<5}) | {} → {}\n",
7061 parts[0], parts[1], parts[2], parts[3]
7062 ));
7063 }
7064 }
7065 if total > n {
7066 out.push_str(&format!(
7067 "\n ... {} more connections not shown\n",
7068 total.saturating_sub(n)
7069 ));
7070 }
7071 }
7072 }
7073
7074 #[cfg(not(target_os = "windows"))]
7075 {
7076 if let Ok(o) = Command::new("ss")
7077 .args(["-tnp", "state", "established"])
7078 .output()
7079 {
7080 let text = String::from_utf8_lossy(&o.stdout);
7081 let lines: Vec<&str> = text
7082 .lines()
7083 .skip(1)
7084 .filter(|l| !l.trim().is_empty())
7085 .collect();
7086 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
7087 for line in lines.iter().take(n) {
7088 out.push_str(&format!(" {}\n", line.trim()));
7089 }
7090 if lines.len() > n {
7091 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
7092 }
7093 } else {
7094 out.push_str("ss not available — install iproute2\n");
7095 }
7096 }
7097
7098 Ok(out.trim_end().to_string())
7099}
7100
7101fn inspect_vpn() -> Result<String, String> {
7104 let mut out = String::from("Host inspection: vpn\n\n");
7105
7106 #[cfg(target_os = "windows")]
7107 {
7108 let script = r#"
7109try {
7110 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7111 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7112 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7113 }
7114 if ($vpn) {
7115 foreach ($a in $vpn) {
7116 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7117 }
7118 } else { "NONE" }
7119} catch { "ERROR:" + $_.Exception.Message }
7120"#;
7121 let output = Command::new("powershell")
7122 .args(["-NoProfile", "-Command", script])
7123 .output()
7124 .map_err(|e| format!("vpn: {e}"))?;
7125
7126 let raw = String::from_utf8_lossy(&output.stdout);
7127 let text = raw.trim();
7128
7129 if text == "NONE" {
7130 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7131 } else if text.starts_with("ERROR:") {
7132 out.push_str(&format!("Unable to query adapters: {text}\n"));
7133 } else {
7134 out.push_str("VPN adapters:\n\n");
7135 for line in text.lines() {
7136 let parts: Vec<&str> = line.splitn(4, '|').collect();
7137 if parts.len() >= 3 {
7138 let name = parts[0];
7139 let desc = parts[1];
7140 let status = parts[2];
7141 let media = parts.get(3).unwrap_or(&"unknown");
7142 let label = if status.trim() == "Up" {
7143 "CONNECTED"
7144 } else {
7145 "disconnected"
7146 };
7147 out.push_str(&format!(
7148 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
7149 ));
7150 }
7151 }
7152 }
7153
7154 let ras_script = r#"
7156try {
7157 $c = Get-VpnConnection -ErrorAction Stop
7158 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7159 else { "NO_RAS" }
7160} catch { "NO_RAS" }
7161"#;
7162 if let Ok(o) = Command::new("powershell")
7163 .args(["-NoProfile", "-Command", ras_script])
7164 .output()
7165 {
7166 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7167 if t != "NO_RAS" && !t.is_empty() {
7168 out.push_str("Windows VPN connections:\n");
7169 for line in t.lines() {
7170 let parts: Vec<&str> = line.splitn(3, '|').collect();
7171 if parts.len() >= 2 {
7172 let name = parts[0];
7173 let status = parts[1];
7174 let server = parts.get(2).unwrap_or(&"");
7175 out.push_str(&format!(" {name} → {server} [{status}]\n"));
7176 }
7177 }
7178 }
7179 }
7180 }
7181
7182 #[cfg(not(target_os = "windows"))]
7183 {
7184 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7185 let text = String::from_utf8_lossy(&o.stdout);
7186 let vpn_ifaces: Vec<&str> = text
7187 .lines()
7188 .filter(|l| {
7189 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7190 })
7191 .collect();
7192 if vpn_ifaces.is_empty() {
7193 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7194 } else {
7195 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
7196 for l in vpn_ifaces {
7197 out.push_str(&format!(" {}\n", l.trim()));
7198 }
7199 }
7200 }
7201 }
7202
7203 Ok(out.trim_end().to_string())
7204}
7205
7206fn inspect_proxy() -> Result<String, String> {
7209 let mut out = String::from("Host inspection: proxy\n\n");
7210
7211 #[cfg(target_os = "windows")]
7212 {
7213 let script = r#"
7214$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7215if ($ie) {
7216 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7217} else { "NONE" }
7218"#;
7219 if let Ok(o) = Command::new("powershell")
7220 .args(["-NoProfile", "-Command", script])
7221 .output()
7222 {
7223 let raw = String::from_utf8_lossy(&o.stdout);
7224 let text = raw.trim();
7225 if text != "NONE" && !text.is_empty() {
7226 let get = |key: &str| -> &str {
7227 text.split('|')
7228 .find(|s| s.starts_with(key))
7229 .and_then(|s| s.splitn(2, ':').nth(1))
7230 .unwrap_or("")
7231 };
7232 let enabled = get("ENABLE");
7233 let server = get("SERVER");
7234 let overrides = get("OVERRIDE");
7235 out.push_str("WinINET / IE proxy:\n");
7236 out.push_str(&format!(
7237 " Enabled: {}\n",
7238 if enabled == "1" { "yes" } else { "no" }
7239 ));
7240 if !server.is_empty() && server != "None" {
7241 out.push_str(&format!(" Proxy server: {server}\n"));
7242 }
7243 if !overrides.is_empty() && overrides != "None" {
7244 out.push_str(&format!(" Bypass list: {overrides}\n"));
7245 }
7246 out.push('\n');
7247 }
7248 }
7249
7250 if let Ok(o) = Command::new("netsh")
7251 .args(["winhttp", "show", "proxy"])
7252 .output()
7253 {
7254 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7255 out.push_str("WinHTTP proxy:\n");
7256 for line in text.lines() {
7257 let l = line.trim();
7258 if !l.is_empty() {
7259 out.push_str(&format!(" {l}\n"));
7260 }
7261 }
7262 out.push('\n');
7263 }
7264
7265 let mut env_found = false;
7266 for var in &[
7267 "http_proxy",
7268 "https_proxy",
7269 "HTTP_PROXY",
7270 "HTTPS_PROXY",
7271 "no_proxy",
7272 "NO_PROXY",
7273 ] {
7274 if let Ok(val) = std::env::var(var) {
7275 if !env_found {
7276 out.push_str("Environment proxy variables:\n");
7277 env_found = true;
7278 }
7279 out.push_str(&format!(" {var}: {val}\n"));
7280 }
7281 }
7282 if !env_found {
7283 out.push_str("No proxy environment variables set.\n");
7284 }
7285 }
7286
7287 #[cfg(not(target_os = "windows"))]
7288 {
7289 let mut found = false;
7290 for var in &[
7291 "http_proxy",
7292 "https_proxy",
7293 "HTTP_PROXY",
7294 "HTTPS_PROXY",
7295 "no_proxy",
7296 "NO_PROXY",
7297 "ALL_PROXY",
7298 "all_proxy",
7299 ] {
7300 if let Ok(val) = std::env::var(var) {
7301 if !found {
7302 out.push_str("Proxy environment variables:\n");
7303 found = true;
7304 }
7305 out.push_str(&format!(" {var}: {val}\n"));
7306 }
7307 }
7308 if !found {
7309 out.push_str("No proxy environment variables set.\n");
7310 }
7311 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7312 let proxy_lines: Vec<&str> = content
7313 .lines()
7314 .filter(|l| l.to_lowercase().contains("proxy"))
7315 .collect();
7316 if !proxy_lines.is_empty() {
7317 out.push_str("\nSystem proxy (/etc/environment):\n");
7318 for l in proxy_lines {
7319 out.push_str(&format!(" {l}\n"));
7320 }
7321 }
7322 }
7323 }
7324
7325 Ok(out.trim_end().to_string())
7326}
7327
7328fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7331 let mut out = String::from("Host inspection: firewall_rules\n\n");
7332 let n = max_entries.clamp(1, 20);
7333
7334 #[cfg(target_os = "windows")]
7335 {
7336 let script = format!(
7337 r#"
7338try {{
7339 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7340 Where-Object {{
7341 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7342 $_.Owner -eq $null
7343 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7344 "TOTAL:" + $rules.Count
7345 $rules | ForEach-Object {{
7346 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7347 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7348 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7349 }}
7350}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7351 );
7352
7353 let output = Command::new("powershell")
7354 .args(["-NoProfile", "-Command", &script])
7355 .output()
7356 .map_err(|e| format!("firewall_rules: {e}"))?;
7357
7358 let raw = String::from_utf8_lossy(&output.stdout);
7359 let text = raw.trim();
7360
7361 if text.starts_with("ERROR:") {
7362 out.push_str(&format!(
7363 "Unable to query firewall rules: {}\n",
7364 text.trim_start_matches("ERROR:").trim()
7365 ));
7366 out.push_str("This query may require running as administrator.\n");
7367 } else if text.is_empty() {
7368 out.push_str("No non-default enabled firewall rules found.\n");
7369 } else {
7370 let mut total = 0usize;
7371 for line in text.lines() {
7372 if let Some(rest) = line.strip_prefix("TOTAL:") {
7373 total = rest.trim().parse().unwrap_or(0);
7374 out.push_str(&format!(
7375 "Non-default enabled rules (showing up to {n}):\n\n"
7376 ));
7377 } else {
7378 let parts: Vec<&str> = line.splitn(4, '|').collect();
7379 if parts.len() >= 3 {
7380 let name = parts[0];
7381 let dir = parts[1];
7382 let action = parts[2];
7383 let profile = parts.get(3).unwrap_or(&"Any");
7384 let icon = if action == "Block" { "[!]" } else { " " };
7385 out.push_str(&format!(
7386 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
7387 ));
7388 }
7389 }
7390 }
7391 if total == 0 {
7392 out.push_str("No non-default enabled rules found.\n");
7393 }
7394 }
7395 }
7396
7397 #[cfg(not(target_os = "windows"))]
7398 {
7399 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7400 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7401 if !text.is_empty() {
7402 out.push_str(&text);
7403 out.push('\n');
7404 }
7405 } else if let Ok(o) = Command::new("iptables")
7406 .args(["-L", "-n", "--line-numbers"])
7407 .output()
7408 {
7409 let text = String::from_utf8_lossy(&o.stdout);
7410 for l in text.lines().take(n * 2) {
7411 out.push_str(&format!(" {l}\n"));
7412 }
7413 } else {
7414 out.push_str("ufw and iptables not available or insufficient permissions.\n");
7415 }
7416 }
7417
7418 Ok(out.trim_end().to_string())
7419}
7420
7421fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7424 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7425 let hops = max_entries.clamp(5, 30);
7426
7427 #[cfg(target_os = "windows")]
7428 {
7429 let output = Command::new("tracert")
7430 .args(["-d", "-h", &hops.to_string(), host])
7431 .output()
7432 .map_err(|e| format!("tracert: {e}"))?;
7433 let raw = String::from_utf8_lossy(&output.stdout);
7434 let mut hop_count = 0usize;
7435 for line in raw.lines() {
7436 let trimmed = line.trim();
7437 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7438 hop_count += 1;
7439 out.push_str(&format!(" {trimmed}\n"));
7440 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7441 out.push_str(&format!("{trimmed}\n"));
7442 }
7443 }
7444 if hop_count == 0 {
7445 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7446 }
7447 }
7448
7449 #[cfg(not(target_os = "windows"))]
7450 {
7451 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7452 || std::path::Path::new("/usr/sbin/traceroute").exists()
7453 {
7454 "traceroute"
7455 } else {
7456 "tracepath"
7457 };
7458 let output = Command::new(cmd)
7459 .args(["-m", &hops.to_string(), "-n", host])
7460 .output()
7461 .map_err(|e| format!("{cmd}: {e}"))?;
7462 let raw = String::from_utf8_lossy(&output.stdout);
7463 let mut hop_count = 0usize;
7464 for line in raw.lines().take(hops + 2) {
7465 let trimmed = line.trim();
7466 if !trimmed.is_empty() {
7467 hop_count += 1;
7468 out.push_str(&format!(" {trimmed}\n"));
7469 }
7470 }
7471 if hop_count == 0 {
7472 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7473 }
7474 }
7475
7476 Ok(out.trim_end().to_string())
7477}
7478
7479fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7482 let mut out = String::from("Host inspection: dns_cache\n\n");
7483 let n = max_entries.clamp(10, 100);
7484
7485 #[cfg(target_os = "windows")]
7486 {
7487 let output = Command::new("powershell")
7488 .args([
7489 "-NoProfile",
7490 "-Command",
7491 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7492 ])
7493 .output()
7494 .map_err(|e| format!("dns_cache: {e}"))?;
7495
7496 let raw = String::from_utf8_lossy(&output.stdout);
7497 let lines: Vec<&str> = raw.lines().skip(1).collect();
7498 let total = lines.len();
7499
7500 if total == 0 {
7501 out.push_str("DNS cache is empty or could not be read.\n");
7502 } else {
7503 out.push_str(&format!(
7504 "DNS cache entries (showing up to {n} of {total}):\n\n"
7505 ));
7506 let mut shown = 0usize;
7507 for line in lines.iter().take(n) {
7508 let cols: Vec<&str> = line.splitn(4, ',').collect();
7509 if cols.len() >= 3 {
7510 let entry = cols[0].trim_matches('"');
7511 let rtype = cols[1].trim_matches('"');
7512 let data = cols[2].trim_matches('"');
7513 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
7514 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
7515 shown += 1;
7516 }
7517 }
7518 if total > shown {
7519 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
7520 }
7521 }
7522 }
7523
7524 #[cfg(not(target_os = "windows"))]
7525 {
7526 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7527 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7528 if !text.is_empty() {
7529 out.push_str("systemd-resolved statistics:\n");
7530 for line in text.lines().take(n) {
7531 out.push_str(&format!(" {line}\n"));
7532 }
7533 out.push('\n');
7534 }
7535 }
7536 if let Ok(o) = Command::new("dscacheutil")
7537 .args(["-cachedump", "-entries", "Host"])
7538 .output()
7539 {
7540 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7541 if !text.is_empty() {
7542 out.push_str("DNS cache (macOS dscacheutil):\n");
7543 for line in text.lines().take(n) {
7544 out.push_str(&format!(" {line}\n"));
7545 }
7546 } else {
7547 out.push_str("DNS cache is empty or not accessible on this platform.\n");
7548 }
7549 } else {
7550 out.push_str(
7551 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7552 );
7553 }
7554 }
7555
7556 Ok(out.trim_end().to_string())
7557}
7558
7559fn inspect_arp() -> Result<String, String> {
7562 let mut out = String::from("Host inspection: arp\n\n");
7563
7564 #[cfg(target_os = "windows")]
7565 {
7566 let output = Command::new("arp")
7567 .args(["-a"])
7568 .output()
7569 .map_err(|e| format!("arp: {e}"))?;
7570 let raw = String::from_utf8_lossy(&output.stdout);
7571 let mut count = 0usize;
7572 for line in raw.lines() {
7573 let t = line.trim();
7574 if t.is_empty() {
7575 continue;
7576 }
7577 out.push_str(&format!(" {t}\n"));
7578 if t.contains("dynamic") || t.contains("static") {
7579 count += 1;
7580 }
7581 }
7582 out.push_str(&format!("\nTotal entries: {count}\n"));
7583 }
7584
7585 #[cfg(not(target_os = "windows"))]
7586 {
7587 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7588 let raw = String::from_utf8_lossy(&o.stdout);
7589 let mut count = 0usize;
7590 for line in raw.lines() {
7591 let t = line.trim();
7592 if !t.is_empty() {
7593 out.push_str(&format!(" {t}\n"));
7594 count += 1;
7595 }
7596 }
7597 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7598 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7599 let raw = String::from_utf8_lossy(&o.stdout);
7600 let mut count = 0usize;
7601 for line in raw.lines() {
7602 let t = line.trim();
7603 if !t.is_empty() {
7604 out.push_str(&format!(" {t}\n"));
7605 count += 1;
7606 }
7607 }
7608 out.push_str(&format!("\nTotal entries: {count}\n"));
7609 } else {
7610 out.push_str("arp and ip neigh not available.\n");
7611 }
7612 }
7613
7614 Ok(out.trim_end().to_string())
7615}
7616
7617fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7620 let mut out = String::from("Host inspection: route_table\n\n");
7621 let n = max_entries.clamp(10, 50);
7622
7623 #[cfg(target_os = "windows")]
7624 {
7625 let script = r#"
7626try {
7627 $routes = Get-NetRoute -ErrorAction Stop |
7628 Where-Object { $_.RouteMetric -lt 9000 } |
7629 Sort-Object RouteMetric |
7630 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7631 "TOTAL:" + $routes.Count
7632 $routes | ForEach-Object {
7633 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7634 }
7635} catch { "ERROR:" + $_.Exception.Message }
7636"#;
7637 let output = Command::new("powershell")
7638 .args(["-NoProfile", "-Command", script])
7639 .output()
7640 .map_err(|e| format!("route_table: {e}"))?;
7641 let raw = String::from_utf8_lossy(&output.stdout);
7642 let text = raw.trim();
7643
7644 if text.starts_with("ERROR:") {
7645 out.push_str(&format!(
7646 "Unable to read route table: {}\n",
7647 text.trim_start_matches("ERROR:").trim()
7648 ));
7649 } else {
7650 let mut shown = 0usize;
7651 for line in text.lines() {
7652 if let Some(rest) = line.strip_prefix("TOTAL:") {
7653 let total: usize = rest.trim().parse().unwrap_or(0);
7654 out.push_str(&format!(
7655 "Routing table (showing up to {n} of {total} routes):\n\n"
7656 ));
7657 out.push_str(&format!(
7658 " {:<22} {:<18} {:>8} Interface\n",
7659 "Destination", "Next Hop", "Metric"
7660 ));
7661 out.push_str(&format!(" {}\n", "-".repeat(70)));
7662 } else if shown < n {
7663 let parts: Vec<&str> = line.splitn(4, '|').collect();
7664 if parts.len() == 4 {
7665 let dest = parts[0];
7666 let hop =
7667 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7668 "on-link"
7669 } else {
7670 parts[1]
7671 };
7672 let metric = parts[2];
7673 let iface = parts[3];
7674 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
7675 shown += 1;
7676 }
7677 }
7678 }
7679 }
7680 }
7681
7682 #[cfg(not(target_os = "windows"))]
7683 {
7684 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7685 let raw = String::from_utf8_lossy(&o.stdout);
7686 let lines: Vec<&str> = raw.lines().collect();
7687 let total = lines.len();
7688 out.push_str(&format!(
7689 "Routing table (showing up to {n} of {total} routes):\n\n"
7690 ));
7691 for line in lines.iter().take(n) {
7692 out.push_str(&format!(" {line}\n"));
7693 }
7694 if total > n {
7695 out.push_str(&format!("\n ... and {} more routes\n", total - n));
7696 }
7697 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7698 let raw = String::from_utf8_lossy(&o.stdout);
7699 for line in raw.lines().take(n) {
7700 out.push_str(&format!(" {line}\n"));
7701 }
7702 } else {
7703 out.push_str("ip route and netstat not available.\n");
7704 }
7705 }
7706
7707 Ok(out.trim_end().to_string())
7708}
7709
7710fn inspect_env(max_entries: usize) -> Result<String, String> {
7713 let mut out = String::from("Host inspection: env\n\n");
7714 let n = max_entries.clamp(10, 50);
7715
7716 fn looks_like_secret(name: &str) -> bool {
7717 let n = name.to_uppercase();
7718 n.contains("KEY")
7719 || n.contains("SECRET")
7720 || n.contains("TOKEN")
7721 || n.contains("PASSWORD")
7722 || n.contains("PASSWD")
7723 || n.contains("CREDENTIAL")
7724 || n.contains("AUTH")
7725 || n.contains("CERT")
7726 || n.contains("PRIVATE")
7727 }
7728
7729 let known_dev_vars: &[&str] = &[
7730 "CARGO_HOME",
7731 "RUSTUP_HOME",
7732 "GOPATH",
7733 "GOROOT",
7734 "GOBIN",
7735 "JAVA_HOME",
7736 "ANDROID_HOME",
7737 "ANDROID_SDK_ROOT",
7738 "PYTHONPATH",
7739 "PYTHONHOME",
7740 "VIRTUAL_ENV",
7741 "CONDA_DEFAULT_ENV",
7742 "CONDA_PREFIX",
7743 "NODE_PATH",
7744 "NVM_DIR",
7745 "NVM_BIN",
7746 "PNPM_HOME",
7747 "DENO_INSTALL",
7748 "DENO_DIR",
7749 "DOTNET_ROOT",
7750 "NUGET_PACKAGES",
7751 "CMAKE_HOME",
7752 "VCPKG_ROOT",
7753 "AWS_PROFILE",
7754 "AWS_REGION",
7755 "AWS_DEFAULT_REGION",
7756 "GCP_PROJECT",
7757 "GOOGLE_CLOUD_PROJECT",
7758 "GOOGLE_APPLICATION_CREDENTIALS",
7759 "AZURE_SUBSCRIPTION_ID",
7760 "DATABASE_URL",
7761 "REDIS_URL",
7762 "MONGO_URI",
7763 "EDITOR",
7764 "VISUAL",
7765 "SHELL",
7766 "TERM",
7767 "XDG_CONFIG_HOME",
7768 "XDG_DATA_HOME",
7769 "XDG_CACHE_HOME",
7770 "HOME",
7771 "USERPROFILE",
7772 "APPDATA",
7773 "LOCALAPPDATA",
7774 "TEMP",
7775 "TMP",
7776 "COMPUTERNAME",
7777 "USERNAME",
7778 "USERDOMAIN",
7779 "PROCESSOR_ARCHITECTURE",
7780 "NUMBER_OF_PROCESSORS",
7781 "OS",
7782 "HOMEDRIVE",
7783 "HOMEPATH",
7784 "HTTP_PROXY",
7785 "HTTPS_PROXY",
7786 "NO_PROXY",
7787 "ALL_PROXY",
7788 "http_proxy",
7789 "https_proxy",
7790 "no_proxy",
7791 "DOCKER_HOST",
7792 "DOCKER_BUILDKIT",
7793 "COMPOSE_PROJECT_NAME",
7794 "KUBECONFIG",
7795 "KUBE_CONTEXT",
7796 "CI",
7797 "GITHUB_ACTIONS",
7798 "GITLAB_CI",
7799 "LMSTUDIO_HOME",
7800 "HEMATITE_URL",
7801 ];
7802
7803 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7804 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7805 let total = all_vars.len();
7806
7807 let mut dev_found: Vec<String> = Vec::new();
7808 let mut secret_found: Vec<String> = Vec::new();
7809
7810 for (k, v) in &all_vars {
7811 if k == "PATH" {
7812 continue;
7813 }
7814 if looks_like_secret(k) {
7815 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7816 } else {
7817 let k_upper = k.to_uppercase();
7818 let is_known = known_dev_vars
7819 .iter()
7820 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7821 if is_known {
7822 let display = if v.len() > 120 {
7823 format!("{k} = {}…", &v[..117])
7824 } else {
7825 format!("{k} = {v}")
7826 };
7827 dev_found.push(display);
7828 }
7829 }
7830 }
7831
7832 out.push_str(&format!("Total environment variables: {total}\n\n"));
7833
7834 if let Ok(p) = std::env::var("PATH") {
7835 let sep = if cfg!(target_os = "windows") {
7836 ';'
7837 } else {
7838 ':'
7839 };
7840 let count = p.split(sep).count();
7841 out.push_str(&format!(
7842 "PATH: {count} entries (use topic=path for full audit)\n\n"
7843 ));
7844 }
7845
7846 if !secret_found.is_empty() {
7847 out.push_str(&format!(
7848 "=== Secret/credential variables ({} detected, values hidden) ===\n",
7849 secret_found.len()
7850 ));
7851 for s in secret_found.iter().take(n) {
7852 out.push_str(&format!(" {s}\n"));
7853 }
7854 out.push('\n');
7855 }
7856
7857 if !dev_found.is_empty() {
7858 out.push_str(&format!(
7859 "=== Developer & tool variables ({}) ===\n",
7860 dev_found.len()
7861 ));
7862 for d in dev_found.iter().take(n) {
7863 out.push_str(&format!(" {d}\n"));
7864 }
7865 out.push('\n');
7866 }
7867
7868 let other_count = all_vars
7869 .iter()
7870 .filter(|(k, _)| {
7871 k != "PATH"
7872 && !looks_like_secret(k)
7873 && !known_dev_vars
7874 .iter()
7875 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7876 })
7877 .count();
7878 if other_count > 0 {
7879 out.push_str(&format!(
7880 "Other variables: {other_count} (use 'env' in shell to see all)\n"
7881 ));
7882 }
7883
7884 Ok(out.trim_end().to_string())
7885}
7886
7887fn inspect_hosts_file() -> Result<String, String> {
7890 let mut out = String::from("Host inspection: hosts_file\n\n");
7891
7892 let hosts_path = if cfg!(target_os = "windows") {
7893 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7894 } else {
7895 std::path::PathBuf::from("/etc/hosts")
7896 };
7897
7898 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7899
7900 match fs::read_to_string(&hosts_path) {
7901 Ok(content) => {
7902 let mut active_entries: Vec<String> = Vec::new();
7903 let mut comment_lines = 0usize;
7904 let mut blank_lines = 0usize;
7905
7906 for line in content.lines() {
7907 let t = line.trim();
7908 if t.is_empty() {
7909 blank_lines += 1;
7910 } else if t.starts_with('#') {
7911 comment_lines += 1;
7912 } else {
7913 active_entries.push(line.to_string());
7914 }
7915 }
7916
7917 out.push_str(&format!(
7918 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7919 active_entries.len(),
7920 comment_lines,
7921 blank_lines
7922 ));
7923
7924 if active_entries.is_empty() {
7925 out.push_str(
7926 "No active host entries (file contains only comments/blanks — standard default state).\n",
7927 );
7928 } else {
7929 out.push_str("=== Active entries ===\n");
7930 for entry in &active_entries {
7931 out.push_str(&format!(" {entry}\n"));
7932 }
7933 out.push('\n');
7934
7935 let custom: Vec<&String> = active_entries
7936 .iter()
7937 .filter(|e| {
7938 let t = e.trim_start();
7939 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7940 })
7941 .collect();
7942 if !custom.is_empty() {
7943 out.push_str(&format!(
7944 "[!] Custom (non-loopback) entries: {}\n",
7945 custom.len()
7946 ));
7947 for e in &custom {
7948 out.push_str(&format!(" {e}\n"));
7949 }
7950 } else {
7951 out.push_str("All active entries are standard loopback or block entries.\n");
7952 }
7953 }
7954
7955 out.push_str("\n=== Full file ===\n");
7956 for line in content.lines() {
7957 out.push_str(&format!(" {line}\n"));
7958 }
7959 }
7960 Err(e) => {
7961 out.push_str(&format!("Could not read hosts file: {e}\n"));
7962 if cfg!(target_os = "windows") {
7963 out.push_str(
7964 "On Windows, run Hematite as Administrator if permission is denied.\n",
7965 );
7966 }
7967 }
7968 }
7969
7970 Ok(out.trim_end().to_string())
7971}
7972
7973struct AuditFinding {
7976 finding: String,
7977 impact: String,
7978 fix: String,
7979}
7980
7981#[cfg(target_os = "windows")]
7982#[derive(Debug, Clone)]
7983struct WindowsPnpDevice {
7984 name: String,
7985 status: String,
7986 problem: Option<u64>,
7987 class_name: Option<String>,
7988 instance_id: Option<String>,
7989}
7990
7991#[cfg(target_os = "windows")]
7992#[derive(Debug, Clone)]
7993struct WindowsSoundDevice {
7994 name: String,
7995 status: String,
7996 manufacturer: Option<String>,
7997}
7998
7999struct DockerMountAudit {
8000 mount_type: String,
8001 source: Option<String>,
8002 destination: String,
8003 name: Option<String>,
8004 read_write: Option<bool>,
8005 driver: Option<String>,
8006 exists_on_host: Option<bool>,
8007}
8008
8009struct DockerContainerAudit {
8010 name: String,
8011 image: String,
8012 status: String,
8013 mounts: Vec<DockerMountAudit>,
8014}
8015
8016struct DockerVolumeAudit {
8017 name: String,
8018 driver: String,
8019 mountpoint: Option<String>,
8020 scope: Option<String>,
8021}
8022
8023#[cfg(target_os = "windows")]
8024struct WslDistroAudit {
8025 name: String,
8026 state: String,
8027 version: String,
8028}
8029
8030#[cfg(target_os = "windows")]
8031struct WslRootUsage {
8032 total_kb: u64,
8033 used_kb: u64,
8034 avail_kb: u64,
8035 use_percent: String,
8036 mnt_c_present: Option<bool>,
8037}
8038
8039fn docker_engine_version() -> Result<String, String> {
8040 let version_output = Command::new("docker")
8041 .args(["version", "--format", "{{.Server.Version}}"])
8042 .output();
8043
8044 match version_output {
8045 Err(_) => Err(
8046 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8047 ),
8048 Ok(o) if !o.status.success() => {
8049 let stderr = String::from_utf8_lossy(&o.stderr);
8050 if stderr.contains("cannot connect")
8051 || stderr.contains("Is the docker daemon running")
8052 || stderr.contains("pipe")
8053 || stderr.contains("socket")
8054 {
8055 Err(
8056 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8057 )
8058 } else {
8059 Err(format!("Docker: error - {}", stderr.trim()))
8060 }
8061 }
8062 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8063 }
8064}
8065
8066fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8067 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8068 return Vec::new();
8069 };
8070 let Value::Array(entries) = value else {
8071 return Vec::new();
8072 };
8073
8074 let mut mounts = Vec::new();
8075 for entry in entries {
8076 let mount_type = entry
8077 .get("Type")
8078 .and_then(|v| v.as_str())
8079 .unwrap_or("unknown")
8080 .to_string();
8081 let source = entry
8082 .get("Source")
8083 .and_then(|v| v.as_str())
8084 .map(|v| v.to_string());
8085 let destination = entry
8086 .get("Destination")
8087 .and_then(|v| v.as_str())
8088 .unwrap_or("?")
8089 .to_string();
8090 let name = entry
8091 .get("Name")
8092 .and_then(|v| v.as_str())
8093 .map(|v| v.to_string());
8094 let read_write = entry.get("RW").and_then(|v| v.as_bool());
8095 let driver = entry
8096 .get("Driver")
8097 .and_then(|v| v.as_str())
8098 .map(|v| v.to_string());
8099 let exists_on_host = if mount_type == "bind" {
8100 source.as_deref().map(|path| Path::new(path).exists())
8101 } else {
8102 None
8103 };
8104 mounts.push(DockerMountAudit {
8105 mount_type,
8106 source,
8107 destination,
8108 name,
8109 read_write,
8110 driver,
8111 exists_on_host,
8112 });
8113 }
8114
8115 mounts
8116}
8117
8118fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8119 let mut audit = DockerVolumeAudit {
8120 name: name.to_string(),
8121 driver: "unknown".to_string(),
8122 mountpoint: None,
8123 scope: None,
8124 };
8125
8126 if let Ok(output) = Command::new("docker")
8127 .args(["volume", "inspect", name, "--format", "{{json .}}"])
8128 .output()
8129 {
8130 if output.status.success() {
8131 if let Ok(value) =
8132 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8133 {
8134 audit.driver = value
8135 .get("Driver")
8136 .and_then(|v| v.as_str())
8137 .unwrap_or("unknown")
8138 .to_string();
8139 audit.mountpoint = value
8140 .get("Mountpoint")
8141 .and_then(|v| v.as_str())
8142 .map(|v| v.to_string());
8143 audit.scope = value
8144 .get("Scope")
8145 .and_then(|v| v.as_str())
8146 .map(|v| v.to_string());
8147 }
8148 }
8149 }
8150
8151 audit
8152}
8153
8154#[cfg(target_os = "windows")]
8155fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8156 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8157 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8158 let path = local_app_data
8159 .join("Docker")
8160 .join("wsl")
8161 .join("disk")
8162 .join(file_name);
8163 if let Ok(metadata) = fs::metadata(&path) {
8164 return Some((path, metadata.len()));
8165 }
8166 }
8167 None
8168}
8169
8170#[cfg(target_os = "windows")]
8171fn clean_wsl_text(raw: &[u8]) -> String {
8172 String::from_utf8_lossy(raw)
8173 .chars()
8174 .filter(|c| *c != '\0')
8175 .collect()
8176}
8177
8178#[cfg(target_os = "windows")]
8179fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8180 let mut distros = Vec::new();
8181 for line in raw.lines() {
8182 let trimmed = line.trim();
8183 if trimmed.is_empty()
8184 || trimmed.to_uppercase().starts_with("NAME")
8185 || trimmed.starts_with("---")
8186 {
8187 continue;
8188 }
8189 let normalized = trimmed.trim_start_matches('*').trim();
8190 let cols: Vec<&str> = normalized.split_whitespace().collect();
8191 if cols.len() < 3 {
8192 continue;
8193 }
8194 let version = cols[cols.len() - 1].to_string();
8195 let state = cols[cols.len() - 2].to_string();
8196 let name = cols[..cols.len() - 2].join(" ");
8197 if !name.is_empty() {
8198 distros.push(WslDistroAudit {
8199 name,
8200 state,
8201 version,
8202 });
8203 }
8204 }
8205 distros
8206}
8207
8208#[cfg(target_os = "windows")]
8209fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8210 let output = Command::new("wsl")
8211 .args([
8212 "-d",
8213 distro_name,
8214 "--",
8215 "sh",
8216 "-lc",
8217 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8218 ])
8219 .output()
8220 .ok()?;
8221 if !output.status.success() {
8222 return None;
8223 }
8224
8225 let text = clean_wsl_text(&output.stdout);
8226 let mut total_kb = 0;
8227 let mut used_kb = 0;
8228 let mut avail_kb = 0;
8229 let mut use_percent = String::from("unknown");
8230 let mut mnt_c_present = None;
8231
8232 for line in text.lines() {
8233 let trimmed = line.trim();
8234 if trimmed.starts_with("__MNTC__:") {
8235 mnt_c_present = Some(trimmed.ends_with("ok"));
8236 continue;
8237 }
8238 let cols: Vec<&str> = trimmed.split_whitespace().collect();
8239 if cols.len() >= 6 {
8240 total_kb = cols[1].parse::<u64>().unwrap_or(0);
8241 used_kb = cols[2].parse::<u64>().unwrap_or(0);
8242 avail_kb = cols[3].parse::<u64>().unwrap_or(0);
8243 use_percent = cols[4].to_string();
8244 }
8245 }
8246
8247 Some(WslRootUsage {
8248 total_kb,
8249 used_kb,
8250 avail_kb,
8251 use_percent,
8252 mnt_c_present,
8253 })
8254}
8255
8256#[cfg(target_os = "windows")]
8257fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8258 let mut vhds = Vec::new();
8259 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8260 return vhds;
8261 };
8262 let packages_dir = local_app_data.join("Packages");
8263 let Ok(entries) = fs::read_dir(packages_dir) else {
8264 return vhds;
8265 };
8266
8267 for entry in entries.flatten() {
8268 let path = entry.path().join("LocalState").join("ext4.vhdx");
8269 if let Ok(metadata) = fs::metadata(&path) {
8270 vhds.push((path, metadata.len()));
8271 }
8272 }
8273 vhds.sort_by(|a, b| b.1.cmp(&a.1));
8274 vhds
8275}
8276
8277fn inspect_docker(max_entries: usize) -> Result<String, String> {
8278 let mut out = String::from("Host inspection: docker\n\n");
8279 let n = max_entries.clamp(5, 25);
8280
8281 let version_output = Command::new("docker")
8282 .args(["version", "--format", "{{.Server.Version}}"])
8283 .output();
8284
8285 match version_output {
8286 Err(_) => {
8287 out.push_str("Docker: not found on PATH.\n");
8288 out.push_str(
8289 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8290 );
8291 return Ok(out.trim_end().to_string());
8292 }
8293 Ok(o) if !o.status.success() => {
8294 let stderr = String::from_utf8_lossy(&o.stderr);
8295 if stderr.contains("cannot connect")
8296 || stderr.contains("Is the docker daemon running")
8297 || stderr.contains("pipe")
8298 || stderr.contains("socket")
8299 {
8300 out.push_str("Docker: installed but daemon is NOT running.\n");
8301 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8302 } else {
8303 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
8304 }
8305 return Ok(out.trim_end().to_string());
8306 }
8307 Ok(o) => {
8308 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8309 out.push_str(&format!("Docker Engine: {version}\n"));
8310 }
8311 }
8312
8313 if let Ok(o) = Command::new("docker")
8314 .args([
8315 "info",
8316 "--format",
8317 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8318 ])
8319 .output()
8320 {
8321 let info = String::from_utf8_lossy(&o.stdout);
8322 for line in info.lines() {
8323 let t = line.trim();
8324 if !t.is_empty() {
8325 out.push_str(&format!(" {t}\n"));
8326 }
8327 }
8328 out.push('\n');
8329 }
8330
8331 if let Ok(o) = Command::new("docker")
8332 .args([
8333 "ps",
8334 "--format",
8335 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8336 ])
8337 .output()
8338 {
8339 let raw = String::from_utf8_lossy(&o.stdout);
8340 let lines: Vec<&str> = raw.lines().collect();
8341 if lines.len() <= 1 {
8342 out.push_str("Running containers: none\n\n");
8343 } else {
8344 out.push_str(&format!(
8345 "=== Running containers ({}) ===\n",
8346 lines.len().saturating_sub(1)
8347 ));
8348 for line in lines.iter().take(n + 1) {
8349 out.push_str(&format!(" {line}\n"));
8350 }
8351 if lines.len() > n + 1 {
8352 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
8353 }
8354 out.push('\n');
8355 }
8356 }
8357
8358 if let Ok(o) = Command::new("docker")
8359 .args([
8360 "images",
8361 "--format",
8362 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8363 ])
8364 .output()
8365 {
8366 let raw = String::from_utf8_lossy(&o.stdout);
8367 let lines: Vec<&str> = raw.lines().collect();
8368 if lines.len() > 1 {
8369 out.push_str(&format!(
8370 "=== Local images ({}) ===\n",
8371 lines.len().saturating_sub(1)
8372 ));
8373 for line in lines.iter().take(n + 1) {
8374 out.push_str(&format!(" {line}\n"));
8375 }
8376 if lines.len() > n + 1 {
8377 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
8378 }
8379 out.push('\n');
8380 }
8381 }
8382
8383 if let Ok(o) = Command::new("docker")
8384 .args([
8385 "compose",
8386 "ls",
8387 "--format",
8388 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8389 ])
8390 .output()
8391 {
8392 let raw = String::from_utf8_lossy(&o.stdout);
8393 let lines: Vec<&str> = raw.lines().collect();
8394 if lines.len() > 1 {
8395 out.push_str(&format!(
8396 "=== Compose projects ({}) ===\n",
8397 lines.len().saturating_sub(1)
8398 ));
8399 for line in lines.iter().take(n + 1) {
8400 out.push_str(&format!(" {line}\n"));
8401 }
8402 out.push('\n');
8403 }
8404 }
8405
8406 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8407 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8408 if !ctx.is_empty() {
8409 out.push_str(&format!("Active context: {ctx}\n"));
8410 }
8411 }
8412
8413 Ok(out.trim_end().to_string())
8414}
8415
8416fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8419 let mut out = String::from("Host inspection: docker_filesystems\n\n");
8420 let n = max_entries.clamp(3, 12);
8421
8422 match docker_engine_version() {
8423 Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
8424 Err(message) => {
8425 out.push_str(&message);
8426 return Ok(out.trim_end().to_string());
8427 }
8428 }
8429
8430 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8431 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8432 if !ctx.is_empty() {
8433 out.push_str(&format!("Active context: {ctx}\n"));
8434 }
8435 }
8436 out.push('\n');
8437
8438 let mut containers = Vec::new();
8439 if let Ok(o) = Command::new("docker")
8440 .args([
8441 "ps",
8442 "-a",
8443 "--format",
8444 "{{.Names}}\t{{.Image}}\t{{.Status}}",
8445 ])
8446 .output()
8447 {
8448 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8449 let cols: Vec<&str> = line.split('\t').collect();
8450 if cols.len() < 3 {
8451 continue;
8452 }
8453 let name = cols[0].trim().to_string();
8454 if name.is_empty() {
8455 continue;
8456 }
8457 let inspect_output = Command::new("docker")
8458 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8459 .output();
8460 let mounts = match inspect_output {
8461 Ok(result) if result.status.success() => {
8462 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8463 }
8464 _ => Vec::new(),
8465 };
8466 containers.push(DockerContainerAudit {
8467 name,
8468 image: cols[1].trim().to_string(),
8469 status: cols[2].trim().to_string(),
8470 mounts,
8471 });
8472 }
8473 }
8474
8475 let mut volumes = Vec::new();
8476 if let Ok(o) = Command::new("docker")
8477 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8478 .output()
8479 {
8480 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8481 let cols: Vec<&str> = line.split('\t').collect();
8482 let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8483 continue;
8484 };
8485 let mut audit = inspect_docker_volume(name);
8486 if audit.driver == "unknown" {
8487 audit.driver = cols
8488 .get(1)
8489 .map(|v| v.trim())
8490 .filter(|v| !v.is_empty())
8491 .unwrap_or("unknown")
8492 .to_string();
8493 }
8494 volumes.push(audit);
8495 }
8496 }
8497
8498 let mut findings = Vec::new();
8499 for container in &containers {
8500 for mount in &container.mounts {
8501 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8502 let source = mount.source.as_deref().unwrap_or("<unknown>");
8503 findings.push(AuditFinding {
8504 finding: format!(
8505 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8506 container.name, source, mount.destination
8507 ),
8508 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8509 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8510 });
8511 }
8512 }
8513 }
8514
8515 #[cfg(target_os = "windows")]
8516 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8517 if size_bytes >= 20 * 1024 * 1024 * 1024 {
8518 findings.push(AuditFinding {
8519 finding: format!(
8520 "Docker Desktop disk image is large: {} at {}",
8521 human_bytes(size_bytes),
8522 path.display()
8523 ),
8524 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8525 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(),
8526 });
8527 }
8528 }
8529
8530 out.push_str("=== Findings ===\n");
8531 if findings.is_empty() {
8532 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8533 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8534 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8535 } else {
8536 for finding in &findings {
8537 out.push_str(&format!("- Finding: {}\n", finding.finding));
8538 out.push_str(&format!(" Impact: {}\n", finding.impact));
8539 out.push_str(&format!(" Fix: {}\n", finding.fix));
8540 }
8541 }
8542
8543 out.push_str("\n=== Container mount summary ===\n");
8544 if containers.is_empty() {
8545 out.push_str("- No containers found.\n");
8546 } else {
8547 for container in &containers {
8548 out.push_str(&format!(
8549 "- {} ({}) [{}]\n",
8550 container.name, container.image, container.status
8551 ));
8552 if container.mounts.is_empty() {
8553 out.push_str(" - no mounts reported\n");
8554 continue;
8555 }
8556 for mount in &container.mounts {
8557 let mut source = mount
8558 .name
8559 .clone()
8560 .or_else(|| mount.source.clone())
8561 .unwrap_or_else(|| "<unknown>".to_string());
8562 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8563 source.push_str(" [missing]");
8564 }
8565 let mut extras = Vec::new();
8566 if let Some(rw) = mount.read_write {
8567 extras.push(if rw { "rw" } else { "ro" }.to_string());
8568 }
8569 if let Some(driver) = &mount.driver {
8570 extras.push(format!("driver={driver}"));
8571 }
8572 let extra_suffix = if extras.is_empty() {
8573 String::new()
8574 } else {
8575 format!(" ({})", extras.join(", "))
8576 };
8577 out.push_str(&format!(
8578 " - {}: {} -> {}{}\n",
8579 mount.mount_type, source, mount.destination, extra_suffix
8580 ));
8581 }
8582 }
8583 }
8584
8585 out.push_str("\n=== Named volumes ===\n");
8586 if volumes.is_empty() {
8587 out.push_str("- No named volumes found.\n");
8588 } else {
8589 for volume in &volumes {
8590 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8591 if let Some(scope) = &volume.scope {
8592 detail.push_str(&format!(", scope: {scope}"));
8593 }
8594 if let Some(mountpoint) = &volume.mountpoint {
8595 detail.push_str(&format!(", mountpoint: {mountpoint}"));
8596 }
8597 out.push_str(&format!("{detail}\n"));
8598 }
8599 }
8600
8601 #[cfg(target_os = "windows")]
8602 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8603 out.push_str("\n=== Docker Desktop disk ===\n");
8604 out.push_str(&format!(
8605 "- {} at {}\n",
8606 human_bytes(size_bytes),
8607 path.display()
8608 ));
8609 }
8610
8611 Ok(out.trim_end().to_string())
8612}
8613
8614fn inspect_wsl() -> Result<String, String> {
8615 let mut out = String::from("Host inspection: wsl\n\n");
8616
8617 #[cfg(target_os = "windows")]
8618 {
8619 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8620 let raw = String::from_utf8_lossy(&o.stdout);
8621 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8622 for line in cleaned.lines().take(4) {
8623 let t = line.trim();
8624 if !t.is_empty() {
8625 out.push_str(&format!(" {t}\n"));
8626 }
8627 }
8628 out.push('\n');
8629 }
8630
8631 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8632 match list_output {
8633 Err(e) => {
8634 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8635 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8636 }
8637 Ok(o) if !o.status.success() => {
8638 let stderr = String::from_utf8_lossy(&o.stderr);
8639 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8640 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8641 out.push_str("Run: wsl --install\n");
8642 }
8643 Ok(o) => {
8644 let raw = String::from_utf8_lossy(&o.stdout);
8645 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8646 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8647 let distro_lines: Vec<&str> = lines
8648 .iter()
8649 .filter(|l| {
8650 let t = l.trim();
8651 !t.is_empty()
8652 && !t.to_uppercase().starts_with("NAME")
8653 && !t.starts_with("---")
8654 })
8655 .copied()
8656 .collect();
8657
8658 if distro_lines.is_empty() {
8659 out.push_str("WSL: installed but no distributions found.\n");
8660 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8661 } else {
8662 out.push_str("=== WSL Distributions ===\n");
8663 for line in &lines {
8664 out.push_str(&format!(" {}\n", line.trim()));
8665 }
8666 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8667 }
8668 }
8669 }
8670
8671 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8672 let raw = String::from_utf8_lossy(&o.stdout);
8673 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8674 let status_lines: Vec<&str> = cleaned
8675 .lines()
8676 .filter(|l| !l.trim().is_empty())
8677 .take(8)
8678 .collect();
8679 if !status_lines.is_empty() {
8680 out.push_str("\n=== WSL status ===\n");
8681 for line in status_lines {
8682 out.push_str(&format!(" {}\n", line.trim()));
8683 }
8684 }
8685 }
8686 }
8687
8688 #[cfg(not(target_os = "windows"))]
8689 {
8690 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8691 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8692 }
8693
8694 Ok(out.trim_end().to_string())
8695}
8696
8697fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8700 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8701
8702 #[cfg(target_os = "windows")]
8703 {
8704 let n = max_entries.clamp(3, 12);
8705 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8706 let distros = match list_output {
8707 Err(e) => {
8708 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8709 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8710 return Ok(out.trim_end().to_string());
8711 }
8712 Ok(o) if !o.status.success() => {
8713 let cleaned = clean_wsl_text(&o.stderr);
8714 out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8715 out.push_str("Run: wsl --install\n");
8716 return Ok(out.trim_end().to_string());
8717 }
8718 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8719 };
8720
8721 out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8722
8723 let vhdx_files = collect_wsl_vhdx_files();
8724 let mut findings = Vec::new();
8725 let mut live_usage = Vec::new();
8726
8727 for distro in distros.iter().take(n) {
8728 if distro.state.eq_ignore_ascii_case("Running") {
8729 if let Some(usage) = wsl_root_usage(&distro.name) {
8730 if let Some(false) = usage.mnt_c_present {
8731 findings.push(AuditFinding {
8732 finding: format!(
8733 "Distro '{}' is running without /mnt/c available",
8734 distro.name
8735 ),
8736 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8737 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8738 });
8739 }
8740
8741 let percent_num = usage
8742 .use_percent
8743 .trim_end_matches('%')
8744 .parse::<u32>()
8745 .unwrap_or(0);
8746 if percent_num >= 85 {
8747 findings.push(AuditFinding {
8748 finding: format!(
8749 "Distro '{}' root filesystem is {} full",
8750 distro.name, usage.use_percent
8751 ),
8752 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8753 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8754 });
8755 }
8756 live_usage.push((distro.name.clone(), usage));
8757 }
8758 }
8759 }
8760
8761 for (path, size_bytes) in vhdx_files.iter().take(n) {
8762 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8763 findings.push(AuditFinding {
8764 finding: format!(
8765 "Host-side WSL disk image is large: {} at {}",
8766 human_bytes(*size_bytes),
8767 path.display()
8768 ),
8769 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8770 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8771 });
8772 }
8773 }
8774
8775 out.push_str("=== Findings ===\n");
8776 if findings.is_empty() {
8777 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8778 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8779 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8780 } else {
8781 for finding in &findings {
8782 out.push_str(&format!("- Finding: {}\n", finding.finding));
8783 out.push_str(&format!(" Impact: {}\n", finding.impact));
8784 out.push_str(&format!(" Fix: {}\n", finding.fix));
8785 }
8786 }
8787
8788 out.push_str("\n=== Distro bridge and root usage ===\n");
8789 if distros.is_empty() {
8790 out.push_str("- No WSL distributions found.\n");
8791 } else {
8792 for distro in distros.iter().take(n) {
8793 out.push_str(&format!(
8794 "- {} [state: {}, version: {}]\n",
8795 distro.name, distro.state, distro.version
8796 ));
8797 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8798 out.push_str(&format!(
8799 " - rootfs: {} used / {} total ({}), free: {}\n",
8800 human_bytes(usage.used_kb * 1024),
8801 human_bytes(usage.total_kb * 1024),
8802 usage.use_percent,
8803 human_bytes(usage.avail_kb * 1024)
8804 ));
8805 match usage.mnt_c_present {
8806 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8807 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8808 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8809 }
8810 } else if distro.state.eq_ignore_ascii_case("Running") {
8811 out.push_str(" - live rootfs check: unavailable\n");
8812 } else {
8813 out.push_str(
8814 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8815 );
8816 }
8817 }
8818 }
8819
8820 out.push_str("\n=== Host-side VHDX files ===\n");
8821 if vhdx_files.is_empty() {
8822 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8823 } else {
8824 for (path, size_bytes) in vhdx_files.iter().take(n) {
8825 out.push_str(&format!(
8826 "- {} at {}\n",
8827 human_bytes(*size_bytes),
8828 path.display()
8829 ));
8830 }
8831 }
8832 }
8833
8834 #[cfg(not(target_os = "windows"))]
8835 {
8836 let _ = max_entries;
8837 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8838 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8839 }
8840
8841 Ok(out.trim_end().to_string())
8842}
8843
8844fn dirs_home() -> Option<PathBuf> {
8845 std::env::var("HOME")
8846 .ok()
8847 .map(PathBuf::from)
8848 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8849}
8850
8851fn inspect_ssh() -> Result<String, String> {
8852 let mut out = String::from("Host inspection: ssh\n\n");
8853
8854 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8855 let ver = if o.stdout.is_empty() {
8856 String::from_utf8_lossy(&o.stderr).trim().to_string()
8857 } else {
8858 String::from_utf8_lossy(&o.stdout).trim().to_string()
8859 };
8860 if !ver.is_empty() {
8861 out.push_str(&format!("SSH client: {ver}\n"));
8862 }
8863 } else {
8864 out.push_str("SSH client: not found on PATH.\n");
8865 }
8866
8867 #[cfg(target_os = "windows")]
8868 {
8869 let script = r#"
8870$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8871if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8872else { "SSHD:not_installed" }
8873"#;
8874 if let Ok(o) = Command::new("powershell")
8875 .args(["-NoProfile", "-Command", script])
8876 .output()
8877 {
8878 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8879 if text.contains("not_installed") {
8880 out.push_str("SSH server (sshd): not installed\n");
8881 } else {
8882 out.push_str(&format!(
8883 "SSH server (sshd): {}\n",
8884 text.trim_start_matches("SSHD:")
8885 ));
8886 }
8887 }
8888 }
8889
8890 #[cfg(not(target_os = "windows"))]
8891 {
8892 if let Ok(o) = Command::new("systemctl")
8893 .args(["is-active", "sshd"])
8894 .output()
8895 {
8896 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8897 out.push_str(&format!("SSH server (sshd): {status}\n"));
8898 } else if let Ok(o) = Command::new("systemctl")
8899 .args(["is-active", "ssh"])
8900 .output()
8901 {
8902 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8903 out.push_str(&format!("SSH server (ssh): {status}\n"));
8904 }
8905 }
8906
8907 out.push('\n');
8908
8909 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8910 if ssh_dir.exists() {
8911 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8912
8913 let kh = ssh_dir.join("known_hosts");
8914 if kh.exists() {
8915 let count = fs::read_to_string(&kh)
8916 .map(|c| {
8917 c.lines()
8918 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8919 .count()
8920 })
8921 .unwrap_or(0);
8922 out.push_str(&format!(" known_hosts: {count} entries\n"));
8923 } else {
8924 out.push_str(" known_hosts: not present\n");
8925 }
8926
8927 let ak = ssh_dir.join("authorized_keys");
8928 if ak.exists() {
8929 let count = fs::read_to_string(&ak)
8930 .map(|c| {
8931 c.lines()
8932 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8933 .count()
8934 })
8935 .unwrap_or(0);
8936 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
8937 } else {
8938 out.push_str(" authorized_keys: not present\n");
8939 }
8940
8941 let key_names = [
8942 "id_rsa",
8943 "id_ed25519",
8944 "id_ecdsa",
8945 "id_dsa",
8946 "id_ecdsa_sk",
8947 "id_ed25519_sk",
8948 ];
8949 let found_keys: Vec<&str> = key_names
8950 .iter()
8951 .filter(|k| ssh_dir.join(k).exists())
8952 .copied()
8953 .collect();
8954 if !found_keys.is_empty() {
8955 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
8956 } else {
8957 out.push_str(" Private keys: none found\n");
8958 }
8959
8960 let config_path = ssh_dir.join("config");
8961 if config_path.exists() {
8962 out.push_str("\n=== SSH config hosts ===\n");
8963 match fs::read_to_string(&config_path) {
8964 Ok(content) => {
8965 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8966 let mut current: Option<(String, Vec<String>)> = None;
8967 for line in content.lines() {
8968 let t = line.trim();
8969 if t.is_empty() || t.starts_with('#') {
8970 continue;
8971 }
8972 if let Some(host) = t.strip_prefix("Host ") {
8973 if let Some(prev) = current.take() {
8974 hosts.push(prev);
8975 }
8976 current = Some((host.trim().to_string(), Vec::new()));
8977 } else if let Some((_, ref mut details)) = current {
8978 let tu = t.to_uppercase();
8979 if tu.starts_with("HOSTNAME ")
8980 || tu.starts_with("USER ")
8981 || tu.starts_with("PORT ")
8982 || tu.starts_with("IDENTITYFILE ")
8983 {
8984 details.push(t.to_string());
8985 }
8986 }
8987 }
8988 if let Some(prev) = current {
8989 hosts.push(prev);
8990 }
8991
8992 if hosts.is_empty() {
8993 out.push_str(" No Host entries found.\n");
8994 } else {
8995 for (h, details) in &hosts {
8996 if details.is_empty() {
8997 out.push_str(&format!(" Host {h}\n"));
8998 } else {
8999 out.push_str(&format!(
9000 " Host {h} [{}]\n",
9001 details.join(", ")
9002 ));
9003 }
9004 }
9005 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
9006 }
9007 }
9008 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
9009 }
9010 } else {
9011 out.push_str(" SSH config: not present\n");
9012 }
9013 } else {
9014 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9015 }
9016 }
9017
9018 Ok(out.trim_end().to_string())
9019}
9020
9021fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9024 let mut out = String::from("Host inspection: installed_software\n\n");
9025 let n = max_entries.clamp(10, 50);
9026
9027 #[cfg(target_os = "windows")]
9028 {
9029 let winget_out = Command::new("winget")
9030 .args(["list", "--accept-source-agreements"])
9031 .output();
9032
9033 if let Ok(o) = winget_out {
9034 if o.status.success() {
9035 let raw = String::from_utf8_lossy(&o.stdout);
9036 let mut header_done = false;
9037 let mut packages: Vec<&str> = Vec::new();
9038 for line in raw.lines() {
9039 let t = line.trim();
9040 if t.starts_with("---") {
9041 header_done = true;
9042 continue;
9043 }
9044 if header_done && !t.is_empty() {
9045 packages.push(line);
9046 }
9047 }
9048 let total = packages.len();
9049 out.push_str(&format!(
9050 "=== Installed software via winget ({total} packages) ===\n\n"
9051 ));
9052 for line in packages.iter().take(n) {
9053 out.push_str(&format!(" {line}\n"));
9054 }
9055 if total > n {
9056 out.push_str(&format!("\n ... and {} more packages\n", total - n));
9057 }
9058 out.push_str("\nFor full list: winget list\n");
9059 return Ok(out.trim_end().to_string());
9060 }
9061 }
9062
9063 let script = format!(
9065 r#"
9066$apps = @()
9067$reg_paths = @(
9068 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9069 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9070 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9071)
9072foreach ($p in $reg_paths) {{
9073 try {{
9074 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9075 Where-Object {{ $_.DisplayName }} |
9076 Select-Object DisplayName, DisplayVersion, Publisher
9077 }} catch {{}}
9078}}
9079$sorted = $apps | Sort-Object DisplayName -Unique
9080"TOTAL:" + $sorted.Count
9081$sorted | Select-Object -First {n} | ForEach-Object {{
9082 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9083}}
9084"#
9085 );
9086 if let Ok(o) = Command::new("powershell")
9087 .args(["-NoProfile", "-Command", &script])
9088 .output()
9089 {
9090 let raw = String::from_utf8_lossy(&o.stdout);
9091 out.push_str("=== Installed software (registry scan) ===\n");
9092 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
9093 out.push_str(&format!(" {}\n", "-".repeat(90)));
9094 for line in raw.lines() {
9095 if let Some(rest) = line.strip_prefix("TOTAL:") {
9096 let total: usize = rest.trim().parse().unwrap_or(0);
9097 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
9098 } else if !line.trim().is_empty() {
9099 let parts: Vec<&str> = line.splitn(3, '|').collect();
9100 let name = parts.first().map(|s| s.trim()).unwrap_or("");
9101 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
9102 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
9103 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
9104 }
9105 }
9106 } else {
9107 out.push_str(
9108 "Could not query installed software (winget and registry scan both failed).\n",
9109 );
9110 }
9111 }
9112
9113 #[cfg(target_os = "linux")]
9114 {
9115 let mut found = false;
9116 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9117 if o.status.success() {
9118 let raw = String::from_utf8_lossy(&o.stdout);
9119 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9120 let total = installed.len();
9121 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
9122 for line in installed.iter().take(n) {
9123 out.push_str(&format!(" {}\n", line.trim()));
9124 }
9125 if total > n {
9126 out.push_str(&format!(" ... and {} more\n", total - n));
9127 }
9128 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9129 found = true;
9130 }
9131 }
9132 if !found {
9133 if let Ok(o) = Command::new("rpm")
9134 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9135 .output()
9136 {
9137 if o.status.success() {
9138 let raw = String::from_utf8_lossy(&o.stdout);
9139 let lines: Vec<&str> = raw.lines().collect();
9140 let total = lines.len();
9141 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
9142 for line in lines.iter().take(n) {
9143 out.push_str(&format!(" {line}\n"));
9144 }
9145 if total > n {
9146 out.push_str(&format!(" ... and {} more\n", total - n));
9147 }
9148 found = true;
9149 }
9150 }
9151 }
9152 if !found {
9153 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9154 if o.status.success() {
9155 let raw = String::from_utf8_lossy(&o.stdout);
9156 let lines: Vec<&str> = raw.lines().collect();
9157 let total = lines.len();
9158 out.push_str(&format!(
9159 "=== Installed packages via pacman ({total}) ===\n"
9160 ));
9161 for line in lines.iter().take(n) {
9162 out.push_str(&format!(" {line}\n"));
9163 }
9164 if total > n {
9165 out.push_str(&format!(" ... and {} more\n", total - n));
9166 }
9167 found = true;
9168 }
9169 }
9170 }
9171 if !found {
9172 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9173 }
9174 }
9175
9176 #[cfg(target_os = "macos")]
9177 {
9178 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9179 if o.status.success() {
9180 let raw = String::from_utf8_lossy(&o.stdout);
9181 let lines: Vec<&str> = raw.lines().collect();
9182 let total = lines.len();
9183 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
9184 for line in lines.iter().take(n) {
9185 out.push_str(&format!(" {line}\n"));
9186 }
9187 if total > n {
9188 out.push_str(&format!(" ... and {} more\n", total - n));
9189 }
9190 out.push_str("\nFor full list: brew list --versions\n");
9191 }
9192 } else {
9193 out.push_str("Homebrew not found.\n");
9194 }
9195 if let Ok(o) = Command::new("mas").args(["list"]).output() {
9196 if o.status.success() {
9197 let raw = String::from_utf8_lossy(&o.stdout);
9198 let lines: Vec<&str> = raw.lines().collect();
9199 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
9200 for line in lines.iter().take(n) {
9201 out.push_str(&format!(" {line}\n"));
9202 }
9203 }
9204 }
9205 }
9206
9207 Ok(out.trim_end().to_string())
9208}
9209
9210fn inspect_git_config() -> Result<String, String> {
9213 let mut out = String::from("Host inspection: git_config\n\n");
9214
9215 if let Ok(o) = Command::new("git").args(["--version"]).output() {
9216 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9217 out.push_str(&format!("Git: {ver}\n\n"));
9218 } else {
9219 out.push_str("Git: not found on PATH.\n");
9220 return Ok(out.trim_end().to_string());
9221 }
9222
9223 if let Ok(o) = Command::new("git")
9224 .args(["config", "--global", "--list"])
9225 .output()
9226 {
9227 if o.status.success() {
9228 let raw = String::from_utf8_lossy(&o.stdout);
9229 let mut pairs: Vec<(String, String)> = raw
9230 .lines()
9231 .filter_map(|l| {
9232 let mut parts = l.splitn(2, '=');
9233 let k = parts.next()?.trim().to_string();
9234 let v = parts.next().unwrap_or("").trim().to_string();
9235 Some((k, v))
9236 })
9237 .collect();
9238 pairs.sort_by(|a, b| a.0.cmp(&b.0));
9239
9240 out.push_str("=== Global git config ===\n");
9241
9242 let sections: &[(&str, &[&str])] = &[
9243 ("Identity", &["user.name", "user.email", "user.signingkey"]),
9244 (
9245 "Core",
9246 &[
9247 "core.editor",
9248 "core.autocrlf",
9249 "core.eol",
9250 "core.ignorecase",
9251 "core.filemode",
9252 ],
9253 ),
9254 (
9255 "Commit/Signing",
9256 &[
9257 "commit.gpgsign",
9258 "tag.gpgsign",
9259 "gpg.format",
9260 "gpg.ssh.allowedsignersfile",
9261 ],
9262 ),
9263 (
9264 "Push/Pull",
9265 &[
9266 "push.default",
9267 "push.autosetupremote",
9268 "pull.rebase",
9269 "pull.ff",
9270 ],
9271 ),
9272 ("Credential", &["credential.helper"]),
9273 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9274 ];
9275
9276 let mut shown_keys: HashSet<String> = HashSet::new();
9277 for (section, keys) in sections {
9278 let mut section_lines: Vec<String> = Vec::new();
9279 for key in *keys {
9280 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9281 section_lines.push(format!(" {k} = {v}"));
9282 shown_keys.insert(k.clone());
9283 }
9284 }
9285 if !section_lines.is_empty() {
9286 out.push_str(&format!("\n[{section}]\n"));
9287 for line in section_lines {
9288 out.push_str(&format!("{line}\n"));
9289 }
9290 }
9291 }
9292
9293 let other: Vec<&(String, String)> = pairs
9294 .iter()
9295 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9296 .collect();
9297 if !other.is_empty() {
9298 out.push_str("\n[Other]\n");
9299 for (k, v) in other.iter().take(20) {
9300 out.push_str(&format!(" {k} = {v}\n"));
9301 }
9302 if other.len() > 20 {
9303 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
9304 }
9305 }
9306
9307 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
9308 } else {
9309 out.push_str("No global git config found.\n");
9310 out.push_str("Set up with:\n");
9311 out.push_str(" git config --global user.name \"Your Name\"\n");
9312 out.push_str(" git config --global user.email \"you@example.com\"\n");
9313 }
9314 }
9315
9316 if let Ok(o) = Command::new("git")
9317 .args(["config", "--local", "--list"])
9318 .output()
9319 {
9320 if o.status.success() {
9321 let raw = String::from_utf8_lossy(&o.stdout);
9322 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9323 if !lines.is_empty() {
9324 out.push_str(&format!(
9325 "\n=== Local repo config ({} keys) ===\n",
9326 lines.len()
9327 ));
9328 for line in lines.iter().take(15) {
9329 out.push_str(&format!(" {line}\n"));
9330 }
9331 if lines.len() > 15 {
9332 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
9333 }
9334 }
9335 }
9336 }
9337
9338 if let Ok(o) = Command::new("git")
9339 .args(["config", "--global", "--get-regexp", r"alias\."])
9340 .output()
9341 {
9342 if o.status.success() {
9343 let raw = String::from_utf8_lossy(&o.stdout);
9344 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9345 if !aliases.is_empty() {
9346 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
9347 for a in aliases.iter().take(20) {
9348 out.push_str(&format!(" {a}\n"));
9349 }
9350 if aliases.len() > 20 {
9351 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
9352 }
9353 }
9354 }
9355 }
9356
9357 Ok(out.trim_end().to_string())
9358}
9359
9360fn inspect_databases() -> Result<String, String> {
9363 let mut out = String::from("Host inspection: databases\n\n");
9364 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9365
9366 struct DbEngine {
9367 name: &'static str,
9368 service_names: &'static [&'static str],
9369 default_port: u16,
9370 cli_name: &'static str,
9371 cli_version_args: &'static [&'static str],
9372 }
9373
9374 let engines: &[DbEngine] = &[
9375 DbEngine {
9376 name: "PostgreSQL",
9377 service_names: &[
9378 "postgresql",
9379 "postgresql-x64-14",
9380 "postgresql-x64-15",
9381 "postgresql-x64-16",
9382 "postgresql-x64-17",
9383 ],
9384
9385 default_port: 5432,
9386 cli_name: "psql",
9387 cli_version_args: &["--version"],
9388 },
9389 DbEngine {
9390 name: "MySQL",
9391 service_names: &["mysql", "mysql80", "mysql57"],
9392
9393 default_port: 3306,
9394 cli_name: "mysql",
9395 cli_version_args: &["--version"],
9396 },
9397 DbEngine {
9398 name: "MariaDB",
9399 service_names: &["mariadb", "mariadb.exe"],
9400
9401 default_port: 3306,
9402 cli_name: "mariadb",
9403 cli_version_args: &["--version"],
9404 },
9405 DbEngine {
9406 name: "MongoDB",
9407 service_names: &["mongodb", "mongod"],
9408
9409 default_port: 27017,
9410 cli_name: "mongod",
9411 cli_version_args: &["--version"],
9412 },
9413 DbEngine {
9414 name: "Redis",
9415 service_names: &["redis", "redis-server"],
9416
9417 default_port: 6379,
9418 cli_name: "redis-server",
9419 cli_version_args: &["--version"],
9420 },
9421 DbEngine {
9422 name: "SQL Server",
9423 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9424
9425 default_port: 1433,
9426 cli_name: "sqlcmd",
9427 cli_version_args: &["-?"],
9428 },
9429 DbEngine {
9430 name: "SQLite",
9431 service_names: &[], default_port: 0, cli_name: "sqlite3",
9435 cli_version_args: &["--version"],
9436 },
9437 DbEngine {
9438 name: "CouchDB",
9439 service_names: &["couchdb", "apache-couchdb"],
9440
9441 default_port: 5984,
9442 cli_name: "couchdb",
9443 cli_version_args: &["--version"],
9444 },
9445 DbEngine {
9446 name: "Cassandra",
9447 service_names: &["cassandra"],
9448
9449 default_port: 9042,
9450 cli_name: "cqlsh",
9451 cli_version_args: &["--version"],
9452 },
9453 DbEngine {
9454 name: "Elasticsearch",
9455 service_names: &["elasticsearch-service-x64", "elasticsearch"],
9456
9457 default_port: 9200,
9458 cli_name: "elasticsearch",
9459 cli_version_args: &["--version"],
9460 },
9461 ];
9462
9463 fn port_listening(port: u16) -> bool {
9465 if port == 0 {
9466 return false;
9467 }
9468 std::net::TcpStream::connect_timeout(
9470 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9471 std::time::Duration::from_millis(150),
9472 )
9473 .is_ok()
9474 }
9475
9476 let mut found_any = false;
9477
9478 for engine in engines {
9479 let mut status_parts: Vec<String> = Vec::new();
9480 let mut detected = false;
9481
9482 let version = Command::new(engine.cli_name)
9484 .args(engine.cli_version_args)
9485 .output()
9486 .ok()
9487 .and_then(|o| {
9488 let combined = if o.stdout.is_empty() {
9489 String::from_utf8_lossy(&o.stderr).trim().to_string()
9490 } else {
9491 String::from_utf8_lossy(&o.stdout).trim().to_string()
9492 };
9493 combined.lines().next().map(|l| l.trim().to_string())
9495 });
9496
9497 if let Some(ref ver) = version {
9498 if !ver.is_empty() {
9499 status_parts.push(format!("version: {ver}"));
9500 detected = true;
9501 }
9502 }
9503
9504 if engine.default_port > 0 && port_listening(engine.default_port) {
9506 status_parts.push(format!("listening on :{}", engine.default_port));
9507 detected = true;
9508 } else if engine.default_port > 0 && detected {
9509 status_parts.push(format!("not listening on :{}", engine.default_port));
9510 }
9511
9512 #[cfg(target_os = "windows")]
9514 {
9515 if !engine.service_names.is_empty() {
9516 let service_list = engine.service_names.join("','");
9517 let script = format!(
9518 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9519 service_list
9520 );
9521 if let Ok(o) = Command::new("powershell")
9522 .args(["-NoProfile", "-Command", &script])
9523 .output()
9524 {
9525 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9526 if !text.is_empty() {
9527 let parts: Vec<&str> = text.splitn(2, ':').collect();
9528 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
9529 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
9530 status_parts.push(format!("service '{svc_name}': {svc_state}"));
9531 detected = true;
9532 }
9533 }
9534 }
9535 }
9536
9537 #[cfg(not(target_os = "windows"))]
9539 {
9540 for svc in engine.service_names {
9541 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9542 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9543 if !state.is_empty() && state != "inactive" {
9544 status_parts.push(format!("systemd '{svc}': {state}"));
9545 detected = true;
9546 break;
9547 }
9548 }
9549 }
9550 }
9551
9552 if detected {
9553 found_any = true;
9554 let label = if engine.default_port > 0 {
9555 format!("{} (default port: {})", engine.name, engine.default_port)
9556 } else {
9557 format!("{} (file-based, no port)", engine.name)
9558 };
9559 out.push_str(&format!("[FOUND] {label}\n"));
9560 for part in &status_parts {
9561 out.push_str(&format!(" {part}\n"));
9562 }
9563 out.push('\n');
9564 }
9565 }
9566
9567 if !found_any {
9568 out.push_str("No local database engines detected.\n");
9569 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9570 out.push_str(
9571 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9572 );
9573 } else {
9574 out.push_str("---\n");
9575 out.push_str(
9576 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9577 );
9578 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9579 }
9580
9581 Ok(out.trim_end().to_string())
9582}
9583
9584fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9587 let mut out = String::from("Host inspection: user_accounts\n\n");
9588
9589 #[cfg(target_os = "windows")]
9590 {
9591 let users_out = Command::new("powershell")
9592 .args([
9593 "-NoProfile", "-NonInteractive", "-Command",
9594 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9595 ])
9596 .output()
9597 .ok()
9598 .and_then(|o| String::from_utf8(o.stdout).ok())
9599 .unwrap_or_default();
9600
9601 out.push_str("=== Local User Accounts ===\n");
9602 if users_out.trim().is_empty() {
9603 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
9604 } else {
9605 for line in users_out.lines().take(max_entries) {
9606 if !line.trim().is_empty() {
9607 out.push_str(line);
9608 out.push('\n');
9609 }
9610 }
9611 }
9612
9613 let admins_out = Command::new("powershell")
9614 .args([
9615 "-NoProfile", "-NonInteractive", "-Command",
9616 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
9617 ])
9618 .output()
9619 .ok()
9620 .and_then(|o| String::from_utf8(o.stdout).ok())
9621 .unwrap_or_default();
9622
9623 out.push_str("\n=== Administrators Group Members ===\n");
9624 if admins_out.trim().is_empty() {
9625 out.push_str(" (unable to retrieve)\n");
9626 } else {
9627 out.push_str(admins_out.trim());
9628 out.push('\n');
9629 }
9630
9631 let sessions_out = Command::new("powershell")
9632 .args([
9633 "-NoProfile",
9634 "-NonInteractive",
9635 "-Command",
9636 "query user 2>$null",
9637 ])
9638 .output()
9639 .ok()
9640 .and_then(|o| String::from_utf8(o.stdout).ok())
9641 .unwrap_or_default();
9642
9643 out.push_str("\n=== Active Logon Sessions ===\n");
9644 if sessions_out.trim().is_empty() {
9645 out.push_str(" (none or requires elevation)\n");
9646 } else {
9647 for line in sessions_out.lines().take(max_entries) {
9648 if !line.trim().is_empty() {
9649 out.push_str(&format!(" {}\n", line));
9650 }
9651 }
9652 }
9653
9654 let is_admin = Command::new("powershell")
9655 .args([
9656 "-NoProfile", "-NonInteractive", "-Command",
9657 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9658 ])
9659 .output()
9660 .ok()
9661 .and_then(|o| String::from_utf8(o.stdout).ok())
9662 .map(|s| s.trim().to_lowercase())
9663 .unwrap_or_default();
9664
9665 out.push_str("\n=== Current Session Elevation ===\n");
9666 out.push_str(&format!(
9667 " Running as Administrator: {}\n",
9668 if is_admin.contains("true") {
9669 "YES"
9670 } else {
9671 "no"
9672 }
9673 ));
9674 }
9675
9676 #[cfg(not(target_os = "windows"))]
9677 {
9678 let who_out = Command::new("who")
9679 .output()
9680 .ok()
9681 .and_then(|o| String::from_utf8(o.stdout).ok())
9682 .unwrap_or_default();
9683 out.push_str("=== Active Sessions ===\n");
9684 if who_out.trim().is_empty() {
9685 out.push_str(" (none)\n");
9686 } else {
9687 for line in who_out.lines().take(max_entries) {
9688 out.push_str(&format!(" {}\n", line));
9689 }
9690 }
9691 let id_out = Command::new("id")
9692 .output()
9693 .ok()
9694 .and_then(|o| String::from_utf8(o.stdout).ok())
9695 .unwrap_or_default();
9696 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
9697 }
9698
9699 Ok(out.trim_end().to_string())
9700}
9701
9702fn inspect_audit_policy() -> Result<String, String> {
9705 let mut out = String::from("Host inspection: audit_policy\n\n");
9706
9707 #[cfg(target_os = "windows")]
9708 {
9709 let auditpol_out = Command::new("auditpol")
9710 .args(["/get", "/category:*"])
9711 .output()
9712 .ok()
9713 .and_then(|o| String::from_utf8(o.stdout).ok())
9714 .unwrap_or_default();
9715
9716 if auditpol_out.trim().is_empty()
9717 || auditpol_out.to_lowercase().contains("access is denied")
9718 {
9719 out.push_str("Audit policy requires Administrator elevation to read.\n");
9720 out.push_str(
9721 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9722 );
9723 } else {
9724 out.push_str("=== Windows Audit Policy ===\n");
9725 let mut any_enabled = false;
9726 for line in auditpol_out.lines() {
9727 let trimmed = line.trim();
9728 if trimmed.is_empty() {
9729 continue;
9730 }
9731 if trimmed.contains("Success") || trimmed.contains("Failure") {
9732 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
9733 any_enabled = true;
9734 } else {
9735 out.push_str(&format!(" {}\n", trimmed));
9736 }
9737 }
9738 if !any_enabled {
9739 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9740 out.push_str(
9741 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9742 );
9743 }
9744 }
9745
9746 let evtlog = Command::new("powershell")
9747 .args([
9748 "-NoProfile", "-NonInteractive", "-Command",
9749 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9750 ])
9751 .output()
9752 .ok()
9753 .and_then(|o| String::from_utf8(o.stdout).ok())
9754 .map(|s| s.trim().to_string())
9755 .unwrap_or_default();
9756
9757 out.push_str(&format!(
9758 "\n=== Windows Event Log Service ===\n Status: {}\n",
9759 if evtlog.is_empty() {
9760 "unknown".to_string()
9761 } else {
9762 evtlog
9763 }
9764 ));
9765 }
9766
9767 #[cfg(not(target_os = "windows"))]
9768 {
9769 let auditd_status = Command::new("systemctl")
9770 .args(["is-active", "auditd"])
9771 .output()
9772 .ok()
9773 .and_then(|o| String::from_utf8(o.stdout).ok())
9774 .map(|s| s.trim().to_string())
9775 .unwrap_or_else(|| "not found".to_string());
9776
9777 out.push_str(&format!(
9778 "=== auditd service ===\n Status: {}\n",
9779 auditd_status
9780 ));
9781
9782 if auditd_status == "active" {
9783 let rules = Command::new("auditctl")
9784 .args(["-l"])
9785 .output()
9786 .ok()
9787 .and_then(|o| String::from_utf8(o.stdout).ok())
9788 .unwrap_or_default();
9789 out.push_str("\n=== Active Audit Rules ===\n");
9790 if rules.trim().is_empty() || rules.contains("No rules") {
9791 out.push_str(" No rules configured.\n");
9792 } else {
9793 for line in rules.lines() {
9794 out.push_str(&format!(" {}\n", line));
9795 }
9796 }
9797 }
9798 }
9799
9800 Ok(out.trim_end().to_string())
9801}
9802
9803fn inspect_shares(max_entries: usize) -> Result<String, String> {
9806 let mut out = String::from("Host inspection: shares\n\n");
9807
9808 #[cfg(target_os = "windows")]
9809 {
9810 let smb_out = Command::new("powershell")
9811 .args([
9812 "-NoProfile", "-NonInteractive", "-Command",
9813 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9814 ])
9815 .output()
9816 .ok()
9817 .and_then(|o| String::from_utf8(o.stdout).ok())
9818 .unwrap_or_default();
9819
9820 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9821 let smb_lines: Vec<&str> = smb_out
9822 .lines()
9823 .filter(|l| !l.trim().is_empty())
9824 .take(max_entries)
9825 .collect();
9826 if smb_lines.is_empty() {
9827 out.push_str(" No SMB shares or unable to retrieve.\n");
9828 } else {
9829 for line in &smb_lines {
9830 let name = line.trim().split('|').next().unwrap_or("").trim();
9831 if name.ends_with('$') {
9832 out.push_str(&format!(" {}\n", line.trim()));
9833 } else {
9834 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
9835 }
9836 }
9837 }
9838
9839 let smb_security = Command::new("powershell")
9840 .args([
9841 "-NoProfile", "-NonInteractive", "-Command",
9842 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9843 ])
9844 .output()
9845 .ok()
9846 .and_then(|o| String::from_utf8(o.stdout).ok())
9847 .unwrap_or_default();
9848
9849 out.push_str("\n=== SMB Server Security Settings ===\n");
9850 if smb_security.trim().is_empty() {
9851 out.push_str(" (unable to retrieve)\n");
9852 } else {
9853 out.push_str(smb_security.trim());
9854 out.push('\n');
9855 if smb_security.to_lowercase().contains("smb1: true") {
9856 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9857 }
9858 }
9859
9860 let drives_out = Command::new("powershell")
9861 .args([
9862 "-NoProfile", "-NonInteractive", "-Command",
9863 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9864 ])
9865 .output()
9866 .ok()
9867 .and_then(|o| String::from_utf8(o.stdout).ok())
9868 .unwrap_or_default();
9869
9870 out.push_str("\n=== Mapped Network Drives ===\n");
9871 if drives_out.trim().is_empty() {
9872 out.push_str(" None.\n");
9873 } else {
9874 for line in drives_out.lines().take(max_entries) {
9875 if !line.trim().is_empty() {
9876 out.push_str(line);
9877 out.push('\n');
9878 }
9879 }
9880 }
9881 }
9882
9883 #[cfg(not(target_os = "windows"))]
9884 {
9885 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9886 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9887 if smb_conf.is_empty() {
9888 out.push_str(" Not found or Samba not installed.\n");
9889 } else {
9890 for line in smb_conf.lines().take(max_entries) {
9891 out.push_str(&format!(" {}\n", line));
9892 }
9893 }
9894 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9895 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9896 if nfs_exports.is_empty() {
9897 out.push_str(" Not configured.\n");
9898 } else {
9899 for line in nfs_exports.lines().take(max_entries) {
9900 out.push_str(&format!(" {}\n", line));
9901 }
9902 }
9903 }
9904
9905 Ok(out.trim_end().to_string())
9906}
9907
9908fn inspect_dns_servers() -> Result<String, String> {
9911 let mut out = String::from("Host inspection: dns_servers\n\n");
9912
9913 #[cfg(target_os = "windows")]
9914 {
9915 let dns_out = Command::new("powershell")
9916 .args([
9917 "-NoProfile", "-NonInteractive", "-Command",
9918 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9919 ])
9920 .output()
9921 .ok()
9922 .and_then(|o| String::from_utf8(o.stdout).ok())
9923 .unwrap_or_default();
9924
9925 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9926 if dns_out.trim().is_empty() {
9927 out.push_str(" (unable to retrieve)\n");
9928 } else {
9929 for line in dns_out.lines() {
9930 if line.trim().is_empty() {
9931 continue;
9932 }
9933 let mut annotation = "";
9934 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9935 annotation = " <- Google Public DNS";
9936 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9937 annotation = " <- Cloudflare DNS";
9938 } else if line.contains("9.9.9.9") {
9939 annotation = " <- Quad9";
9940 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9941 annotation = " <- OpenDNS";
9942 }
9943 out.push_str(line);
9944 out.push_str(annotation);
9945 out.push('\n');
9946 }
9947 }
9948
9949 let doh_out = Command::new("powershell")
9950 .args([
9951 "-NoProfile", "-NonInteractive", "-Command",
9952 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
9953 ])
9954 .output()
9955 .ok()
9956 .and_then(|o| String::from_utf8(o.stdout).ok())
9957 .unwrap_or_default();
9958
9959 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9960 if doh_out.trim().is_empty() {
9961 out.push_str(" Not configured (plain DNS).\n");
9962 } else {
9963 out.push_str(doh_out.trim());
9964 out.push('\n');
9965 }
9966
9967 let suffixes = Command::new("powershell")
9968 .args([
9969 "-NoProfile", "-NonInteractive", "-Command",
9970 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
9971 ])
9972 .output()
9973 .ok()
9974 .and_then(|o| String::from_utf8(o.stdout).ok())
9975 .unwrap_or_default();
9976
9977 if !suffixes.trim().is_empty() {
9978 out.push_str("\n=== DNS Search Suffix List ===\n");
9979 out.push_str(suffixes.trim());
9980 out.push('\n');
9981 }
9982 }
9983
9984 #[cfg(not(target_os = "windows"))]
9985 {
9986 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9987 out.push_str("=== /etc/resolv.conf ===\n");
9988 if resolv.is_empty() {
9989 out.push_str(" Not found.\n");
9990 } else {
9991 for line in resolv.lines() {
9992 if !line.trim().is_empty() && !line.starts_with('#') {
9993 out.push_str(&format!(" {}\n", line));
9994 }
9995 }
9996 }
9997 let resolved_out = Command::new("resolvectl")
9998 .args(["status", "--no-pager"])
9999 .output()
10000 .ok()
10001 .and_then(|o| String::from_utf8(o.stdout).ok())
10002 .unwrap_or_default();
10003 if !resolved_out.is_empty() {
10004 out.push_str("\n=== systemd-resolved ===\n");
10005 for line in resolved_out.lines().take(30) {
10006 out.push_str(&format!(" {}\n", line));
10007 }
10008 }
10009 }
10010
10011 Ok(out.trim_end().to_string())
10012}
10013
10014fn inspect_bitlocker() -> Result<String, String> {
10015 let mut out = String::from("Host inspection: bitlocker\n\n");
10016
10017 #[cfg(target_os = "windows")]
10018 {
10019 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10020 let output = Command::new("powershell")
10021 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10022 .output()
10023 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10024
10025 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10026 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10027
10028 if !stdout.trim().is_empty() {
10029 out.push_str("=== BitLocker Volumes ===\n");
10030 for line in stdout.lines() {
10031 out.push_str(&format!(" {}\n", line));
10032 }
10033 } else if !stderr.trim().is_empty() {
10034 if stderr.contains("Access is denied") {
10035 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10036 } else {
10037 out.push_str(&format!(
10038 "Error retrieving BitLocker info: {}\n",
10039 stderr.trim()
10040 ));
10041 }
10042 } else {
10043 out.push_str("No BitLocker volumes detected or access denied.\n");
10044 }
10045 }
10046
10047 #[cfg(not(target_os = "windows"))]
10048 {
10049 out.push_str(
10050 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10051 );
10052 let lsblk = Command::new("lsblk")
10053 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10054 .output()
10055 .ok()
10056 .and_then(|o| String::from_utf8(o.stdout).ok())
10057 .unwrap_or_default();
10058 if lsblk.contains("crypto_LUKS") {
10059 out.push_str("=== LUKS Encrypted Volumes ===\n");
10060 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10061 out.push_str(&format!(" {}\n", line));
10062 }
10063 } else {
10064 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10065 }
10066 }
10067
10068 Ok(out.trim_end().to_string())
10069}
10070
10071fn inspect_rdp() -> Result<String, String> {
10072 let mut out = String::from("Host inspection: rdp\n\n");
10073
10074 #[cfg(target_os = "windows")]
10075 {
10076 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10077 let f_deny = Command::new("powershell")
10078 .args([
10079 "-NoProfile",
10080 "-Command",
10081 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10082 ])
10083 .output()
10084 .ok()
10085 .and_then(|o| String::from_utf8(o.stdout).ok())
10086 .unwrap_or_default()
10087 .trim()
10088 .to_string();
10089
10090 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10091 out.push_str(&format!("=== RDP Status: {} ===\n", status));
10092
10093 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"])
10094 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10095 out.push_str(&format!(
10096 " Port: {}\n",
10097 if port.is_empty() {
10098 "3389 (default)"
10099 } else {
10100 &port
10101 }
10102 ));
10103
10104 let nla = Command::new("powershell")
10105 .args([
10106 "-NoProfile",
10107 "-Command",
10108 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10109 ])
10110 .output()
10111 .ok()
10112 .and_then(|o| String::from_utf8(o.stdout).ok())
10113 .unwrap_or_default()
10114 .trim()
10115 .to_string();
10116 out.push_str(&format!(
10117 " NLA Required: {}\n",
10118 if nla == "1" { "Yes" } else { "No" }
10119 ));
10120
10121 let rdp_tcp_path =
10122 "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10123 let sec_layer = Command::new("powershell")
10124 .args([
10125 "-NoProfile",
10126 "-Command",
10127 &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10128 ])
10129 .output()
10130 .ok()
10131 .and_then(|o| String::from_utf8(o.stdout).ok())
10132 .unwrap_or_default()
10133 .trim()
10134 .to_string();
10135 let sec_label = match sec_layer.as_str() {
10136 "0" => "RDP Security (no SSL)",
10137 "1" => "Negotiate (prefer TLS)",
10138 "2" => "SSL/TLS required",
10139 _ => &sec_layer,
10140 };
10141 out.push_str(&format!(
10142 " Security Layer: {} ({})\n",
10143 sec_layer, sec_label
10144 ));
10145
10146 let enc_level = Command::new("powershell")
10147 .args([
10148 "-NoProfile",
10149 "-Command",
10150 &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10151 ])
10152 .output()
10153 .ok()
10154 .and_then(|o| String::from_utf8(o.stdout).ok())
10155 .unwrap_or_default()
10156 .trim()
10157 .to_string();
10158 let enc_label = match enc_level.as_str() {
10159 "1" => "Low",
10160 "2" => "Client Compatible",
10161 "3" => "High",
10162 "4" => "FIPS Compliant",
10163 _ => "Unknown",
10164 };
10165 out.push_str(&format!(
10166 " Encryption Level: {} ({})\n",
10167 enc_level, enc_label
10168 ));
10169
10170 out.push_str("\n=== Active Sessions ===\n");
10171 let qwinsta = Command::new("qwinsta")
10172 .output()
10173 .ok()
10174 .and_then(|o| String::from_utf8(o.stdout).ok())
10175 .unwrap_or_default();
10176 if qwinsta.trim().is_empty() {
10177 out.push_str(" No active sessions listed.\n");
10178 } else {
10179 for line in qwinsta.lines() {
10180 out.push_str(&format!(" {}\n", line));
10181 }
10182 }
10183
10184 out.push_str("\n=== Firewall Rule Check ===\n");
10185 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))\" }"])
10186 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10187 if fw.trim().is_empty() {
10188 out.push_str(" No enabled RDP firewall rules found.\n");
10189 } else {
10190 out.push_str(fw.trim_end());
10191 out.push('\n');
10192 }
10193 }
10194
10195 #[cfg(not(target_os = "windows"))]
10196 {
10197 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10198 let ss = Command::new("ss")
10199 .args(["-tlnp"])
10200 .output()
10201 .ok()
10202 .and_then(|o| String::from_utf8(o.stdout).ok())
10203 .unwrap_or_default();
10204 let matches: Vec<&str> = ss
10205 .lines()
10206 .filter(|l| l.contains(":3389") || l.contains(":590"))
10207 .collect();
10208 if matches.is_empty() {
10209 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
10210 } else {
10211 for m in matches {
10212 out.push_str(&format!(" {}\n", m));
10213 }
10214 }
10215 }
10216
10217 Ok(out.trim_end().to_string())
10218}
10219
10220fn inspect_shadow_copies() -> Result<String, String> {
10221 let mut out = String::from("Host inspection: shadow_copies\n\n");
10222
10223 #[cfg(target_os = "windows")]
10224 {
10225 let output = Command::new("vssadmin")
10226 .args(["list", "shadows"])
10227 .output()
10228 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10229 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10230
10231 if stdout.contains("No items found") || stdout.trim().is_empty() {
10232 out.push_str("No Volume Shadow Copies found.\n");
10233 } else {
10234 out.push_str("=== Volume Shadow Copies ===\n");
10235 for line in stdout.lines().take(50) {
10236 if line.contains("Creation Time:")
10237 || line.contains("Contents:")
10238 || line.contains("Volume Name:")
10239 {
10240 out.push_str(&format!(" {}\n", line.trim()));
10241 }
10242 }
10243 }
10244
10245 let age_script = r#"
10247try {
10248 $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10249 if ($snaps) {
10250 $newest = $snaps[0]
10251 $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10252 $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10253 $count = @($snaps).Count
10254 "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm')) ($age days ago) — $count total snapshots"
10255 } else { "No snapshots found via WMI." }
10256} catch { "WMI snapshot query unavailable: $_" }
10257"#;
10258 if let Ok(age_out) = Command::new("powershell")
10259 .args(["-NoProfile", "-Command", age_script])
10260 .output()
10261 {
10262 let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10263 if !age_text.is_empty() {
10264 out.push_str("\n=== Snapshot Age ===\n");
10265 out.push_str(&format!(" {}\n", age_text));
10266 }
10267 }
10268
10269 out.push_str("\n=== Shadow Copy Storage ===\n");
10270 let storage_out = Command::new("vssadmin")
10271 .args(["list", "shadowstorage"])
10272 .output()
10273 .ok();
10274 if let Some(o) = storage_out {
10275 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10276 for line in stdout.lines() {
10277 if line.contains("Used Shadow Copy Storage space:")
10278 || line.contains("Max Shadow Copy Storage space:")
10279 {
10280 out.push_str(&format!(" {}\n", line.trim()));
10281 }
10282 }
10283 }
10284 }
10285
10286 #[cfg(not(target_os = "windows"))]
10287 {
10288 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10289 let lvs = Command::new("lvs")
10290 .output()
10291 .ok()
10292 .and_then(|o| String::from_utf8(o.stdout).ok())
10293 .unwrap_or_default();
10294 if !lvs.is_empty() {
10295 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10296 out.push_str(&lvs);
10297 } else {
10298 out.push_str("No LVM volumes detected.\n");
10299 }
10300 }
10301
10302 Ok(out.trim_end().to_string())
10303}
10304
10305fn inspect_pagefile() -> Result<String, String> {
10306 let mut out = String::from("Host inspection: pagefile\n\n");
10307
10308 #[cfg(target_os = "windows")]
10309 {
10310 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)\" }";
10311 let output = Command::new("powershell")
10312 .args(["-NoProfile", "-Command", ps_cmd])
10313 .output()
10314 .ok()
10315 .and_then(|o| String::from_utf8(o.stdout).ok())
10316 .unwrap_or_default();
10317
10318 if output.trim().is_empty() {
10319 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10320 let managed = Command::new("powershell")
10321 .args([
10322 "-NoProfile",
10323 "-Command",
10324 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10325 ])
10326 .output()
10327 .ok()
10328 .and_then(|o| String::from_utf8(o.stdout).ok())
10329 .unwrap_or_default()
10330 .trim()
10331 .to_string();
10332 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
10333 } else {
10334 out.push_str("=== Page File Usage ===\n");
10335 out.push_str(&output);
10336 }
10337 }
10338
10339 #[cfg(not(target_os = "windows"))]
10340 {
10341 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10342 let swap = Command::new("swapon")
10343 .args(["--show"])
10344 .output()
10345 .ok()
10346 .and_then(|o| String::from_utf8(o.stdout).ok())
10347 .unwrap_or_default();
10348 if swap.is_empty() {
10349 let free = Command::new("free")
10350 .args(["-h"])
10351 .output()
10352 .ok()
10353 .and_then(|o| String::from_utf8(o.stdout).ok())
10354 .unwrap_or_default();
10355 out.push_str(&free);
10356 } else {
10357 out.push_str(&swap);
10358 }
10359 }
10360
10361 Ok(out.trim_end().to_string())
10362}
10363
10364fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10365 let mut out = String::from("Host inspection: windows_features\n\n");
10366
10367 #[cfg(target_os = "windows")]
10368 {
10369 out.push_str("=== Quick Check: Notable Features ===\n");
10370 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10371 let output = Command::new("powershell")
10372 .args(["-NoProfile", "-Command", quick_ps])
10373 .output()
10374 .ok();
10375
10376 if let Some(o) = output {
10377 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10378 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10379
10380 if !stdout.trim().is_empty() {
10381 for f in stdout.lines() {
10382 out.push_str(&format!(" [ENABLED] {}\n", f));
10383 }
10384 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10385 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10386 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10387 out.push_str(
10388 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10389 );
10390 }
10391 }
10392
10393 out.push_str(&format!(
10394 "\n=== All Enabled Features (capped at {}) ===\n",
10395 max_entries
10396 ));
10397 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10398 let all_out = Command::new("powershell")
10399 .args(["-NoProfile", "-Command", &all_ps])
10400 .output()
10401 .ok();
10402 if let Some(o) = all_out {
10403 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10404 if !stdout.trim().is_empty() {
10405 out.push_str(&stdout);
10406 }
10407 }
10408 }
10409
10410 #[cfg(not(target_os = "windows"))]
10411 {
10412 let _ = max_entries;
10413 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10414 }
10415
10416 Ok(out.trim_end().to_string())
10417}
10418
10419fn inspect_audio(max_entries: usize) -> Result<String, String> {
10420 let mut out = String::from("Host inspection: audio\n\n");
10421
10422 #[cfg(target_os = "windows")]
10423 {
10424 let n = max_entries.clamp(5, 20);
10425 let services = collect_services().unwrap_or_default();
10426 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10427 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10428
10429 let core_services: Vec<&ServiceEntry> = services
10430 .iter()
10431 .filter(|entry| {
10432 core_service_names
10433 .iter()
10434 .any(|name| entry.name.eq_ignore_ascii_case(name))
10435 })
10436 .collect();
10437 let bluetooth_audio_services: Vec<&ServiceEntry> = services
10438 .iter()
10439 .filter(|entry| {
10440 bluetooth_audio_service_names
10441 .iter()
10442 .any(|name| entry.name.eq_ignore_ascii_case(name))
10443 })
10444 .collect();
10445
10446 let probe_script = r#"
10447$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10448 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10449$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10450 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10451$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10452 Select-Object Name, Status, Manufacturer, PNPDeviceID)
10453[pscustomobject]@{
10454 Media = $media
10455 Endpoints = $endpoints
10456 SoundDevices = $sound
10457} | ConvertTo-Json -Compress -Depth 4
10458"#;
10459 let probe_raw = Command::new("powershell")
10460 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10461 .output()
10462 .ok()
10463 .and_then(|o| String::from_utf8(o.stdout).ok())
10464 .unwrap_or_default();
10465 let probe_loaded = !probe_raw.trim().is_empty();
10466 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10467
10468 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10469 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10470 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10471
10472 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10473 .iter()
10474 .filter(|device| !is_microphone_like_name(&device.name))
10475 .collect();
10476 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10477 .iter()
10478 .filter(|device| is_microphone_like_name(&device.name))
10479 .collect();
10480 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10481 .iter()
10482 .filter(|device| is_bluetooth_like_name(&device.name))
10483 .collect();
10484 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10485 .iter()
10486 .filter(|device| windows_device_has_issue(device))
10487 .collect();
10488 let media_problems: Vec<&WindowsPnpDevice> = media_devices
10489 .iter()
10490 .filter(|device| windows_device_has_issue(device))
10491 .collect();
10492 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10493 .iter()
10494 .filter(|device| windows_sound_device_has_issue(device))
10495 .collect();
10496
10497 let mut findings = Vec::new();
10498
10499 let stopped_core_services: Vec<&ServiceEntry> = core_services
10500 .iter()
10501 .copied()
10502 .filter(|service| !service_is_running(service))
10503 .collect();
10504 if !stopped_core_services.is_empty() {
10505 let names = stopped_core_services
10506 .iter()
10507 .map(|service| service.name.as_str())
10508 .collect::<Vec<_>>()
10509 .join(", ");
10510 findings.push(AuditFinding {
10511 finding: format!("Core audio services are not running: {names}"),
10512 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10513 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10514 });
10515 }
10516
10517 if probe_loaded
10518 && endpoints.is_empty()
10519 && media_devices.is_empty()
10520 && sound_devices.is_empty()
10521 {
10522 findings.push(AuditFinding {
10523 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10524 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(),
10525 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(),
10526 });
10527 }
10528
10529 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10530 {
10531 let mut problem_labels = Vec::new();
10532 problem_labels.extend(
10533 endpoint_problems
10534 .iter()
10535 .take(3)
10536 .map(|device| device.name.clone()),
10537 );
10538 problem_labels.extend(
10539 media_problems
10540 .iter()
10541 .take(3)
10542 .map(|device| device.name.clone()),
10543 );
10544 problem_labels.extend(
10545 sound_problems
10546 .iter()
10547 .take(3)
10548 .map(|device| device.name.clone()),
10549 );
10550 findings.push(AuditFinding {
10551 finding: format!(
10552 "Windows reports audio device issues for: {}",
10553 problem_labels.join(", ")
10554 ),
10555 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10556 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(),
10557 });
10558 }
10559
10560 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10561 .iter()
10562 .copied()
10563 .filter(|service| !service_is_running(service))
10564 .collect();
10565 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10566 let names = stopped_bt_audio_services
10567 .iter()
10568 .map(|service| service.name.as_str())
10569 .collect::<Vec<_>>()
10570 .join(", ");
10571 findings.push(AuditFinding {
10572 finding: format!(
10573 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10574 ),
10575 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10576 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10577 });
10578 }
10579
10580 out.push_str("=== Findings ===\n");
10581 if findings.is_empty() {
10582 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10583 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
10584 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");
10585 } else {
10586 for finding in &findings {
10587 out.push_str(&format!("- Finding: {}\n", finding.finding));
10588 out.push_str(&format!(" Impact: {}\n", finding.impact));
10589 out.push_str(&format!(" Fix: {}\n", finding.fix));
10590 }
10591 }
10592
10593 out.push_str("\n=== Audio services ===\n");
10594 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10595 out.push_str(
10596 "- No Windows audio services were retrieved from the service inventory.\n",
10597 );
10598 } else {
10599 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10600 out.push_str(&format!(
10601 "- {} | Status: {} | Startup: {}\n",
10602 service.name,
10603 service.status,
10604 service.startup.as_deref().unwrap_or("Unknown")
10605 ));
10606 }
10607 }
10608
10609 out.push_str("\n=== Playback and recording endpoints ===\n");
10610 if !probe_loaded {
10611 out.push_str("- Windows endpoint inventory probe returned no data.\n");
10612 } else if endpoints.is_empty() {
10613 out.push_str("- No audio endpoints detected.\n");
10614 } else {
10615 out.push_str(&format!(
10616 "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
10617 playback_endpoints.len(),
10618 recording_endpoints.len()
10619 ));
10620 for device in playback_endpoints.iter().take(n) {
10621 out.push_str(&format!(
10622 "- [PLAYBACK] {} | Status: {}{}\n",
10623 device.name,
10624 device.status,
10625 device
10626 .problem
10627 .filter(|problem| *problem != 0)
10628 .map(|problem| format!(" | ProblemCode: {problem}"))
10629 .unwrap_or_default()
10630 ));
10631 }
10632 for device in recording_endpoints.iter().take(n) {
10633 out.push_str(&format!(
10634 "- [MIC] {} | Status: {}{}\n",
10635 device.name,
10636 device.status,
10637 device
10638 .problem
10639 .filter(|problem| *problem != 0)
10640 .map(|problem| format!(" | ProblemCode: {problem}"))
10641 .unwrap_or_default()
10642 ));
10643 }
10644 }
10645
10646 out.push_str("\n=== Sound hardware devices ===\n");
10647 if sound_devices.is_empty() {
10648 out.push_str("- No Win32_SoundDevice entries were returned.\n");
10649 } else {
10650 for device in sound_devices.iter().take(n) {
10651 out.push_str(&format!(
10652 "- {} | Status: {}{}\n",
10653 device.name,
10654 device.status,
10655 device
10656 .manufacturer
10657 .as_deref()
10658 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10659 .unwrap_or_default()
10660 ));
10661 }
10662 }
10663
10664 out.push_str("\n=== Media-class device inventory ===\n");
10665 if media_devices.is_empty() {
10666 out.push_str("- No media-class PnP devices were returned.\n");
10667 } else {
10668 for device in media_devices.iter().take(n) {
10669 out.push_str(&format!(
10670 "- {} | Status: {}{}\n",
10671 device.name,
10672 device.status,
10673 device
10674 .class_name
10675 .as_deref()
10676 .map(|class_name| format!(" | Class: {class_name}"))
10677 .unwrap_or_default()
10678 ));
10679 }
10680 }
10681 }
10682
10683 #[cfg(not(target_os = "windows"))]
10684 {
10685 let _ = max_entries;
10686 out.push_str(
10687 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10688 );
10689 out.push_str(
10690 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10691 );
10692 }
10693
10694 Ok(out.trim_end().to_string())
10695}
10696
10697fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10698 let mut out = String::from("Host inspection: bluetooth\n\n");
10699
10700 #[cfg(target_os = "windows")]
10701 {
10702 let n = max_entries.clamp(5, 20);
10703 let services = collect_services().unwrap_or_default();
10704 let bluetooth_services: Vec<&ServiceEntry> = services
10705 .iter()
10706 .filter(|entry| {
10707 entry.name.eq_ignore_ascii_case("bthserv")
10708 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10709 || entry.name.eq_ignore_ascii_case("BTAGService")
10710 || entry.name.starts_with("BluetoothUserService")
10711 || entry
10712 .display_name
10713 .as_deref()
10714 .unwrap_or("")
10715 .to_ascii_lowercase()
10716 .contains("bluetooth")
10717 })
10718 .collect();
10719
10720 let probe_script = r#"
10721$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10722 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10723$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10724 Where-Object {
10725 $_.Class -eq 'Bluetooth' -or
10726 $_.FriendlyName -match 'Bluetooth' -or
10727 $_.InstanceId -like 'BTH*'
10728 } |
10729 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10730$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10731 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10732 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10733[pscustomobject]@{
10734 Radios = $radios
10735 Devices = $devices
10736 AudioEndpoints = $audio
10737} | ConvertTo-Json -Compress -Depth 4
10738"#;
10739 let probe_raw = Command::new("powershell")
10740 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10741 .output()
10742 .ok()
10743 .and_then(|o| String::from_utf8(o.stdout).ok())
10744 .unwrap_or_default();
10745 let probe_loaded = !probe_raw.trim().is_empty();
10746 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10747
10748 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10749 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10750 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10751 let radio_problems: Vec<&WindowsPnpDevice> = radios
10752 .iter()
10753 .filter(|device| windows_device_has_issue(device))
10754 .collect();
10755 let device_problems: Vec<&WindowsPnpDevice> = devices
10756 .iter()
10757 .filter(|device| windows_device_has_issue(device))
10758 .collect();
10759
10760 let mut findings = Vec::new();
10761
10762 if probe_loaded && radios.is_empty() {
10763 findings.push(AuditFinding {
10764 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10765 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10766 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10767 });
10768 }
10769
10770 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10771 .iter()
10772 .copied()
10773 .filter(|service| !service_is_running(service))
10774 .collect();
10775 if !stopped_bluetooth_services.is_empty() {
10776 let names = stopped_bluetooth_services
10777 .iter()
10778 .map(|service| service.name.as_str())
10779 .collect::<Vec<_>>()
10780 .join(", ");
10781 findings.push(AuditFinding {
10782 finding: format!("Bluetooth-related services are not fully running: {names}"),
10783 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10784 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10785 });
10786 }
10787
10788 if !radio_problems.is_empty() || !device_problems.is_empty() {
10789 let problem_labels = radio_problems
10790 .iter()
10791 .chain(device_problems.iter())
10792 .take(5)
10793 .map(|device| device.name.as_str())
10794 .collect::<Vec<_>>()
10795 .join(", ");
10796 findings.push(AuditFinding {
10797 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10798 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10799 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(),
10800 });
10801 }
10802
10803 if !audio_endpoints.is_empty()
10804 && bluetooth_services
10805 .iter()
10806 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10807 && bluetooth_services
10808 .iter()
10809 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10810 .any(|service| !service_is_running(service))
10811 {
10812 findings.push(AuditFinding {
10813 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10814 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10815 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10816 });
10817 }
10818
10819 out.push_str("=== Findings ===\n");
10820 if findings.is_empty() {
10821 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10822 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10823 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");
10824 } else {
10825 for finding in &findings {
10826 out.push_str(&format!("- Finding: {}\n", finding.finding));
10827 out.push_str(&format!(" Impact: {}\n", finding.impact));
10828 out.push_str(&format!(" Fix: {}\n", finding.fix));
10829 }
10830 }
10831
10832 out.push_str("\n=== Bluetooth services ===\n");
10833 if bluetooth_services.is_empty() {
10834 out.push_str(
10835 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10836 );
10837 } else {
10838 for service in bluetooth_services.iter().take(n) {
10839 out.push_str(&format!(
10840 "- {} | Status: {} | Startup: {}\n",
10841 service.name,
10842 service.status,
10843 service.startup.as_deref().unwrap_or("Unknown")
10844 ));
10845 }
10846 }
10847
10848 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10849 if !probe_loaded {
10850 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10851 } else if radios.is_empty() {
10852 out.push_str("- No Bluetooth radios detected.\n");
10853 } else {
10854 for device in radios.iter().take(n) {
10855 out.push_str(&format!(
10856 "- {} | Status: {}{}\n",
10857 device.name,
10858 device.status,
10859 device
10860 .problem
10861 .filter(|problem| *problem != 0)
10862 .map(|problem| format!(" | ProblemCode: {problem}"))
10863 .unwrap_or_default()
10864 ));
10865 }
10866 }
10867
10868 out.push_str("\n=== Bluetooth-associated devices ===\n");
10869 if devices.is_empty() {
10870 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10871 } else {
10872 for device in devices.iter().take(n) {
10873 out.push_str(&format!(
10874 "- {} | Status: {}{}\n",
10875 device.name,
10876 device.status,
10877 device
10878 .class_name
10879 .as_deref()
10880 .map(|class_name| format!(" | Class: {class_name}"))
10881 .unwrap_or_default()
10882 ));
10883 }
10884 }
10885
10886 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10887 if audio_endpoints.is_empty() {
10888 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10889 } else {
10890 for device in audio_endpoints.iter().take(n) {
10891 out.push_str(&format!(
10892 "- {} | Status: {}{}\n",
10893 device.name,
10894 device.status,
10895 device
10896 .instance_id
10897 .as_deref()
10898 .map(|instance_id| format!(" | Instance: {instance_id}"))
10899 .unwrap_or_default()
10900 ));
10901 }
10902 }
10903 }
10904
10905 #[cfg(not(target_os = "windows"))]
10906 {
10907 let _ = max_entries;
10908 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10909 out.push_str(
10910 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10911 );
10912 }
10913
10914 Ok(out.trim_end().to_string())
10915}
10916
10917fn inspect_printers(max_entries: usize) -> Result<String, String> {
10918 let mut out = String::from("Host inspection: printers\n\n");
10919
10920 #[cfg(target_os = "windows")]
10921 {
10922 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)])
10923 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10924 if list.trim().is_empty() {
10925 out.push_str("No printers detected.\n");
10926 } else {
10927 out.push_str("=== Installed Printers ===\n");
10928 out.push_str(&list);
10929 }
10930
10931 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10932 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10933 if !jobs.trim().is_empty() {
10934 out.push_str("\n=== Active Print Jobs ===\n");
10935 out.push_str(&jobs);
10936 }
10937 }
10938
10939 #[cfg(not(target_os = "windows"))]
10940 {
10941 let _ = max_entries;
10942 out.push_str("Checking LPSTAT for printers...\n");
10943 let lpstat = Command::new("lpstat")
10944 .args(["-p", "-d"])
10945 .output()
10946 .ok()
10947 .and_then(|o| String::from_utf8(o.stdout).ok())
10948 .unwrap_or_default();
10949 if lpstat.is_empty() {
10950 out.push_str(" No CUPS/LP printers found.\n");
10951 } else {
10952 out.push_str(&lpstat);
10953 }
10954 }
10955
10956 Ok(out.trim_end().to_string())
10957}
10958
10959fn inspect_winrm() -> Result<String, String> {
10960 let mut out = String::from("Host inspection: winrm\n\n");
10961
10962 #[cfg(target_os = "windows")]
10963 {
10964 let svc = Command::new("powershell")
10965 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10966 .output()
10967 .ok()
10968 .and_then(|o| String::from_utf8(o.stdout).ok())
10969 .unwrap_or_default()
10970 .trim()
10971 .to_string();
10972 out.push_str(&format!(
10973 "WinRM Service Status: {}\n\n",
10974 if svc.is_empty() { "NOT_FOUND" } else { &svc }
10975 ));
10976
10977 out.push_str("=== WinRM Listeners ===\n");
10978 let output = Command::new("powershell")
10979 .args([
10980 "-NoProfile",
10981 "-Command",
10982 "winrm enumerate winrm/config/listener 2>$null",
10983 ])
10984 .output()
10985 .ok();
10986 if let Some(o) = output {
10987 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10988 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10989
10990 if !stdout.trim().is_empty() {
10991 for line in stdout.lines() {
10992 if line.contains("Address =")
10993 || line.contains("Transport =")
10994 || line.contains("Port =")
10995 {
10996 out.push_str(&format!(" {}\n", line.trim()));
10997 }
10998 }
10999 } else if stderr.contains("Access is denied") {
11000 out.push_str(" Error: Access denied to WinRM configuration.\n");
11001 } else {
11002 out.push_str(" No listeners configured.\n");
11003 }
11004 }
11005
11006 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11007 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))\" }"])
11008 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11009 if test_out.trim().is_empty() {
11010 out.push_str(" WinRM not responding to local WS-Man requests.\n");
11011 } else {
11012 out.push_str(&test_out);
11013 }
11014 }
11015
11016 #[cfg(not(target_os = "windows"))]
11017 {
11018 out.push_str(
11019 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11020 );
11021 let ss = Command::new("ss")
11022 .args(["-tln"])
11023 .output()
11024 .ok()
11025 .and_then(|o| String::from_utf8(o.stdout).ok())
11026 .unwrap_or_default();
11027 if ss.contains(":5985") || ss.contains(":5986") {
11028 out.push_str(" WinRM ports (5985/5986) are listening.\n");
11029 } else {
11030 out.push_str(" WinRM ports not detected.\n");
11031 }
11032 }
11033
11034 Ok(out.trim_end().to_string())
11035}
11036
11037fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11038 let mut out = String::from("Host inspection: network_stats\n\n");
11039
11040 #[cfg(target_os = "windows")]
11041 {
11042 let ps_cmd = format!(
11043 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11044 Start-Sleep -Milliseconds 250; \
11045 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11046 $s2 | ForEach-Object {{ \
11047 $name = $_.Name; \
11048 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11049 if ($prev) {{ \
11050 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11051 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11052 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11053 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11054 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11055 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11056 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11057 }} \
11058 }}",
11059 max_entries
11060 );
11061 let output = Command::new("powershell")
11062 .args(["-NoProfile", "-Command", &ps_cmd])
11063 .output()
11064 .ok()
11065 .and_then(|o| String::from_utf8(o.stdout).ok())
11066 .unwrap_or_default();
11067 if output.trim().is_empty() {
11068 out.push_str("No network adapter statistics available.\n");
11069 } else {
11070 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11071 out.push_str(&output);
11072 }
11073
11074 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)\" } }"])
11075 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11076 if !discards.trim().is_empty() {
11077 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11078 out.push_str(&discards);
11079 }
11080 }
11081
11082 #[cfg(not(target_os = "windows"))]
11083 {
11084 let _ = max_entries;
11085 out.push_str("=== Network Stats (ip -s link) ===\n");
11086 let ip_s = Command::new("ip")
11087 .args(["-s", "link"])
11088 .output()
11089 .ok()
11090 .and_then(|o| String::from_utf8(o.stdout).ok())
11091 .unwrap_or_default();
11092 if ip_s.is_empty() {
11093 let netstat = Command::new("netstat")
11094 .args(["-i"])
11095 .output()
11096 .ok()
11097 .and_then(|o| String::from_utf8(o.stdout).ok())
11098 .unwrap_or_default();
11099 out.push_str(&netstat);
11100 } else {
11101 out.push_str(&ip_s);
11102 }
11103 }
11104
11105 Ok(out.trim_end().to_string())
11106}
11107
11108fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11109 let mut out = String::from("Host inspection: udp_ports\n\n");
11110
11111 #[cfg(target_os = "windows")]
11112 {
11113 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);
11114 let output = Command::new("powershell")
11115 .args(["-NoProfile", "-Command", &ps_cmd])
11116 .output()
11117 .ok();
11118
11119 if let Some(o) = output {
11120 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11121 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11122
11123 if !stdout.trim().is_empty() {
11124 out.push_str("=== UDP Listeners (Local:Port) ===\n");
11125 for line in stdout.lines() {
11126 let mut note = "";
11127 if line.contains(":53 ") {
11128 note = " [DNS]";
11129 } else if line.contains(":67 ") || line.contains(":68 ") {
11130 note = " [DHCP]";
11131 } else if line.contains(":123 ") {
11132 note = " [NTP]";
11133 } else if line.contains(":161 ") {
11134 note = " [SNMP]";
11135 } else if line.contains(":1900 ") {
11136 note = " [SSDP/UPnP]";
11137 } else if line.contains(":5353 ") {
11138 note = " [mDNS]";
11139 }
11140
11141 out.push_str(&format!("{}{}\n", line, note));
11142 }
11143 } else if stderr.contains("Access is denied") {
11144 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11145 } else {
11146 out.push_str("No UDP listeners detected.\n");
11147 }
11148 }
11149 }
11150
11151 #[cfg(not(target_os = "windows"))]
11152 {
11153 let ss_out = Command::new("ss")
11154 .args(["-ulnp"])
11155 .output()
11156 .ok()
11157 .and_then(|o| String::from_utf8(o.stdout).ok())
11158 .unwrap_or_default();
11159 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11160 if ss_out.is_empty() {
11161 let netstat_out = Command::new("netstat")
11162 .args(["-ulnp"])
11163 .output()
11164 .ok()
11165 .and_then(|o| String::from_utf8(o.stdout).ok())
11166 .unwrap_or_default();
11167 if netstat_out.is_empty() {
11168 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
11169 } else {
11170 for line in netstat_out.lines().take(max_entries) {
11171 out.push_str(&format!(" {}\n", line));
11172 }
11173 }
11174 } else {
11175 for line in ss_out.lines().take(max_entries) {
11176 out.push_str(&format!(" {}\n", line));
11177 }
11178 }
11179 }
11180
11181 Ok(out.trim_end().to_string())
11182}
11183
11184fn inspect_gpo() -> Result<String, String> {
11185 let mut out = String::from("Host inspection: gpo\n\n");
11186
11187 #[cfg(target_os = "windows")]
11188 {
11189 let output = Command::new("gpresult")
11190 .args(["/r", "/scope", "computer"])
11191 .output()
11192 .ok();
11193
11194 if let Some(o) = output {
11195 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11196 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11197
11198 if stdout.contains("Applied Group Policy Objects") {
11199 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11200 let mut capture = false;
11201 for line in stdout.lines() {
11202 if line.contains("Applied Group Policy Objects") {
11203 capture = true;
11204 } else if capture && line.contains("The following GPOs were not applied") {
11205 break;
11206 }
11207 if capture && !line.trim().is_empty() {
11208 out.push_str(&format!(" {}\n", line.trim()));
11209 }
11210 }
11211 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11212 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11213 } else {
11214 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11215 }
11216 }
11217 }
11218
11219 #[cfg(not(target_os = "windows"))]
11220 {
11221 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11222 }
11223
11224 Ok(out.trim_end().to_string())
11225}
11226
11227fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11228 let mut out = String::from("Host inspection: certificates\n\n");
11229
11230 #[cfg(target_os = "windows")]
11231 {
11232 let ps_cmd = format!(
11233 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11234 $days = ($_.NotAfter - (Get-Date)).Days; \
11235 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11236 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11237 }}",
11238 max_entries
11239 );
11240 let output = Command::new("powershell")
11241 .args(["-NoProfile", "-Command", &ps_cmd])
11242 .output()
11243 .ok();
11244
11245 if let Some(o) = output {
11246 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11247 if !stdout.trim().is_empty() {
11248 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11249 out.push_str(&stdout);
11250 } else {
11251 out.push_str("No certificates found in the Local Machine Personal store.\n");
11252 }
11253 }
11254 }
11255
11256 #[cfg(not(target_os = "windows"))]
11257 {
11258 let _ = max_entries;
11259 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11260 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11262 if Path::new(path).exists() {
11263 out.push_str(&format!(" Cert directory found: {}\n", path));
11264 }
11265 }
11266 }
11267
11268 Ok(out.trim_end().to_string())
11269}
11270
11271fn inspect_integrity() -> Result<String, String> {
11272 let mut out = String::from("Host inspection: integrity\n\n");
11273
11274 #[cfg(target_os = "windows")]
11275 {
11276 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11277 let output = Command::new("powershell")
11278 .args(["-NoProfile", "-Command", &ps_cmd])
11279 .output()
11280 .ok();
11281
11282 if let Some(o) = output {
11283 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11284 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11285 out.push_str("=== Windows Component Store Health (CBS) ===\n");
11286 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11287 let repair = val
11288 .get("AutoRepairNeeded")
11289 .and_then(|v| v.as_u64())
11290 .unwrap_or(0);
11291
11292 out.push_str(&format!(
11293 " Corruption Detected: {}\n",
11294 if corrupt != 0 {
11295 "YES (SFC/DISM recommended)"
11296 } else {
11297 "No"
11298 }
11299 ));
11300 out.push_str(&format!(
11301 " Auto-Repair Needed: {}\n",
11302 if repair != 0 { "YES" } else { "No" }
11303 ));
11304
11305 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11306 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
11307 }
11308 } else {
11309 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11310 }
11311 }
11312
11313 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11314 out.push_str(
11315 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11316 );
11317 }
11318 }
11319
11320 #[cfg(not(target_os = "windows"))]
11321 {
11322 out.push_str("System integrity check (Linux)\n\n");
11323 let pkg_check = Command::new("rpm")
11324 .args(["-Va"])
11325 .output()
11326 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11327 .ok();
11328 if let Some(o) = pkg_check {
11329 out.push_str(" Package verification system active.\n");
11330 if o.status.success() {
11331 out.push_str(" No major package integrity issues detected.\n");
11332 }
11333 }
11334 }
11335
11336 Ok(out.trim_end().to_string())
11337}
11338
11339fn inspect_domain() -> Result<String, String> {
11340 let mut out = String::from("Host inspection: domain\n\n");
11341
11342 #[cfg(target_os = "windows")]
11343 {
11344 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11345 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11346 let output = Command::new("powershell")
11347 .args(["-NoProfile", "-Command", &ps_cmd])
11348 .output()
11349 .ok();
11350
11351 if let Some(o) = output {
11352 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11353 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11354 let part_of_domain = val
11355 .get("PartOfDomain")
11356 .and_then(|v| v.as_bool())
11357 .unwrap_or(false);
11358 let domain = val
11359 .get("Domain")
11360 .and_then(|v| v.as_str())
11361 .unwrap_or("Unknown");
11362 let workgroup = val
11363 .get("Workgroup")
11364 .and_then(|v| v.as_str())
11365 .unwrap_or("Unknown");
11366
11367 out.push_str(&format!(
11368 " Join Status: {}\n",
11369 if part_of_domain {
11370 "DOMAIN JOINED"
11371 } else {
11372 "WORKGROUP"
11373 }
11374 ));
11375 if part_of_domain {
11376 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
11377 } else {
11378 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
11379 }
11380
11381 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11382 out.push_str(&format!(" NetBIOS Name: {}\n", name));
11383 }
11384 } else {
11385 out.push_str(" Domain identity data unavailable from WMI.\n");
11386 }
11387 } else {
11388 out.push_str(" Domain identity data unavailable from WMI.\n");
11389 }
11390 }
11391
11392 #[cfg(not(target_os = "windows"))]
11393 {
11394 let domainname = Command::new("domainname")
11395 .output()
11396 .ok()
11397 .and_then(|o| String::from_utf8(o.stdout).ok())
11398 .unwrap_or_default();
11399 out.push_str("=== Linux Domain Identity ===\n");
11400 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11401 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
11402 } else {
11403 out.push_str(" No NIS domain configured.\n");
11404 }
11405 }
11406
11407 Ok(out.trim_end().to_string())
11408}
11409
11410fn inspect_device_health() -> Result<String, String> {
11411 let mut out = String::from("Host inspection: device_health\n\n");
11412
11413 #[cfg(target_os = "windows")]
11414 {
11415 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)\" }";
11416 let output = Command::new("powershell")
11417 .args(["-NoProfile", "-Command", ps_cmd])
11418 .output()
11419 .ok()
11420 .and_then(|o| String::from_utf8(o.stdout).ok())
11421 .unwrap_or_default();
11422
11423 if output.trim().is_empty() {
11424 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11425 } else {
11426 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11427 out.push_str(&output);
11428 out.push_str(
11429 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11430 );
11431 }
11432 }
11433
11434 #[cfg(not(target_os = "windows"))]
11435 {
11436 out.push_str("Checking dmesg for hardware errors...\n");
11437 let dmesg = Command::new("dmesg")
11438 .args(["--level=err,crit,alert"])
11439 .output()
11440 .ok()
11441 .and_then(|o| String::from_utf8(o.stdout).ok())
11442 .unwrap_or_default();
11443 if dmesg.is_empty() {
11444 out.push_str(" No critical hardware errors found in dmesg.\n");
11445 } else {
11446 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
11447 }
11448 }
11449
11450 Ok(out.trim_end().to_string())
11451}
11452
11453fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11454 let mut out = String::from("Host inspection: drivers\n\n");
11455
11456 #[cfg(target_os = "windows")]
11457 {
11458 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11459 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);
11460 let output = Command::new("powershell")
11461 .args(["-NoProfile", "-Command", &ps_cmd])
11462 .output()
11463 .ok()
11464 .and_then(|o| String::from_utf8(o.stdout).ok())
11465 .unwrap_or_default();
11466
11467 if output.trim().is_empty() {
11468 out.push_str(" No drivers retrieved via WMI.\n");
11469 } else {
11470 out.push_str(&output);
11471 }
11472 }
11473
11474 #[cfg(not(target_os = "windows"))]
11475 {
11476 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11477 let lsmod = Command::new("lsmod")
11478 .output()
11479 .ok()
11480 .and_then(|o| String::from_utf8(o.stdout).ok())
11481 .unwrap_or_default();
11482 out.push_str(
11483 &lsmod
11484 .lines()
11485 .take(max_entries)
11486 .collect::<Vec<_>>()
11487 .join("\n"),
11488 );
11489 }
11490
11491 Ok(out.trim_end().to_string())
11492}
11493
11494fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11495 let mut out = String::from("Host inspection: peripherals\n\n");
11496
11497 #[cfg(target_os = "windows")]
11498 {
11499 let _ = max_entries;
11500 out.push_str("=== USB Controllers & Hubs ===\n");
11501 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
11502 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11503 out.push_str(if usb.is_empty() {
11504 " None detected.\n"
11505 } else {
11506 &usb
11507 });
11508
11509 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11510 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
11511 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11512 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
11513 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11514 out.push_str(&kb);
11515 out.push_str(&mouse);
11516
11517 out.push_str("\n=== Connected Monitors (WMI) ===\n");
11518 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11519 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11520 out.push_str(if mon.is_empty() {
11521 " No active monitors identified via WMI.\n"
11522 } else {
11523 &mon
11524 });
11525 }
11526
11527 #[cfg(not(target_os = "windows"))]
11528 {
11529 out.push_str("=== Connected USB Devices (lsusb) ===\n");
11530 let lsusb = Command::new("lsusb")
11531 .output()
11532 .ok()
11533 .and_then(|o| String::from_utf8(o.stdout).ok())
11534 .unwrap_or_default();
11535 out.push_str(
11536 &lsusb
11537 .lines()
11538 .take(max_entries)
11539 .collect::<Vec<_>>()
11540 .join("\n"),
11541 );
11542 }
11543
11544 Ok(out.trim_end().to_string())
11545}
11546
11547fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11548 let mut out = String::from("Host inspection: sessions\n\n");
11549
11550 #[cfg(target_os = "windows")]
11551 {
11552 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11553 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11554 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11555}"#;
11556 if let Ok(o) = Command::new("powershell")
11557 .args(["-NoProfile", "-Command", script])
11558 .output()
11559 {
11560 let text = String::from_utf8_lossy(&o.stdout);
11561 let lines: Vec<&str> = text.lines().collect();
11562 if lines.is_empty() {
11563 out.push_str(" No active logon sessions enumerated via WMI.\n");
11564 } else {
11565 for line in lines
11566 .iter()
11567 .take(max_entries)
11568 .filter(|l| !l.trim().is_empty())
11569 {
11570 let parts: Vec<&str> = line.trim().split('|').collect();
11571 if parts.len() == 4 {
11572 let logon_type = match parts[2] {
11573 "2" => "Interactive",
11574 "3" => "Network",
11575 "4" => "Batch",
11576 "5" => "Service",
11577 "7" => "Unlock",
11578 "8" => "NetworkCleartext",
11579 "9" => "NewCredentials",
11580 "10" => "RemoteInteractive",
11581 "11" => "CachedInteractive",
11582 _ => "Other",
11583 };
11584 out.push_str(&format!(
11585 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
11586 parts[0], logon_type, parts[1], parts[3]
11587 ));
11588 }
11589 }
11590 }
11591 } else {
11592 out.push_str(" Active logon session data unavailable from WMI.\n");
11593 }
11594 }
11595
11596 #[cfg(not(target_os = "windows"))]
11597 {
11598 out.push_str("=== Logged-in Users (who) ===\n");
11599 let who = Command::new("who")
11600 .output()
11601 .ok()
11602 .and_then(|o| String::from_utf8(o.stdout).ok())
11603 .unwrap_or_default();
11604 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
11605 }
11606
11607 Ok(out.trim_end().to_string())
11608}
11609
11610async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11611 let mut out = String::from("Host inspection: disk_benchmark\n\n");
11612 let mut final_path = path;
11613
11614 if !final_path.exists() {
11615 if let Ok(current_exe) = std::env::current_exe() {
11616 out.push_str(&format!(
11617 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
11618 final_path.display()
11619 ));
11620 final_path = current_exe;
11621 } else {
11622 return Err(format!("Target not found: {}", final_path.display()));
11623 }
11624 }
11625
11626 let target = if final_path.is_dir() {
11627 let mut target_file = final_path.join("Cargo.toml");
11629 if !target_file.exists() {
11630 target_file = final_path.join("README.md");
11631 }
11632 if !target_file.exists() {
11633 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11634 }
11635 target_file
11636 } else {
11637 final_path
11638 };
11639
11640 out.push_str(&format!("Target: {}\n", target.display()));
11641 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11642
11643 #[cfg(target_os = "windows")]
11644 {
11645 let script = format!(
11646 r#"
11647$target = "{}"
11648if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11649
11650$diskQueue = @()
11651$readStats = @()
11652$startTime = Get-Date
11653$duration = 5
11654
11655# Background reader job
11656$job = Start-Job -ScriptBlock {{
11657 param($t, $d)
11658 $stop = (Get-Date).AddSeconds($d)
11659 while ((Get-Date) -lt $stop) {{
11660 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11661 }}
11662}} -ArgumentList $target, $duration
11663
11664# Metrics collector loop
11665$stopTime = (Get-Date).AddSeconds($duration)
11666while ((Get-Date) -lt $stopTime) {{
11667 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11668 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11669
11670 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11671 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11672
11673 Start-Sleep -Milliseconds 250
11674}}
11675
11676Stop-Job $job
11677Receive-Job $job | Out-Null
11678Remove-Job $job
11679
11680$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11681$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11682$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11683
11684"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11685"#,
11686 target.display()
11687 );
11688
11689 let output = Command::new("powershell")
11690 .args(["-NoProfile", "-Command", &script])
11691 .output()
11692 .map_err(|e| format!("Benchmark failed: {e}"))?;
11693
11694 let raw = String::from_utf8_lossy(&output.stdout);
11695 let text = raw.trim();
11696
11697 if text.starts_with("ERROR") {
11698 return Err(text.to_string());
11699 }
11700
11701 let mut lines = text.lines();
11702 if let Some(metrics_line) = lines.next() {
11703 let parts: Vec<&str> = metrics_line.split('|').collect();
11704 let mut avg_q = "unknown".to_string();
11705 let mut max_q = "unknown".to_string();
11706 let mut avg_r = "unknown".to_string();
11707
11708 for p in parts {
11709 if let Some((k, v)) = p.split_once(':') {
11710 match k {
11711 "AVG_Q" => avg_q = v.to_string(),
11712 "MAX_Q" => max_q = v.to_string(),
11713 "AVG_R" => avg_r = v.to_string(),
11714 _ => {}
11715 }
11716 }
11717 }
11718
11719 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11720 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11721 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11722 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
11723 out.push_str("\nVerdict: ");
11724 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11725 if q_num > 1.0 {
11726 out.push_str(
11727 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11728 );
11729 } else if q_num > 0.1 {
11730 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11731 } else {
11732 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11733 }
11734 }
11735 }
11736
11737 #[cfg(not(target_os = "windows"))]
11738 {
11739 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11740 out.push_str("Generic disk load simulated.\n");
11741 }
11742
11743 Ok(out)
11744}
11745
11746fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11747 let mut out = String::from("Host inspection: permissions\n\n");
11748 out.push_str(&format!(
11749 "Auditing access control for: {}\n\n",
11750 path.display()
11751 ));
11752
11753 #[cfg(target_os = "windows")]
11754 {
11755 let script = format!(
11756 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11757 path.display()
11758 );
11759 let output = Command::new("powershell")
11760 .args(["-NoProfile", "-Command", &script])
11761 .output()
11762 .map_err(|e| format!("ACL check failed: {e}"))?;
11763
11764 let text = String::from_utf8_lossy(&output.stdout);
11765 if text.trim().is_empty() {
11766 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11767 } else {
11768 out.push_str("=== Windows NTFS Permissions ===\n");
11769 out.push_str(&text);
11770 }
11771 }
11772
11773 #[cfg(not(target_os = "windows"))]
11774 {
11775 let output = Command::new("ls")
11776 .args(["-ld", &path.to_string_lossy()])
11777 .output()
11778 .map_err(|e| format!("ls check failed: {e}"))?;
11779 out.push_str("=== Unix File Permissions ===\n");
11780 out.push_str(&String::from_utf8_lossy(&output.stdout));
11781 }
11782
11783 Ok(out.trim_end().to_string())
11784}
11785
11786fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11787 let mut out = String::from("Host inspection: login_history\n\n");
11788
11789 #[cfg(target_os = "windows")]
11790 {
11791 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11792 out.push_str("Note: This typically requires Administrator elevation.\n\n");
11793
11794 let n = max_entries.clamp(1, 50);
11795 let script = format!(
11796 r#"try {{
11797 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11798 $events | ForEach-Object {{
11799 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11800 # Extract target user name from the XML/Properties if possible
11801 $user = $_.Properties[5].Value
11802 $type = $_.Properties[8].Value
11803 "[$time] User: $user | Type: $type"
11804 }}
11805}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11806 );
11807
11808 let output = Command::new("powershell")
11809 .args(["-NoProfile", "-Command", &script])
11810 .output()
11811 .map_err(|e| format!("Login history query failed: {e}"))?;
11812
11813 let text = String::from_utf8_lossy(&output.stdout);
11814 if text.starts_with("ERROR:") {
11815 out.push_str(&format!("Unable to query Security Log: {}\n", text));
11816 } else if text.trim().is_empty() {
11817 out.push_str("No recent logon events found or access denied.\n");
11818 } else {
11819 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11820 out.push_str(&text);
11821 }
11822 }
11823
11824 #[cfg(not(target_os = "windows"))]
11825 {
11826 let output = Command::new("last")
11827 .args(["-n", &max_entries.to_string()])
11828 .output()
11829 .map_err(|e| format!("last command failed: {e}"))?;
11830 out.push_str("=== Unix Login History (last) ===\n");
11831 out.push_str(&String::from_utf8_lossy(&output.stdout));
11832 }
11833
11834 Ok(out.trim_end().to_string())
11835}
11836
11837fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11838 let mut out = String::from("Host inspection: share_access\n\n");
11839 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11840
11841 #[cfg(target_os = "windows")]
11842 {
11843 let script = format!(
11844 r#"
11845$p = '{}'
11846$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11847if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11848 $res.Reachable = $true
11849 try {{
11850 $null = Get-ChildItem -Path $p -ErrorAction Stop
11851 $res.Readable = $true
11852 }} catch {{
11853 $res.Error = $_.Exception.Message
11854 }}
11855}} else {{
11856 $res.Error = "Server unreachable (Ping failed)"
11857}}
11858"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11859 path.display()
11860 );
11861
11862 let output = Command::new("powershell")
11863 .args(["-NoProfile", "-Command", &script])
11864 .output()
11865 .map_err(|e| format!("Share test failed: {e}"))?;
11866
11867 let text = String::from_utf8_lossy(&output.stdout);
11868 out.push_str("=== Share Triage Results ===\n");
11869 out.push_str(&text);
11870 }
11871
11872 #[cfg(not(target_os = "windows"))]
11873 {
11874 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11875 }
11876
11877 Ok(out.trim_end().to_string())
11878}
11879
11880fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11881 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11882 out.push_str(&format!("Issue: {}\n\n", issue));
11883 out.push_str("Proposed Remediation Steps:\n");
11884 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11885 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11886 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11887 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11888 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11889 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11890 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11891 out.push_str(
11892 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11893 );
11894
11895 Ok(out)
11896}
11897
11898fn inspect_registry_audit() -> Result<String, String> {
11899 let mut out = String::from("Host inspection: registry_audit\n\n");
11900 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11901
11902 #[cfg(target_os = "windows")]
11903 {
11904 let script = r#"
11905$findings = @()
11906
11907# 1. Image File Execution Options (Debugger Hijacking)
11908$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11909if (Test-Path $ifeo) {
11910 Get-ChildItem $ifeo | ForEach-Object {
11911 $p = Get-ItemProperty $_.PSPath
11912 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11913 }
11914}
11915
11916# 2. Winlogon Shell Integrity
11917$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11918$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11919if ($shell -and $shell -ne "explorer.exe") {
11920 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11921}
11922
11923# 3. Session Manager BootExecute
11924$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11925$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11926if ($boot -and $boot -notcontains "autocheck autochk *") {
11927 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11928}
11929
11930if ($findings.Count -eq 0) {
11931 "PASS: No common registry hijacking or shell overrides detected."
11932} else {
11933 $findings -join "`n"
11934}
11935"#;
11936 let output = Command::new("powershell")
11937 .args(["-NoProfile", "-Command", &script])
11938 .output()
11939 .map_err(|e| format!("Registry audit failed: {e}"))?;
11940
11941 let text = String::from_utf8_lossy(&output.stdout);
11942 out.push_str("=== Persistence & Integrity Check ===\n");
11943 out.push_str(&text);
11944 }
11945
11946 #[cfg(not(target_os = "windows"))]
11947 {
11948 out.push_str("Registry auditing is specific to Windows environments.\n");
11949 }
11950
11951 Ok(out.trim_end().to_string())
11952}
11953
11954fn inspect_thermal() -> Result<String, String> {
11955 let mut out = String::from("Host inspection: thermal\n\n");
11956 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11957
11958 #[cfg(target_os = "windows")]
11959 {
11960 let script = r#"
11961$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11962if ($thermal) {
11963 $thermal | ForEach-Object {
11964 $temp = [math]::Round(($_.Temperature - 273.15), 1)
11965 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11966 }
11967} else {
11968 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11969 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11970 "Current CPU Load: $throttling%"
11971}
11972"#;
11973 let output = Command::new("powershell")
11974 .args(["-NoProfile", "-Command", script])
11975 .output()
11976 .map_err(|e| format!("Thermal check failed: {e}"))?;
11977 out.push_str("=== Windows Thermal State ===\n");
11978 out.push_str(&String::from_utf8_lossy(&output.stdout));
11979 }
11980
11981 #[cfg(not(target_os = "windows"))]
11982 {
11983 out.push_str(
11984 "Thermal inspection is currently optimized for Windows performance counters.\n",
11985 );
11986 }
11987
11988 Ok(out.trim_end().to_string())
11989}
11990
11991fn inspect_activation() -> Result<String, String> {
11992 let mut out = String::from("Host inspection: activation\n\n");
11993 out.push_str("Auditing Windows activation and license state...\n\n");
11994
11995 #[cfg(target_os = "windows")]
11996 {
11997 let script = r#"
11998$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11999$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
12000"Status: $($xpr.Trim())"
12001"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12002"#;
12003 let output = Command::new("powershell")
12004 .args(["-NoProfile", "-Command", script])
12005 .output()
12006 .map_err(|e| format!("Activation check failed: {e}"))?;
12007 out.push_str("=== Windows License Report ===\n");
12008 out.push_str(&String::from_utf8_lossy(&output.stdout));
12009 }
12010
12011 #[cfg(not(target_os = "windows"))]
12012 {
12013 out.push_str("Windows activation check is specific to the Windows platform.\n");
12014 }
12015
12016 Ok(out.trim_end().to_string())
12017}
12018
12019fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12020 let mut out = String::from("Host inspection: patch_history\n\n");
12021 out.push_str(&format!(
12022 "Listing the last {} installed Windows updates (KBs)...\n\n",
12023 max_entries
12024 ));
12025
12026 #[cfg(target_os = "windows")]
12027 {
12028 let n = max_entries.clamp(1, 50);
12029 let script = format!(
12030 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12031 n
12032 );
12033 let output = Command::new("powershell")
12034 .args(["-NoProfile", "-Command", &script])
12035 .output()
12036 .map_err(|e| format!("Patch history query failed: {e}"))?;
12037 out.push_str("=== Recent HotFixes (KBs) ===\n");
12038 out.push_str(&String::from_utf8_lossy(&output.stdout));
12039 }
12040
12041 #[cfg(not(target_os = "windows"))]
12042 {
12043 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12044 }
12045
12046 Ok(out.trim_end().to_string())
12047}
12048
12049fn inspect_ad_user(identity: &str) -> Result<String, String> {
12052 let mut out = String::from("Host inspection: ad_user\n\n");
12053 let ident = identity.trim();
12054 if ident.is_empty() {
12055 out.push_str("Status: No identity specified. Performing self-discovery...\n");
12056 #[cfg(target_os = "windows")]
12057 {
12058 let script = r#"
12059$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12060"USER: " + $u.Name
12061"SID: " + $u.User.Value
12062"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12063"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12064"#;
12065 let output = Command::new("powershell")
12066 .args(["-NoProfile", "-Command", script])
12067 .output()
12068 .ok();
12069 if let Some(o) = output {
12070 out.push_str(&String::from_utf8_lossy(&o.stdout));
12071 }
12072 }
12073 return Ok(out);
12074 }
12075
12076 #[cfg(target_os = "windows")]
12077 {
12078 let script = format!(
12079 r#"
12080try {{
12081 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12082 "NAME: " + $u.Name
12083 "SID: " + $u.SID
12084 "ENABLED: " + $u.Enabled
12085 "EXPIRED: " + $u.PasswordExpired
12086 "LOGON: " + $u.LastLogonDate
12087 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
12088}} catch {{
12089 # Fallback to net user if AD module is missing or fails
12090 $net = net user "{ident}" /domain 2>&1
12091 if ($LASTEXITCODE -eq 0) {{
12092 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12093 }} else {{
12094 "ERROR: " + $_.Exception.Message
12095 }}
12096}}"#
12097 );
12098
12099 let output = Command::new("powershell")
12100 .args(["-NoProfile", "-Command", &script])
12101 .output()
12102 .ok();
12103
12104 if let Some(o) = output {
12105 let stdout = String::from_utf8_lossy(&o.stdout);
12106 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12107 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12108 }
12109 out.push_str(&stdout);
12110 }
12111 }
12112
12113 #[cfg(not(target_os = "windows"))]
12114 {
12115 let _ = ident;
12116 out.push_str("(AD User lookup only available on Windows nodes)\n");
12117 }
12118
12119 Ok(out.trim_end().to_string())
12120}
12121
12122fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12125 let mut out = String::from("Host inspection: dns_lookup\n\n");
12126 let target = name.trim();
12127 if target.is_empty() {
12128 return Err("Missing required target name for dns_lookup.".to_string());
12129 }
12130
12131 #[cfg(target_os = "windows")]
12132 {
12133 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12134 let output = Command::new("powershell")
12135 .args(["-NoProfile", "-Command", &script])
12136 .output()
12137 .ok();
12138 if let Some(o) = output {
12139 let stdout = String::from_utf8_lossy(&o.stdout);
12140 if stdout.trim().is_empty() {
12141 out.push_str(&format!("No {record_type} records found for {target}.\n"));
12142 } else {
12143 out.push_str(&stdout);
12144 }
12145 }
12146 }
12147
12148 #[cfg(not(target_os = "windows"))]
12149 {
12150 let output = Command::new("dig")
12151 .args([target, record_type, "+short"])
12152 .output()
12153 .ok();
12154 if let Some(o) = output {
12155 out.push_str(&String::from_utf8_lossy(&o.stdout));
12156 }
12157 }
12158
12159 Ok(out.trim_end().to_string())
12160}
12161
12162#[cfg(target_os = "windows")]
12165fn ps_exec(script: &str) -> String {
12166 Command::new("powershell")
12167 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12168 .output()
12169 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12170 .unwrap_or_default()
12171}
12172
12173fn inspect_mdm_enrollment() -> Result<String, String> {
12174 #[cfg(target_os = "windows")]
12175 {
12176 let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12177
12178 out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12180 let ps_dsreg = r#"
12181$raw = dsregcmd /status 2>$null
12182$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12183 'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12184foreach ($line in $raw) {
12185 $t = $line.Trim()
12186 foreach ($f in $fields) {
12187 if ($t -like "$f :*") {
12188 $val = ($t -split ':',2)[1].Trim()
12189 "$f`: $val"
12190 }
12191 }
12192}
12193"#;
12194 match run_powershell(ps_dsreg) {
12195 Ok(o) if !o.trim().is_empty() => {
12196 for line in o.lines() {
12197 let l = line.trim();
12198 if !l.is_empty() {
12199 out.push_str(&format!("- {l}\n"));
12200 }
12201 }
12202 }
12203 Ok(_) => out.push_str(
12204 "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12205 ),
12206 Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
12207 }
12208
12209 out.push_str("\n=== Enrollment accounts (registry) ===\n");
12211 let ps_enroll = r#"
12212$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12213if (Test-Path $base) {
12214 $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12215 if ($accounts) {
12216 foreach ($acct in $accounts) {
12217 $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12218 $upn = if ($p.UPN) { $p.UPN } else { '(none)' }
12219 $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12220 $type = switch ($p.EnrollmentType) {
12221 6 { 'MDM' }
12222 13 { 'MAM' }
12223 default { "Type=$($p.EnrollmentType)" }
12224 }
12225 $state = switch ($p.EnrollmentState) {
12226 1 { 'Enrolled' }
12227 2 { 'InProgress' }
12228 6 { 'Unenrolled' }
12229 default { "State=$($p.EnrollmentState)" }
12230 }
12231 "Account: $upn | $type | $state | $server"
12232 }
12233 } else { "No enrollment accounts found under $base" }
12234} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12235"#;
12236 match run_powershell(ps_enroll) {
12237 Ok(o) => {
12238 for line in o.lines() {
12239 let l = line.trim();
12240 if !l.is_empty() {
12241 out.push_str(&format!("- {l}\n"));
12242 }
12243 }
12244 }
12245 Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
12246 }
12247
12248 out.push_str("\n=== MDM services ===\n");
12250 let ps_svc = r#"
12251$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12252foreach ($n in $names) {
12253 $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12254 if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12255}
12256"#;
12257 match run_powershell(ps_svc) {
12258 Ok(o) if !o.trim().is_empty() => {
12259 for line in o.lines() {
12260 let l = line.trim();
12261 if !l.is_empty() {
12262 out.push_str(&format!("- {l}\n"));
12263 }
12264 }
12265 }
12266 Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12267 Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
12268 }
12269
12270 out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12272 let ps_evt = r#"
12273$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12274 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12275$cutoff = (Get-Date).AddHours(-24)
12276$found = $false
12277foreach ($log in $logs) {
12278 $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12279 Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12280 foreach ($e in $evts) {
12281 $found = $true
12282 $ts = $e.TimeCreated.ToString('HH:mm')
12283 $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12284 "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12285 }
12286}
12287if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12288"#;
12289 match run_powershell(ps_evt) {
12290 Ok(o) => {
12291 for line in o.lines() {
12292 let l = line.trim();
12293 if !l.is_empty() {
12294 out.push_str(&format!("- {l}\n"));
12295 }
12296 }
12297 }
12298 Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
12299 }
12300
12301 out.push_str("\n=== Findings ===\n");
12303 let body = out.clone();
12304 let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12305 let intune_running = body.contains("IntuneManagementExtension: Running");
12306 let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12307
12308 if !enrolled {
12309 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");
12310 } else {
12311 out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12312 if !intune_running {
12313 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");
12314 }
12315 }
12316 if has_errors {
12317 out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12318 }
12319 if !enrolled && !has_errors {
12320 out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12321 }
12322
12323 Ok(out)
12324 }
12325
12326 #[cfg(not(target_os = "windows"))]
12327 {
12328 Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12329 }
12330}
12331
12332fn inspect_hyperv() -> Result<String, String> {
12333 #[cfg(target_os = "windows")]
12334 {
12335 let mut findings: Vec<String> = Vec::new();
12336 let mut out = String::new();
12337
12338 let ps_role = r#"
12340$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12341$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12342$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12343$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12344"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12345 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12346 $(if ($feature) { $feature.State } else { "Unknown" }),
12347 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12348 $(if ($ram) { $ram } else { "0" })
12349"#;
12350 let role_out = ps_exec(ps_role);
12351 out.push_str("=== Hyper-V role state ===\n");
12352
12353 let mut vmms_running = false;
12354 let mut host_ram_bytes: u64 = 0;
12355
12356 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12357 let kv: std::collections::HashMap<&str, &str> = line
12358 .split('|')
12359 .filter_map(|p| {
12360 let mut it = p.splitn(2, ':');
12361 Some((it.next()?, it.next()?))
12362 })
12363 .collect();
12364 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12365 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12366 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12367 host_ram_bytes = kv
12368 .get("HostRAMBytes")
12369 .and_then(|v| v.parse().ok())
12370 .unwrap_or(0);
12371
12372 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12373 vmms_running = vmms_status.starts_with("Running");
12374
12375 out.push_str(&format!("- Host: {host_name}\n"));
12376 out.push_str(&format!(
12377 "- Hyper-V feature: {}\n",
12378 if hyperv_installed {
12379 "Enabled"
12380 } else {
12381 "Not installed"
12382 }
12383 ));
12384 out.push_str(&format!("- VMMS service: {vmms_status}\n"));
12385 if host_ram_bytes > 0 {
12386 out.push_str(&format!(
12387 "- Host physical RAM: {} GB\n",
12388 host_ram_bytes / 1_073_741_824
12389 ));
12390 }
12391
12392 if !hyperv_installed {
12393 findings.push(
12394 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12395 );
12396 } else if !vmms_running {
12397 findings.push(
12398 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12399 );
12400 }
12401 } else {
12402 out.push_str("- Could not determine Hyper-V role state\n");
12403 findings.push("Hyper-V does not appear to be installed on this machine.".into());
12404 }
12405
12406 out.push_str("\n=== Virtual machines ===\n");
12408 if vmms_running {
12409 let ps_vms = r#"
12410Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12411 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12412 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12413 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12414 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12415 $_.Status, $_.Generation
12416}
12417"#;
12418 let vms_out = ps_exec(ps_vms);
12419 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12420
12421 if vm_lines.is_empty() {
12422 out.push_str("- No virtual machines found on this host\n");
12423 } else {
12424 let mut total_ram_bytes: u64 = 0;
12425 let mut saved_vms: Vec<String> = Vec::new();
12426 for line in &vm_lines {
12427 let kv: std::collections::HashMap<&str, &str> = line
12428 .split('|')
12429 .filter_map(|p| {
12430 let mut it = p.splitn(2, ':');
12431 Some((it.next()?, it.next()?))
12432 })
12433 .collect();
12434 let name = kv.get("VM").copied().unwrap_or("Unknown");
12435 let state = kv.get("State").copied().unwrap_or("Unknown");
12436 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12437 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12438 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12439 let status = kv.get("Status").copied().unwrap_or("");
12440 let gen = kv.get("Generation").copied().unwrap_or("?");
12441
12442 if let Ok(r) = ram.parse::<f64>() {
12443 total_ram_bytes += (r * 1_073_741_824.0) as u64;
12444 }
12445 if state.eq_ignore_ascii_case("Saved") {
12446 saved_vms.push(name.to_string());
12447 }
12448
12449 out.push_str(&format!(
12450 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
12451 ));
12452 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12453 out.push_str(&format!(" Status: {status}\n"));
12454 }
12455 }
12456
12457 out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
12458 if total_ram_bytes > 0 && host_ram_bytes > 0 {
12459 let pct = (total_ram_bytes * 100) / host_ram_bytes;
12460 out.push_str(&format!(
12461 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
12462 total_ram_bytes / 1_073_741_824
12463 ));
12464 if pct > 90 {
12465 findings.push(format!(
12466 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12467 ));
12468 }
12469 }
12470 if !saved_vms.is_empty() {
12471 findings.push(format!(
12472 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12473 saved_vms.join(", ")
12474 ));
12475 }
12476 }
12477 } else {
12478 out.push_str("- VMMS not running — cannot enumerate VMs\n");
12479 }
12480
12481 out.push_str("\n=== VM network switches ===\n");
12483 if vmms_running {
12484 let ps_switches = r#"
12485Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12486 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12487 $_.Name, $_.SwitchType,
12488 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12489}
12490"#;
12491 let sw_out = ps_exec(ps_switches);
12492 let switch_lines: Vec<&str> = sw_out
12493 .lines()
12494 .filter(|l| l.starts_with("Switch:"))
12495 .collect();
12496
12497 if switch_lines.is_empty() {
12498 out.push_str("- No VM switches configured\n");
12499 } else {
12500 for line in &switch_lines {
12501 let kv: std::collections::HashMap<&str, &str> = line
12502 .split('|')
12503 .filter_map(|p| {
12504 let mut it = p.splitn(2, ':');
12505 Some((it.next()?, it.next()?))
12506 })
12507 .collect();
12508 let name = kv.get("Switch").copied().unwrap_or("Unknown");
12509 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12510 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12511 out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
12512 }
12513 }
12514 } else {
12515 out.push_str("- VMMS not running — cannot enumerate switches\n");
12516 }
12517
12518 out.push_str("\n=== VM checkpoints ===\n");
12520 if vmms_running {
12521 let ps_checkpoints = r#"
12522$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12523if ($all) {
12524 $all | ForEach-Object {
12525 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12526 $_.Name, $_.VMName,
12527 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12528 $_.SnapshotType
12529 }
12530} else {
12531 "NONE"
12532}
12533"#;
12534 let cp_out = ps_exec(ps_checkpoints);
12535 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12536 out.push_str("- No checkpoints found\n");
12537 } else {
12538 let cp_lines: Vec<&str> = cp_out
12539 .lines()
12540 .filter(|l| l.starts_with("Checkpoint:"))
12541 .collect();
12542 let mut per_vm: std::collections::HashMap<&str, usize> =
12543 std::collections::HashMap::new();
12544 for line in &cp_lines {
12545 let kv: std::collections::HashMap<&str, &str> = line
12546 .split('|')
12547 .filter_map(|p| {
12548 let mut it = p.splitn(2, ':');
12549 Some((it.next()?, it.next()?))
12550 })
12551 .collect();
12552 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12553 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12554 let created = kv.get("Created").copied().unwrap_or("");
12555 let cp_type = kv.get("Type").copied().unwrap_or("");
12556 out.push_str(&format!(
12557 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
12558 ));
12559 *per_vm.entry(vm_name).or_insert(0) += 1;
12560 }
12561 for (vm, count) in &per_vm {
12562 if *count >= 3 {
12563 findings.push(format!(
12564 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12565 ));
12566 }
12567 }
12568 }
12569 } else {
12570 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12571 }
12572
12573 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12574 if findings.is_empty() {
12575 result.push_str("- No Hyper-V health issues detected.\n");
12576 } else {
12577 for f in &findings {
12578 result.push_str(&format!("- Finding: {f}\n"));
12579 }
12580 }
12581 result.push('\n');
12582 result.push_str(&out);
12583 return Ok(result.trim_end().to_string());
12584 }
12585
12586 #[cfg(not(target_os = "windows"))]
12587 Ok(
12588 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12589 .into(),
12590 )
12591}
12592
12593fn inspect_ip_config() -> Result<String, String> {
12596 let mut out = String::from("Host inspection: ip_config\n\n");
12597
12598 #[cfg(target_os = "windows")]
12599 {
12600 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12601 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12602 '\\n Status: ' + $_.NetAdapter.Status + \
12603 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12604 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12605 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12606 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12607 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12608 }";
12609 let output = Command::new("powershell")
12610 .args(["-NoProfile", "-Command", script])
12611 .output()
12612 .ok();
12613 if let Some(o) = output {
12614 out.push_str(&String::from_utf8_lossy(&o.stdout));
12615 }
12616 }
12617
12618 #[cfg(not(target_os = "windows"))]
12619 {
12620 let output = Command::new("ip").args(["addr", "show"]).output().ok();
12621 if let Some(o) = output {
12622 out.push_str(&String::from_utf8_lossy(&o.stdout));
12623 }
12624 }
12625
12626 Ok(out.trim_end().to_string())
12627}
12628
12629fn inspect_event_query(
12632 event_id: Option<u32>,
12633 log_name: Option<&str>,
12634 source: Option<&str>,
12635 hours: u32,
12636 level: Option<&str>,
12637 max_entries: usize,
12638) -> Result<String, String> {
12639 #[cfg(target_os = "windows")]
12640 {
12641 let mut findings: Vec<String> = Vec::new();
12642
12643 let log = log_name.unwrap_or("*");
12645 let cap = max_entries.min(50);
12646
12647 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12649 Some("error") | Some("errors") => Some(2u8),
12650 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12651 Some("information") | Some("info") => Some(4u8),
12652 _ => None,
12653 };
12654
12655 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12657 if log != "*" {
12658 filter_parts.push(format!("LogName = '{log}'"));
12659 }
12660 if let Some(id) = event_id {
12661 filter_parts.push(format!("Id = {id}"));
12662 }
12663 if let Some(src) = source {
12664 filter_parts.push(format!("ProviderName = '{src}'"));
12665 }
12666 if let Some(lvl) = level_filter {
12667 filter_parts.push(format!("Level = {lvl}"));
12668 }
12669
12670 let filter_ht = filter_parts.join("; ");
12671
12672 let ps = format!(
12673 r#"
12674$filter = @{{ {filter_ht} }}
12675try {{
12676 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12677 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12678 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12679 if ($events) {{
12680 $events | ForEach-Object {{
12681 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12682 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12683 $_.Id, $_.LevelDisplayName, $_.ProviderName,
12684 ($_.Msg -replace '\|','/')
12685 }}
12686 }} else {{
12687 "NONE"
12688 }}
12689}} catch {{
12690 "ERROR:$($_.Exception.Message)"
12691}}
12692"#
12693 );
12694
12695 let raw = ps_exec(&ps);
12696 let lines: Vec<&str> = raw.lines().collect();
12697
12698 let mut query_desc = format!("last {hours}h");
12700 if let Some(id) = event_id {
12701 query_desc.push_str(&format!(", Event ID {id}"));
12702 }
12703 if let Some(src) = source {
12704 query_desc.push_str(&format!(", source '{src}'"));
12705 }
12706 if log != "*" {
12707 query_desc.push_str(&format!(", log '{log}'"));
12708 }
12709 if let Some(l) = level {
12710 query_desc.push_str(&format!(", level '{l}'"));
12711 }
12712
12713 let mut out = format!("=== Event query: {query_desc} ===\n");
12714
12715 if lines
12716 .iter()
12717 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12718 {
12719 out.push_str("- No matching events found.\n");
12720 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12721 let msg = err_line.trim_start_matches("ERROR:").trim();
12722 if is_event_query_no_results_message(msg) {
12723 out.push_str("- No matching events found.\n");
12724 } else {
12725 out.push_str(&format!("- Query error: {msg}\n"));
12726 findings.push(format!("Event query failed: {msg}"));
12727 }
12728 } else {
12729 let event_lines: Vec<&str> = lines
12730 .iter()
12731 .filter(|l| l.starts_with("TIME:"))
12732 .copied()
12733 .collect();
12734 if event_lines.is_empty() {
12735 out.push_str("- No matching events found.\n");
12736 } else {
12737 let mut error_count = 0usize;
12739 let mut warning_count = 0usize;
12740
12741 for line in &event_lines {
12742 let kv: std::collections::HashMap<&str, &str> = line
12743 .split('|')
12744 .filter_map(|p| {
12745 let mut it = p.splitn(2, ':');
12746 Some((it.next()?, it.next()?))
12747 })
12748 .collect();
12749 let time = kv.get("TIME").copied().unwrap_or("?");
12750 let id = kv.get("ID").copied().unwrap_or("?");
12751 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12752 let src = kv.get("SOURCE").copied().unwrap_or("?");
12753 let msg = kv.get("MSG").copied().unwrap_or("").trim();
12754
12755 let msg_display = if msg.len() > 120 {
12757 format!("{}…", &msg[..120])
12758 } else {
12759 msg.to_string()
12760 };
12761
12762 out.push_str(&format!(
12763 "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n"
12764 ));
12765
12766 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12767 error_count += 1;
12768 } else if lvl.eq_ignore_ascii_case("warning") {
12769 warning_count += 1;
12770 }
12771 }
12772
12773 out.push_str(&format!(
12774 "\n- Total shown: {} event(s)\n",
12775 event_lines.len()
12776 ));
12777
12778 if error_count > 0 {
12779 findings.push(format!(
12780 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12781 ));
12782 }
12783 if warning_count > 5 {
12784 findings.push(format!(
12785 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12786 ));
12787 }
12788 }
12789 }
12790
12791 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12792 if findings.is_empty() {
12793 result.push_str("- No actionable findings from this event query.\n");
12794 } else {
12795 for f in &findings {
12796 result.push_str(&format!("- Finding: {f}\n"));
12797 }
12798 }
12799 result.push('\n');
12800 result.push_str(&out);
12801 return Ok(result.trim_end().to_string());
12802 }
12803
12804 #[cfg(not(target_os = "windows"))]
12805 {
12806 let _ = (event_id, log_name, source, hours, level, max_entries);
12807 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12808 }
12809}
12810
12811fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12814 let n = max_entries.clamp(5, 50);
12815 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12816 let mut findings: Vec<String> = Vec::new();
12817 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12818 let mut sections = String::new();
12819
12820 #[cfg(target_os = "windows")]
12821 {
12822 let proc_filter_ps = match process_filter {
12823 Some(proc) => format!(
12824 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12825 proc.replace('\'', "''")
12826 ),
12827 None => String::new(),
12828 };
12829
12830 let ps = format!(
12831 r#"
12832$results = @()
12833try {{
12834 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12835 if ($events) {{
12836 foreach ($e in $events) {{
12837 $msg = $e.Message
12838 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12839 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12840 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12841 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12842 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12843 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12844 }}
12845 $results
12846 }} else {{ 'NONE' }}
12847}} catch {{ 'ERROR:' + $_.Exception.Message }}
12848"#
12849 );
12850
12851 let raw = ps_exec(&ps);
12852 let text = raw.trim();
12853
12854 let wer_ps = r#"
12856$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12857$count = 0
12858if (Test-Path $wer) {
12859 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12860}
12861$count
12862"#;
12863 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12864
12865 if text == "NONE" {
12866 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12867 } else if text.starts_with("ERROR:") {
12868 let msg = text.trim_start_matches("ERROR:").trim();
12869 sections.push_str(&format!(
12870 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12871 ));
12872 } else {
12873 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12874 let crash_count = events
12875 .iter()
12876 .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12877 .count();
12878 let hang_count = events
12879 .iter()
12880 .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12881 .count();
12882
12883 let mut app_counts: std::collections::HashMap<String, usize> =
12885 std::collections::HashMap::new();
12886 for line in &events {
12887 let parts: Vec<&str> = line.splitn(6, '|').collect();
12888 if parts.len() >= 3 {
12889 *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12890 }
12891 }
12892
12893 if crash_count > 0 {
12894 findings.push(format!(
12895 "{crash_count} application crash event(s) — review below for faulting app and exception code."
12896 ));
12897 }
12898 if hang_count > 0 {
12899 findings.push(format!(
12900 "{hang_count} application hang event(s) — process stopped responding."
12901 ));
12902 }
12903 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12904 if count > 1 {
12905 findings.push(format!(
12906 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12907 ));
12908 }
12909 }
12910 if wer_count > 10 {
12911 findings.push(format!(
12912 "{wer_count} WER reports archived — elevated crash history on this machine."
12913 ));
12914 }
12915
12916 let filter_note = match process_filter {
12917 Some(p) => format!(" (filtered: {p})"),
12918 None => String::new(),
12919 };
12920 sections.push_str(&format!(
12921 "=== Application crashes and hangs{filter_note} ===\n"
12922 ));
12923
12924 for line in &events {
12925 let parts: Vec<&str> = line.splitn(6, '|').collect();
12926 if parts.len() >= 6 {
12927 let time = parts[0];
12928 let kind = parts[1];
12929 let app = parts[2];
12930 let ver = parts[3];
12931 let module = parts[4];
12932 let exc = parts[5];
12933 let ver_note = if !ver.is_empty() {
12934 format!(" v{ver}")
12935 } else {
12936 String::new()
12937 };
12938 sections.push_str(&format!(" [{time}] {kind}: {app}{ver_note}\n"));
12939 if !module.is_empty() && module != "?" {
12940 let exc_note = if !exc.is_empty() {
12941 format!(" (exc {exc})")
12942 } else {
12943 String::new()
12944 };
12945 sections.push_str(&format!(" faulting module: {module}{exc_note}\n"));
12946 } else if !exc.is_empty() {
12947 sections.push_str(&format!(" exception: {exc}\n"));
12948 }
12949 }
12950 }
12951 sections.push_str(&format!(
12952 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12953 ));
12954
12955 if wer_count > 0 {
12956 sections.push_str(&format!(
12957 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12958 ));
12959 }
12960 }
12961 }
12962
12963 #[cfg(not(target_os = "windows"))]
12964 {
12965 let _ = (process_filter, n);
12966 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12967 }
12968
12969 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12970 if findings.is_empty() {
12971 result.push_str("- No actionable findings.\n");
12972 } else {
12973 for f in &findings {
12974 result.push_str(&format!("- Finding: {f}\n"));
12975 }
12976 }
12977 result.push('\n');
12978 result.push_str(§ions);
12979 Ok(result.trim_end().to_string())
12980}
12981
12982#[cfg(target_os = "windows")]
12983fn gpu_voltage_telemetry_note() -> String {
12984 let output = Command::new("nvidia-smi")
12985 .args(["--help-query-gpu"])
12986 .output();
12987
12988 match output {
12989 Ok(o) => {
12990 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12991 if text.contains("\"voltage\"") || text.contains("voltage.") {
12992 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12993 } else {
12994 "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()
12995 }
12996 }
12997 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12998 }
12999}
13000
13001#[cfg(target_os = "windows")]
13002fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13003 if raw == 0 {
13004 return None;
13005 }
13006 if raw & 0x80 != 0 {
13007 let tenths = raw & 0x7f;
13008 return Some(format!(
13009 "{:.1} V (firmware-reported WMI current voltage)",
13010 tenths as f64 / 10.0
13011 ));
13012 }
13013
13014 let legacy = match raw {
13015 1 => Some("5.0 V"),
13016 2 => Some("3.3 V"),
13017 4 => Some("2.9 V"),
13018 _ => None,
13019 }?;
13020 Some(format!(
13021 "{} (legacy WMI voltage capability flag, not live telemetry)",
13022 legacy
13023 ))
13024}
13025
13026async fn inspect_overclocker() -> Result<String, String> {
13027 let mut out = String::from("Host inspection: overclocker\n\n");
13028
13029 #[cfg(target_os = "windows")]
13030 {
13031 out.push_str(
13032 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13033 );
13034
13035 let nvidia = Command::new("nvidia-smi")
13037 .args([
13038 "--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",
13039 "--format=csv,noheader,nounits",
13040 ])
13041 .output();
13042
13043 if let Ok(o) = nvidia {
13044 let stdout = String::from_utf8_lossy(&o.stdout);
13045 if !stdout.trim().is_empty() {
13046 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13047 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
13048 if parts.len() >= 10 {
13049 out.push_str(&format!("- Model: {}\n", parts[0]));
13050 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
13051 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
13052 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
13053 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
13054 if !parts[6].eq_ignore_ascii_case("[N/A]") {
13055 out.push_str(&format!("- Power Avg: {} W\n", parts[6]));
13056 }
13057 if !parts[7].eq_ignore_ascii_case("[N/A]") {
13058 out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
13059 }
13060 if !parts[8].eq_ignore_ascii_case("[N/A]") {
13061 out.push_str(&format!("- Power Cap: {} W requested\n", parts[8]));
13062 }
13063 if !parts[9].eq_ignore_ascii_case("[N/A]") {
13064 out.push_str(&format!("- Power Enf: {} W enforced\n", parts[9]));
13065 }
13066 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
13067
13068 if parts.len() > 10 {
13069 let throttle_hex = parts[10];
13070 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13071 if !reasons.is_empty() {
13072 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
13073 } else {
13074 out.push_str("- Throttling: None (Performance State: Max)\n");
13075 }
13076 }
13077 }
13078 out.push_str("\n");
13079 }
13080 }
13081
13082 out.push_str("=== VOLTAGE TELEMETRY ===\n");
13083 out.push_str(&format!(
13084 "- GPU Voltage: {}\n\n",
13085 gpu_voltage_telemetry_note()
13086 ));
13087
13088 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13090 let history = gpu_state.history.lock().unwrap();
13091 if history.len() >= 2 {
13092 out.push_str("=== SILICON TRENDS (Session) ===\n");
13093 let first = history.front().unwrap();
13094 let last = history.back().unwrap();
13095
13096 let temp_diff = last.temperature as i32 - first.temperature as i32;
13097 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13098
13099 let temp_trend = if temp_diff > 1 {
13100 "Rising"
13101 } else if temp_diff < -1 {
13102 "Falling"
13103 } else {
13104 "Stable"
13105 };
13106 let clock_trend = if clock_diff > 10 {
13107 "Increasing"
13108 } else if clock_diff < -10 {
13109 "Decreasing"
13110 } else {
13111 "Stable"
13112 };
13113
13114 out.push_str(&format!(
13115 "- Temperature: {} ({}°C anomaly)\n",
13116 temp_trend, temp_diff
13117 ));
13118 out.push_str(&format!(
13119 "- Core Clock: {} ({} MHz delta)\n",
13120 clock_trend, clock_diff
13121 ));
13122 out.push_str("\n");
13123 }
13124
13125 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))\" }";
13127 let cpu_stats = Command::new("powershell")
13128 .args(["-NoProfile", "-Command", ps_cmd])
13129 .output();
13130
13131 if let Ok(o) = cpu_stats {
13132 let stdout = String::from_utf8_lossy(&o.stdout);
13133 if !stdout.trim().is_empty() {
13134 out.push_str("=== SILICON CORE (CPU) ===\n");
13135 for line in stdout.lines() {
13136 if let Some((path, val)) = line.split_once(':') {
13137 let path_lower = path.to_lowercase();
13138 if path_lower.contains("processor frequency") {
13139 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
13140 } else if path_lower.contains("% of maximum frequency") {
13141 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
13142 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13143 if throttle_num < 95.0 {
13144 out.push_str(
13145 " [WARNING] Active downclocking or power-saving detected.\n",
13146 );
13147 }
13148 }
13149 }
13150 }
13151 }
13152 }
13153
13154 let thermal = Command::new("powershell")
13156 .args([
13157 "-NoProfile",
13158 "-Command",
13159 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13160 ])
13161 .output();
13162 if let Ok(o) = thermal {
13163 let stdout = String::from_utf8_lossy(&o.stdout);
13164 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13165 let temp = if v.is_array() {
13166 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13167 } else {
13168 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13169 };
13170 if temp > 1.0 {
13171 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
13172 }
13173 }
13174 }
13175
13176 let wmi = Command::new("powershell")
13178 .args([
13179 "-NoProfile",
13180 "-Command",
13181 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13182 ])
13183 .output();
13184
13185 if let Ok(o) = wmi {
13186 let stdout = String::from_utf8_lossy(&o.stdout);
13187 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13188 out.push_str("\n=== HARDWARE DNA ===\n");
13189 out.push_str(&format!(
13190 "- Rated Max: {} MHz\n",
13191 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13192 ));
13193 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13194 Some(raw) => {
13195 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13196 out.push_str(&format!("- CPU Voltage: {}\n", decoded));
13197 } else {
13198 out.push_str(
13199 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
13200 );
13201 }
13202 }
13203 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
13204 }
13205 }
13206 }
13207 }
13208
13209 #[cfg(not(target_os = "windows"))]
13210 {
13211 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13212 }
13213
13214 Ok(out.trim_end().to_string())
13215}
13216
13217#[cfg(target_os = "windows")]
13219fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13220 let hex = hex.trim().trim_start_matches("0x");
13221 let val = match u64::from_str_radix(hex, 16) {
13222 Ok(v) => v,
13223 Err(_) => return String::new(),
13224 };
13225
13226 if val == 0 {
13227 return String::new();
13228 }
13229
13230 let mut reasons = Vec::new();
13231 if val & 0x01 != 0 {
13232 reasons.push("GPU Idle");
13233 }
13234 if val & 0x02 != 0 {
13235 reasons.push("Applications Clocks Setting");
13236 }
13237 if val & 0x04 != 0 {
13238 reasons.push("SW Power Cap (PL1/PL2)");
13239 }
13240 if val & 0x08 != 0 {
13241 reasons.push("HW Slowdown (Thermal/Power)");
13242 }
13243 if val & 0x10 != 0 {
13244 reasons.push("Sync Boost");
13245 }
13246 if val & 0x20 != 0 {
13247 reasons.push("SW Thermal Slowdown");
13248 }
13249 if val & 0x40 != 0 {
13250 reasons.push("HW Thermal Slowdown");
13251 }
13252 if val & 0x80 != 0 {
13253 reasons.push("HW Power Brake Slowdown");
13254 }
13255 if val & 0x100 != 0 {
13256 reasons.push("Display Clock Setting");
13257 }
13258
13259 reasons.join(", ")
13260}
13261
13262#[cfg(windows)]
13265fn run_powershell(script: &str) -> Result<String, String> {
13266 use std::process::Command;
13267 let out = Command::new("powershell")
13268 .args(["-NoProfile", "-NonInteractive", "-Command", script])
13269 .output()
13270 .map_err(|e| format!("powershell launch failed: {e}"))?;
13271 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13272}
13273
13274#[cfg(windows)]
13277fn inspect_camera(max_entries: usize) -> Result<String, String> {
13278 let mut out = String::from("=== Camera devices ===\n");
13279
13280 let ps_devices = r#"
13282Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13283ForEach-Object {
13284 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13285 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13286}
13287"#;
13288 match run_powershell(ps_devices) {
13289 Ok(o) if !o.trim().is_empty() => {
13290 for line in o.lines().take(max_entries) {
13291 let l = line.trim();
13292 if !l.is_empty() {
13293 out.push_str(&format!("- {l}\n"));
13294 }
13295 }
13296 }
13297 _ => out.push_str("- No camera devices found via PnP\n"),
13298 }
13299
13300 out.push_str("\n=== Windows camera privacy ===\n");
13302 let ps_privacy = r#"
13303$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13304$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13305"Global: $global"
13306$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13307 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13308 ForEach-Object {
13309 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13310 if ($v) { " $($_.PSChildName): $v" }
13311 }
13312$apps
13313"#;
13314 match run_powershell(ps_privacy) {
13315 Ok(o) if !o.trim().is_empty() => {
13316 for line in o.lines().take(max_entries) {
13317 let l = line.trim_end();
13318 if !l.is_empty() {
13319 out.push_str(&format!("{l}\n"));
13320 }
13321 }
13322 }
13323 _ => out.push_str("- Could not read camera privacy registry\n"),
13324 }
13325
13326 out.push_str("\n=== Biometric / Hello camera ===\n");
13328 let ps_bio = r#"
13329Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13330ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13331"#;
13332 match run_powershell(ps_bio) {
13333 Ok(o) if !o.trim().is_empty() => {
13334 for line in o.lines().take(max_entries) {
13335 let l = line.trim();
13336 if !l.is_empty() {
13337 out.push_str(&format!("- {l}\n"));
13338 }
13339 }
13340 }
13341 _ => out.push_str("- No biometric devices found\n"),
13342 }
13343
13344 let mut findings: Vec<String> = Vec::new();
13346 if out.contains("Status: Error") || out.contains("Status: Unknown") {
13347 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13348 }
13349 if out.contains("Global: Deny") {
13350 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());
13351 }
13352
13353 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13354 if findings.is_empty() {
13355 result.push_str("- No obvious camera or privacy gate issue detected.\n");
13356 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13357 } else {
13358 for f in &findings {
13359 result.push_str(&format!("- Finding: {f}\n"));
13360 }
13361 }
13362 result.push('\n');
13363 result.push_str(&out);
13364 Ok(result)
13365}
13366
13367#[cfg(not(windows))]
13368fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13369 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13370}
13371
13372#[cfg(windows)]
13375fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13376 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13377
13378 let ps_hello = r#"
13380$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13381$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13382$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13383"PIN-style logon path: $helloKey"
13384"WbioSrvc start type: $faceConfigured"
13385"FingerPrint key present: $pinConfigured"
13386"#;
13387 match run_powershell(ps_hello) {
13388 Ok(o) => {
13389 for line in o.lines().take(max_entries) {
13390 let l = line.trim();
13391 if !l.is_empty() {
13392 out.push_str(&format!("- {l}\n"));
13393 }
13394 }
13395 }
13396 Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
13397 }
13398
13399 out.push_str("\n=== Biometric service ===\n");
13401 let ps_bio_svc = r#"
13402$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13403if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13404else { "WbioSrvc not found" }
13405"#;
13406 match run_powershell(ps_bio_svc) {
13407 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
13408 Err(_) => out.push_str("- Could not query biometric service\n"),
13409 }
13410
13411 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13413 let ps_events = r#"
13414$cutoff = (Get-Date).AddHours(-24)
13415Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13416ForEach-Object {
13417 $xml = [xml]$_.ToXml()
13418 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13419 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13420 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13421} | Select-Object -First 10
13422"#;
13423 match run_powershell(ps_events) {
13424 Ok(o) if !o.trim().is_empty() => {
13425 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13426 out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
13427 for line in o.lines().take(max_entries) {
13428 let l = line.trim();
13429 if !l.is_empty() {
13430 out.push_str(&format!(" {l}\n"));
13431 }
13432 }
13433 }
13434 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13435 }
13436
13437 out.push_str("\n=== Active credential providers ===\n");
13439 let ps_cp = r#"
13440Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13441ForEach-Object {
13442 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13443 if ($name) { $name }
13444} | Select-Object -First 15
13445"#;
13446 match run_powershell(ps_cp) {
13447 Ok(o) if !o.trim().is_empty() => {
13448 for line in o.lines().take(max_entries) {
13449 let l = line.trim();
13450 if !l.is_empty() {
13451 out.push_str(&format!("- {l}\n"));
13452 }
13453 }
13454 }
13455 _ => out.push_str("- Could not enumerate credential providers\n"),
13456 }
13457
13458 let mut findings: Vec<String> = Vec::new();
13459 if out.contains("WbioSrvc | Status: Stopped") {
13460 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13461 }
13462 if out.contains("recent logon failure") && !out.contains("0 recent") {
13463 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13464 }
13465
13466 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13467 if findings.is_empty() {
13468 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13469 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13470 } else {
13471 for f in &findings {
13472 result.push_str(&format!("- Finding: {f}\n"));
13473 }
13474 }
13475 result.push('\n');
13476 result.push_str(&out);
13477 Ok(result)
13478}
13479
13480#[cfg(not(windows))]
13481fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13482 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13483}
13484
13485#[cfg(windows)]
13488fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13489 let mut out = String::from("=== Installer engines ===\n");
13490
13491 let ps_engines = r#"
13492$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13493foreach ($name in $services) {
13494 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13495 if ($svc) {
13496 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13497 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13498 "$name | Status: $($svc.Status) | StartType: $startType"
13499 } else {
13500 "$name | Not present"
13501 }
13502}
13503if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13504 "msiexec.exe | Present: Yes"
13505} else {
13506 "msiexec.exe | Present: No"
13507}
13508"#;
13509 match run_powershell(ps_engines) {
13510 Ok(o) if !o.trim().is_empty() => {
13511 for line in o.lines().take(max_entries + 6) {
13512 let l = line.trim();
13513 if !l.is_empty() {
13514 out.push_str(&format!("- {l}\n"));
13515 }
13516 }
13517 }
13518 _ => out.push_str("- Could not inspect installer engine services\n"),
13519 }
13520
13521 out.push_str("\n=== winget and App Installer ===\n");
13522 let ps_winget = r#"
13523$cmd = Get-Command winget -ErrorAction SilentlyContinue
13524if ($cmd) {
13525 try {
13526 $v = & winget --version 2>$null
13527 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13528 } catch { "winget | Present but invocation failed" }
13529} else {
13530 "winget | Missing"
13531}
13532$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13533if ($appInstaller) {
13534 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13535} else {
13536 "DesktopAppInstaller | Status: Missing"
13537}
13538"#;
13539 match run_powershell(ps_winget) {
13540 Ok(o) if !o.trim().is_empty() => {
13541 for line in o.lines().take(max_entries) {
13542 let l = line.trim();
13543 if !l.is_empty() {
13544 out.push_str(&format!("- {l}\n"));
13545 }
13546 }
13547 }
13548 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13549 }
13550
13551 out.push_str("\n=== Microsoft Store packages ===\n");
13552 let ps_store = r#"
13553$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13554if ($store) {
13555 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13556} else {
13557 "Microsoft.WindowsStore | Status: Missing"
13558}
13559"#;
13560 match run_powershell(ps_store) {
13561 Ok(o) if !o.trim().is_empty() => {
13562 for line in o.lines().take(max_entries) {
13563 let l = line.trim();
13564 if !l.is_empty() {
13565 out.push_str(&format!("- {l}\n"));
13566 }
13567 }
13568 }
13569 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13570 }
13571
13572 out.push_str("\n=== Reboot and transaction blockers ===\n");
13573 let ps_blockers = r#"
13574$pending = $false
13575if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13576 "RebootPending: CBS"
13577 $pending = $true
13578}
13579if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13580 "RebootPending: WindowsUpdate"
13581 $pending = $true
13582}
13583$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13584if ($rename) {
13585 "PendingFileRenameOperations: Yes"
13586 $pending = $true
13587}
13588if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13589 "InstallerInProgress: Yes"
13590 $pending = $true
13591}
13592if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13593"#;
13594 match run_powershell(ps_blockers) {
13595 Ok(o) if !o.trim().is_empty() => {
13596 for line in o.lines().take(max_entries) {
13597 let l = line.trim();
13598 if !l.is_empty() {
13599 out.push_str(&format!("- {l}\n"));
13600 }
13601 }
13602 }
13603 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13604 }
13605
13606 out.push_str("\n=== Recent installer failures (7d) ===\n");
13607 let ps_failures = r#"
13608$cutoff = (Get-Date).AddDays(-7)
13609$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13610 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13611$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13612 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13613 Select-Object -First 6 |
13614 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13615$all = @($msi) + @($appx)
13616if ($all.Count -eq 0) {
13617 "No recent MSI/AppX installer errors detected"
13618} else {
13619 $all | Select-Object -First 8
13620}
13621"#;
13622 match run_powershell(ps_failures) {
13623 Ok(o) if !o.trim().is_empty() => {
13624 for line in o.lines().take(max_entries + 2) {
13625 let l = line.trim();
13626 if !l.is_empty() {
13627 out.push_str(&format!("- {l}\n"));
13628 }
13629 }
13630 }
13631 _ => out.push_str("- Could not inspect recent installer failure events\n"),
13632 }
13633
13634 let mut findings: Vec<String> = Vec::new();
13635 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13636 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13637 }
13638 if out.contains("msiexec.exe | Present: No") {
13639 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13640 }
13641 if out.contains("winget | Missing") {
13642 findings.push(
13643 "winget is missing - App Installer may not be installed or registered for this user."
13644 .into(),
13645 );
13646 }
13647 if out.contains("DesktopAppInstaller | Status: Missing") {
13648 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13649 }
13650 if out.contains("Microsoft.WindowsStore | Status: Missing") {
13651 findings.push(
13652 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13653 .into(),
13654 );
13655 }
13656 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13657 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13658 }
13659 if out.contains("InstallerInProgress: Yes") {
13660 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13661 }
13662 if out.contains("MSI | ") || out.contains("AppX | ") {
13663 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());
13664 }
13665
13666 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13667 if findings.is_empty() {
13668 result.push_str("- No obvious installer-platform blocker detected.\n");
13669 } else {
13670 for finding in &findings {
13671 result.push_str(&format!("- Finding: {finding}\n"));
13672 }
13673 }
13674 result.push('\n');
13675 result.push_str(&out);
13676 Ok(result)
13677}
13678
13679#[cfg(not(windows))]
13680fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13681 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())
13682}
13683
13684#[cfg(windows)]
13687fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13688 let mut out = String::from("=== OneDrive client ===\n");
13689
13690 let ps_client = r#"
13691$candidatePaths = @(
13692 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13693 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13694 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13695) | Where-Object { $_ -and (Test-Path $_) }
13696$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13697$exe = $candidatePaths | Select-Object -First 1
13698if (-not $exe -and $proc) {
13699 try { $exe = $proc.Path } catch {}
13700}
13701if ($exe) {
13702 "Installed: Yes"
13703 "Executable: $exe"
13704 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13705} else {
13706 "Installed: Unknown"
13707}
13708if ($proc) {
13709 "Process: Running | PID: $($proc.Id)"
13710} else {
13711 "Process: Not running"
13712}
13713"#;
13714 match run_powershell(ps_client) {
13715 Ok(o) if !o.trim().is_empty() => {
13716 for line in o.lines().take(max_entries) {
13717 let l = line.trim();
13718 if !l.is_empty() {
13719 out.push_str(&format!("- {l}\n"));
13720 }
13721 }
13722 }
13723 _ => out.push_str("- Could not inspect OneDrive client state\n"),
13724 }
13725
13726 out.push_str("\n=== OneDrive accounts ===\n");
13727 let ps_accounts = r#"
13728function MaskEmail([string]$Email) {
13729 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13730 $parts = $Email.Split('@', 2)
13731 $local = $parts[0]
13732 $domain = $parts[1]
13733 if ($local.Length -le 1) { return "*@$domain" }
13734 return ($local.Substring(0,1) + "***@" + $domain)
13735}
13736$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13737if (Test-Path $base) {
13738 Get-ChildItem $base -ErrorAction SilentlyContinue |
13739 Sort-Object PSChildName |
13740 Select-Object -First 12 |
13741 ForEach-Object {
13742 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13743 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13744 $mail = MaskEmail ([string]$p.UserEmail)
13745 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13746 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13747 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13748 }
13749} else {
13750 "No OneDrive accounts configured"
13751}
13752"#;
13753 match run_powershell(ps_accounts) {
13754 Ok(o) if !o.trim().is_empty() => {
13755 for line in o.lines().take(max_entries) {
13756 let l = line.trim();
13757 if !l.is_empty() {
13758 out.push_str(&format!("- {l}\n"));
13759 }
13760 }
13761 }
13762 _ => out.push_str("- Could not read OneDrive account registry state\n"),
13763 }
13764
13765 out.push_str("\n=== OneDrive policy overrides ===\n");
13766 let ps_policy = r#"
13767$paths = @(
13768 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13769 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13770)
13771$names = @(
13772 'DisableFileSyncNGSC',
13773 'DisableLibrariesDefaultSaveToOneDrive',
13774 'KFMSilentOptIn',
13775 'KFMBlockOptIn',
13776 'SilentAccountConfig'
13777)
13778$found = $false
13779foreach ($path in $paths) {
13780 if (Test-Path $path) {
13781 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13782 foreach ($name in $names) {
13783 $value = $p.$name
13784 if ($null -ne $value -and [string]$value -ne '') {
13785 "$path | $name=$value"
13786 $found = $true
13787 }
13788 }
13789 }
13790}
13791if (-not $found) { "No OneDrive policy overrides detected" }
13792"#;
13793 match run_powershell(ps_policy) {
13794 Ok(o) if !o.trim().is_empty() => {
13795 for line in o.lines().take(max_entries) {
13796 let l = line.trim();
13797 if !l.is_empty() {
13798 out.push_str(&format!("- {l}\n"));
13799 }
13800 }
13801 }
13802 _ => out.push_str("- Could not read OneDrive policy state\n"),
13803 }
13804
13805 out.push_str("\n=== Known Folder Backup ===\n");
13806 let ps_kfm = r#"
13807$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13808$roots = @()
13809if (Test-Path $base) {
13810 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13811 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13812 if ($p.UserFolder) {
13813 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13814 }
13815 }
13816}
13817$roots = $roots | Select-Object -Unique
13818$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13819if (Test-Path $shell) {
13820 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13821 $folders = @(
13822 @{ Name='Desktop'; Value=$props.Desktop },
13823 @{ Name='Documents'; Value=$props.Personal },
13824 @{ Name='Pictures'; Value=$props.'My Pictures' }
13825 )
13826 foreach ($folder in $folders) {
13827 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13828 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13829 $protected = $false
13830 foreach ($root in $roots) {
13831 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13832 $protected = $true
13833 break
13834 }
13835 }
13836 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13837 }
13838} else {
13839 "Explorer shell folders unavailable"
13840}
13841"#;
13842 match run_powershell(ps_kfm) {
13843 Ok(o) if !o.trim().is_empty() => {
13844 for line in o.lines().take(max_entries) {
13845 let l = line.trim();
13846 if !l.is_empty() {
13847 out.push_str(&format!("- {l}\n"));
13848 }
13849 }
13850 }
13851 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13852 }
13853
13854 let mut findings: Vec<String> = Vec::new();
13855 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13856 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13857 }
13858 if out.contains("No OneDrive accounts configured") {
13859 findings.push(
13860 "No OneDrive accounts are configured - sync cannot start until the user signs in."
13861 .into(),
13862 );
13863 }
13864 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13865 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13866 }
13867 if out.contains("Exists: No") {
13868 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13869 }
13870 if out.contains("DisableFileSyncNGSC=1") {
13871 findings
13872 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13873 }
13874 if out.contains("KFMBlockOptIn=1") {
13875 findings
13876 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13877 }
13878 if out.contains("SyncRoot: C:\\") {
13879 let mut missing_kfm: Vec<&str> = Vec::new();
13880 for folder in ["Desktop", "Documents", "Pictures"] {
13881 if out.lines().any(|line| {
13882 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13883 }) {
13884 missing_kfm.push(folder);
13885 }
13886 }
13887 if !missing_kfm.is_empty() {
13888 findings.push(format!(
13889 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13890 missing_kfm.join(", ")
13891 ));
13892 }
13893 }
13894
13895 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13896 if findings.is_empty() {
13897 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13898 } else {
13899 for finding in &findings {
13900 result.push_str(&format!("- Finding: {finding}\n"));
13901 }
13902 }
13903 result.push('\n');
13904 result.push_str(&out);
13905 Ok(result)
13906}
13907
13908#[cfg(not(windows))]
13909fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13910 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13911}
13912
13913#[cfg(windows)]
13914fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13915 let mut out = String::from("=== Browser inventory ===\n");
13916
13917 let ps_inventory = r#"
13918$browsers = @(
13919 @{ Name='Edge'; Paths=@(
13920 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13921 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13922 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13923 @{ Name='Chrome'; Paths=@(
13924 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13925 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13926 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13927 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13928 @{ Name='Firefox'; Paths=@(
13929 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13930 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13931 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13932)
13933foreach ($browser in $browsers) {
13934 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13935 if ($exe) {
13936 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13937 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13938 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13939 } else {
13940 "$($browser.Name) | Installed: No"
13941 }
13942}
13943$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13944$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13945$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13946"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13947"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13948"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13949"#;
13950 match run_powershell(ps_inventory) {
13951 Ok(o) if !o.trim().is_empty() => {
13952 for line in o.lines().take(max_entries + 6) {
13953 let l = line.trim();
13954 if !l.is_empty() {
13955 out.push_str(&format!("- {l}\n"));
13956 }
13957 }
13958 }
13959 _ => out.push_str("- Could not inspect installed browser inventory\n"),
13960 }
13961
13962 out.push_str("\n=== Runtime state ===\n");
13963 let ps_runtime = r#"
13964$targets = 'msedge','chrome','firefox','msedgewebview2'
13965foreach ($name in $targets) {
13966 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13967 if ($procs) {
13968 $count = @($procs).Count
13969 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13970 "$name | Processes: $count | WorkingSetMB: $wsMb"
13971 } else {
13972 "$name | Processes: 0 | WorkingSetMB: 0"
13973 }
13974}
13975"#;
13976 match run_powershell(ps_runtime) {
13977 Ok(o) if !o.trim().is_empty() => {
13978 for line in o.lines().take(max_entries + 4) {
13979 let l = line.trim();
13980 if !l.is_empty() {
13981 out.push_str(&format!("- {l}\n"));
13982 }
13983 }
13984 }
13985 _ => out.push_str("- Could not inspect browser runtime state\n"),
13986 }
13987
13988 out.push_str("\n=== WebView2 runtime ===\n");
13989 let ps_webview = r#"
13990$paths = @(
13991 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13992 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13993) | Where-Object { $_ -and (Test-Path $_) }
13994$runtimeDir = $paths | ForEach-Object {
13995 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13996 Where-Object { $_.Name -match '^\d+\.' } |
13997 Sort-Object Name -Descending |
13998 Select-Object -First 1
13999} | Select-Object -First 1
14000if ($runtimeDir) {
14001 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14002 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14003 "Installed: Yes"
14004 "Version: $version"
14005 "Executable: $exe"
14006} else {
14007 "Installed: No"
14008}
14009$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14010"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14011"#;
14012 match run_powershell(ps_webview) {
14013 Ok(o) if !o.trim().is_empty() => {
14014 for line in o.lines().take(max_entries) {
14015 let l = line.trim();
14016 if !l.is_empty() {
14017 out.push_str(&format!("- {l}\n"));
14018 }
14019 }
14020 }
14021 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14022 }
14023
14024 out.push_str("\n=== Policy and proxy surface ===\n");
14025 let ps_policy = r#"
14026$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14027$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14028$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14029$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14030$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14031"UserProxyEnabled: $proxyEnabled"
14032"UserProxyServer: $proxyServer"
14033"UserAutoConfigURL: $autoConfig"
14034"UserAutoDetect: $autoDetect"
14035$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14036if ($winhttp) {
14037 $normalized = ($winhttp -replace '\s+', ' ').Trim()
14038 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14039 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14040 "WinHTTP: $normalized"
14041}
14042$policyTargets = @(
14043 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14044 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14045)
14046foreach ($policy in $policyTargets) {
14047 if (Test-Path $policy.Path) {
14048 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14049 foreach ($key in $policy.Keys) {
14050 $value = $item.$key
14051 if ($null -ne $value -and [string]$value -ne '') {
14052 if ($value -is [array]) {
14053 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14054 } else {
14055 "$($policy.Name)Policy | $key=$value"
14056 }
14057 }
14058 }
14059 }
14060}
14061"#;
14062 match run_powershell(ps_policy) {
14063 Ok(o) if !o.trim().is_empty() => {
14064 for line in o.lines().take(max_entries + 8) {
14065 let l = line.trim();
14066 if !l.is_empty() {
14067 out.push_str(&format!("- {l}\n"));
14068 }
14069 }
14070 }
14071 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14072 }
14073
14074 out.push_str("\n=== Profile and cache pressure ===\n");
14075 let ps_profiles = r#"
14076$profiles = @(
14077 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14078 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14079 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14080)
14081foreach ($profile in $profiles) {
14082 if (Test-Path $profile.Root) {
14083 if ($profile.Name -eq 'Firefox') {
14084 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14085 } else {
14086 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14087 Where-Object {
14088 $_.Name -eq 'Default' -or
14089 $_.Name -eq 'Guest Profile' -or
14090 $_.Name -eq 'System Profile' -or
14091 $_.Name -like 'Profile *'
14092 }
14093 }
14094 $profileCount = @($dirs).Count
14095 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14096 if (-not $sizeBytes) { $sizeBytes = 0 }
14097 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14098 $extCount = 'Unknown'
14099 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14100 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14101 }
14102 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14103 } else {
14104 "$($profile.Name) | ProfileRoot: Missing"
14105 }
14106}
14107"#;
14108 match run_powershell(ps_profiles) {
14109 Ok(o) if !o.trim().is_empty() => {
14110 for line in o.lines().take(max_entries + 4) {
14111 let l = line.trim();
14112 if !l.is_empty() {
14113 out.push_str(&format!("- {l}\n"));
14114 }
14115 }
14116 }
14117 _ => out.push_str("- Could not inspect browser profile pressure\n"),
14118 }
14119
14120 out.push_str("\n=== Recent browser failures (7d) ===\n");
14121 let ps_failures = r#"
14122$cutoff = (Get-Date).AddDays(-7)
14123$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14124$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14125 Where-Object {
14126 $msg = [string]$_.Message
14127 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14128 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14129 } |
14130 Select-Object -First 6
14131if ($events) {
14132 foreach ($event in $events) {
14133 $msg = ($event.Message -replace '\s+', ' ')
14134 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14135 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14136 }
14137} else {
14138 "No recent browser crash or WER events detected"
14139}
14140"#;
14141 match run_powershell(ps_failures) {
14142 Ok(o) if !o.trim().is_empty() => {
14143 for line in o.lines().take(max_entries + 2) {
14144 let l = line.trim();
14145 if !l.is_empty() {
14146 out.push_str(&format!("- {l}\n"));
14147 }
14148 }
14149 }
14150 _ => out.push_str("- Could not inspect recent browser failure events\n"),
14151 }
14152
14153 let mut findings: Vec<String> = Vec::new();
14154 if out.contains("Edge | Installed: No")
14155 && out.contains("Chrome | Installed: No")
14156 && out.contains("Firefox | Installed: No")
14157 {
14158 findings.push(
14159 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14160 .into(),
14161 );
14162 }
14163 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14164 findings.push(
14165 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14166 .into(),
14167 );
14168 }
14169 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14170 findings.push(
14171 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14172 .into(),
14173 );
14174 }
14175 if out.contains("EdgePolicy | Proxy")
14176 || out.contains("ChromePolicy | Proxy")
14177 || out.contains("ExtensionInstallForcelist=")
14178 {
14179 findings.push(
14180 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14181 .into(),
14182 );
14183 }
14184 for browser in ["msedge", "chrome", "firefox"] {
14185 let process_marker = format!("{browser} | Processes: ");
14186 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14187 let count = line
14188 .split("| Processes: ")
14189 .nth(1)
14190 .and_then(|rest| rest.split(" |").next())
14191 .and_then(|value| value.trim().parse::<usize>().ok())
14192 .unwrap_or(0);
14193 let ws_mb = line
14194 .split("| WorkingSetMB: ")
14195 .nth(1)
14196 .and_then(|value| value.trim().parse::<f64>().ok())
14197 .unwrap_or(0.0);
14198 if count >= 25 {
14199 findings.push(format!(
14200 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14201 ));
14202 } else if ws_mb >= 2500.0 {
14203 findings.push(format!(
14204 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14205 ));
14206 }
14207 }
14208 }
14209 if out.contains("=== WebView2 runtime ===\n- Installed: No")
14210 || (out.contains("=== WebView2 runtime ===")
14211 && out.contains("- Installed: No")
14212 && out.contains("- ProcessCount: 0"))
14213 {
14214 findings.push(
14215 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14216 .into(),
14217 );
14218 }
14219 for browser in ["Edge", "Chrome", "Firefox"] {
14220 let prefix = format!("{browser} | ProfileRoot:");
14221 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14222 let size_gb = line
14223 .split("| SizeGB: ")
14224 .nth(1)
14225 .and_then(|rest| rest.split(" |").next())
14226 .and_then(|value| value.trim().parse::<f64>().ok())
14227 .unwrap_or(0.0);
14228 let ext_count = line
14229 .split("| Extensions: ")
14230 .nth(1)
14231 .and_then(|value| value.trim().parse::<usize>().ok())
14232 .unwrap_or(0);
14233 if size_gb >= 2.5 {
14234 findings.push(format!(
14235 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14236 ));
14237 }
14238 if ext_count >= 20 {
14239 findings.push(format!(
14240 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14241 ));
14242 }
14243 }
14244 }
14245 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14246 findings.push(
14247 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14248 .into(),
14249 );
14250 }
14251
14252 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14253 if findings.is_empty() {
14254 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14255 } else {
14256 for finding in &findings {
14257 result.push_str(&format!("- Finding: {finding}\n"));
14258 }
14259 }
14260 result.push('\n');
14261 result.push_str(&out);
14262 Ok(result)
14263}
14264
14265#[cfg(not(windows))]
14266fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14267 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())
14268}
14269
14270#[cfg(windows)]
14271fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14272 let mut out = String::from("=== Outlook install inventory ===\n");
14273
14274 let ps_install = r#"
14275$installPaths = @(
14276 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14277 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14278 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14279 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14280 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14281 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14282)
14283$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14284if ($exe) {
14285 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14286 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14287 "Installed: Yes"
14288 "Executable: $exe"
14289 "Version: $version"
14290 "Product: $productName"
14291} else {
14292 "Installed: No"
14293}
14294$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14295if ($newOutlook) {
14296 "NewOutlook: Installed | Version: $($newOutlook.Version)"
14297} else {
14298 "NewOutlook: Not installed"
14299}
14300"#;
14301 match run_powershell(ps_install) {
14302 Ok(o) if !o.trim().is_empty() => {
14303 for line in o.lines().take(max_entries + 4) {
14304 let l = line.trim();
14305 if !l.is_empty() {
14306 out.push_str(&format!("- {l}\n"));
14307 }
14308 }
14309 }
14310 _ => out.push_str("- Could not inspect Outlook install paths\n"),
14311 }
14312
14313 out.push_str("\n=== Runtime state ===\n");
14314 let ps_runtime = r#"
14315$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14316if ($proc) {
14317 $count = @($proc).Count
14318 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14319 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14320 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14321} else {
14322 "Running: No"
14323}
14324"#;
14325 match run_powershell(ps_runtime) {
14326 Ok(o) if !o.trim().is_empty() => {
14327 for line in o.lines().take(4) {
14328 let l = line.trim();
14329 if !l.is_empty() {
14330 out.push_str(&format!("- {l}\n"));
14331 }
14332 }
14333 }
14334 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14335 }
14336
14337 out.push_str("\n=== Mail profiles ===\n");
14338 let ps_profiles = r#"
14339$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14340if (-not (Test-Path $profileKey)) {
14341 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14342}
14343if (Test-Path $profileKey) {
14344 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14345 $count = @($profiles).Count
14346 "ProfileCount: $count"
14347 foreach ($p in $profiles | Select-Object -First 10) {
14348 "Profile: $($p.PSChildName)"
14349 }
14350} else {
14351 "ProfileCount: 0"
14352 "No Outlook profiles found in registry"
14353}
14354"#;
14355 match run_powershell(ps_profiles) {
14356 Ok(o) if !o.trim().is_empty() => {
14357 for line in o.lines().take(max_entries + 2) {
14358 let l = line.trim();
14359 if !l.is_empty() {
14360 out.push_str(&format!("- {l}\n"));
14361 }
14362 }
14363 }
14364 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14365 }
14366
14367 out.push_str("\n=== OST and PST data files ===\n");
14368 let ps_datafiles = r#"
14369$searchRoots = @(
14370 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14371 (Join-Path $env:USERPROFILE 'Documents'),
14372 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14373) | Where-Object { $_ -and (Test-Path $_) }
14374$files = foreach ($root in $searchRoots) {
14375 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14376 Select-Object FullName,
14377 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14378 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14379 LastWriteTime
14380}
14381if ($files) {
14382 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14383 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14384 }
14385} else {
14386 "No OST or PST files found in standard locations"
14387}
14388"#;
14389 match run_powershell(ps_datafiles) {
14390 Ok(o) if !o.trim().is_empty() => {
14391 for line in o.lines().take(max_entries + 4) {
14392 let l = line.trim();
14393 if !l.is_empty() {
14394 out.push_str(&format!("- {l}\n"));
14395 }
14396 }
14397 }
14398 _ => out.push_str("- Could not inspect OST/PST data files\n"),
14399 }
14400
14401 out.push_str("\n=== Add-in pressure ===\n");
14402 let ps_addins = r#"
14403$addinPaths = @(
14404 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14405 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14406 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14407)
14408$addins = foreach ($path in $addinPaths) {
14409 if (Test-Path $path) {
14410 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14411 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14412 $loadBehavior = $item.LoadBehavior
14413 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14414 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14415 }
14416 }
14417}
14418$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14419$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14420"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14421foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14422 $state = switch ($a.LoadBehavior) {
14423 0 { 'Disabled' }
14424 2 { 'LoadOnStart(inactive)' }
14425 3 { 'ActiveOnStart' }
14426 8 { 'DemandLoad' }
14427 9 { 'ActiveDemand' }
14428 16 { 'ConnectedFirst' }
14429 default { "LoadBehavior=$($a.LoadBehavior)" }
14430 }
14431 "$($a.Name) | $state"
14432}
14433$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14434$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14435if (Test-Path $disabledByResiliency) {
14436 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14437 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14438 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14439}
14440"#;
14441 match run_powershell(ps_addins) {
14442 Ok(o) if !o.trim().is_empty() => {
14443 for line in o.lines().take(max_entries + 8) {
14444 let l = line.trim();
14445 if !l.is_empty() {
14446 out.push_str(&format!("- {l}\n"));
14447 }
14448 }
14449 }
14450 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14451 }
14452
14453 out.push_str("\n=== Authentication and cache friction ===\n");
14454 let ps_auth = r#"
14455$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14456$tokenCount = if (Test-Path $tokenCache) {
14457 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14458} else { 0 }
14459"TokenBrokerCacheFiles: $tokenCount"
14460$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14461$credsCount = @($credentialManager).Count
14462"OfficeCredentialsInVault: $credsCount"
14463$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14464if (Test-Path $samlKey) {
14465 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14466 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14467 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14468 "WAMOverride: $connected"
14469 "SignedInUserId: $signedIn"
14470}
14471$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14472if (Test-Path $outlookReg) {
14473 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14474 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14475}
14476"#;
14477 match run_powershell(ps_auth) {
14478 Ok(o) if !o.trim().is_empty() => {
14479 for line in o.lines().take(max_entries + 4) {
14480 let l = line.trim();
14481 if !l.is_empty() {
14482 out.push_str(&format!("- {l}\n"));
14483 }
14484 }
14485 }
14486 _ => out.push_str("- Could not inspect Outlook auth state\n"),
14487 }
14488
14489 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14490 let ps_events = r#"
14491$cutoff = (Get-Date).AddDays(-7)
14492$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14493 Where-Object {
14494 $msg = [string]$_.Message
14495 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14496 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14497 } |
14498 Select-Object -First 8
14499if ($events) {
14500 foreach ($event in $events) {
14501 $msg = ($event.Message -replace '\s+', ' ')
14502 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14503 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14504 }
14505} else {
14506 "No recent Outlook crash or error events detected in Application log"
14507}
14508"#;
14509 match run_powershell(ps_events) {
14510 Ok(o) if !o.trim().is_empty() => {
14511 for line in o.lines().take(max_entries + 4) {
14512 let l = line.trim();
14513 if !l.is_empty() {
14514 out.push_str(&format!("- {l}\n"));
14515 }
14516 }
14517 }
14518 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14519 }
14520
14521 let mut findings: Vec<String> = Vec::new();
14522
14523 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14524 findings.push(
14525 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14526 .into(),
14527 );
14528 }
14529
14530 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14531 let ws_mb = line
14532 .split("WorkingSetMB: ")
14533 .nth(1)
14534 .and_then(|r| r.split(" |").next())
14535 .and_then(|v| v.trim().parse::<f64>().ok())
14536 .unwrap_or(0.0);
14537 if ws_mb >= 1500.0 {
14538 findings.push(format!(
14539 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14540 ));
14541 }
14542 }
14543
14544 let large_ost: Vec<String> = out
14545 .lines()
14546 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14547 .filter_map(|l| {
14548 let mb = l
14549 .split("SizeMB: ")
14550 .nth(1)
14551 .and_then(|r| r.split(" |").next())
14552 .and_then(|v| v.trim().parse::<f64>().ok())
14553 .unwrap_or(0.0);
14554 if mb >= 10_000.0 {
14555 Some(format!("{mb:.0} MB OST file detected"))
14556 } else {
14557 None
14558 }
14559 })
14560 .collect();
14561 for msg in large_ost {
14562 findings.push(format!(
14563 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14564 ));
14565 }
14566
14567 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14568 let active_count = line
14569 .split("Active: ")
14570 .nth(1)
14571 .and_then(|r| r.split(" |").next())
14572 .and_then(|v| v.trim().parse::<usize>().ok())
14573 .unwrap_or(0);
14574 if active_count >= 8 {
14575 findings.push(format!(
14576 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14577 ));
14578 }
14579 }
14580
14581 if out.contains("ResiliencyDisabledItems:") {
14582 findings.push(
14583 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14584 .into(),
14585 );
14586 }
14587
14588 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14589 findings.push(
14590 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14591 .into(),
14592 );
14593 }
14594
14595 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14596 findings.push(
14597 "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)."
14598 .into(),
14599 );
14600 }
14601
14602 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14603 if findings.is_empty() {
14604 result.push_str("- No obvious Outlook health blocker detected.\n");
14605 } else {
14606 for finding in &findings {
14607 result.push_str(&format!("- Finding: {finding}\n"));
14608 }
14609 }
14610 result.push('\n');
14611 result.push_str(&out);
14612 Ok(result)
14613}
14614
14615#[cfg(not(windows))]
14616fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14617 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14618}
14619
14620#[cfg(windows)]
14621fn inspect_teams(max_entries: usize) -> Result<String, String> {
14622 let mut out = String::from("=== Teams install inventory ===\n");
14623
14624 let ps_install = r#"
14625# Classic Teams (Teams 1.0)
14626$classicExe = @(
14627 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14628 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14629) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14630
14631if ($classicExe) {
14632 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14633 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14634} else {
14635 "ClassicTeams: Not installed"
14636}
14637
14638# New Teams (Teams 2.0 / ms-teams.exe)
14639$newTeamsExe = @(
14640 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14641 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14642) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14643
14644$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14645if ($newTeamsPkg) {
14646 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14647} elseif ($newTeamsExe) {
14648 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14649 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14650} else {
14651 "NewTeams: Not installed"
14652}
14653
14654# Teams Machine-Wide Installer (MSI/per-machine)
14655$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14656 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14657 Select-Object -First 1
14658if ($mwi) {
14659 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14660} else {
14661 "MachineWideInstaller: Not found"
14662}
14663"#;
14664 match run_powershell(ps_install) {
14665 Ok(o) if !o.trim().is_empty() => {
14666 for line in o.lines().take(max_entries + 4) {
14667 let l = line.trim();
14668 if !l.is_empty() {
14669 out.push_str(&format!("- {l}\n"));
14670 }
14671 }
14672 }
14673 _ => out.push_str("- Could not inspect Teams install paths\n"),
14674 }
14675
14676 out.push_str("\n=== Runtime state ===\n");
14677 let ps_runtime = r#"
14678$targets = @('Teams','ms-teams')
14679foreach ($name in $targets) {
14680 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14681 if ($procs) {
14682 $count = @($procs).Count
14683 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14684 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14685 } else {
14686 "$name | Running: No"
14687 }
14688}
14689"#;
14690 match run_powershell(ps_runtime) {
14691 Ok(o) if !o.trim().is_empty() => {
14692 for line in o.lines().take(6) {
14693 let l = line.trim();
14694 if !l.is_empty() {
14695 out.push_str(&format!("- {l}\n"));
14696 }
14697 }
14698 }
14699 _ => out.push_str("- Could not inspect Teams runtime state\n"),
14700 }
14701
14702 out.push_str("\n=== Cache directory sizing ===\n");
14703 let ps_cache = r#"
14704$cachePaths = @(
14705 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14706 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14707 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14708 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14709)
14710foreach ($entry in $cachePaths) {
14711 if (Test-Path $entry.Path) {
14712 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14713 if (-not $sizeBytes) { $sizeBytes = 0 }
14714 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14715 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14716 } else {
14717 "$($entry.Name) | Path: $($entry.Path) | Not found"
14718 }
14719}
14720"#;
14721 match run_powershell(ps_cache) {
14722 Ok(o) if !o.trim().is_empty() => {
14723 for line in o.lines().take(max_entries + 4) {
14724 let l = line.trim();
14725 if !l.is_empty() {
14726 out.push_str(&format!("- {l}\n"));
14727 }
14728 }
14729 }
14730 _ => out.push_str("- Could not inspect Teams cache directories\n"),
14731 }
14732
14733 out.push_str("\n=== WebView2 runtime ===\n");
14734 let ps_webview = r#"
14735$paths = @(
14736 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14737 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14738) | Where-Object { $_ -and (Test-Path $_) }
14739$runtimeDir = $paths | ForEach-Object {
14740 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14741 Where-Object { $_.Name -match '^\d+\.' } |
14742 Sort-Object Name -Descending |
14743 Select-Object -First 1
14744} | Select-Object -First 1
14745if ($runtimeDir) {
14746 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14747 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14748 "Installed: Yes | Version: $version"
14749} else {
14750 "Installed: No -- New Teams and some Office features require WebView2"
14751}
14752"#;
14753 match run_powershell(ps_webview) {
14754 Ok(o) if !o.trim().is_empty() => {
14755 for line in o.lines().take(4) {
14756 let l = line.trim();
14757 if !l.is_empty() {
14758 out.push_str(&format!("- {l}\n"));
14759 }
14760 }
14761 }
14762 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14763 }
14764
14765 out.push_str("\n=== Account and sign-in state ===\n");
14766 let ps_auth = r#"
14767# Classic Teams account registry
14768$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14769if (Test-Path $classicAcct) {
14770 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14771 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14772 "ClassicTeamsAccount: $email"
14773} else {
14774 "ClassicTeamsAccount: Not configured"
14775}
14776# WAM / token broker state for Teams
14777$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14778$tokenCount = if (Test-Path $tokenCache) {
14779 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14780} else { 0 }
14781"TokenBrokerCacheFiles: $tokenCount"
14782# Office identity
14783$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14784if (Test-Path $officeId) {
14785 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14786 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14787 "OfficeSignedInUserId: $signedIn"
14788}
14789# Check if Teams is in startup
14790$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14791$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14792"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14793"#;
14794 match run_powershell(ps_auth) {
14795 Ok(o) if !o.trim().is_empty() => {
14796 for line in o.lines().take(max_entries + 4) {
14797 let l = line.trim();
14798 if !l.is_empty() {
14799 out.push_str(&format!("- {l}\n"));
14800 }
14801 }
14802 }
14803 _ => out.push_str("- Could not inspect Teams account state\n"),
14804 }
14805
14806 out.push_str("\n=== Audio and video device binding ===\n");
14807 let ps_devices = r#"
14808# Teams stores device prefs in the settings file
14809$settingsPaths = @(
14810 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14811 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14812)
14813$found = $false
14814foreach ($sp in $settingsPaths) {
14815 if (Test-Path $sp) {
14816 $found = $true
14817 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14818 if ($raw) {
14819 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14820 if ($json) {
14821 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14822 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14823 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14824 "ConfigFile: $sp"
14825 "Microphone: $mic"
14826 "Speaker: $spk"
14827 "Camera: $cam"
14828 } else {
14829 "ConfigFile: $sp (not parseable as JSON)"
14830 }
14831 } else {
14832 "ConfigFile: $sp (empty)"
14833 }
14834 break
14835 }
14836}
14837if (-not $found) {
14838 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14839}
14840"#;
14841 match run_powershell(ps_devices) {
14842 Ok(o) if !o.trim().is_empty() => {
14843 for line in o.lines().take(max_entries + 4) {
14844 let l = line.trim();
14845 if !l.is_empty() {
14846 out.push_str(&format!("- {l}\n"));
14847 }
14848 }
14849 }
14850 _ => out.push_str("- Could not inspect Teams device binding\n"),
14851 }
14852
14853 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14854 let ps_events = r#"
14855$cutoff = (Get-Date).AddDays(-7)
14856$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14857 Where-Object {
14858 $msg = [string]$_.Message
14859 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14860 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14861 } |
14862 Select-Object -First 8
14863if ($events) {
14864 foreach ($event in $events) {
14865 $msg = ($event.Message -replace '\s+', ' ')
14866 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14867 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14868 }
14869} else {
14870 "No recent Teams crash or error events detected in Application log"
14871}
14872"#;
14873 match run_powershell(ps_events) {
14874 Ok(o) if !o.trim().is_empty() => {
14875 for line in o.lines().take(max_entries + 4) {
14876 let l = line.trim();
14877 if !l.is_empty() {
14878 out.push_str(&format!("- {l}\n"));
14879 }
14880 }
14881 }
14882 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14883 }
14884
14885 let mut findings: Vec<String> = Vec::new();
14886
14887 let classic_installed = out.contains("- ClassicTeams: Installed");
14888 let new_installed = out.contains("- NewTeams: Installed");
14889 if !classic_installed && !new_installed {
14890 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14891 }
14892
14893 for name in ["Teams", "ms-teams"] {
14894 let marker = format!("{name} | Running: Yes | Processes:");
14895 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14896 let ws_mb = line
14897 .split("WorkingSetMB: ")
14898 .nth(1)
14899 .and_then(|v| v.trim().parse::<f64>().ok())
14900 .unwrap_or(0.0);
14901 if ws_mb >= 1000.0 {
14902 findings.push(format!(
14903 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14904 ));
14905 }
14906 }
14907 }
14908
14909 for (label, threshold_mb) in [
14910 ("ClassicTeamsCache", 500.0_f64),
14911 ("ClassicTeamsSquirrel", 2000.0),
14912 ("NewTeamsCache", 500.0),
14913 ("NewTeamsAppData", 3000.0),
14914 ] {
14915 let marker = format!("{label} |");
14916 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14917 let mb = line
14918 .split("SizeMB: ")
14919 .nth(1)
14920 .and_then(|v| v.trim().parse::<f64>().ok())
14921 .unwrap_or(0.0);
14922 if mb >= threshold_mb {
14923 findings.push(format!(
14924 "{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."
14925 ));
14926 }
14927 }
14928 }
14929
14930 if out.contains("- Installed: No -- New Teams") {
14931 findings.push(
14932 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14933 .into(),
14934 );
14935 }
14936
14937 if out.contains("- ClassicTeamsAccount: Not configured")
14938 && out.contains("- OfficeSignedInUserId: None")
14939 {
14940 findings.push(
14941 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14942 .into(),
14943 );
14944 }
14945
14946 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14947 findings.push(
14948 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14949 .into(),
14950 );
14951 }
14952
14953 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14954 if findings.is_empty() {
14955 result.push_str("- No obvious Teams health blocker detected.\n");
14956 } else {
14957 for finding in &findings {
14958 result.push_str(&format!("- Finding: {finding}\n"));
14959 }
14960 }
14961 result.push('\n');
14962 result.push_str(&out);
14963 Ok(result)
14964}
14965
14966#[cfg(not(windows))]
14967fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14968 Ok(
14969 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14970 .into(),
14971 )
14972}
14973
14974#[cfg(windows)]
14975fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14976 let mut out = String::from("=== Identity broker services ===\n");
14977
14978 let ps_services = r#"
14979$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14980foreach ($name in $serviceNames) {
14981 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14982 if ($svc) {
14983 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14984 } else {
14985 "$name | Not found"
14986 }
14987}
14988"#;
14989 match run_powershell(ps_services) {
14990 Ok(o) if !o.trim().is_empty() => {
14991 for line in o.lines().take(max_entries) {
14992 let l = line.trim();
14993 if !l.is_empty() {
14994 out.push_str(&format!("- {l}\n"));
14995 }
14996 }
14997 }
14998 _ => out.push_str("- Could not inspect identity broker services\n"),
14999 }
15000
15001 out.push_str("\n=== Device registration ===\n");
15002 let ps_device = r#"
15003$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15004if ($dsreg) {
15005 try {
15006 $raw = & $dsreg.Source /status 2>$null
15007 $text = ($raw -join "`n")
15008 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15009 $seen = $false
15010 foreach ($key in $keys) {
15011 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15012 if ($match.Success) {
15013 "${key}: $($match.Groups[1].Value.Trim())"
15014 $seen = $true
15015 }
15016 }
15017 if (-not $seen) {
15018 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15019 }
15020 } catch {
15021 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15022 }
15023} else {
15024 "DeviceRegistration: dsregcmd unavailable"
15025}
15026"#;
15027 match run_powershell(ps_device) {
15028 Ok(o) if !o.trim().is_empty() => {
15029 for line in o.lines().take(max_entries + 4) {
15030 let l = line.trim();
15031 if !l.is_empty() {
15032 out.push_str(&format!("- {l}\n"));
15033 }
15034 }
15035 }
15036 _ => out.push_str(
15037 "- DeviceRegistration: Could not inspect device registration state in this session\n",
15038 ),
15039 }
15040
15041 out.push_str("\n=== Broker packages and caches ===\n");
15042 let ps_broker = r#"
15043$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15044if ($pkg) {
15045 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15046} else {
15047 "AADBrokerPlugin: Not installed"
15048}
15049$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15050$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15051"TokenBrokerCacheFiles: $tokenCount"
15052$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15053$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15054"IdentityCacheFiles: $identityCount"
15055$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15056$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15057"OneAuthFiles: $oneAuthCount"
15058"#;
15059 match run_powershell(ps_broker) {
15060 Ok(o) if !o.trim().is_empty() => {
15061 for line in o.lines().take(max_entries + 4) {
15062 let l = line.trim();
15063 if !l.is_empty() {
15064 out.push_str(&format!("- {l}\n"));
15065 }
15066 }
15067 }
15068 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15069 }
15070
15071 out.push_str("\n=== Microsoft app account signals ===\n");
15072 let ps_accounts = r#"
15073function MaskEmail([string]$Email) {
15074 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15075 $parts = $Email.Split('@', 2)
15076 $local = $parts[0]
15077 $domain = $parts[1]
15078 if ($local.Length -le 1) { return "*@$domain" }
15079 return ($local.Substring(0,1) + "***@" + $domain)
15080}
15081$allAccounts = @()
15082$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15083if (Test-Path $officeId) {
15084 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15085 if ($id.SignedInUserId) {
15086 $allAccounts += [string]$id.SignedInUserId
15087 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15088 } else {
15089 "OfficeSignedInUserId: None"
15090 }
15091} else {
15092 "OfficeSignedInUserId: Not configured"
15093}
15094$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15095if (Test-Path $teamsAcct) {
15096 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15097 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15098 if (-not [string]::IsNullOrWhiteSpace($email)) {
15099 $allAccounts += $email
15100 "TeamsAccount: $(MaskEmail $email)"
15101 } else {
15102 "TeamsAccount: Unknown"
15103 }
15104} else {
15105 "TeamsAccount: Not configured"
15106}
15107$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15108$oneDriveEmails = @()
15109if (Test-Path $oneDriveBase) {
15110 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15111 ForEach-Object {
15112 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15113 if ($p.UserEmail) { [string]$p.UserEmail }
15114 } |
15115 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15116 Sort-Object -Unique
15117}
15118$allAccounts += $oneDriveEmails
15119"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15120if (@($oneDriveEmails).Count -gt 0) {
15121 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15122}
15123$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15124"DistinctIdentityCount: $($distinct.Count)"
15125if ($distinct.Count -gt 0) {
15126 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15127}
15128"#;
15129 match run_powershell(ps_accounts) {
15130 Ok(o) if !o.trim().is_empty() => {
15131 for line in o.lines().take(max_entries + 6) {
15132 let l = line.trim();
15133 if !l.is_empty() {
15134 out.push_str(&format!("- {l}\n"));
15135 }
15136 }
15137 }
15138 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15139 }
15140
15141 out.push_str("\n=== WebView2 auth dependency ===\n");
15142 let ps_webview = r#"
15143$paths = @(
15144 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15145 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15146) | Where-Object { $_ -and (Test-Path $_) }
15147$runtimeDir = $paths | ForEach-Object {
15148 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15149 Where-Object { $_.Name -match '^\d+\.' } |
15150 Sort-Object Name -Descending |
15151 Select-Object -First 1
15152} | Select-Object -First 1
15153if ($runtimeDir) {
15154 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15155 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15156 "WebView2: Installed | Version: $version"
15157} else {
15158 "WebView2: Not installed"
15159}
15160"#;
15161 match run_powershell(ps_webview) {
15162 Ok(o) if !o.trim().is_empty() => {
15163 for line in o.lines().take(4) {
15164 let l = line.trim();
15165 if !l.is_empty() {
15166 out.push_str(&format!("- {l}\n"));
15167 }
15168 }
15169 }
15170 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15171 }
15172
15173 out.push_str("\n=== Recent auth-related events (24h) ===\n");
15174 let ps_events = r#"
15175try {
15176 $cutoff = (Get-Date).AddHours(-24)
15177 $events = @()
15178 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15179 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15180 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15181 Select-Object -First 4
15182 }
15183 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15184 Where-Object {
15185 ($_.LevelDisplayName -in @('Error','Warning')) -and (
15186 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15187 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15188 )
15189 } |
15190 Select-Object -First 6
15191 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15192 "AuthEventCount: $(@($events).Count)"
15193 if ($events) {
15194 foreach ($e in $events) {
15195 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15196 'No message'
15197 } else {
15198 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15199 }
15200 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15201 }
15202 } else {
15203 "No auth-related warning/error events detected"
15204 }
15205} catch {
15206 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15207}
15208"#;
15209 match run_powershell(ps_events) {
15210 Ok(o) if !o.trim().is_empty() => {
15211 for line in o.lines().take(max_entries + 8) {
15212 let l = line.trim();
15213 if !l.is_empty() {
15214 out.push_str(&format!("- {l}\n"));
15215 }
15216 }
15217 }
15218 _ => out
15219 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15220 }
15221
15222 let parse_count = |prefix: &str| -> Option<u64> {
15223 out.lines().find_map(|line| {
15224 line.trim()
15225 .strip_prefix(prefix)
15226 .and_then(|value| value.trim().parse::<u64>().ok())
15227 })
15228 };
15229
15230 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15231 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15232
15233 let mut findings: Vec<String> = Vec::new();
15234 if out.contains("TokenBroker | Status: Stopped")
15235 || out.contains("wlidsvc | Status: Stopped")
15236 || out.contains("OneAuth | Status: Stopped")
15237 {
15238 findings.push(
15239 "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."
15240 .into(),
15241 );
15242 }
15243 if out.contains("AADBrokerPlugin: Not installed") {
15244 findings.push(
15245 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15246 .into(),
15247 );
15248 }
15249 if out.contains("WebView2: Not installed") {
15250 findings.push(
15251 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15252 .into(),
15253 );
15254 }
15255 if distinct_identity_count > 1 {
15256 findings.push(format!(
15257 "{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."
15258 ));
15259 }
15260 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15261 && distinct_identity_count > 0
15262 {
15263 findings.push(
15264 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15265 .into(),
15266 );
15267 }
15268 if out.contains("DeviceRegistration: dsregcmd")
15269 || out.contains("DeviceRegistration: Could not inspect device registration state")
15270 {
15271 findings.push(
15272 "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."
15273 .into(),
15274 );
15275 }
15276 if auth_event_count > 0 {
15277 findings.push(format!(
15278 "{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."
15279 ));
15280 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15281 findings.push(
15282 "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."
15283 .into(),
15284 );
15285 }
15286
15287 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15288 if findings.is_empty() {
15289 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15290 } else {
15291 for finding in &findings {
15292 result.push_str(&format!("- Finding: {finding}\n"));
15293 }
15294 }
15295 result.push('\n');
15296 result.push_str(&out);
15297 Ok(result)
15298}
15299
15300#[cfg(not(windows))]
15301fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15302 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())
15303}
15304
15305#[cfg(windows)]
15306fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15307 let mut out = String::from("=== File History ===\n");
15308
15309 let ps_fh = r#"
15310$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15311if ($svc) {
15312 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15313} else {
15314 "FileHistoryService: Not found"
15315}
15316# File History config in registry
15317$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15318$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15319if (Test-Path $fhUser) {
15320 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15321 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15322 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15323 $lastBackup = if ($fh.ProtectedUpToTime) {
15324 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15325 } else { 'Never' }
15326 "Enabled: $enabled"
15327 "BackupDrive: $target"
15328 "LastBackup: $lastBackup"
15329} else {
15330 "Enabled: Not configured"
15331 "BackupDrive: Not configured"
15332 "LastBackup: Never"
15333}
15334"#;
15335 match run_powershell(ps_fh) {
15336 Ok(o) if !o.trim().is_empty() => {
15337 for line in o.lines().take(6) {
15338 let l = line.trim();
15339 if !l.is_empty() {
15340 out.push_str(&format!("- {l}\n"));
15341 }
15342 }
15343 }
15344 _ => out.push_str("- Could not inspect File History state\n"),
15345 }
15346
15347 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15348 let ps_wbadmin = r#"
15349$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15350"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15351# Last backup from wbadmin
15352$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15353if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15354 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15355 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15356 if ($lastDate) { $lastDate.Trim() }
15357 if ($lastTarget) { $lastTarget.Trim() }
15358} else {
15359 "LastWbadminBackup: No backup versions found"
15360}
15361# Task-based backup
15362$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15363foreach ($t in $task) {
15364 "BackupTask: $($t.TaskName) | State: $($t.State)"
15365}
15366"#;
15367 match run_powershell(ps_wbadmin) {
15368 Ok(o) if !o.trim().is_empty() => {
15369 for line in o.lines().take(8) {
15370 let l = line.trim();
15371 if !l.is_empty() {
15372 out.push_str(&format!("- {l}\n"));
15373 }
15374 }
15375 }
15376 _ => out.push_str("- Could not inspect Windows Backup state\n"),
15377 }
15378
15379 out.push_str("\n=== System Restore ===\n");
15380 let ps_sr = r#"
15381$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15382 Select-Object -ExpandProperty DeviceID
15383foreach ($drive in $drives) {
15384 $protection = try {
15385 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15386 } catch { $null }
15387 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15388 $rpConf = try {
15389 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15390 } catch { $null }
15391 # Check if SR is disabled for this drive
15392 $disabled = $false
15393 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15394 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15395}
15396# Most recent restore point
15397$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15398if ($points) {
15399 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15400 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15401 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15402} else {
15403 "MostRecentRestorePoint: None found"
15404}
15405$srEnabled = try {
15406 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15407 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15408} catch { 'Unknown' }
15409"SystemRestoreState: $srEnabled"
15410"#;
15411 match run_powershell(ps_sr) {
15412 Ok(o) if !o.trim().is_empty() => {
15413 for line in o.lines().take(8) {
15414 let l = line.trim();
15415 if !l.is_empty() {
15416 out.push_str(&format!("- {l}\n"));
15417 }
15418 }
15419 }
15420 _ => out.push_str("- Could not inspect System Restore state\n"),
15421 }
15422
15423 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15424 let ps_kfm = r#"
15425$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15426if (Test-Path $kfmKey) {
15427 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15428 foreach ($acct in $accounts | Select-Object -First 3) {
15429 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15430 $email = $props.UserEmail
15431 $kfmDesktop = $props.'KFMSilentOptInDesktop'
15432 $kfmDocs = $props.'KFMSilentOptInDocuments'
15433 $kfmPics = $props.'KFMSilentOptInPictures'
15434 "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' })"
15435 }
15436} else {
15437 "OneDriveKFM: No OneDrive accounts found"
15438}
15439"#;
15440 match run_powershell(ps_kfm) {
15441 Ok(o) if !o.trim().is_empty() => {
15442 for line in o.lines().take(6) {
15443 let l = line.trim();
15444 if !l.is_empty() {
15445 out.push_str(&format!("- {l}\n"));
15446 }
15447 }
15448 }
15449 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15450 }
15451
15452 out.push_str("\n=== Recent backup failure events (7d) ===\n");
15453 let ps_events = r#"
15454$cutoff = (Get-Date).AddDays(-7)
15455$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15456 Where-Object {
15457 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15458 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15459 } |
15460 Where-Object { $_.Level -le 3 } |
15461 Select-Object -First 6
15462if ($events) {
15463 foreach ($event in $events) {
15464 $msg = ($event.Message -replace '\s+', ' ')
15465 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15466 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15467 }
15468} else {
15469 "No recent backup failure events detected"
15470}
15471"#;
15472 match run_powershell(ps_events) {
15473 Ok(o) if !o.trim().is_empty() => {
15474 for line in o.lines().take(8) {
15475 let l = line.trim();
15476 if !l.is_empty() {
15477 out.push_str(&format!("- {l}\n"));
15478 }
15479 }
15480 }
15481 _ => out.push_str("- Could not inspect backup failure events\n"),
15482 }
15483
15484 let mut findings: Vec<String> = Vec::new();
15485
15486 let fh_enabled = out.contains("- Enabled: Enabled");
15487 let fh_never =
15488 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15489 let no_wbadmin = out.contains("No backup versions found");
15490 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15491
15492 if !fh_enabled && no_wbadmin {
15493 findings.push(
15494 "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(),
15495 );
15496 } else if fh_enabled && fh_never {
15497 findings.push(
15498 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15499 );
15500 }
15501
15502 if no_restore_point {
15503 findings.push(
15504 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15505 );
15506 }
15507
15508 if out.contains("- FileHistoryService: Stopped")
15509 || out.contains("- FileHistoryService: Not found")
15510 {
15511 findings.push(
15512 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15513 );
15514 }
15515
15516 if out.contains("Application Error |")
15517 || out.contains("Microsoft-Windows-Backup |")
15518 || out.contains("wbengine |")
15519 {
15520 findings.push(
15521 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15522 );
15523 }
15524
15525 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15526 if findings.is_empty() {
15527 result.push_str("- No obvious backup health blocker detected.\n");
15528 } else {
15529 for finding in &findings {
15530 result.push_str(&format!("- Finding: {finding}\n"));
15531 }
15532 }
15533 result.push('\n');
15534 result.push_str(&out);
15535 Ok(result)
15536}
15537
15538#[cfg(not(windows))]
15539fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15540 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15541}
15542
15543#[cfg(windows)]
15544fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15545 let mut out = String::from("=== Windows Search service ===\n");
15546
15547 let ps_svc = r#"
15549$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15550if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15551else { "WSearch service not found" }
15552"#;
15553 match run_powershell(ps_svc) {
15554 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15555 Err(_) => out.push_str("- Could not query WSearch service\n"),
15556 }
15557
15558 out.push_str("\n=== Indexer state ===\n");
15560 let ps_idx = r#"
15561$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15562$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15563if ($props) {
15564 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15565 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15566 "DataDirectory: $($props.DataDirectory)"
15567} else { "Registry key not found" }
15568"#;
15569 match run_powershell(ps_idx) {
15570 Ok(o) => {
15571 for line in o.lines() {
15572 let l = line.trim();
15573 if !l.is_empty() {
15574 out.push_str(&format!("- {l}\n"));
15575 }
15576 }
15577 }
15578 Err(_) => out.push_str("- Could not read indexer registry\n"),
15579 }
15580
15581 out.push_str("\n=== Indexed locations ===\n");
15583 let ps_locs = r#"
15584$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15585if ($comObj) {
15586 $catalog = $comObj.GetCatalog('SystemIndex')
15587 $manager = $catalog.GetCrawlScopeManager()
15588 $rules = $manager.EnumerateRoots()
15589 while ($true) {
15590 try {
15591 $root = $rules.Next(1)
15592 if ($root.Count -eq 0) { break }
15593 $r = $root[0]
15594 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15595 } catch { break }
15596 }
15597} else { " COM admin interface not available (normal on non-admin sessions)" }
15598"#;
15599 match run_powershell(ps_locs) {
15600 Ok(o) if !o.trim().is_empty() => {
15601 for line in o.lines() {
15602 let l = line.trim_end();
15603 if !l.is_empty() {
15604 out.push_str(&format!("{l}\n"));
15605 }
15606 }
15607 }
15608 _ => {
15609 let ps_reg = r#"
15611Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15612ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
15613"#;
15614 match run_powershell(ps_reg) {
15615 Ok(o) if !o.trim().is_empty() => {
15616 for line in o.lines() {
15617 let l = line.trim_end();
15618 if !l.is_empty() {
15619 out.push_str(&format!("{l}\n"));
15620 }
15621 }
15622 }
15623 _ => out.push_str(" - Could not enumerate indexed locations\n"),
15624 }
15625 }
15626 }
15627
15628 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15630 let ps_evts = r#"
15631Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15632Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15633ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15634"#;
15635 match run_powershell(ps_evts) {
15636 Ok(o) if !o.trim().is_empty() => {
15637 for line in o.lines() {
15638 let l = line.trim();
15639 if !l.is_empty() {
15640 out.push_str(&format!("- {l}\n"));
15641 }
15642 }
15643 }
15644 _ => out.push_str("- No recent indexer errors found\n"),
15645 }
15646
15647 let mut findings: Vec<String> = Vec::new();
15648 if out.contains("Status: Stopped") {
15649 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15650 }
15651 if out.contains("IsContentIndexingEnabled: 0")
15652 || out.contains("IsContentIndexingEnabled: False")
15653 {
15654 findings.push(
15655 "Content indexing is disabled — file content won't be searchable, only filenames."
15656 .into(),
15657 );
15658 }
15659 if out.contains("SetupCompletedSuccessfully: 0")
15660 || out.contains("SetupCompletedSuccessfully: False")
15661 {
15662 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15663 }
15664
15665 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15666 if findings.is_empty() {
15667 result.push_str("- Windows Search service and indexer appear healthy.\n");
15668 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15669 } else {
15670 for f in &findings {
15671 result.push_str(&format!("- Finding: {f}\n"));
15672 }
15673 }
15674 result.push('\n');
15675 result.push_str(&out);
15676 Ok(result)
15677}
15678
15679#[cfg(not(windows))]
15680fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15681 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15682}
15683
15684#[cfg(windows)]
15687fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15688 let mut out = String::new();
15689
15690 out.push_str("=== Active displays ===\n");
15692 let ps_displays = r#"
15693Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15694Select-Object -First 20 |
15695ForEach-Object {
15696 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15697}
15698"#;
15699 match run_powershell(ps_displays) {
15700 Ok(o) if !o.trim().is_empty() => {
15701 for line in o.lines().take(max_entries) {
15702 let l = line.trim();
15703 if !l.is_empty() {
15704 out.push_str(&format!("- {l}\n"));
15705 }
15706 }
15707 }
15708 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15709 }
15710
15711 out.push_str("\n=== Video adapters ===\n");
15713 let ps_gpu = r#"
15714Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15715ForEach-Object {
15716 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15717 $hz = "$($_.CurrentRefreshRate) Hz"
15718 $bits = "$($_.CurrentBitsPerPixel) bpp"
15719 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15720}
15721"#;
15722 match run_powershell(ps_gpu) {
15723 Ok(o) if !o.trim().is_empty() => {
15724 for line in o.lines().take(max_entries) {
15725 let l = line.trim();
15726 if !l.is_empty() {
15727 out.push_str(&format!("- {l}\n"));
15728 }
15729 }
15730 }
15731 _ => out.push_str("- Could not query video adapter info\n"),
15732 }
15733
15734 out.push_str("\n=== Connected monitors ===\n");
15736 let ps_monitors = r#"
15737Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15738ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15739"#;
15740 match run_powershell(ps_monitors) {
15741 Ok(o) if !o.trim().is_empty() => {
15742 for line in o.lines().take(max_entries) {
15743 let l = line.trim();
15744 if !l.is_empty() {
15745 out.push_str(&format!("- {l}\n"));
15746 }
15747 }
15748 }
15749 _ => out.push_str("- No monitor info available via WMI\n"),
15750 }
15751
15752 out.push_str("\n=== DPI / scaling ===\n");
15754 let ps_dpi = r#"
15755Add-Type -TypeDefinition @'
15756using System; using System.Runtime.InteropServices;
15757public class DPI {
15758 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15759 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15760 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15761}
15762'@ -ErrorAction SilentlyContinue
15763try {
15764 $hdc = [DPI]::GetDC([IntPtr]::Zero)
15765 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15766 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15767 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15768 $scale = [Math]::Round($dpiX / 96.0 * 100)
15769 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15770} catch { "DPI query unavailable" }
15771"#;
15772 match run_powershell(ps_dpi) {
15773 Ok(o) if !o.trim().is_empty() => {
15774 out.push_str(&format!("- {}\n", o.trim()));
15775 }
15776 _ => out.push_str("- DPI info unavailable\n"),
15777 }
15778
15779 let mut findings: Vec<String> = Vec::new();
15780 if out.contains("0x0") || out.contains("@ 0 Hz") {
15781 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15782 }
15783
15784 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15785 if findings.is_empty() {
15786 result.push_str("- Display configuration appears normal.\n");
15787 } else {
15788 for f in &findings {
15789 result.push_str(&format!("- Finding: {f}\n"));
15790 }
15791 }
15792 result.push('\n');
15793 result.push_str(&out);
15794 Ok(result)
15795}
15796
15797#[cfg(not(windows))]
15798fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15799 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15800}
15801
15802#[cfg(windows)]
15805fn inspect_ntp() -> Result<String, String> {
15806 let mut out = String::new();
15807
15808 out.push_str("=== Windows Time service ===\n");
15810 let ps_svc = r#"
15811$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15812if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15813else { "W32Time service not found" }
15814"#;
15815 match run_powershell(ps_svc) {
15816 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15817 Err(_) => out.push_str("- Could not query W32Time service\n"),
15818 }
15819
15820 out.push_str("\n=== NTP source and sync status ===\n");
15822 let ps_sync = r#"
15823$q = w32tm /query /status 2>$null
15824if ($q) { $q } else { "w32tm query unavailable" }
15825"#;
15826 match run_powershell(ps_sync) {
15827 Ok(o) if !o.trim().is_empty() => {
15828 for line in o.lines() {
15829 let l = line.trim();
15830 if !l.is_empty() {
15831 out.push_str(&format!(" {l}\n"));
15832 }
15833 }
15834 }
15835 _ => out.push_str(" - Could not query w32tm status\n"),
15836 }
15837
15838 out.push_str("\n=== Configured NTP servers ===\n");
15840 let ps_peers = r#"
15841w32tm /query /peers 2>$null | Select-Object -First 10
15842"#;
15843 match run_powershell(ps_peers) {
15844 Ok(o) if !o.trim().is_empty() => {
15845 for line in o.lines() {
15846 let l = line.trim();
15847 if !l.is_empty() {
15848 out.push_str(&format!(" {l}\n"));
15849 }
15850 }
15851 }
15852 _ => {
15853 let ps_reg = r#"
15855(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15856"#;
15857 match run_powershell(ps_reg) {
15858 Ok(o) if !o.trim().is_empty() => {
15859 out.push_str(&format!(" NtpServer (registry): {}\n", o.trim()));
15860 }
15861 _ => out.push_str(" - Could not enumerate NTP peers\n"),
15862 }
15863 }
15864 }
15865
15866 let mut findings: Vec<String> = Vec::new();
15867 if out.contains("W32Time | Status: Stopped") {
15868 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15869 }
15870 if out.contains("The computer did not resync") || out.contains("Error") {
15871 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15872 }
15873
15874 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15875 if findings.is_empty() {
15876 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15877 } else {
15878 for f in &findings {
15879 result.push_str(&format!("- Finding: {f}\n"));
15880 }
15881 }
15882 result.push('\n');
15883 result.push_str(&out);
15884 Ok(result)
15885}
15886
15887#[cfg(not(windows))]
15888fn inspect_ntp() -> Result<String, String> {
15889 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15891
15892 let timedatectl = std::process::Command::new("timedatectl")
15893 .arg("status")
15894 .output();
15895
15896 if let Ok(o) = timedatectl {
15897 let text = String::from_utf8_lossy(&o.stdout);
15898 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15899 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15900 } else {
15901 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15902 }
15903 for line in text.lines() {
15904 let l = line.trim();
15905 if !l.is_empty() {
15906 out.push_str(&format!(" {l}\n"));
15907 }
15908 }
15909 return Ok(out);
15910 }
15911
15912 let sntp = std::process::Command::new("sntp")
15914 .args(["-d", "time.apple.com"])
15915 .output();
15916 if let Ok(o) = sntp {
15917 out.push_str("- NTP check via sntp:\n");
15918 out.push_str(&String::from_utf8_lossy(&o.stdout));
15919 return Ok(out);
15920 }
15921
15922 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15923 Ok(out)
15924}
15925
15926#[cfg(windows)]
15929fn inspect_cpu_power() -> Result<String, String> {
15930 let mut out = String::new();
15931
15932 out.push_str("=== Active power plan ===\n");
15934 let ps_plan = r#"
15935$plan = powercfg /getactivescheme 2>$null
15936if ($plan) { $plan } else { "Could not query power scheme" }
15937"#;
15938 match run_powershell(ps_plan) {
15939 Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15940 _ => out.push_str("- Could not read active power plan\n"),
15941 }
15942
15943 out.push_str("\n=== Processor performance policy ===\n");
15945 let ps_proc = r#"
15946$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15947$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15948$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15949$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15950if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15951if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15952if ($boost) {
15953 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15954 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15955 "Turbo boost mode: $bname"
15956}
15957"#;
15958 match run_powershell(ps_proc) {
15959 Ok(o) if !o.trim().is_empty() => {
15960 for line in o.lines() {
15961 let l = line.trim();
15962 if !l.is_empty() {
15963 out.push_str(&format!("- {l}\n"));
15964 }
15965 }
15966 }
15967 _ => out.push_str("- Could not query processor performance settings\n"),
15968 }
15969
15970 out.push_str("\n=== CPU frequency ===\n");
15972 let ps_freq = r#"
15973Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15974ForEach-Object {
15975 $cur = $_.CurrentClockSpeed
15976 $max = $_.MaxClockSpeed
15977 $load = $_.LoadPercentage
15978 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15979}
15980"#;
15981 match run_powershell(ps_freq) {
15982 Ok(o) if !o.trim().is_empty() => {
15983 for line in o.lines() {
15984 let l = line.trim();
15985 if !l.is_empty() {
15986 out.push_str(&format!("- {l}\n"));
15987 }
15988 }
15989 }
15990 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15991 }
15992
15993 out.push_str("\n=== Throttling indicators ===\n");
15995 let ps_throttle = r#"
15996$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15997if ($pwr) {
15998 $pwr | Select-Object -First 4 | ForEach-Object {
15999 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
16000 "Thermal zone $($_.InstanceName): ${c}°C"
16001 }
16002} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16003"#;
16004 match run_powershell(ps_throttle) {
16005 Ok(o) if !o.trim().is_empty() => {
16006 for line in o.lines() {
16007 let l = line.trim();
16008 if !l.is_empty() {
16009 out.push_str(&format!("- {l}\n"));
16010 }
16011 }
16012 }
16013 _ => out.push_str("- Thermal zone info unavailable\n"),
16014 }
16015
16016 let mut findings: Vec<String> = Vec::new();
16017 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
16018 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16019 }
16020 if out.contains("Turbo boost mode: Disabled") {
16021 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16022 }
16023 if out.contains("Min processor state: 100%") {
16024 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16025 }
16026
16027 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16028 if findings.is_empty() {
16029 result.push_str("- CPU power and frequency settings appear normal.\n");
16030 } else {
16031 for f in &findings {
16032 result.push_str(&format!("- Finding: {f}\n"));
16033 }
16034 }
16035 result.push('\n');
16036 result.push_str(&out);
16037 Ok(result)
16038}
16039
16040#[cfg(windows)]
16041fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16042 let mut out = String::new();
16043
16044 out.push_str("=== Credential vault summary ===\n");
16045 let ps_summary = r#"
16046$raw = cmdkey /list 2>&1
16047$lines = $raw -split "`n"
16048$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16049"Total stored credentials: $total"
16050$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16051$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16052$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16053" Windows credentials: $windows"
16054" Generic credentials: $generic"
16055" Certificate-based: $cert"
16056"#;
16057 match run_powershell(ps_summary) {
16058 Ok(o) => {
16059 for line in o.lines() {
16060 let l = line.trim();
16061 if !l.is_empty() {
16062 out.push_str(&format!("- {l}\n"));
16063 }
16064 }
16065 }
16066 Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
16067 }
16068
16069 out.push_str("\n=== Credential targets (up to 20) ===\n");
16070 let ps_list = r#"
16071$raw = cmdkey /list 2>&1
16072$entries = @(); $cur = @{}
16073foreach ($line in ($raw -split "`n")) {
16074 $l = $line.Trim()
16075 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
16076 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
16077 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16078}
16079$entries | Select-Object -Last 20 | ForEach-Object {
16080 "[$($_.Type)] $($_.Target) (user: $($_.User))"
16081}
16082"#;
16083 match run_powershell(ps_list) {
16084 Ok(o) => {
16085 let lines: Vec<&str> = o
16086 .lines()
16087 .map(|l| l.trim())
16088 .filter(|l| !l.is_empty())
16089 .collect();
16090 if lines.is_empty() {
16091 out.push_str("- No credential entries found\n");
16092 } else {
16093 for l in &lines {
16094 out.push_str(&format!("- {l}\n"));
16095 }
16096 }
16097 }
16098 Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
16099 }
16100
16101 let total_creds: usize = {
16102 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16103 run_powershell(ps_count)
16104 .ok()
16105 .and_then(|s| s.trim().parse().ok())
16106 .unwrap_or(0)
16107 };
16108
16109 let mut findings: Vec<String> = Vec::new();
16110 if total_creds > 30 {
16111 findings.push(format!(
16112 "{total_creds} stored credentials found — consider auditing for stale entries."
16113 ));
16114 }
16115
16116 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16117 if findings.is_empty() {
16118 result.push_str("- Credential store looks normal.\n");
16119 } else {
16120 for f in &findings {
16121 result.push_str(&format!("- Finding: {f}\n"));
16122 }
16123 }
16124 result.push('\n');
16125 result.push_str(&out);
16126 Ok(result)
16127}
16128
16129#[cfg(not(windows))]
16130fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16131 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16132}
16133
16134#[cfg(windows)]
16135fn inspect_tpm() -> Result<String, String> {
16136 let mut out = String::new();
16137
16138 out.push_str("=== TPM state ===\n");
16139 let ps_tpm = r#"
16140function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16141 $text = if ($null -eq $Value) { "" } else { [string]$Value }
16142 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16143 "$Name$text"
16144}
16145$t = Get-Tpm -ErrorAction SilentlyContinue
16146if ($t) {
16147 Emit-Field "TpmPresent: " $t.TpmPresent
16148 Emit-Field "TpmReady: " $t.TpmReady
16149 Emit-Field "TpmEnabled: " $t.TpmEnabled
16150 Emit-Field "TpmOwned: " $t.TpmOwned
16151 Emit-Field "RestartPending: " $t.RestartPending
16152 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
16153 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16154} else { "TPM module unavailable" }
16155"#;
16156 match run_powershell(ps_tpm) {
16157 Ok(o) => {
16158 for line in o.lines() {
16159 let l = line.trim();
16160 if !l.is_empty() {
16161 out.push_str(&format!("- {l}\n"));
16162 }
16163 }
16164 }
16165 Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
16166 }
16167
16168 out.push_str("\n=== TPM spec version (WMI) ===\n");
16169 let ps_spec = r#"
16170$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16171if ($wmi) {
16172 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16173 "SpecVersion: $spec"
16174 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16175 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16176 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16177} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16178"#;
16179 match run_powershell(ps_spec) {
16180 Ok(o) => {
16181 for line in o.lines() {
16182 let l = line.trim();
16183 if !l.is_empty() {
16184 out.push_str(&format!("- {l}\n"));
16185 }
16186 }
16187 }
16188 Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
16189 }
16190
16191 out.push_str("\n=== Secure Boot state ===\n");
16192 let ps_sb = r#"
16193try {
16194 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16195 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16196} catch {
16197 $msg = $_.Exception.Message
16198 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16199 "Secure Boot: Unknown (administrator privileges required)"
16200 } elseif ($msg -match "Cmdlet not supported on this platform") {
16201 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16202 } else {
16203 "Secure Boot: N/A ($msg)"
16204 }
16205}
16206"#;
16207 match run_powershell(ps_sb) {
16208 Ok(o) => {
16209 for line in o.lines() {
16210 let l = line.trim();
16211 if !l.is_empty() {
16212 out.push_str(&format!("- {l}\n"));
16213 }
16214 }
16215 }
16216 Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
16217 }
16218
16219 out.push_str("\n=== Firmware type ===\n");
16220 let ps_fw = r#"
16221$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16222switch ($fw) {
16223 1 { "Firmware type: BIOS (Legacy)" }
16224 2 { "Firmware type: UEFI" }
16225 default {
16226 $bcd = bcdedit /enum firmware 2>$null
16227 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16228 else { "Firmware type: Unknown or not set" }
16229 }
16230}
16231"#;
16232 match run_powershell(ps_fw) {
16233 Ok(o) => {
16234 for line in o.lines() {
16235 let l = line.trim();
16236 if !l.is_empty() {
16237 out.push_str(&format!("- {l}\n"));
16238 }
16239 }
16240 }
16241 Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
16242 }
16243
16244 let mut findings: Vec<String> = Vec::new();
16245 let mut indeterminate = false;
16246 if out.contains("TpmPresent: False") {
16247 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16248 }
16249 if out.contains("TpmReady: False") {
16250 findings.push(
16251 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16252 );
16253 }
16254 if out.contains("SpecVersion: 1.2") {
16255 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16256 }
16257 if out.contains("Secure Boot: DISABLED") {
16258 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16259 }
16260 if out.contains("Firmware type: BIOS (Legacy)") {
16261 findings.push(
16262 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16263 );
16264 }
16265
16266 if out.contains("TPM module unavailable")
16267 || out.contains("Win32_Tpm WMI class unavailable")
16268 || out.contains("Secure Boot: N/A")
16269 || out.contains("Secure Boot: Unknown")
16270 || out.contains("Firmware type: Unknown or not set")
16271 || out.contains("TpmPresent: Unknown")
16272 || out.contains("TpmReady: Unknown")
16273 || out.contains("TpmEnabled: Unknown")
16274 {
16275 indeterminate = true;
16276 }
16277 if indeterminate {
16278 findings.push(
16279 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16280 .into(),
16281 );
16282 }
16283
16284 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16285 if findings.is_empty() {
16286 result.push_str("- TPM and Secure Boot appear healthy.\n");
16287 } else {
16288 for f in &findings {
16289 result.push_str(&format!("- Finding: {f}\n"));
16290 }
16291 }
16292 result.push('\n');
16293 result.push_str(&out);
16294 Ok(result)
16295}
16296
16297#[cfg(not(windows))]
16298fn inspect_tpm() -> Result<String, String> {
16299 Ok(
16300 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16301 .into(),
16302 )
16303}
16304
16305#[cfg(windows)]
16306fn inspect_latency() -> Result<String, String> {
16307 let mut out = String::new();
16308
16309 let ps_gw = r#"
16311$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16312 Sort-Object RouteMetric | Select-Object -First 1).NextHop
16313if ($gw) { $gw } else { "" }
16314"#;
16315 let gateway = run_powershell(ps_gw)
16316 .ok()
16317 .map(|s| s.trim().to_string())
16318 .filter(|s| !s.is_empty());
16319
16320 let targets: Vec<(&str, String)> = {
16321 let mut t = Vec::new();
16322 if let Some(ref gw) = gateway {
16323 t.push(("Default gateway", gw.clone()));
16324 }
16325 t.push(("Cloudflare DNS", "1.1.1.1".into()));
16326 t.push(("Google DNS", "8.8.8.8".into()));
16327 t
16328 };
16329
16330 let mut findings: Vec<String> = Vec::new();
16331
16332 for (label, host) in &targets {
16333 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16334 let ps_ping = format!(
16336 r#"
16337$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16338if ($r) {{
16339 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16340 $min = ($rtts | Measure-Object -Minimum).Minimum
16341 $max = ($rtts | Measure-Object -Maximum).Maximum
16342 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16343 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16344 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16345 "Packet loss: ${{loss}}%"
16346 "Sent: 4 Received: $($r.Count)"
16347}} else {{
16348 "UNREACHABLE — 100% packet loss"
16349}}
16350"#
16351 );
16352 match run_powershell(&ps_ping) {
16353 Ok(o) => {
16354 let body = o.trim().to_string();
16355 for line in body.lines() {
16356 let l = line.trim();
16357 if !l.is_empty() {
16358 out.push_str(&format!("- {l}\n"));
16359 }
16360 }
16361 if body.contains("UNREACHABLE") {
16362 findings.push(format!(
16363 "{label} ({host}) is unreachable — possible routing or firewall issue."
16364 ));
16365 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16366 let pct: u32 = loss_line
16367 .chars()
16368 .filter(|c| c.is_ascii_digit())
16369 .collect::<String>()
16370 .parse()
16371 .unwrap_or(0);
16372 if pct >= 25 {
16373 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16374 }
16375 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16377 let parts: Vec<&str> = rtt_line.split('/').collect();
16379 if parts.len() >= 2 {
16380 let avg_str: String =
16381 parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
16382 let avg: u32 = avg_str.parse().unwrap_or(0);
16383 if avg > 150 {
16384 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16385 }
16386 }
16387 }
16388 }
16389 }
16390 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16391 }
16392 }
16393
16394 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16395 if findings.is_empty() {
16396 result.push_str("- Latency and reachability look normal.\n");
16397 } else {
16398 for f in &findings {
16399 result.push_str(&format!("- Finding: {f}\n"));
16400 }
16401 }
16402 result.push('\n');
16403 result.push_str(&out);
16404 Ok(result)
16405}
16406
16407#[cfg(not(windows))]
16408fn inspect_latency() -> Result<String, String> {
16409 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16410 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16411 let mut findings: Vec<String> = Vec::new();
16412
16413 for (label, host) in &targets {
16414 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16415 let ping = std::process::Command::new("ping")
16416 .args(["-c", "4", "-W", "2", host])
16417 .output();
16418 match ping {
16419 Ok(o) => {
16420 let body = String::from_utf8_lossy(&o.stdout).into_owned();
16421 for line in body.lines() {
16422 let l = line.trim();
16423 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16424 out.push_str(&format!("- {l}\n"));
16425 }
16426 }
16427 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16428 findings.push(format!("{label} ({host}) is unreachable."));
16429 }
16430 }
16431 Err(e) => out.push_str(&format!("- ping error: {e}\n")),
16432 }
16433 }
16434
16435 if findings.is_empty() {
16436 out.insert_str(
16437 "Host inspection: latency\n\n=== Findings ===\n".len(),
16438 "- Latency and reachability look normal.\n",
16439 );
16440 } else {
16441 let mut prefix = String::new();
16442 for f in &findings {
16443 prefix.push_str(&format!("- Finding: {f}\n"));
16444 }
16445 out.insert_str(
16446 "Host inspection: latency\n\n=== Findings ===\n".len(),
16447 &prefix,
16448 );
16449 }
16450 Ok(out)
16451}
16452
16453#[cfg(windows)]
16454fn inspect_network_adapter() -> Result<String, String> {
16455 let mut out = String::new();
16456
16457 out.push_str("=== Network adapters ===\n");
16458 let ps_adapters = r#"
16459Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16460 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16461 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16462}
16463"#;
16464 match run_powershell(ps_adapters) {
16465 Ok(o) => {
16466 for line in o.lines() {
16467 let l = line.trim();
16468 if !l.is_empty() {
16469 out.push_str(&format!("- {l}\n"));
16470 }
16471 }
16472 }
16473 Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
16474 }
16475
16476 out.push_str("\n=== Duplex and negotiated speed ===\n");
16477 let ps_duplex = r#"
16478Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16479 $name = $_.Name
16480 $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16481 Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16482 Select-Object DisplayName, DisplayValue
16483 if ($duplex) {
16484 "--- $name ---"
16485 $duplex | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16486 } else {
16487 "--- $name --- (no duplex/speed property exposed by driver)"
16488 }
16489}
16490"#;
16491 match run_powershell(ps_duplex) {
16492 Ok(o) => {
16493 let lines: Vec<&str> = o
16494 .lines()
16495 .map(|l| l.trim())
16496 .filter(|l| !l.is_empty())
16497 .collect();
16498 for l in &lines {
16499 out.push_str(&format!("- {l}\n"));
16500 }
16501 }
16502 Err(e) => out.push_str(&format!("- Duplex query error: {e}\n")),
16503 }
16504
16505 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16506 let ps_offload = r#"
16507Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16508 $name = $_.Name
16509 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16510 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16511 Select-Object DisplayName, DisplayValue
16512 if ($props) {
16513 "--- $name ---"
16514 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16515 }
16516}
16517"#;
16518 match run_powershell(ps_offload) {
16519 Ok(o) => {
16520 let lines: Vec<&str> = o
16521 .lines()
16522 .map(|l| l.trim())
16523 .filter(|l| !l.is_empty())
16524 .collect();
16525 if lines.is_empty() {
16526 out.push_str(
16527 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16528 );
16529 } else {
16530 for l in &lines {
16531 out.push_str(&format!("- {l}\n"));
16532 }
16533 }
16534 }
16535 Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
16536 }
16537
16538 out.push_str("\n=== Adapter error counters ===\n");
16539 let ps_errors = r#"
16540Get-NetAdapterStatistics | ForEach-Object {
16541 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16542 if ($errs -gt 0) {
16543 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16544 }
16545}
16546"#;
16547 match run_powershell(ps_errors) {
16548 Ok(o) => {
16549 let lines: Vec<&str> = o
16550 .lines()
16551 .map(|l| l.trim())
16552 .filter(|l| !l.is_empty())
16553 .collect();
16554 if lines.is_empty() {
16555 out.push_str("- No adapter errors or discards detected.\n");
16556 } else {
16557 for l in &lines {
16558 out.push_str(&format!("- {l}\n"));
16559 }
16560 }
16561 }
16562 Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
16563 }
16564
16565 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16566 let ps_wol = r#"
16567Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16568 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16569 if ($wol) {
16570 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16571 }
16572}
16573"#;
16574 match run_powershell(ps_wol) {
16575 Ok(o) => {
16576 let lines: Vec<&str> = o
16577 .lines()
16578 .map(|l| l.trim())
16579 .filter(|l| !l.is_empty())
16580 .collect();
16581 if lines.is_empty() {
16582 out.push_str("- Power management data unavailable for active adapters.\n");
16583 } else {
16584 for l in &lines {
16585 out.push_str(&format!("- {l}\n"));
16586 }
16587 }
16588 }
16589 Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
16590 }
16591
16592 let mut findings: Vec<String> = Vec::new();
16593 if out.contains("RX errors:") || out.contains("TX errors:") {
16595 findings
16596 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16597 }
16598 if out.contains("Half") {
16600 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16601 }
16602
16603 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16604 if findings.is_empty() {
16605 result.push_str("- Network adapter configuration looks normal.\n");
16606 } else {
16607 for f in &findings {
16608 result.push_str(&format!("- Finding: {f}\n"));
16609 }
16610 }
16611 result.push('\n');
16612 result.push_str(&out);
16613 Ok(result)
16614}
16615
16616#[cfg(not(windows))]
16617fn inspect_network_adapter() -> Result<String, String> {
16618 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16619
16620 out.push_str("=== Network adapters (ip link) ===\n");
16621 let ip_link = std::process::Command::new("ip")
16622 .args(["link", "show"])
16623 .output();
16624 if let Ok(o) = ip_link {
16625 for line in String::from_utf8_lossy(&o.stdout).lines() {
16626 let l = line.trim();
16627 if !l.is_empty() {
16628 out.push_str(&format!("- {l}\n"));
16629 }
16630 }
16631 }
16632
16633 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16634 let ip_stats = std::process::Command::new("ip")
16635 .args(["-s", "link", "show"])
16636 .output();
16637 if let Ok(o) = ip_stats {
16638 for line in String::from_utf8_lossy(&o.stdout).lines() {
16639 let l = line.trim();
16640 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16641 {
16642 out.push_str(&format!("- {l}\n"));
16643 }
16644 }
16645 }
16646 Ok(out)
16647}
16648
16649#[cfg(windows)]
16650fn inspect_dhcp() -> Result<String, String> {
16651 let mut out = String::new();
16652
16653 out.push_str("=== DHCP lease details (per adapter) ===\n");
16654 let ps_dhcp = r#"
16655$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16656 Where-Object { $_.IPEnabled -eq $true }
16657foreach ($a in $adapters) {
16658 "--- $($a.Description) ---"
16659 " DHCP Enabled: $($a.DHCPEnabled)"
16660 if ($a.DHCPEnabled) {
16661 " DHCP Server: $($a.DHCPServer)"
16662 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16663 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
16664 " Lease Obtained: $obtained"
16665 " Lease Expires: $expires"
16666 }
16667 " IP Address: $($a.IPAddress -join ', ')"
16668 " Subnet Mask: $($a.IPSubnet -join ', ')"
16669 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
16670 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
16671 " MAC Address: $($a.MACAddress)"
16672 ""
16673}
16674"#;
16675 match run_powershell(ps_dhcp) {
16676 Ok(o) => {
16677 for line in o.lines() {
16678 let l = line.trim_end();
16679 if !l.is_empty() {
16680 out.push_str(&format!("{l}\n"));
16681 }
16682 }
16683 }
16684 Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16685 }
16686
16687 let mut findings: Vec<String> = Vec::new();
16689 let ps_expiry = r#"
16690$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16691foreach ($a in $adapters) {
16692 try {
16693 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16694 $now = Get-Date
16695 $hrs = ($exp - $now).TotalHours
16696 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16697 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16698 } catch {}
16699}
16700"#;
16701 if let Ok(o) = run_powershell(ps_expiry) {
16702 for line in o.lines() {
16703 let l = line.trim();
16704 if !l.is_empty() {
16705 if l.contains("EXPIRED") {
16706 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16707 } else if l.contains("expires in") {
16708 findings.push(format!("DHCP lease expiring soon — {l}"));
16709 }
16710 }
16711 }
16712 }
16713
16714 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16715 if findings.is_empty() {
16716 result.push_str("- DHCP leases look healthy.\n");
16717 } else {
16718 for f in &findings {
16719 result.push_str(&format!("- Finding: {f}\n"));
16720 }
16721 }
16722 result.push('\n');
16723 result.push_str(&out);
16724 Ok(result)
16725}
16726
16727#[cfg(not(windows))]
16728fn inspect_dhcp() -> Result<String, String> {
16729 let mut out = String::from(
16730 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16731 );
16732 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16733 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16734 if std::path::Path::new(path).exists() {
16735 let cat = std::process::Command::new("cat").arg(path).output();
16736 if let Ok(o) = cat {
16737 let text = String::from_utf8_lossy(&o.stdout);
16738 for line in text.lines().take(40) {
16739 let l = line.trim();
16740 if l.contains("lease")
16741 || l.contains("expire")
16742 || l.contains("server")
16743 || l.contains("address")
16744 {
16745 out.push_str(&format!("- {l}\n"));
16746 }
16747 }
16748 }
16749 }
16750 }
16751 let ip = std::process::Command::new("ip")
16753 .args(["addr", "show"])
16754 .output();
16755 if let Ok(o) = ip {
16756 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16757 for line in String::from_utf8_lossy(&o.stdout).lines() {
16758 let l = line.trim();
16759 if l.starts_with("inet") || l.contains("dynamic") {
16760 out.push_str(&format!("- {l}\n"));
16761 }
16762 }
16763 }
16764 Ok(out)
16765}
16766
16767#[cfg(windows)]
16768fn inspect_mtu() -> Result<String, String> {
16769 let mut out = String::new();
16770
16771 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16772 let ps_mtu = r#"
16773Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16774 Sort-Object ConnectionState, InterfaceAlias |
16775 ForEach-Object {
16776 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16777 }
16778"#;
16779 match run_powershell(ps_mtu) {
16780 Ok(o) => {
16781 for line in o.lines() {
16782 let l = line.trim();
16783 if !l.is_empty() {
16784 out.push_str(&format!("- {l}\n"));
16785 }
16786 }
16787 }
16788 Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16789 }
16790
16791 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16792 let ps_mtu6 = r#"
16793Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16794 Sort-Object ConnectionState, InterfaceAlias |
16795 ForEach-Object {
16796 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16797 }
16798"#;
16799 match run_powershell(ps_mtu6) {
16800 Ok(o) => {
16801 for line in o.lines() {
16802 let l = line.trim();
16803 if !l.is_empty() {
16804 out.push_str(&format!("- {l}\n"));
16805 }
16806 }
16807 }
16808 Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16809 }
16810
16811 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16812 let ps_pmtu = r#"
16814$sizes = @(1472, 1400, 1280, 576)
16815$result = $null
16816foreach ($s in $sizes) {
16817 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16818 if ($r) { $result = $s; break }
16819}
16820if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16821else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16822"#;
16823 match run_powershell(ps_pmtu) {
16824 Ok(o) => {
16825 for line in o.lines() {
16826 let l = line.trim();
16827 if !l.is_empty() {
16828 out.push_str(&format!("- {l}\n"));
16829 }
16830 }
16831 }
16832 Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16833 }
16834
16835 let mut findings: Vec<String> = Vec::new();
16836 if out.contains("MTU: 576 bytes") {
16837 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16838 }
16839 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16840 findings.push(
16841 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16842 .into(),
16843 );
16844 }
16845 if out.contains("All test sizes failed") {
16846 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16847 }
16848
16849 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16850 if findings.is_empty() {
16851 result.push_str("- MTU configuration looks normal.\n");
16852 } else {
16853 for f in &findings {
16854 result.push_str(&format!("- Finding: {f}\n"));
16855 }
16856 }
16857 result.push('\n');
16858 result.push_str(&out);
16859 Ok(result)
16860}
16861
16862#[cfg(not(windows))]
16863fn inspect_mtu() -> Result<String, String> {
16864 let mut out = String::from(
16865 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16866 );
16867
16868 out.push_str("=== Per-interface MTU (ip link) ===\n");
16869 let ip = std::process::Command::new("ip")
16870 .args(["link", "show"])
16871 .output();
16872 if let Ok(o) = ip {
16873 for line in String::from_utf8_lossy(&o.stdout).lines() {
16874 let l = line.trim();
16875 if l.contains("mtu") || l.starts_with("\\d") {
16876 out.push_str(&format!("- {l}\n"));
16877 }
16878 }
16879 }
16880
16881 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16882 let ping = std::process::Command::new("ping")
16883 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16884 .output();
16885 match ping {
16886 Ok(o) => {
16887 let body = String::from_utf8_lossy(&o.stdout);
16888 for line in body.lines() {
16889 let l = line.trim();
16890 if !l.is_empty() {
16891 out.push_str(&format!("- {l}\n"));
16892 }
16893 }
16894 }
16895 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16896 }
16897 Ok(out)
16898}
16899
16900#[cfg(not(windows))]
16901fn inspect_cpu_power() -> Result<String, String> {
16902 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16903
16904 out.push_str("=== CPU frequency (Linux) ===\n");
16906 let cat_scaling = std::process::Command::new("cat")
16907 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16908 .output();
16909 if let Ok(o) = cat_scaling {
16910 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16911 .trim()
16912 .parse()
16913 .unwrap_or(0);
16914 if khz > 0 {
16915 out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16916 }
16917 }
16918 let cat_max = std::process::Command::new("cat")
16919 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16920 .output();
16921 if let Ok(o) = cat_max {
16922 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16923 .trim()
16924 .parse()
16925 .unwrap_or(0);
16926 if khz > 0 {
16927 out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16928 }
16929 }
16930 let governor = std::process::Command::new("cat")
16931 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16932 .output();
16933 if let Ok(o) = governor {
16934 let g = String::from_utf8_lossy(&o.stdout);
16935 let g = g.trim();
16936 if !g.is_empty() {
16937 out.push_str(&format!("- Governor: {g}\n"));
16938 }
16939 }
16940 Ok(out)
16941}
16942
16943#[cfg(windows)]
16946fn inspect_ipv6() -> Result<String, String> {
16947 let script = r#"
16948$result = [System.Text.StringBuilder]::new()
16949
16950# Per-adapter IPv6 addresses
16951$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16952$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16953 Where-Object { $_.IPAddress -notmatch '^::1$' } |
16954 Sort-Object InterfaceAlias
16955foreach ($a in $adapters) {
16956 $prefix = $a.PrefixOrigin
16957 $suffix = $a.SuffixOrigin
16958 $scope = $a.AddressState
16959 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
16960}
16961if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
16962
16963# Default gateway IPv6
16964$result.AppendLine("") | Out-Null
16965$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16966$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16967if ($gw6) {
16968 foreach ($g in $gw6) {
16969 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
16970 }
16971} else {
16972 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
16973}
16974
16975# DHCPv6 lease info
16976$result.AppendLine("") | Out-Null
16977$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16978$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16979 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16980if ($dhcpv6) {
16981 foreach ($d in $dhcpv6) {
16982 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16983 }
16984} else {
16985 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16986}
16987
16988# Privacy extensions
16989$result.AppendLine("") | Out-Null
16990$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16991try {
16992 $priv = netsh interface ipv6 show privacy
16993 $result.AppendLine(($priv -join "`n")) | Out-Null
16994} catch {
16995 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
16996}
16997
16998# Tunnel adapters
16999$result.AppendLine("") | Out-Null
17000$result.AppendLine("=== Tunnel adapters ===") | Out-Null
17001$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17002if ($tunnels) {
17003 foreach ($t in $tunnels) {
17004 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
17005 }
17006} else {
17007 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17008}
17009
17010# Findings
17011$findings = [System.Collections.Generic.List[string]]::new()
17012$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17013 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17014if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17015$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17016if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17017
17018$result.AppendLine("") | Out-Null
17019$result.AppendLine("=== Findings ===") | Out-Null
17020if ($findings.Count -eq 0) {
17021 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17022} else {
17023 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17024}
17025
17026Write-Output $result.ToString()
17027"#;
17028 let out = run_powershell(script)?;
17029 Ok(format!("Host inspection: ipv6\n\n{out}"))
17030}
17031
17032#[cfg(not(windows))]
17033fn inspect_ipv6() -> Result<String, String> {
17034 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17035 if let Ok(o) = std::process::Command::new("ip")
17036 .args(["-6", "addr", "show"])
17037 .output()
17038 {
17039 out.push_str(&String::from_utf8_lossy(&o.stdout));
17040 }
17041 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17042 if let Ok(o) = std::process::Command::new("ip")
17043 .args(["-6", "route"])
17044 .output()
17045 {
17046 out.push_str(&String::from_utf8_lossy(&o.stdout));
17047 }
17048 Ok(out)
17049}
17050
17051#[cfg(windows)]
17054fn inspect_tcp_params() -> Result<String, String> {
17055 let script = r#"
17056$result = [System.Text.StringBuilder]::new()
17057
17058# Autotuning and global TCP settings
17059$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17060try {
17061 $global = netsh interface tcp show global
17062 foreach ($line in $global) {
17063 $l = $line.Trim()
17064 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17065 $result.AppendLine(" $l") | Out-Null
17066 }
17067 }
17068} catch {
17069 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
17070}
17071
17072# Supplemental params via Get-NetTCPSetting
17073$result.AppendLine("") | Out-Null
17074$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17075try {
17076 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17077 foreach ($s in $tcpSettings) {
17078 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
17079 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
17080 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17081 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
17082 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
17083 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
17084 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17085 $result.AppendLine("") | Out-Null
17086 }
17087} catch {
17088 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
17089}
17090
17091# Chimney offload state
17092$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17093try {
17094 $chimney = netsh interface tcp show chimney
17095 $result.AppendLine(($chimney -join "`n ")) | Out-Null
17096} catch {
17097 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
17098}
17099
17100# ECN state
17101$result.AppendLine("") | Out-Null
17102$result.AppendLine("=== ECN capability ===") | Out-Null
17103try {
17104 $ecn = netsh interface tcp show ecncapability
17105 $result.AppendLine(($ecn -join "`n ")) | Out-Null
17106} catch {
17107 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
17108}
17109
17110# Findings
17111$findings = [System.Collections.Generic.List[string]]::new()
17112try {
17113 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17114 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17115 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17116 }
17117 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17118 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17119 }
17120} catch {}
17121
17122$result.AppendLine("") | Out-Null
17123$result.AppendLine("=== Findings ===") | Out-Null
17124if ($findings.Count -eq 0) {
17125 $result.AppendLine("- TCP parameters look normal.") | Out-Null
17126} else {
17127 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17128}
17129
17130Write-Output $result.ToString()
17131"#;
17132 let out = run_powershell(script)?;
17133 Ok(format!("Host inspection: tcp_params\n\n{out}"))
17134}
17135
17136#[cfg(not(windows))]
17137fn inspect_tcp_params() -> Result<String, String> {
17138 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17139 for key in &[
17140 "net.ipv4.tcp_congestion_control",
17141 "net.ipv4.tcp_rmem",
17142 "net.ipv4.tcp_wmem",
17143 "net.ipv4.tcp_window_scaling",
17144 "net.ipv4.tcp_ecn",
17145 "net.ipv4.tcp_timestamps",
17146 ] {
17147 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17148 out.push_str(&format!(
17149 " {}\n",
17150 String::from_utf8_lossy(&o.stdout).trim()
17151 ));
17152 }
17153 }
17154 Ok(out)
17155}
17156
17157#[cfg(windows)]
17160fn inspect_wlan_profiles() -> Result<String, String> {
17161 let script = r#"
17162$result = [System.Text.StringBuilder]::new()
17163
17164# List all saved profiles
17165$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17166try {
17167 $profilesRaw = netsh wlan show profiles
17168 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17169 $_.Matches[0].Groups[1].Value.Trim()
17170 }
17171
17172 if (-not $profiles) {
17173 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
17174 } else {
17175 foreach ($p in $profiles) {
17176 $result.AppendLine("") | Out-Null
17177 $result.AppendLine(" Profile: $p") | Out-Null
17178 # Get detail for each profile
17179 $detail = netsh wlan show profile name="$p" key=clear 2>$null
17180 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17181 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17182 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17183 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17184 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17185 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17186 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17187 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17188 }
17189 }
17190} catch {
17191 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17192}
17193
17194# Currently connected SSID
17195$result.AppendLine("") | Out-Null
17196$result.AppendLine("=== Currently connected ===") | Out-Null
17197try {
17198 $conn = netsh wlan show interfaces
17199 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17200 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17201 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17202 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17203 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17204 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17205 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17206 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17207 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
17208} catch {
17209 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
17210}
17211
17212# Findings
17213$findings = [System.Collections.Generic.List[string]]::new()
17214try {
17215 $allDetail = netsh wlan show profiles 2>$null
17216 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17217 $_.Matches[0].Groups[1].Value.Trim()
17218 }
17219 foreach ($pn in $profileNames) {
17220 $det = netsh wlan show profile name="$pn" key=clear 2>$null
17221 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17222 if ($authLine) {
17223 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17224 if ($authVal -match 'Open|WEP|None') {
17225 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17226 }
17227 }
17228 }
17229} catch {}
17230
17231$result.AppendLine("") | Out-Null
17232$result.AppendLine("=== Findings ===") | Out-Null
17233if ($findings.Count -eq 0) {
17234 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17235} else {
17236 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17237}
17238
17239Write-Output $result.ToString()
17240"#;
17241 let out = run_powershell(script)?;
17242 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17243}
17244
17245#[cfg(not(windows))]
17246fn inspect_wlan_profiles() -> Result<String, String> {
17247 let mut out =
17248 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17249 if let Ok(o) = std::process::Command::new("nmcli")
17251 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17252 .output()
17253 {
17254 for line in String::from_utf8_lossy(&o.stdout).lines() {
17255 if line.contains("wireless") || line.contains("wifi") {
17256 out.push_str(&format!(" {line}\n"));
17257 }
17258 }
17259 } else {
17260 out.push_str(" nmcli not available.\n");
17261 }
17262 Ok(out)
17263}
17264
17265#[cfg(windows)]
17268fn inspect_ipsec() -> Result<String, String> {
17269 let script = r#"
17270$result = [System.Text.StringBuilder]::new()
17271
17272# IPSec rules (firewall-integrated)
17273$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17274try {
17275 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17276 if ($rules) {
17277 foreach ($r in $rules) {
17278 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
17279 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
17280 $result.AppendLine(" Action: $($r.Action)") | Out-Null
17281 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
17282 }
17283 } else {
17284 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
17285 }
17286} catch {
17287 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
17288}
17289
17290# Active main-mode SAs
17291$result.AppendLine("") | Out-Null
17292$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17293try {
17294 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17295 if ($mmSAs) {
17296 foreach ($sa in $mmSAs) {
17297 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17298 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
17299 }
17300 } else {
17301 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
17302 }
17303} catch {
17304 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
17305}
17306
17307# Active quick-mode SAs
17308$result.AppendLine("") | Out-Null
17309$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17310try {
17311 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17312 if ($qmSAs) {
17313 foreach ($sa in $qmSAs) {
17314 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17315 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17316 }
17317 } else {
17318 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
17319 }
17320} catch {
17321 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17322}
17323
17324# IKE service state
17325$result.AppendLine("") | Out-Null
17326$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17327$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17328if ($ikeAgentSvc) {
17329 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17330} else {
17331 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
17332}
17333
17334# Findings
17335$findings = [System.Collections.Generic.List[string]]::new()
17336$mmSACount = 0
17337try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17338if ($mmSACount -gt 0) {
17339 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17340}
17341
17342$result.AppendLine("") | Out-Null
17343$result.AppendLine("=== Findings ===") | Out-Null
17344if ($findings.Count -eq 0) {
17345 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17346} else {
17347 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17348}
17349
17350Write-Output $result.ToString()
17351"#;
17352 let out = run_powershell(script)?;
17353 Ok(format!("Host inspection: ipsec\n\n{out}"))
17354}
17355
17356#[cfg(not(windows))]
17357fn inspect_ipsec() -> Result<String, String> {
17358 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17359 if let Ok(o) = std::process::Command::new("ip")
17360 .args(["xfrm", "state"])
17361 .output()
17362 {
17363 let body = String::from_utf8_lossy(&o.stdout);
17364 if body.trim().is_empty() {
17365 out.push_str(" No active IPSec SAs.\n");
17366 } else {
17367 out.push_str(&body);
17368 }
17369 }
17370 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17371 if let Ok(o) = std::process::Command::new("ip")
17372 .args(["xfrm", "policy"])
17373 .output()
17374 {
17375 let body = String::from_utf8_lossy(&o.stdout);
17376 if body.trim().is_empty() {
17377 out.push_str(" No IPSec policies.\n");
17378 } else {
17379 out.push_str(&body);
17380 }
17381 }
17382 Ok(out)
17383}
17384
17385#[cfg(windows)]
17388fn inspect_netbios() -> Result<String, String> {
17389 let script = r#"
17390$result = [System.Text.StringBuilder]::new()
17391
17392# NetBIOS node type and WINS per adapter
17393$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17394try {
17395 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17396 Where-Object { $_.IPEnabled -eq $true }
17397 foreach ($a in $adapters) {
17398 $nodeType = switch ($a.TcpipNetbiosOptions) {
17399 0 { "EnableNetBIOSViaDHCP" }
17400 1 { "Enabled" }
17401 2 { "Disabled" }
17402 default { "Unknown ($($a.TcpipNetbiosOptions))" }
17403 }
17404 $result.AppendLine(" [$($a.Description)]") | Out-Null
17405 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
17406 if ($a.WINSPrimaryServer) {
17407 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
17408 }
17409 if ($a.WINSSecondaryServer) {
17410 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
17411 }
17412 }
17413} catch {
17414 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
17415}
17416
17417# nbtstat -n — registered local NetBIOS names
17418$result.AppendLine("") | Out-Null
17419$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17420try {
17421 $nbt = nbtstat -n 2>$null
17422 foreach ($line in $nbt) {
17423 $l = $line.Trim()
17424 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17425 $result.AppendLine(" $l") | Out-Null
17426 }
17427 }
17428} catch {
17429 $result.AppendLine(" nbtstat not available.") | Out-Null
17430}
17431
17432# NetBIOS session table
17433$result.AppendLine("") | Out-Null
17434$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17435try {
17436 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17437 if ($sessions) {
17438 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
17439 } else {
17440 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
17441 }
17442} catch {
17443 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
17444}
17445
17446# Findings
17447$findings = [System.Collections.Generic.List[string]]::new()
17448try {
17449 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17450 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17451 if ($enabled) {
17452 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17453 }
17454 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17455 Where-Object { $_.WINSPrimaryServer }
17456 if ($wins) {
17457 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17458 }
17459} catch {}
17460
17461$result.AppendLine("") | Out-Null
17462$result.AppendLine("=== Findings ===") | Out-Null
17463if ($findings.Count -eq 0) {
17464 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17465} else {
17466 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17467}
17468
17469Write-Output $result.ToString()
17470"#;
17471 let out = run_powershell(script)?;
17472 Ok(format!("Host inspection: netbios\n\n{out}"))
17473}
17474
17475#[cfg(not(windows))]
17476fn inspect_netbios() -> Result<String, String> {
17477 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17478 if let Ok(o) = std::process::Command::new("nmblookup")
17479 .arg("-A")
17480 .arg("localhost")
17481 .output()
17482 {
17483 out.push_str(&String::from_utf8_lossy(&o.stdout));
17484 } else {
17485 out.push_str(" nmblookup not available (Samba not installed).\n");
17486 }
17487 Ok(out)
17488}
17489
17490#[cfg(windows)]
17493fn inspect_nic_teaming() -> Result<String, String> {
17494 let script = r#"
17495$result = [System.Text.StringBuilder]::new()
17496
17497# Team inventory
17498$result.AppendLine("=== NIC teams ===") | Out-Null
17499try {
17500 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17501 if ($teams) {
17502 foreach ($t in $teams) {
17503 $result.AppendLine(" Team: $($t.Name)") | Out-Null
17504 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
17505 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
17506 $result.AppendLine(" Status: $($t.Status)") | Out-Null
17507 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
17508 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17509 }
17510 } else {
17511 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
17512 }
17513} catch {
17514 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17515}
17516
17517# Team members detail
17518$result.AppendLine("") | Out-Null
17519$result.AppendLine("=== Team member detail ===") | Out-Null
17520try {
17521 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17522 if ($members) {
17523 foreach ($m in $members) {
17524 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
17525 }
17526 } else {
17527 $result.AppendLine(" No team members found.") | Out-Null
17528 }
17529} catch {
17530 $result.AppendLine(" Could not query team members.") | Out-Null
17531}
17532
17533# Findings
17534$findings = [System.Collections.Generic.List[string]]::new()
17535try {
17536 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17537 if ($degraded) {
17538 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17539 }
17540 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17541 if ($downMembers) {
17542 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17543 }
17544} catch {}
17545
17546$result.AppendLine("") | Out-Null
17547$result.AppendLine("=== Findings ===") | Out-Null
17548if ($findings.Count -eq 0) {
17549 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17550} else {
17551 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17552}
17553
17554Write-Output $result.ToString()
17555"#;
17556 let out = run_powershell(script)?;
17557 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17558}
17559
17560#[cfg(not(windows))]
17561fn inspect_nic_teaming() -> Result<String, String> {
17562 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17563 if let Ok(o) = std::process::Command::new("cat")
17564 .arg("/proc/net/bonding/bond0")
17565 .output()
17566 {
17567 if o.status.success() {
17568 out.push_str(&String::from_utf8_lossy(&o.stdout));
17569 } else {
17570 out.push_str(" No bond0 interface found.\n");
17571 }
17572 }
17573 if let Ok(o) = std::process::Command::new("ip")
17574 .args(["link", "show", "type", "bond"])
17575 .output()
17576 {
17577 let body = String::from_utf8_lossy(&o.stdout);
17578 if !body.trim().is_empty() {
17579 out.push_str("\n=== Bond links (ip link) ===\n");
17580 out.push_str(&body);
17581 }
17582 }
17583 Ok(out)
17584}
17585
17586#[cfg(windows)]
17589fn inspect_snmp() -> Result<String, String> {
17590 let script = r#"
17591$result = [System.Text.StringBuilder]::new()
17592
17593# SNMP service state
17594$result.AppendLine("=== SNMP service state ===") | Out-Null
17595$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17596if ($svc) {
17597 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17598} else {
17599 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
17600}
17601
17602$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17603if ($svcTrap) {
17604 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17605}
17606
17607# Community strings (presence only — values redacted)
17608$result.AppendLine("") | Out-Null
17609$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17610try {
17611 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17612 if ($communities) {
17613 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17614 if ($names) {
17615 foreach ($n in $names) {
17616 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
17617 }
17618 } else {
17619 $result.AppendLine(" No community strings configured.") | Out-Null
17620 }
17621 } else {
17622 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
17623 }
17624} catch {
17625 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
17626}
17627
17628# Permitted managers
17629$result.AppendLine("") | Out-Null
17630$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17631try {
17632 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17633 if ($managers) {
17634 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17635 if ($mgrs) {
17636 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
17637 } else {
17638 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
17639 }
17640 } else {
17641 $result.AppendLine(" No manager restrictions configured.") | Out-Null
17642 }
17643} catch {
17644 $result.AppendLine(" Could not read permitted managers.") | Out-Null
17645}
17646
17647# Findings
17648$findings = [System.Collections.Generic.List[string]]::new()
17649$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17650if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17651 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17652 try {
17653 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17654 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17655 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17656 } catch {}
17657}
17658
17659$result.AppendLine("") | Out-Null
17660$result.AppendLine("=== Findings ===") | Out-Null
17661if ($findings.Count -eq 0) {
17662 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17663} else {
17664 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17665}
17666
17667Write-Output $result.ToString()
17668"#;
17669 let out = run_powershell(script)?;
17670 Ok(format!("Host inspection: snmp\n\n{out}"))
17671}
17672
17673#[cfg(not(windows))]
17674fn inspect_snmp() -> Result<String, String> {
17675 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17676 for svc in &["snmpd", "snmp"] {
17677 if let Ok(o) = std::process::Command::new("systemctl")
17678 .args(["is-active", svc])
17679 .output()
17680 {
17681 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17682 out.push_str(&format!(" {svc}: {status}\n"));
17683 }
17684 }
17685 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17686 if let Ok(o) = std::process::Command::new("grep")
17687 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17688 .output()
17689 {
17690 if o.status.success() {
17691 for line in String::from_utf8_lossy(&o.stdout).lines() {
17692 out.push_str(&format!(" {line}\n"));
17693 }
17694 } else {
17695 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
17696 }
17697 }
17698 Ok(out)
17699}
17700
17701#[cfg(windows)]
17704fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17705 let target_host = host.unwrap_or("8.8.8.8");
17706 let target_port = port.unwrap_or(443);
17707
17708 let script = format!(
17709 r#"
17710$result = [System.Text.StringBuilder]::new()
17711$result.AppendLine("=== Port reachability test ===") | Out-Null
17712$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
17713$result.AppendLine("") | Out-Null
17714
17715try {{
17716 $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17717 if ($test) {{
17718 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17719 $result.AppendLine(" Result: $status") | Out-Null
17720 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
17721 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
17722 if ($test.PingSucceeded) {{
17723 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17724 }} else {{
17725 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
17726 }}
17727 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
17728 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
17729
17730 $result.AppendLine("") | Out-Null
17731 $result.AppendLine("=== Findings ===") | Out-Null
17732 if ($test.TcpTestSucceeded) {{
17733 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17734 }} else {{
17735 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17736 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17737 }}
17738 }}
17739}} catch {{
17740 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17741}}
17742
17743Write-Output $result.ToString()
17744"#
17745 );
17746 let out = run_powershell(&script)?;
17747 Ok(format!("Host inspection: port_test\n\n{out}"))
17748}
17749
17750#[cfg(not(windows))]
17751fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17752 let target_host = host.unwrap_or("8.8.8.8");
17753 let target_port = port.unwrap_or(443);
17754 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
17755 let nc = std::process::Command::new("nc")
17757 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17758 .output();
17759 match nc {
17760 Ok(o) => {
17761 let stderr = String::from_utf8_lossy(&o.stderr);
17762 let stdout = String::from_utf8_lossy(&o.stdout);
17763 let body = if !stdout.trim().is_empty() {
17764 stdout.as_ref()
17765 } else {
17766 stderr.as_ref()
17767 };
17768 out.push_str(&format!(" {}\n", body.trim()));
17769 out.push_str("\n=== Findings ===\n");
17770 if o.status.success() {
17771 out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17772 } else {
17773 out.push_str(&format!(
17774 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17775 ));
17776 }
17777 }
17778 Err(e) => out.push_str(&format!(" nc not available: {e}\n")),
17779 }
17780 Ok(out)
17781}
17782
17783#[cfg(windows)]
17786fn inspect_network_profile() -> Result<String, String> {
17787 let script = r#"
17788$result = [System.Text.StringBuilder]::new()
17789
17790$result.AppendLine("=== Network location profiles ===") | Out-Null
17791try {
17792 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17793 if ($profiles) {
17794 foreach ($p in $profiles) {
17795 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
17796 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
17797 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
17798 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
17799 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
17800 $result.AppendLine("") | Out-Null
17801 }
17802 } else {
17803 $result.AppendLine(" No network connection profiles found.") | Out-Null
17804 }
17805} catch {
17806 $result.AppendLine(" Could not query network profiles.") | Out-Null
17807}
17808
17809# Findings
17810$findings = [System.Collections.Generic.List[string]]::new()
17811try {
17812 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17813 if ($pub) {
17814 foreach ($p in $pub) {
17815 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17816 }
17817 }
17818 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17819 if ($domain) {
17820 foreach ($d in $domain) {
17821 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17822 }
17823 }
17824} catch {}
17825
17826$result.AppendLine("=== Findings ===") | Out-Null
17827if ($findings.Count -eq 0) {
17828 $result.AppendLine("- Network profiles look normal.") | Out-Null
17829} else {
17830 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17831}
17832
17833Write-Output $result.ToString()
17834"#;
17835 let out = run_powershell(script)?;
17836 Ok(format!("Host inspection: network_profile\n\n{out}"))
17837}
17838
17839#[cfg(not(windows))]
17840fn inspect_network_profile() -> Result<String, String> {
17841 let mut out = String::from(
17842 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17843 );
17844 if let Ok(o) = std::process::Command::new("nmcli")
17845 .args([
17846 "-t",
17847 "-f",
17848 "NAME,TYPE,STATE,DEVICE",
17849 "connection",
17850 "show",
17851 "--active",
17852 ])
17853 .output()
17854 {
17855 out.push_str(&String::from_utf8_lossy(&o.stdout));
17856 } else {
17857 out.push_str(" nmcli not available.\n");
17858 }
17859 Ok(out)
17860}
17861
17862#[cfg(windows)]
17865fn inspect_storage_spaces() -> Result<String, String> {
17866 let script = r#"
17867$result = [System.Text.StringBuilder]::new()
17868
17869# Storage Pools
17870try {
17871 $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
17872 if ($pools) {
17873 $result.AppendLine("=== Storage Pools ===") | Out-Null
17874 foreach ($pool in $pools) {
17875 $health = $pool.HealthStatus
17876 $oper = $pool.OperationalStatus
17877 $sizGB = [math]::Round($pool.Size / 1GB, 1)
17878 $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
17879 $result.AppendLine(" Pool: $($pool.FriendlyName) Size: ${sizGB}GB Allocated: ${allocGB}GB Health: $health Status: $oper") | Out-Null
17880 }
17881 $result.AppendLine("") | Out-Null
17882 } else {
17883 $result.AppendLine("=== Storage Pools ===") | Out-Null
17884 $result.AppendLine(" No Storage Spaces pools configured.") | Out-Null
17885 $result.AppendLine("") | Out-Null
17886 }
17887} catch {
17888 $result.AppendLine("=== Storage Pools ===") | Out-Null
17889 $result.AppendLine(" Unable to query storage pools (may require elevation).") | Out-Null
17890 $result.AppendLine("") | Out-Null
17891}
17892
17893# Virtual Disks
17894try {
17895 $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
17896 if ($vdisks) {
17897 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17898 foreach ($vd in $vdisks) {
17899 $health = $vd.HealthStatus
17900 $oper = $vd.OperationalStatus
17901 $layout = $vd.ResiliencySettingName
17902 $sizGB = [math]::Round($vd.Size / 1GB, 1)
17903 $result.AppendLine(" VDisk: $($vd.FriendlyName) Layout: $layout Size: ${sizGB}GB Health: $health Status: $oper") | Out-Null
17904 }
17905 $result.AppendLine("") | Out-Null
17906 } else {
17907 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17908 $result.AppendLine(" No Storage Spaces virtual disks configured.") | Out-Null
17909 $result.AppendLine("") | Out-Null
17910 }
17911} catch {
17912 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17913 $result.AppendLine(" Unable to query virtual disks.") | Out-Null
17914 $result.AppendLine("") | Out-Null
17915}
17916
17917# Physical Disks in pools
17918try {
17919 $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
17920 if ($pdisks) {
17921 $result.AppendLine("=== Physical Disks ===") | Out-Null
17922 foreach ($pd in $pdisks) {
17923 $sizGB = [math]::Round($pd.Size / 1GB, 1)
17924 $health = $pd.HealthStatus
17925 $usage = $pd.Usage
17926 $media = $pd.MediaType
17927 $result.AppendLine(" $($pd.FriendlyName) ${sizGB}GB $media Usage: $usage Health: $health") | Out-Null
17928 }
17929 $result.AppendLine("") | Out-Null
17930 }
17931} catch {}
17932
17933# Findings
17934$findings = @()
17935try {
17936 $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17937 foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
17938 $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17939 foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
17940 $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
17941 foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
17942} catch {}
17943
17944if ($findings.Count -gt 0) {
17945 $result.AppendLine("=== Findings ===") | Out-Null
17946 foreach ($f in $findings) { $result.AppendLine(" $f") | Out-Null }
17947} else {
17948 $result.AppendLine("=== Findings ===") | Out-Null
17949 $result.AppendLine(" All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
17950}
17951
17952Write-Output $result.ToString().TrimEnd()
17953"#;
17954 let out = run_powershell(script)?;
17955 Ok(format!("Host inspection: storage_spaces\n\n{out}"))
17956}
17957
17958#[cfg(not(windows))]
17959fn inspect_storage_spaces() -> Result<String, String> {
17960 let mut out = String::from("Host inspection: storage_spaces\n\n");
17961 let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
17963 if !mdstat.is_empty() {
17964 out.push_str("=== Software RAID (/proc/mdstat) ===\n");
17965 out.push_str(&mdstat);
17966 } else {
17967 out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
17968 }
17969 if let Ok(o) = Command::new("lvs")
17971 .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
17972 .output()
17973 {
17974 let lvs = String::from_utf8_lossy(&o.stdout).to_string();
17975 if !lvs.trim().is_empty() {
17976 out.push_str("\n=== LVM Logical Volumes ===\n");
17977 out.push_str(&lvs);
17978 }
17979 }
17980 Ok(out)
17981}
17982
17983#[cfg(windows)]
17986fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
17987 let limit = max_entries.min(50);
17988 let script = format!(
17989 r#"
17990$result = [System.Text.StringBuilder]::new()
17991
17992# Current threat detections (active + quarantined)
17993try {{
17994 $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
17995 if ($threats) {{
17996 $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
17997 foreach ($t in $threats) {{
17998 $name = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
17999 if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
18000 $time = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
18001 $action = $t.ActionSuccess
18002 $status = $t.CurrentThreatExecutionStatusID
18003 $result.AppendLine(" [$time] $name ActionSuccess:$action Status:$status") | Out-Null
18004 }}
18005 $result.AppendLine("") | Out-Null
18006 }} else {{
18007 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18008 $result.AppendLine(" No threat detections on record — Defender history is clean.") | Out-Null
18009 $result.AppendLine("") | Out-Null
18010 }}
18011}} catch {{
18012 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18013 $result.AppendLine(" Unable to query threat detections: $_") | Out-Null
18014 $result.AppendLine("") | Out-Null
18015}}
18016
18017# Quarantine items
18018try {{
18019 $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18020 if ($quarantine) {{
18021 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18022 foreach ($q in $quarantine) {{
18023 $result.AppendLine(" $($q.ThreatName) Severity:$($q.SeverityID) Category:$($q.CategoryID) Active:$($q.IsActive)") | Out-Null
18024 }}
18025 $result.AppendLine("") | Out-Null
18026 }} else {{
18027 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18028 $result.AppendLine(" No quarantined threats found.") | Out-Null
18029 $result.AppendLine("") | Out-Null
18030 }}
18031}} catch {{
18032 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18033 $result.AppendLine(" Unable to query quarantine list: $_") | Out-Null
18034 $result.AppendLine("") | Out-Null
18035}}
18036
18037# Defender scan stats
18038try {{
18039 $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18040 if ($status) {{
18041 $lastScan = $status.QuickScanStartTime
18042 $lastFull = $status.FullScanStartTime
18043 $sigDate = $status.AntivirusSignatureLastUpdated
18044 $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18045 $result.AppendLine(" Last quick scan : $lastScan") | Out-Null
18046 $result.AppendLine(" Last full scan : $lastFull") | Out-Null
18047 $result.AppendLine(" Signature date : $sigDate") | Out-Null
18048 }}
18049}} catch {{}}
18050
18051Write-Output $result.ToString().TrimEnd()
18052"#,
18053 limit = limit
18054 );
18055 let out = run_powershell(&script)?;
18056 Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18057}
18058
18059#[cfg(windows)]
18062fn inspect_domain_health() -> Result<String, String> {
18063 let script = r#"
18064$result = [System.Text.StringBuilder]::new()
18065
18066# Domain membership
18067try {
18068 $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18069 $joined = $cs.PartOfDomain
18070 $domain = $cs.Domain
18071 $result.AppendLine("=== Domain Membership ===") | Out-Null
18072 $result.AppendLine(" Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18073 if ($joined) { $result.AppendLine(" Domain : $domain") | Out-Null }
18074 $result.AppendLine(" Computer : $($cs.Name)") | Out-Null
18075} catch {
18076 $result.AppendLine(" Domain membership check failed: $_") | Out-Null
18077}
18078
18079# dsregcmd device registration state
18080try {
18081 $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18082 if ($dsreg) {
18083 $result.AppendLine("") | Out-Null
18084 $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18085 foreach ($line in $dsreg) { $result.AppendLine(" $($line.Trim())") | Out-Null }
18086 }
18087} catch {}
18088
18089# DC discovery via nltest
18090$result.AppendLine("") | Out-Null
18091$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18092try {
18093 $nl = nltest /dsgetdc:. 2>&1
18094 $dc_name = $null
18095 foreach ($line in $nl) {
18096 if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18097 $result.AppendLine(" $($line.Trim())") | Out-Null
18098 }
18099 if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18100 }
18101 if ($dc_name) {
18102 $result.AppendLine("") | Out-Null
18103 $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18104 foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18105 try {
18106 $tcp = New-Object System.Net.Sockets.TcpClient
18107 $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18108 $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18109 $tcp.Close()
18110 $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18111 } catch { $status = 'FAILED' }
18112 $result.AppendLine(" Port $($entry.p) ($($entry.n)): $status") | Out-Null
18113 }
18114 }
18115} catch {
18116 $result.AppendLine(" nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18117}
18118
18119# Last GPO machine refresh time
18120try {
18121 $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18122 if (Test-Path $gpoKey) {
18123 $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18124 $result.AppendLine("") | Out-Null
18125 $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18126 $result.AppendLine(" Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18127 }
18128} catch {}
18129
18130Write-Output $result.ToString().TrimEnd()
18131"#;
18132 let out = run_powershell(script)?;
18133 Ok(format!("Host inspection: domain_health\n\n{out}"))
18134}
18135
18136#[cfg(not(windows))]
18137fn inspect_domain_health() -> Result<String, String> {
18138 let mut out = String::from("Host inspection: domain_health\n\n");
18139 for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18140 if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18141 let s = String::from_utf8_lossy(&o.stdout);
18142 if !s.trim().is_empty() {
18143 out.push_str(&format!("$ {}\n{}\n", cmd_args.join(" "), s.trim_end()));
18144 }
18145 }
18146 }
18147 if out.trim_end().ends_with("domain_health") {
18148 out.push_str("Not domain-joined or realm/sssd not installed.\n");
18149 }
18150 Ok(out)
18151}
18152
18153#[cfg(windows)]
18156fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18157 let limit = max_entries.min(60);
18158 let script = format!(
18159 r#"
18160$result = [System.Text.StringBuilder]::new()
18161$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18162$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18163$result.AppendLine("") | Out-Null
18164$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18165foreach ($s in $svc) {{
18166 $req = if ($s.RequiredServices.Count -gt 0) {{ "requires: $($s.RequiredServices.Name -join ', ')" }} else {{ "" }}
18167 $dep = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18168 $parts = @($req, $dep) | Where-Object {{ $_ }}
18169 if ($parts) {{
18170 $result.AppendLine(" [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18171 }}
18172}}
18173Write-Output $result.ToString().TrimEnd()
18174"#,
18175 limit = limit
18176 );
18177 let out = run_powershell(&script)?;
18178 Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18179}
18180
18181#[cfg(not(windows))]
18182fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18183 let out = Command::new("systemctl")
18184 .args(["list-dependencies", "--no-pager", "--plain"])
18185 .output()
18186 .ok()
18187 .and_then(|o| String::from_utf8(o.stdout).ok())
18188 .unwrap_or_else(|| "systemctl not available.\n".to_string());
18189 Ok(format!(
18190 "Host inspection: service_dependencies\n\n{}",
18191 out.trim_end()
18192 ))
18193}
18194
18195#[cfg(windows)]
18198fn inspect_wmi_health() -> Result<String, String> {
18199 let script = r#"
18200$result = [System.Text.StringBuilder]::new()
18201$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18202
18203# Basic WMI query test
18204try {
18205 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18206 $result.AppendLine(" Query (Win32_OperatingSystem): OK") | Out-Null
18207 $result.AppendLine(" OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18208} catch {
18209 $result.AppendLine(" Query FAILED: $_") | Out-Null
18210 $result.AppendLine(" FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18211}
18212
18213# Repository integrity
18214try {
18215 $verify = & winmgmt /verifyrepository 2>&1
18216 $result.AppendLine(" winmgmt /verifyrepository: $verify") | Out-Null
18217} catch {
18218 $result.AppendLine(" winmgmt check unavailable: $_") | Out-Null
18219}
18220
18221# WMI service state
18222$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18223if ($svc) {
18224 $result.AppendLine(" Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18225}
18226
18227# Repository folder size
18228$repPath = "$env:SystemRoot\System32\wbem\Repository"
18229if (Test-Path $repPath) {
18230 $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18231 $mb = [math]::Round($bytes / 1MB, 1)
18232 $result.AppendLine(" Repository size: $mb MB ($repPath)") | Out-Null
18233 if ($mb -gt 200) {
18234 $result.AppendLine(" FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18235 }
18236}
18237
18238$result.AppendLine("") | Out-Null
18239$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18240$result.AppendLine(" 1. net stop winmgmt") | Out-Null
18241$result.AppendLine(" 2. winmgmt /salvagerepository (try first)") | Out-Null
18242$result.AppendLine(" 3. winmgmt /resetrepository (last resort — loses custom namespaces)") | Out-Null
18243$result.AppendLine(" 4. net start winmgmt") | Out-Null
18244
18245Write-Output $result.ToString().TrimEnd()
18246"#;
18247 let out = run_powershell(script)?;
18248 Ok(format!("Host inspection: wmi_health\n\n{out}"))
18249}
18250
18251#[cfg(not(windows))]
18252fn inspect_wmi_health() -> Result<String, String> {
18253 Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18254}
18255
18256#[cfg(windows)]
18259fn inspect_local_security_policy() -> Result<String, String> {
18260 let script = r#"
18261$result = [System.Text.StringBuilder]::new()
18262$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18263$na = net accounts 2>&1
18264foreach ($line in $na) {
18265 if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18266 $result.AppendLine(" $($line.Trim())") | Out-Null
18267 }
18268}
18269
18270$result.AppendLine("") | Out-Null
18271$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18272try {
18273 $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18274 if ($null -eq $lmLevel) { $lmLevel = 3 }
18275 $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'}
18276 $result.AppendLine(" LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18277 if ($lmLevel -lt 3) {
18278 $result.AppendLine(" FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18279 }
18280} catch {}
18281
18282$result.AppendLine("") | Out-Null
18283$result.AppendLine("=== UAC Settings ===") | Out-Null
18284try {
18285 $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18286 if ($uac) {
18287 $result.AppendLine(" UAC Enabled : $($uac.EnableLUA) (1=on, 0=disabled)") | Out-Null
18288 $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18289 $bval = $uac.ConsentPromptBehaviorAdmin
18290 $result.AppendLine(" Admin Prompt Behavior : $bval — $($behavMap[$bval])") | Out-Null
18291 if ($uac.EnableLUA -eq 0) {
18292 $result.AppendLine(" FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18293 }
18294 }
18295} catch {}
18296
18297Write-Output $result.ToString().TrimEnd()
18298"#;
18299 let out = run_powershell(script)?;
18300 Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18301}
18302
18303#[cfg(not(windows))]
18304fn inspect_local_security_policy() -> Result<String, String> {
18305 let mut out = String::from("Host inspection: local_security_policy\n\n");
18306 if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18307 out.push_str("=== /etc/login.defs ===\n");
18308 for line in content.lines() {
18309 let t = line.trim();
18310 if !t.is_empty() && !t.starts_with('#') {
18311 out.push_str(&format!(" {t}\n"));
18312 }
18313 }
18314 }
18315 Ok(out)
18316}
18317
18318#[cfg(windows)]
18321fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18322 let limit = max_entries.min(50);
18323 let script = format!(
18324 r#"
18325$result = [System.Text.StringBuilder]::new()
18326$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18327$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18328if (Test-Path $usbPath) {{
18329 $count = 0
18330 $seen = @{{}}
18331 $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18332 foreach ($class in $classes) {{
18333 $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18334 foreach ($inst in $instances) {{
18335 if ($count -ge {limit}) {{ break }}
18336 try {{
18337 $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18338 $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18339 if (-not $seen[$fn]) {{
18340 $seen[$fn] = $true
18341 $result.AppendLine(" $fn") | Out-Null
18342 $count++
18343 }}
18344 }} catch {{}}
18345 }}
18346 }}
18347 if ($count -eq 0) {{
18348 $result.AppendLine(" No USB storage devices found in registry.") | Out-Null
18349 }} else {{
18350 $result.AppendLine("") | Out-Null
18351 $result.AppendLine(" ($count unique devices; requires elevation for full history)") | Out-Null
18352 }}
18353}} else {{
18354 $result.AppendLine(" USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18355}}
18356Write-Output $result.ToString().TrimEnd()
18357"#,
18358 limit = limit
18359 );
18360 let out = run_powershell(&script)?;
18361 Ok(format!("Host inspection: usb_history\n\n{out}"))
18362}
18363
18364#[cfg(not(windows))]
18365fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18366 let mut out = String::from("Host inspection: usb_history\n\n");
18367 if let Ok(o) = Command::new("journalctl")
18368 .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18369 .output()
18370 {
18371 let s = String::from_utf8_lossy(&o.stdout);
18372 let usb_lines: Vec<&str> = s
18373 .lines()
18374 .filter(|l| l.to_ascii_lowercase().contains("usb"))
18375 .take(30)
18376 .collect();
18377 if !usb_lines.is_empty() {
18378 out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18379 for line in usb_lines {
18380 out.push_str(&format!(" {line}\n"));
18381 }
18382 }
18383 } else {
18384 out.push_str("USB history via journalctl not available.\n");
18385 }
18386 Ok(out)
18387}
18388
18389#[cfg(windows)]
18392fn inspect_print_spooler() -> Result<String, String> {
18393 let script = r#"
18394$result = [System.Text.StringBuilder]::new()
18395
18396$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18397$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18398if ($svc) {
18399 $result.AppendLine(" Status : $($svc.Status)") | Out-Null
18400 $result.AppendLine(" Start Type : $($svc.StartType)") | Out-Null
18401} else {
18402 $result.AppendLine(" Spooler service not found.") | Out-Null
18403}
18404
18405# PrintNightmare mitigations (CVE-2021-34527)
18406$result.AppendLine("") | Out-Null
18407$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18408try {
18409 $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18410 if ($val -eq 1) {
18411 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18412 } else {
18413 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18414 $result.AppendLine(" FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18415 }
18416} catch { $result.AppendLine(" Mitigation key not readable: $_") | Out-Null }
18417
18418try {
18419 $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18420 if (Test-Path $pnpPath) {
18421 $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18422 $result.AppendLine(" RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18423 $result.AppendLine(" NoWarningNoElevationOnInstall : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18424 if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18425 $result.AppendLine(" FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18426 }
18427 } else {
18428 $result.AppendLine(" No Point and Print policy (using Windows defaults).") | Out-Null
18429 }
18430} catch {}
18431
18432# Pending print jobs
18433$result.AppendLine("") | Out-Null
18434$result.AppendLine("=== Print Queue ===") | Out-Null
18435try {
18436 $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18437 if ($jobs) {
18438 foreach ($j in $jobs | Select-Object -First 5) {
18439 $result.AppendLine(" $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18440 }
18441 } else {
18442 $result.AppendLine(" No pending print jobs.") | Out-Null
18443 }
18444} catch {
18445 $result.AppendLine(" Print queue check requires elevation.") | Out-Null
18446}
18447
18448Write-Output $result.ToString().TrimEnd()
18449"#;
18450 let out = run_powershell(script)?;
18451 Ok(format!("Host inspection: print_spooler\n\n{out}"))
18452}
18453
18454#[cfg(not(windows))]
18455fn inspect_print_spooler() -> Result<String, String> {
18456 let mut out = String::from("Host inspection: print_spooler\n\n");
18457 if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18458 let s = String::from_utf8_lossy(&o.stdout);
18459 if !s.trim().is_empty() {
18460 out.push_str("=== CUPS Status (lpstat -s) ===\n");
18461 out.push_str(s.trim_end());
18462 out.push('\n');
18463 }
18464 } else {
18465 out.push_str("CUPS not detected (lpstat not found).\n");
18466 }
18467 Ok(out)
18468}
18469
18470#[cfg(not(windows))]
18471fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18472 let mut out = String::from("Host inspection: defender_quarantine\n\n");
18473 out.push_str("Windows Defender is Windows-only.\n");
18474 if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18476 if o.status.success() {
18477 out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18478 if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18479 out.push_str("\n=== ClamAV Recent Log ===\n");
18480 for line in log.lines().rev().take(20) {
18481 out.push_str(&format!(" {line}\n"));
18482 }
18483 }
18484 }
18485 } else {
18486 out.push_str("No AV tool detected (ClamAV not found).\n");
18487 }
18488 Ok(out)
18489}