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 is_licensed = activation_status.to_lowercase().contains("licensed")
1409 && !activation_status.to_lowercase().contains("not licensed");
1410
1411 let mut out = String::from("Host inspection: fix_plan\n\n");
1412 out.push_str(&format!("- Requested issue: {}\n", issue));
1413 out.push_str("- Fix-plan type: windows_activation\n");
1414 if !activation_status.is_empty() {
1415 out.push_str(&format!(
1416 "- Current activation state:\n{}\n",
1417 activation_status
1418 ));
1419 }
1420
1421 if is_licensed {
1422 out.push_str(
1423 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1424 );
1425 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1426 out.push_str(" (Forces an online activation attempt)\n");
1427 out.push_str("2. Check activation details: slmgr /dli\n");
1428 } else {
1429 out.push_str("\nFix plan — Activating Windows:\n");
1430 out.push_str("1. Check your current status first:\n");
1431 out.push_str(" slmgr /dli (basic info)\n");
1432 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1433 out.push_str("\n2. If you have a retail product key:\n");
1434 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1435 out.push_str(" slmgr /ato (activate online)\n");
1436 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1437 out.push_str(" - Go to Settings → System → Activation\n");
1438 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1439 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1440 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1441 out.push_str(" - Contact your IT department for the KMS server address\n");
1442 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1443 out.push_str(" - Activate: slmgr /ato\n");
1444 }
1445 out.push_str("\nVerification:\n");
1446 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1447 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1448 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.");
1449 Ok(out.trim_end().to_string())
1450}
1451
1452fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1453 let mut out = String::from("Host inspection: fix_plan\n\n");
1454 out.push_str(&format!("- Requested issue: {}\n", issue));
1455 out.push_str("- Fix-plan type: registry_edit\n");
1456 out.push_str(
1457 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1458 );
1459 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1460 out.push_str("\n1. Back up before you touch anything:\n");
1461 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1462 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1463 out.push_str(" # Or export the whole registry (takes a while):\n");
1464 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1465 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1466 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1467 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1468 out.push_str(
1469 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1470 );
1471 out.push_str("\n4. Create a new key:\n");
1472 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1473 out.push_str("\n5. Delete a value:\n");
1474 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1475 out.push_str("\n6. Restore from backup if something breaks:\n");
1476 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1477 out.push_str("\nCommon registry hives:\n");
1478 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1479 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1480 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1481 out.push_str("\nVerification:\n");
1482 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1483 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.");
1484 Ok(out.trim_end().to_string())
1485}
1486
1487fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1488 let mut out = String::from("Host inspection: fix_plan\n\n");
1489 out.push_str(&format!("- Requested issue: {}\n", issue));
1490 out.push_str("- Fix-plan type: scheduled_task_create\n");
1491 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1492 out.push_str("\nExample: Run a script at 9 AM every day\n");
1493 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1494 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1495 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1496 out.push_str("\nExample: Run at Windows startup\n");
1497 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1498 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1499 out.push_str("\nExample: Run at user logon\n");
1500 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1501 out.push_str(
1502 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1503 );
1504 out.push_str("\nExample: Run every 30 minutes\n");
1505 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1506 out.push_str("\nView all tasks:\n");
1507 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1508 out.push_str("\nDelete a task:\n");
1509 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1510 out.push_str("\nRun a task immediately:\n");
1511 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1512 out.push_str("\nVerification:\n");
1513 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1514 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.");
1515 Ok(out.trim_end().to_string())
1516}
1517
1518fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1519 #[cfg(target_os = "windows")]
1520 let disk_info = {
1521 Command::new("powershell")
1522 .args([
1523 "-NoProfile",
1524 "-NonInteractive",
1525 "-Command",
1526 "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\" }",
1527 ])
1528 .output()
1529 .ok()
1530 .and_then(|o| String::from_utf8(o.stdout).ok())
1531 .unwrap_or_default()
1532 .trim()
1533 .to_string()
1534 };
1535 #[cfg(not(target_os = "windows"))]
1536 let disk_info = String::new();
1537
1538 let mut out = String::from("Host inspection: fix_plan\n\n");
1539 out.push_str(&format!("- Requested issue: {}\n", issue));
1540 out.push_str("- Fix-plan type: disk_cleanup\n");
1541 if !disk_info.is_empty() {
1542 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1543 }
1544 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1545 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1546 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1547 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1548 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1549 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1550 out.push_str(" Stop-Service wuauserv\n");
1551 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1552 out.push_str(" Start-Service wuauserv\n");
1553 out.push_str("\n3. Clear Windows Temp folder:\n");
1554 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1555 out.push_str(
1556 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1557 );
1558 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1559 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1560 out.push_str(" - npm cache: npm cache clean --force\n");
1561 out.push_str(" - pip cache: pip cache purge\n");
1562 out.push_str(
1563 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1564 );
1565 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1566 out.push_str("\n5. Check for large files:\n");
1567 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");
1568 out.push_str("\nVerification:\n");
1569 out.push_str(
1570 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1571 );
1572 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.");
1573 Ok(out.trim_end().to_string())
1574}
1575
1576fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1577 let mut out = String::from("Host inspection: fix_plan\n\n");
1578 out.push_str(&format!("- Requested issue: {}\n", issue));
1579 out.push_str("- Fix-plan type: generic\n");
1580 out.push_str(
1581 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1582 Structured lanes available:\n\
1583 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1584 - Port conflict (address already in use, what owns port)\n\
1585 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1586 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1587 - Group Policy (gpedit, local policy, administrative template)\n\
1588 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1589 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1590 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1591 - Service config (start/stop/restart/enable/disable a service)\n\
1592 - Windows activation (product key, not activated, kms)\n\
1593 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1594 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1595 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1596 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1597 );
1598 Ok(out.trim_end().to_string())
1599}
1600
1601fn inspect_resource_load() -> Result<String, String> {
1602 #[cfg(target_os = "windows")]
1603 {
1604 let output = Command::new("powershell")
1605 .args([
1606 "-NoProfile",
1607 "-Command",
1608 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1609 ])
1610 .output()
1611 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1612
1613 let text = String::from_utf8_lossy(&output.stdout);
1614 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1615
1616 let cpu_load = lines
1617 .next()
1618 .and_then(|l| l.parse::<u32>().ok())
1619 .unwrap_or(0);
1620 let mem_json = lines.collect::<Vec<_>>().join("");
1621 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1622
1623 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1624 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1625 let used_kb = total_kb.saturating_sub(free_kb);
1626 let mem_percent = if total_kb > 0 {
1627 (used_kb * 100) / total_kb
1628 } else {
1629 0
1630 };
1631
1632 let mut out = String::from("Host inspection: resource_load\n\n");
1633 out.push_str("**System Performance Summary:**\n");
1634 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1635 out.push_str(&format!(
1636 "- Memory Usage: {} / {} ({}%)\n",
1637 human_bytes(used_kb * 1024),
1638 human_bytes(total_kb * 1024),
1639 mem_percent
1640 ));
1641
1642 if cpu_load > 85 {
1643 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1644 }
1645 if mem_percent > 90 {
1646 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1647 }
1648
1649 Ok(out)
1650 }
1651 #[cfg(not(target_os = "windows"))]
1652 {
1653 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1654 }
1655}
1656
1657#[derive(Debug)]
1658enum EndpointProbe {
1659 Reachable(u16),
1660 Unreachable(String),
1661}
1662
1663async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1664 let client = match reqwest::Client::builder()
1665 .timeout(std::time::Duration::from_secs(3))
1666 .build()
1667 {
1668 Ok(client) => client,
1669 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1670 };
1671
1672 match client.get(url).send().await {
1673 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1674 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1675 }
1676}
1677
1678async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1679 if configured_api.contains("11434") {
1680 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1681 let url = format!("{}/api/ps", base);
1682 let client = reqwest::Client::builder()
1683 .timeout(std::time::Duration::from_secs(3))
1684 .build()
1685 .ok()?;
1686 let response = client.get(url).send().await.ok()?;
1687 let body = response.json::<serde_json::Value>().await.ok()?;
1688 let entries = body["models"].as_array()?;
1689 for entry in entries {
1690 let name = entry["name"]
1691 .as_str()
1692 .or_else(|| entry["model"].as_str())
1693 .unwrap_or_default();
1694 let lower = name.to_ascii_lowercase();
1695 if lower.contains("embed")
1696 || lower.contains("embedding")
1697 || lower.contains("minilm")
1698 || lower.contains("bge")
1699 || lower.contains("e5")
1700 {
1701 return Some(name.to_string());
1702 }
1703 }
1704 return None;
1705 }
1706
1707 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1708 let url = format!("{}/api/v0/models", base);
1709 let client = reqwest::Client::builder()
1710 .timeout(std::time::Duration::from_secs(3))
1711 .build()
1712 .ok()?;
1713
1714 #[derive(serde::Deserialize)]
1715 struct ModelList {
1716 data: Vec<ModelEntry>,
1717 }
1718 #[derive(serde::Deserialize)]
1719 struct ModelEntry {
1720 id: String,
1721 #[serde(rename = "type", default)]
1722 model_type: String,
1723 #[serde(default)]
1724 state: String,
1725 }
1726
1727 let response = client.get(url).send().await.ok()?;
1728 let models = response.json::<ModelList>().await.ok()?;
1729 models
1730 .data
1731 .into_iter()
1732 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1733 .map(|model| model.id)
1734}
1735
1736fn first_port_in_text(text: &str) -> Option<u16> {
1737 text.split(|c: char| !c.is_ascii_digit())
1738 .find(|fragment| !fragment.is_empty())
1739 .and_then(|fragment| fragment.parse::<u16>().ok())
1740}
1741
1742fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1743 let mut processes = collect_processes()?;
1744 if let Some(filter) = name_filter.as_deref() {
1745 let lowered = filter.to_ascii_lowercase();
1746 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1747 }
1748 processes.sort_by(|a, b| {
1749 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1750 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1751 b_cpu
1752 .partial_cmp(&a_cpu)
1753 .unwrap_or(std::cmp::Ordering::Equal)
1754 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1755 .then_with(|| a.name.cmp(&b.name))
1756 .then_with(|| a.pid.cmp(&b.pid))
1757 });
1758
1759 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1760
1761 let mut out = String::from("Host inspection: processes\n\n");
1762 if let Some(filter) = name_filter.as_deref() {
1763 out.push_str(&format!("- Filter name: {}\n", filter));
1764 }
1765 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1766 out.push_str(&format!(
1767 "- Total reported working set: {}\n",
1768 human_bytes(total_memory)
1769 ));
1770
1771 if processes.is_empty() {
1772 out.push_str("\nNo running processes matched.");
1773 return Ok(out);
1774 }
1775
1776 out.push_str("\nTop processes by resource usage:\n");
1777 for entry in processes.iter().take(max_entries) {
1778 let cpu_str = entry
1779 .cpu_percent
1780 .map(|p| format!(" [CPU: {:.1}%]", p))
1781 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1782 .unwrap_or_default();
1783 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1784 format!(" [I/O R:{}/W:{}]", r, w)
1785 } else {
1786 " [I/O unknown]".to_string()
1787 };
1788 out.push_str(&format!(
1789 "- {} (pid {}) - {}{}{}{}\n",
1790 entry.name,
1791 entry.pid,
1792 human_bytes(entry.memory_bytes),
1793 cpu_str,
1794 io_str,
1795 entry
1796 .detail
1797 .as_deref()
1798 .map(|detail| format!(" [{}]", detail))
1799 .unwrap_or_default()
1800 ));
1801 }
1802 if processes.len() > max_entries {
1803 out.push_str(&format!(
1804 "- ... {} more processes omitted\n",
1805 processes.len() - max_entries
1806 ));
1807 }
1808
1809 Ok(out.trim_end().to_string())
1810}
1811
1812fn inspect_network(max_entries: usize) -> Result<String, String> {
1813 let adapters = collect_network_adapters()?;
1814 let active_count = adapters
1815 .iter()
1816 .filter(|adapter| adapter.is_active())
1817 .count();
1818 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1819
1820 let mut out = String::from("Host inspection: network\n\n");
1821 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1822 out.push_str(&format!("- Active adapters: {}\n", active_count));
1823 out.push_str(&format!(
1824 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1825 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1826 ));
1827
1828 if adapters.is_empty() {
1829 out.push_str("\nNo adapter details were detected.");
1830 return Ok(out);
1831 }
1832
1833 out.push_str("\nAdapter summary:\n");
1834 for adapter in adapters.iter().take(max_entries) {
1835 let status = if adapter.is_active() {
1836 "active"
1837 } else if adapter.disconnected {
1838 "disconnected"
1839 } else {
1840 "idle"
1841 };
1842 let mut details = vec![status.to_string()];
1843 if !adapter.ipv4.is_empty() {
1844 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1845 }
1846 if !adapter.ipv6.is_empty() {
1847 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1848 }
1849 if !adapter.gateways.is_empty() {
1850 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1851 }
1852 if !adapter.dns_servers.is_empty() {
1853 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1854 }
1855 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1856 }
1857 if adapters.len() > max_entries {
1858 out.push_str(&format!(
1859 "- ... {} more adapters omitted\n",
1860 adapters.len() - max_entries
1861 ));
1862 }
1863
1864 Ok(out.trim_end().to_string())
1865}
1866
1867fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1868 let mut out = String::from("Host inspection: lan_discovery\n\n");
1869
1870 #[cfg(target_os = "windows")]
1871 {
1872 let n = max_entries.clamp(5, 20);
1873 let adapters = collect_network_adapters()?;
1874 let services = collect_services().unwrap_or_default();
1875 let active_adapters: Vec<&NetworkAdapter> = adapters
1876 .iter()
1877 .filter(|adapter| adapter.is_active())
1878 .collect();
1879 let gateways: Vec<String> = active_adapters
1880 .iter()
1881 .flat_map(|adapter| adapter.gateways.clone())
1882 .collect::<HashSet<_>>()
1883 .into_iter()
1884 .collect();
1885
1886 let neighbor_script = r#"
1887$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1888 Where-Object {
1889 $_.IPAddress -notlike '127.*' -and
1890 $_.IPAddress -notlike '169.254*' -and
1891 $_.State -notin @('Unreachable','Invalid')
1892 } |
1893 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1894$neighbors | ConvertTo-Json -Compress
1895"#;
1896 let neighbor_text = Command::new("powershell")
1897 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1898 .output()
1899 .ok()
1900 .and_then(|o| String::from_utf8(o.stdout).ok())
1901 .unwrap_or_default();
1902 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1903 .into_iter()
1904 .take(n)
1905 .collect();
1906
1907 let listener_script = r#"
1908Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1909 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1910 Select-Object LocalAddress, LocalPort, OwningProcess |
1911 ForEach-Object {
1912 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1913 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1914 }
1915"#;
1916 let listener_text = Command::new("powershell")
1917 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1918 .output()
1919 .ok()
1920 .and_then(|o| String::from_utf8(o.stdout).ok())
1921 .unwrap_or_default();
1922 let listeners: Vec<(String, u16, String, String)> = listener_text
1923 .lines()
1924 .filter_map(|line| {
1925 let parts: Vec<&str> = line.trim().split('|').collect();
1926 if parts.len() < 4 {
1927 return None;
1928 }
1929 Some((
1930 parts[0].to_string(),
1931 parts[1].parse::<u16>().ok()?,
1932 parts[2].to_string(),
1933 parts[3].to_string(),
1934 ))
1935 })
1936 .take(n)
1937 .collect();
1938
1939 let smb_mapping_script = r#"
1940Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1941 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1942"#;
1943 let smb_mappings: Vec<String> = Command::new("powershell")
1944 .args([
1945 "-NoProfile",
1946 "-NonInteractive",
1947 "-Command",
1948 smb_mapping_script,
1949 ])
1950 .output()
1951 .ok()
1952 .and_then(|o| String::from_utf8(o.stdout).ok())
1953 .unwrap_or_default()
1954 .lines()
1955 .take(n)
1956 .map(|line| line.trim().to_string())
1957 .filter(|line| !line.is_empty())
1958 .collect();
1959
1960 let smb_connections_script = r#"
1961Get-SmbConnection -ErrorAction SilentlyContinue |
1962 Select-Object ServerName, ShareName, NumOpens |
1963 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1964"#;
1965 let smb_connections: Vec<String> = Command::new("powershell")
1966 .args([
1967 "-NoProfile",
1968 "-NonInteractive",
1969 "-Command",
1970 smb_connections_script,
1971 ])
1972 .output()
1973 .ok()
1974 .and_then(|o| String::from_utf8(o.stdout).ok())
1975 .unwrap_or_default()
1976 .lines()
1977 .take(n)
1978 .map(|line| line.trim().to_string())
1979 .filter(|line| !line.is_empty())
1980 .collect();
1981
1982 let discovery_service_names = [
1983 "FDResPub",
1984 "fdPHost",
1985 "SSDPSRV",
1986 "upnphost",
1987 "LanmanServer",
1988 "LanmanWorkstation",
1989 "lmhosts",
1990 ];
1991 let discovery_services: Vec<&ServiceEntry> = services
1992 .iter()
1993 .filter(|entry| {
1994 discovery_service_names
1995 .iter()
1996 .any(|name| entry.name.eq_ignore_ascii_case(name))
1997 })
1998 .collect();
1999
2000 let mut findings = Vec::new();
2001 if active_adapters.is_empty() {
2002 findings.push(AuditFinding {
2003 finding: "No active LAN adapters were detected.".to_string(),
2004 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2005 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(),
2006 });
2007 }
2008
2009 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2010 .iter()
2011 .copied()
2012 .filter(|entry| {
2013 !entry.status.eq_ignore_ascii_case("running")
2014 && !entry.status.eq_ignore_ascii_case("active")
2015 })
2016 .collect();
2017 if !stopped_discovery_services.is_empty() {
2018 let names = stopped_discovery_services
2019 .iter()
2020 .map(|entry| entry.name.as_str())
2021 .collect::<Vec<_>>()
2022 .join(", ");
2023 findings.push(AuditFinding {
2024 finding: format!("Discovery-related services are not running: {names}"),
2025 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2026 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(),
2027 });
2028 }
2029
2030 if listeners.is_empty() {
2031 findings.push(AuditFinding {
2032 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2033 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2034 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(),
2035 });
2036 }
2037
2038 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2039 findings.push(AuditFinding {
2040 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2041 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2042 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(),
2043 });
2044 }
2045
2046 out.push_str("=== Findings ===\n");
2047 if findings.is_empty() {
2048 out.push_str(
2049 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2050 );
2051 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2052 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");
2053 } else {
2054 for finding in &findings {
2055 out.push_str(&format!("- Finding: {}\n", finding.finding));
2056 out.push_str(&format!(" Impact: {}\n", finding.impact));
2057 out.push_str(&format!(" Fix: {}\n", finding.fix));
2058 }
2059 }
2060
2061 out.push_str("\n=== Active adapter and gateway summary ===\n");
2062 if active_adapters.is_empty() {
2063 out.push_str("- No active adapters detected.\n");
2064 } else {
2065 for adapter in active_adapters.iter().take(n) {
2066 let ipv4 = if adapter.ipv4.is_empty() {
2067 "no IPv4".to_string()
2068 } else {
2069 adapter.ipv4.join(", ")
2070 };
2071 let gateway = if adapter.gateways.is_empty() {
2072 "no gateway".to_string()
2073 } else {
2074 adapter.gateways.join(", ")
2075 };
2076 out.push_str(&format!(
2077 "- {} | IPv4: {} | Gateway: {}\n",
2078 adapter.name, ipv4, gateway
2079 ));
2080 }
2081 }
2082
2083 out.push_str("\n=== Neighborhood evidence ===\n");
2084 out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2085 out.push_str(&format!(
2086 "- Neighbor entries observed: {}\n",
2087 neighbors.len()
2088 ));
2089 if neighbors.is_empty() {
2090 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2091 } else {
2092 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2093 out.push_str(&format!(
2094 "- {} on {} | MAC: {} | State: {}\n",
2095 ip, iface, mac, state
2096 ));
2097 }
2098 }
2099
2100 out.push_str("\n=== Discovery services ===\n");
2101 if discovery_services.is_empty() {
2102 out.push_str("- Discovery service status unavailable.\n");
2103 } else {
2104 for entry in discovery_services.iter().take(n) {
2105 let startup = entry.startup.as_deref().unwrap_or("unknown");
2106 out.push_str(&format!(
2107 "- {} | Status: {} | Startup: {}\n",
2108 entry.name, entry.status, startup
2109 ));
2110 }
2111 }
2112
2113 out.push_str("\n=== Discovery listener surface ===\n");
2114 if listeners.is_empty() {
2115 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2116 } else {
2117 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2118 let label = match *port {
2119 137 => "NetBIOS Name Service",
2120 138 => "NetBIOS Datagram",
2121 1900 => "SSDP/UPnP",
2122 5353 => "mDNS",
2123 5355 => "LLMNR",
2124 _ => "Discovery",
2125 };
2126 let proc_label = if proc_name.is_empty() {
2127 "unknown".to_string()
2128 } else {
2129 proc_name.clone()
2130 };
2131 out.push_str(&format!(
2132 "- {}:{} | {} | PID {} ({})\n",
2133 addr, port, label, pid, proc_label
2134 ));
2135 }
2136 }
2137
2138 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2139 if smb_mappings.is_empty() && smb_connections.is_empty() {
2140 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2141 } else {
2142 if !smb_mappings.is_empty() {
2143 out.push_str("- Mapped drives:\n");
2144 for mapping in smb_mappings.iter().take(n) {
2145 let parts: Vec<&str> = mapping.split('|').collect();
2146 if parts.len() >= 2 {
2147 out.push_str(&format!(" - {} -> {}\n", parts[0], parts[1]));
2148 }
2149 }
2150 }
2151 if !smb_connections.is_empty() {
2152 out.push_str("- Active SMB connections:\n");
2153 for connection in smb_connections.iter().take(n) {
2154 let parts: Vec<&str> = connection.split('|').collect();
2155 if parts.len() >= 3 {
2156 out.push_str(&format!(
2157 " - {}\\{} | Opens: {}\n",
2158 parts[0], parts[1], parts[2]
2159 ));
2160 }
2161 }
2162 }
2163 }
2164 }
2165
2166 #[cfg(not(target_os = "windows"))]
2167 {
2168 let n = max_entries.clamp(5, 20);
2169 let adapters = collect_network_adapters()?;
2170 let arp_output = Command::new("ip")
2171 .args(["neigh"])
2172 .output()
2173 .ok()
2174 .and_then(|o| String::from_utf8(o.stdout).ok())
2175 .unwrap_or_default();
2176 let neighbors: Vec<&str> = arp_output
2177 .lines()
2178 .filter(|line| !line.trim().is_empty())
2179 .take(n)
2180 .collect();
2181
2182 out.push_str("=== Findings ===\n");
2183 if adapters.iter().any(|adapter| adapter.is_active()) {
2184 out.push_str(
2185 "- Finding: LAN discovery support is partially available on this platform.\n",
2186 );
2187 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2188 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2189 } else {
2190 out.push_str("- Finding: No active LAN adapters were detected.\n");
2191 out.push_str(
2192 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2193 );
2194 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2195 }
2196
2197 out.push_str("\n=== Active adapter and gateway summary ===\n");
2198 if adapters.is_empty() {
2199 out.push_str("- No adapters detected.\n");
2200 } else {
2201 for adapter in adapters.iter().take(n) {
2202 let ipv4 = if adapter.ipv4.is_empty() {
2203 "no IPv4".to_string()
2204 } else {
2205 adapter.ipv4.join(", ")
2206 };
2207 let gateway = if adapter.gateways.is_empty() {
2208 "no gateway".to_string()
2209 } else {
2210 adapter.gateways.join(", ")
2211 };
2212 out.push_str(&format!(
2213 "- {} | IPv4: {} | Gateway: {}\n",
2214 adapter.name, ipv4, gateway
2215 ));
2216 }
2217 }
2218
2219 out.push_str("\n=== Neighborhood evidence ===\n");
2220 if neighbors.is_empty() {
2221 out.push_str("- No neighbor entries detected.\n");
2222 } else {
2223 for line in neighbors {
2224 out.push_str(&format!("- {}\n", line.trim()));
2225 }
2226 }
2227 }
2228
2229 Ok(out.trim_end().to_string())
2230}
2231
2232fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2233 let mut services = collect_services()?;
2234 if let Some(filter) = name_filter.as_deref() {
2235 let lowered = filter.to_ascii_lowercase();
2236 services.retain(|entry| {
2237 entry.name.to_ascii_lowercase().contains(&lowered)
2238 || entry
2239 .display_name
2240 .as_deref()
2241 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2242 .unwrap_or(false)
2243 });
2244 }
2245
2246 services.sort_by(|a, b| {
2247 let a_running =
2248 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2249 let b_running =
2250 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2251 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2252 });
2253
2254 let running = services
2255 .iter()
2256 .filter(|entry| {
2257 entry.status.eq_ignore_ascii_case("running")
2258 || entry.status.eq_ignore_ascii_case("active")
2259 })
2260 .count();
2261 let failed = services
2262 .iter()
2263 .filter(|entry| {
2264 entry.status.eq_ignore_ascii_case("failed")
2265 || entry.status.eq_ignore_ascii_case("error")
2266 || entry.status.eq_ignore_ascii_case("stopped")
2267 })
2268 .count();
2269
2270 let mut out = String::from("Host inspection: services\n\n");
2271 if let Some(filter) = name_filter.as_deref() {
2272 out.push_str(&format!("- Filter name: {}\n", filter));
2273 }
2274 out.push_str(&format!("- Services found: {}\n", services.len()));
2275 out.push_str(&format!("- Running/active: {}\n", running));
2276 out.push_str(&format!("- Failed/stopped: {}\n", failed));
2277
2278 if services.is_empty() {
2279 out.push_str("\nNo services matched.");
2280 return Ok(out);
2281 }
2282
2283 let per_section = (max_entries / 2).max(5);
2285
2286 let running_services: Vec<_> = services
2287 .iter()
2288 .filter(|e| {
2289 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2290 })
2291 .collect();
2292 let stopped_services: Vec<_> = services
2293 .iter()
2294 .filter(|e| {
2295 e.status.eq_ignore_ascii_case("stopped")
2296 || e.status.eq_ignore_ascii_case("failed")
2297 || e.status.eq_ignore_ascii_case("error")
2298 })
2299 .collect();
2300
2301 let fmt_entry = |entry: &&ServiceEntry| {
2302 let startup = entry
2303 .startup
2304 .as_deref()
2305 .map(|v| format!(" | startup {}", v))
2306 .unwrap_or_default();
2307 let logon = entry
2308 .start_name
2309 .as_deref()
2310 .map(|v| format!(" | LogOn: {}", v))
2311 .unwrap_or_default();
2312 let display = entry
2313 .display_name
2314 .as_deref()
2315 .filter(|v| *v != &entry.name)
2316 .map(|v| format!(" [{}]", v))
2317 .unwrap_or_default();
2318 format!(
2319 "- {}{} - {}{}{}\n",
2320 entry.name, display, entry.status, startup, logon
2321 )
2322 };
2323
2324 out.push_str(&format!(
2325 "\nRunning services ({} total, showing up to {}):\n",
2326 running_services.len(),
2327 per_section
2328 ));
2329 for entry in running_services.iter().take(per_section) {
2330 out.push_str(&fmt_entry(entry));
2331 }
2332 if running_services.len() > per_section {
2333 out.push_str(&format!(
2334 "- ... {} more running services omitted\n",
2335 running_services.len() - per_section
2336 ));
2337 }
2338
2339 out.push_str(&format!(
2340 "\nStopped/failed services ({} total, showing up to {}):\n",
2341 stopped_services.len(),
2342 per_section
2343 ));
2344 for entry in stopped_services.iter().take(per_section) {
2345 out.push_str(&fmt_entry(entry));
2346 }
2347 if stopped_services.len() > per_section {
2348 out.push_str(&format!(
2349 "- ... {} more stopped services omitted\n",
2350 stopped_services.len() - per_section
2351 ));
2352 }
2353
2354 Ok(out.trim_end().to_string())
2355}
2356
2357async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2358 inspect_directory("Disk", path, max_entries).await
2359}
2360
2361fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2362 let mut listeners = collect_listening_ports()?;
2363 if let Some(port) = port_filter {
2364 listeners.retain(|entry| entry.port == port);
2365 }
2366 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2367
2368 let mut out = String::from("Host inspection: ports\n\n");
2369 if let Some(port) = port_filter {
2370 out.push_str(&format!("- Filter port: {}\n", port));
2371 }
2372 out.push_str(&format!(
2373 "- Listening endpoints found: {}\n",
2374 listeners.len()
2375 ));
2376
2377 if listeners.is_empty() {
2378 out.push_str("\nNo listening endpoints matched.");
2379 return Ok(out);
2380 }
2381
2382 out.push_str("\nListening endpoints:\n");
2383 for entry in listeners.iter().take(max_entries) {
2384 let pid_str = entry
2385 .pid
2386 .as_deref()
2387 .map(|p| format!(" pid {}", p))
2388 .unwrap_or_default();
2389 let name_str = entry
2390 .process_name
2391 .as_deref()
2392 .map(|n| format!(" [{}]", n))
2393 .unwrap_or_default();
2394 out.push_str(&format!(
2395 "- {} {} ({}){}{}\n",
2396 entry.protocol, entry.local, entry.state, pid_str, name_str
2397 ));
2398 }
2399 if listeners.len() > max_entries {
2400 out.push_str(&format!(
2401 "- ... {} more listening endpoints omitted\n",
2402 listeners.len() - max_entries
2403 ));
2404 }
2405
2406 Ok(out.trim_end().to_string())
2407}
2408
2409fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2410 if !path.exists() {
2411 return Err(format!("Path does not exist: {}", path.display()));
2412 }
2413 if !path.is_dir() {
2414 return Err(format!("Path is not a directory: {}", path.display()));
2415 }
2416
2417 let markers = collect_project_markers(&path);
2418 let hematite_state = collect_hematite_state(&path);
2419 let git_state = inspect_git_state(&path);
2420 let release_state = inspect_release_artifacts(&path);
2421
2422 let mut out = String::from("Host inspection: repo_doctor\n\n");
2423 out.push_str(&format!("- Path: {}\n", path.display()));
2424 out.push_str(&format!(
2425 "- Workspace mode: {}\n",
2426 workspace_mode_for_path(&path)
2427 ));
2428
2429 if markers.is_empty() {
2430 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");
2431 } else {
2432 out.push_str("- Project markers:\n");
2433 for marker in markers.iter().take(max_entries) {
2434 out.push_str(&format!(" - {}\n", marker));
2435 }
2436 }
2437
2438 match git_state {
2439 Some(git) => {
2440 out.push_str(&format!("- Git root: {}\n", git.root.display()));
2441 out.push_str(&format!("- Git branch: {}\n", git.branch));
2442 out.push_str(&format!("- Git status: {}\n", git.status_label()));
2443 }
2444 None => out.push_str("- Git: not inside a detected work tree\n"),
2445 }
2446
2447 out.push_str(&format!(
2448 "- Hematite docs/imports/reports: {}/{}/{}\n",
2449 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2450 ));
2451 if hematite_state.workspace_profile {
2452 out.push_str("- Workspace profile: present\n");
2453 } else {
2454 out.push_str("- Workspace profile: absent\n");
2455 }
2456
2457 if let Some(release) = release_state {
2458 out.push_str(&format!("- Cargo version: {}\n", release.version));
2459 out.push_str(&format!(
2460 "- Windows artifacts for current version: {}/{}/{}\n",
2461 bool_label(release.portable_dir),
2462 bool_label(release.portable_zip),
2463 bool_label(release.setup_exe)
2464 ));
2465 }
2466
2467 Ok(out.trim_end().to_string())
2468}
2469
2470async fn inspect_known_directory(
2471 label: &str,
2472 path: Option<PathBuf>,
2473 max_entries: usize,
2474) -> Result<String, String> {
2475 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2476 inspect_directory(label, path, max_entries).await
2477}
2478
2479async fn inspect_directory(
2480 label: &str,
2481 path: PathBuf,
2482 max_entries: usize,
2483) -> Result<String, String> {
2484 let label = label.to_string();
2485 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2486 .await
2487 .map_err(|e| format!("inspect_host task failed: {e}"))?
2488}
2489
2490fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2491 if !path.exists() {
2492 return Err(format!("Path does not exist: {}", path.display()));
2493 }
2494 if !path.is_dir() {
2495 return Err(format!("Path is not a directory: {}", path.display()));
2496 }
2497
2498 let mut top_level_entries = Vec::new();
2499 for entry in fs::read_dir(path)
2500 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2501 {
2502 match entry {
2503 Ok(entry) => top_level_entries.push(entry),
2504 Err(_) => continue,
2505 }
2506 }
2507 top_level_entries.sort_by_key(|entry| entry.file_name());
2508
2509 let top_level_count = top_level_entries.len();
2510 let mut sample_names = Vec::new();
2511 let mut largest_entries = Vec::new();
2512 let mut aggregate = PathAggregate::default();
2513 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2514
2515 for entry in top_level_entries {
2516 let name = entry.file_name().to_string_lossy().to_string();
2517 if sample_names.len() < max_entries {
2518 sample_names.push(name.clone());
2519 }
2520 let kind = match entry.file_type() {
2521 Ok(ft) if ft.is_dir() => "dir",
2522 Ok(ft) if ft.is_symlink() => "symlink",
2523 _ => "file",
2524 };
2525 let stats = measure_path(&entry.path(), &mut budget);
2526 aggregate.merge(&stats);
2527 largest_entries.push(LargestEntry {
2528 name,
2529 kind,
2530 bytes: stats.total_bytes,
2531 });
2532 }
2533
2534 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2535
2536 let mut out = format!("Directory inspection: {}\n\n", label);
2537 out.push_str(&format!("- Path: {}\n", path.display()));
2538 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2539 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2540 out.push_str(&format!(
2541 "- Recursive directories: {}\n",
2542 aggregate.dir_count
2543 ));
2544 out.push_str(&format!(
2545 "- Total size: {}{}\n",
2546 human_bytes(aggregate.total_bytes),
2547 if aggregate.partial {
2548 " (partial scan)"
2549 } else {
2550 ""
2551 }
2552 ));
2553 if aggregate.skipped_entries > 0 {
2554 out.push_str(&format!(
2555 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2556 aggregate.skipped_entries
2557 ));
2558 }
2559
2560 if !largest_entries.is_empty() {
2561 out.push_str("\nLargest top-level entries:\n");
2562 for entry in largest_entries.iter().take(max_entries) {
2563 out.push_str(&format!(
2564 "- {} [{}] - {}\n",
2565 entry.name,
2566 entry.kind,
2567 human_bytes(entry.bytes)
2568 ));
2569 }
2570 }
2571
2572 if !sample_names.is_empty() {
2573 out.push_str("\nSample names:\n");
2574 for name in sample_names {
2575 out.push_str(&format!("- {}\n", name));
2576 }
2577 }
2578
2579 Ok(out.trim_end().to_string())
2580}
2581
2582fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2583 let trimmed = raw.trim();
2584 if trimmed.is_empty() {
2585 return Err("Path must not be empty.".to_string());
2586 }
2587
2588 if let Some(rest) = trimmed
2589 .strip_prefix("~/")
2590 .or_else(|| trimmed.strip_prefix("~\\"))
2591 {
2592 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2593 return Ok(home.join(rest));
2594 }
2595
2596 let path = PathBuf::from(trimmed);
2597 if path.is_absolute() {
2598 Ok(path)
2599 } else {
2600 let cwd =
2601 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2602 let full_path = cwd.join(&path);
2603
2604 if !full_path.exists()
2607 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2608 {
2609 if let Some(home) = home::home_dir() {
2610 let home_path = home.join(trimmed);
2611 if home_path.exists() {
2612 return Ok(home_path);
2613 }
2614 }
2615 }
2616
2617 Ok(full_path)
2618 }
2619}
2620
2621fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2622 workspace_mode_for_path(workspace_root)
2623}
2624
2625fn workspace_mode_for_path(path: &Path) -> &'static str {
2626 if is_project_marker_path(path) {
2627 "project"
2628 } else if path.join(".hematite").join("docs").exists()
2629 || path.join(".hematite").join("imports").exists()
2630 || path.join(".hematite").join("reports").exists()
2631 {
2632 "docs-only"
2633 } else {
2634 "general directory"
2635 }
2636}
2637
2638fn is_project_marker_path(path: &Path) -> bool {
2639 [
2640 "Cargo.toml",
2641 "package.json",
2642 "pyproject.toml",
2643 "go.mod",
2644 "composer.json",
2645 "requirements.txt",
2646 "Makefile",
2647 "justfile",
2648 ]
2649 .iter()
2650 .any(|name| path.join(name).exists())
2651 || path.join(".git").exists()
2652}
2653
2654fn preferred_shell_label() -> &'static str {
2655 #[cfg(target_os = "windows")]
2656 {
2657 "PowerShell"
2658 }
2659 #[cfg(not(target_os = "windows"))]
2660 {
2661 "sh"
2662 }
2663}
2664
2665fn desktop_dir() -> Option<PathBuf> {
2666 home::home_dir().map(|home| home.join("Desktop"))
2667}
2668
2669fn downloads_dir() -> Option<PathBuf> {
2670 home::home_dir().map(|home| home.join("Downloads"))
2671}
2672
2673fn count_top_level_items(path: &Path) -> Result<usize, String> {
2674 let mut count = 0usize;
2675 for entry in
2676 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2677 {
2678 if entry.is_ok() {
2679 count += 1;
2680 }
2681 }
2682 Ok(count)
2683}
2684
2685#[derive(Default)]
2686struct PathAggregate {
2687 total_bytes: u64,
2688 file_count: u64,
2689 dir_count: u64,
2690 skipped_entries: u64,
2691 partial: bool,
2692}
2693
2694impl PathAggregate {
2695 fn merge(&mut self, other: &PathAggregate) {
2696 self.total_bytes += other.total_bytes;
2697 self.file_count += other.file_count;
2698 self.dir_count += other.dir_count;
2699 self.skipped_entries += other.skipped_entries;
2700 self.partial |= other.partial;
2701 }
2702}
2703
2704struct LargestEntry {
2705 name: String,
2706 kind: &'static str,
2707 bytes: u64,
2708}
2709
2710fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2711 if *budget == 0 {
2712 return PathAggregate {
2713 partial: true,
2714 skipped_entries: 1,
2715 ..PathAggregate::default()
2716 };
2717 }
2718 *budget -= 1;
2719
2720 let metadata = match fs::symlink_metadata(path) {
2721 Ok(metadata) => metadata,
2722 Err(_) => {
2723 return PathAggregate {
2724 skipped_entries: 1,
2725 ..PathAggregate::default()
2726 }
2727 }
2728 };
2729
2730 let file_type = metadata.file_type();
2731 if file_type.is_symlink() {
2732 return PathAggregate {
2733 skipped_entries: 1,
2734 ..PathAggregate::default()
2735 };
2736 }
2737
2738 if metadata.is_file() {
2739 return PathAggregate {
2740 total_bytes: metadata.len(),
2741 file_count: 1,
2742 ..PathAggregate::default()
2743 };
2744 }
2745
2746 if !metadata.is_dir() {
2747 return PathAggregate::default();
2748 }
2749
2750 let mut aggregate = PathAggregate {
2751 dir_count: 1,
2752 ..PathAggregate::default()
2753 };
2754
2755 let read_dir = match fs::read_dir(path) {
2756 Ok(read_dir) => read_dir,
2757 Err(_) => {
2758 aggregate.skipped_entries += 1;
2759 return aggregate;
2760 }
2761 };
2762
2763 for child in read_dir {
2764 match child {
2765 Ok(child) => {
2766 let child_stats = measure_path(&child.path(), budget);
2767 aggregate.merge(&child_stats);
2768 }
2769 Err(_) => aggregate.skipped_entries += 1,
2770 }
2771 }
2772
2773 aggregate
2774}
2775
2776struct PathAnalysis {
2777 total_entries: usize,
2778 unique_entries: usize,
2779 entries: Vec<String>,
2780 duplicate_entries: Vec<String>,
2781 missing_entries: Vec<String>,
2782}
2783
2784fn analyze_path_env() -> PathAnalysis {
2785 let mut entries = Vec::new();
2786 let mut duplicate_entries = Vec::new();
2787 let mut missing_entries = Vec::new();
2788 let mut seen = HashSet::new();
2789
2790 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2791 for path in std::env::split_paths(&raw_path) {
2792 let display = path.display().to_string();
2793 if display.trim().is_empty() {
2794 continue;
2795 }
2796
2797 let normalized = normalize_path_entry(&display);
2798 if !seen.insert(normalized) {
2799 duplicate_entries.push(display.clone());
2800 }
2801 if !path.exists() {
2802 missing_entries.push(display.clone());
2803 }
2804 entries.push(display);
2805 }
2806
2807 let total_entries = entries.len();
2808 let unique_entries = seen.len();
2809
2810 PathAnalysis {
2811 total_entries,
2812 unique_entries,
2813 entries,
2814 duplicate_entries,
2815 missing_entries,
2816 }
2817}
2818
2819fn normalize_path_entry(value: &str) -> String {
2820 #[cfg(target_os = "windows")]
2821 {
2822 value
2823 .replace('/', "\\")
2824 .trim_end_matches(['\\', '/'])
2825 .to_ascii_lowercase()
2826 }
2827 #[cfg(not(target_os = "windows"))]
2828 {
2829 value.trim_end_matches('/').to_string()
2830 }
2831}
2832
2833struct ToolchainReport {
2834 found: Vec<(String, String)>,
2835 missing: Vec<String>,
2836}
2837
2838struct PackageManagerReport {
2839 found: Vec<(String, String)>,
2840}
2841
2842#[derive(Debug, Clone)]
2843struct ProcessEntry {
2844 name: String,
2845 pid: u32,
2846 memory_bytes: u64,
2847 cpu_seconds: Option<f64>,
2848 cpu_percent: Option<f64>,
2849 read_ops: Option<u64>,
2850 write_ops: Option<u64>,
2851 detail: Option<String>,
2852}
2853
2854#[derive(Debug, Clone)]
2855struct ServiceEntry {
2856 name: String,
2857 status: String,
2858 startup: Option<String>,
2859 display_name: Option<String>,
2860 start_name: Option<String>,
2861}
2862
2863#[derive(Debug, Clone, Default)]
2864struct NetworkAdapter {
2865 name: String,
2866 ipv4: Vec<String>,
2867 ipv6: Vec<String>,
2868 gateways: Vec<String>,
2869 dns_servers: Vec<String>,
2870 disconnected: bool,
2871}
2872
2873impl NetworkAdapter {
2874 fn is_active(&self) -> bool {
2875 !self.disconnected
2876 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2877 }
2878}
2879
2880#[derive(Debug, Clone, Copy, Default)]
2881struct ListenerExposureSummary {
2882 loopback_only: usize,
2883 wildcard_public: usize,
2884 specific_bind: usize,
2885}
2886
2887#[derive(Debug, Clone)]
2888struct ListeningPort {
2889 protocol: String,
2890 local: String,
2891 port: u16,
2892 state: String,
2893 pid: Option<String>,
2894 process_name: Option<String>,
2895}
2896
2897fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2898 #[cfg(target_os = "windows")]
2899 {
2900 collect_windows_listening_ports()
2901 }
2902 #[cfg(not(target_os = "windows"))]
2903 {
2904 collect_unix_listening_ports()
2905 }
2906}
2907
2908fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2909 #[cfg(target_os = "windows")]
2910 {
2911 collect_windows_network_adapters()
2912 }
2913 #[cfg(not(target_os = "windows"))]
2914 {
2915 collect_unix_network_adapters()
2916 }
2917}
2918
2919fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2920 #[cfg(target_os = "windows")]
2921 {
2922 collect_windows_services()
2923 }
2924 #[cfg(not(target_os = "windows"))]
2925 {
2926 collect_unix_services()
2927 }
2928}
2929
2930#[cfg(target_os = "windows")]
2931fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2932 let output = Command::new("netstat")
2933 .args(["-ano", "-p", "tcp"])
2934 .output()
2935 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2936 if !output.status.success() {
2937 return Err("netstat returned a non-success status.".to_string());
2938 }
2939
2940 let text = String::from_utf8_lossy(&output.stdout);
2941 let mut listeners = Vec::new();
2942 for line in text.lines() {
2943 let trimmed = line.trim();
2944 if !trimmed.starts_with("TCP") {
2945 continue;
2946 }
2947 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2948 if cols.len() < 5 || cols[3] != "LISTENING" {
2949 continue;
2950 }
2951 let Some(port) = extract_port_from_socket(cols[1]) else {
2952 continue;
2953 };
2954 listeners.push(ListeningPort {
2955 protocol: cols[0].to_string(),
2956 local: cols[1].to_string(),
2957 port,
2958 state: cols[3].to_string(),
2959 pid: Some(cols[4].to_string()),
2960 process_name: None,
2961 });
2962 }
2963
2964 let unique_pids: Vec<String> = listeners
2967 .iter()
2968 .filter_map(|l| l.pid.clone())
2969 .collect::<HashSet<_>>()
2970 .into_iter()
2971 .collect();
2972
2973 if !unique_pids.is_empty() {
2974 let pid_list = unique_pids.join(",");
2975 let ps_cmd = format!(
2976 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2977 pid_list
2978 );
2979 if let Ok(ps_out) = Command::new("powershell")
2980 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2981 .output()
2982 {
2983 let mut pid_map = std::collections::HashMap::<String, String>::new();
2984 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2985 for line in ps_text.lines() {
2986 let parts: Vec<&str> = line.split_whitespace().collect();
2987 if parts.len() >= 2 {
2988 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2989 }
2990 }
2991 for listener in &mut listeners {
2992 if let Some(pid) = &listener.pid {
2993 listener.process_name = pid_map.get(pid).cloned();
2994 }
2995 }
2996 }
2997 }
2998
2999 Ok(listeners)
3000}
3001
3002#[cfg(not(target_os = "windows"))]
3003fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3004 let output = Command::new("ss")
3005 .args(["-ltn"])
3006 .output()
3007 .map_err(|e| format!("Failed to run ss: {e}"))?;
3008 if !output.status.success() {
3009 return Err("ss returned a non-success status.".to_string());
3010 }
3011
3012 let text = String::from_utf8_lossy(&output.stdout);
3013 let mut listeners = Vec::new();
3014 for line in text.lines().skip(1) {
3015 let cols: Vec<&str> = line.split_whitespace().collect();
3016 if cols.len() < 4 {
3017 continue;
3018 }
3019 let Some(port) = extract_port_from_socket(cols[3]) else {
3020 continue;
3021 };
3022 listeners.push(ListeningPort {
3023 protocol: "tcp".to_string(),
3024 local: cols[3].to_string(),
3025 port,
3026 state: cols[0].to_string(),
3027 pid: None,
3028 process_name: None,
3029 });
3030 }
3031
3032 Ok(listeners)
3033}
3034
3035fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3036 #[cfg(target_os = "windows")]
3037 {
3038 collect_windows_processes()
3039 }
3040 #[cfg(not(target_os = "windows"))]
3041 {
3042 collect_unix_processes()
3043 }
3044}
3045
3046#[cfg(target_os = "windows")]
3047fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3048 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3049 let output = Command::new("powershell")
3050 .args(["-NoProfile", "-Command", command])
3051 .output()
3052 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3053 if !output.status.success() {
3054 return Err("PowerShell service inspection returned a non-success status.".to_string());
3055 }
3056
3057 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3058}
3059
3060#[cfg(not(target_os = "windows"))]
3061fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3062 let status_output = Command::new("systemctl")
3063 .args([
3064 "list-units",
3065 "--type=service",
3066 "--all",
3067 "--no-pager",
3068 "--no-legend",
3069 "--plain",
3070 ])
3071 .output()
3072 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3073 if !status_output.status.success() {
3074 return Err("systemctl list-units returned a non-success status.".to_string());
3075 }
3076
3077 let startup_output = Command::new("systemctl")
3078 .args([
3079 "list-unit-files",
3080 "--type=service",
3081 "--no-legend",
3082 "--no-pager",
3083 "--plain",
3084 ])
3085 .output()
3086 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3087 if !startup_output.status.success() {
3088 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3089 }
3090
3091 Ok(parse_unix_services(
3092 &String::from_utf8_lossy(&status_output.stdout),
3093 &String::from_utf8_lossy(&startup_output.stdout),
3094 ))
3095}
3096
3097#[cfg(target_os = "windows")]
3098fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3099 let output = Command::new("ipconfig")
3100 .args(["/all"])
3101 .output()
3102 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3103 if !output.status.success() {
3104 return Err("ipconfig returned a non-success status.".to_string());
3105 }
3106
3107 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3108 &output.stdout,
3109 )))
3110}
3111
3112#[cfg(not(target_os = "windows"))]
3113fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3114 let addr_output = Command::new("ip")
3115 .args(["-o", "addr", "show", "up"])
3116 .output()
3117 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3118 if !addr_output.status.success() {
3119 return Err("ip addr returned a non-success status.".to_string());
3120 }
3121
3122 let route_output = Command::new("ip")
3123 .args(["route", "show", "default"])
3124 .output()
3125 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3126 if !route_output.status.success() {
3127 return Err("ip route returned a non-success status.".to_string());
3128 }
3129
3130 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3131 apply_unix_default_routes(
3132 &mut adapters,
3133 &String::from_utf8_lossy(&route_output.stdout),
3134 );
3135 apply_unix_dns_servers(&mut adapters);
3136 Ok(adapters)
3137}
3138
3139#[cfg(target_os = "windows")]
3140fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3141 let script = r#"
3143 $s1 = Get-Process | Select-Object Id, CPU
3144 Start-Sleep -Milliseconds 250
3145 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3146 $s2 | ForEach-Object {
3147 $p2 = $_
3148 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3149 $pct = 0.0
3150 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3151 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3152 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3153 # Standard Task Manager style is (delta / interval) * 100.
3154 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3155 }
3156 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3157 }
3158 "#;
3159
3160 let output = Command::new("powershell")
3161 .args(["-NoProfile", "-Command", script])
3162 .output()
3163 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3164
3165 let text = String::from_utf8_lossy(&output.stdout);
3166 let mut out = Vec::new();
3167 for line in text.lines() {
3168 let parts: Vec<&str> = line.trim().split('|').collect();
3169 if parts.len() < 5 {
3170 continue;
3171 }
3172 let mut entry = ProcessEntry {
3173 name: "unknown".to_string(),
3174 pid: 0,
3175 memory_bytes: 0,
3176 cpu_seconds: None,
3177 cpu_percent: None,
3178 read_ops: None,
3179 write_ops: None,
3180 detail: None,
3181 };
3182 for p in parts {
3183 if let Some((k, v)) = p.split_once(':') {
3184 match k {
3185 "PID" => entry.pid = v.parse().unwrap_or(0),
3186 "NAME" => entry.name = v.to_string(),
3187 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3188 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3189 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3190 "READ" => entry.read_ops = v.parse().ok(),
3191 "WRITE" => entry.write_ops = v.parse().ok(),
3192 _ => {}
3193 }
3194 }
3195 }
3196 out.push(entry);
3197 }
3198 Ok(out)
3199}
3200
3201#[cfg(not(target_os = "windows"))]
3202fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3203 let output = Command::new("ps")
3204 .args(["-eo", "pid=,rss=,comm="])
3205 .output()
3206 .map_err(|e| format!("Failed to run ps: {e}"))?;
3207 if !output.status.success() {
3208 return Err("ps returned a non-success status.".to_string());
3209 }
3210
3211 let text = String::from_utf8_lossy(&output.stdout);
3212 let mut processes = Vec::new();
3213 for line in text.lines() {
3214 let cols: Vec<&str> = line.split_whitespace().collect();
3215 if cols.len() < 3 {
3216 continue;
3217 }
3218 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3219 else {
3220 continue;
3221 };
3222 processes.push(ProcessEntry {
3223 name: cols[2..].join(" "),
3224 pid,
3225 memory_bytes: rss_kib * 1024,
3226 cpu_seconds: None,
3227 cpu_percent: None,
3228 read_ops: None,
3229 write_ops: None,
3230 detail: None,
3231 });
3232 }
3233
3234 Ok(processes)
3235}
3236
3237fn extract_port_from_socket(value: &str) -> Option<u16> {
3238 let cleaned = value.trim().trim_matches(['[', ']']);
3239 let port_str = cleaned.rsplit(':').next()?;
3240 port_str.parse::<u16>().ok()
3241}
3242
3243fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3244 let mut summary = ListenerExposureSummary::default();
3245 for entry in listeners {
3246 let local = entry.local.to_ascii_lowercase();
3247 if is_loopback_listener(&local) {
3248 summary.loopback_only += 1;
3249 } else if is_wildcard_listener(&local) {
3250 summary.wildcard_public += 1;
3251 } else {
3252 summary.specific_bind += 1;
3253 }
3254 }
3255 summary
3256}
3257
3258fn is_loopback_listener(local: &str) -> bool {
3259 local.starts_with("127.")
3260 || local.starts_with("[::1]")
3261 || local.starts_with("::1")
3262 || local.starts_with("localhost:")
3263}
3264
3265fn is_wildcard_listener(local: &str) -> bool {
3266 local.starts_with("0.0.0.0:")
3267 || local.starts_with("[::]:")
3268 || local.starts_with(":::")
3269 || local == "*:*"
3270}
3271
3272struct GitState {
3273 root: PathBuf,
3274 branch: String,
3275 dirty_entries: usize,
3276}
3277
3278impl GitState {
3279 fn status_label(&self) -> String {
3280 if self.dirty_entries == 0 {
3281 "clean".to_string()
3282 } else {
3283 format!("dirty ({} changed path(s))", self.dirty_entries)
3284 }
3285 }
3286}
3287
3288fn inspect_git_state(path: &Path) -> Option<GitState> {
3289 let root = capture_first_line(
3290 "git",
3291 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3292 )?;
3293 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3294 .unwrap_or_else(|| "detached".to_string());
3295 let output = Command::new("git")
3296 .args(["-C", path.to_str()?, "status", "--short"])
3297 .output()
3298 .ok()?;
3299 if !output.status.success() {
3300 return None;
3301 }
3302 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3303 Some(GitState {
3304 root: PathBuf::from(root),
3305 branch,
3306 dirty_entries,
3307 })
3308}
3309
3310struct HematiteState {
3311 docs_count: usize,
3312 import_count: usize,
3313 report_count: usize,
3314 workspace_profile: bool,
3315}
3316
3317fn collect_hematite_state(path: &Path) -> HematiteState {
3318 let root = path.join(".hematite");
3319 HematiteState {
3320 docs_count: count_entries_if_exists(&root.join("docs")),
3321 import_count: count_entries_if_exists(&root.join("imports")),
3322 report_count: count_entries_if_exists(&root.join("reports")),
3323 workspace_profile: root.join("workspace_profile.json").exists(),
3324 }
3325}
3326
3327fn count_entries_if_exists(path: &Path) -> usize {
3328 if !path.exists() || !path.is_dir() {
3329 return 0;
3330 }
3331 fs::read_dir(path)
3332 .ok()
3333 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3334 .unwrap_or(0)
3335}
3336
3337fn collect_project_markers(path: &Path) -> Vec<String> {
3338 [
3339 "Cargo.toml",
3340 "package.json",
3341 "pyproject.toml",
3342 "go.mod",
3343 "justfile",
3344 "Makefile",
3345 ".git",
3346 ]
3347 .iter()
3348 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3349 .collect()
3350}
3351
3352struct ReleaseArtifactState {
3353 version: String,
3354 portable_dir: bool,
3355 portable_zip: bool,
3356 setup_exe: bool,
3357}
3358
3359fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3360 let cargo_toml = path.join("Cargo.toml");
3361 if !cargo_toml.exists() {
3362 return None;
3363 }
3364 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3365 let version = [regex_line_capture(
3366 &cargo_text,
3367 r#"(?m)^version\s*=\s*"([^"]+)""#,
3368 )?]
3369 .concat();
3370 let dist_windows = path.join("dist").join("windows");
3371 let prefix = format!("Hematite-{}", version);
3372 Some(ReleaseArtifactState {
3373 version,
3374 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3375 portable_zip: dist_windows
3376 .join(format!("{}-portable.zip", prefix))
3377 .exists(),
3378 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3379 })
3380}
3381
3382fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3383 let regex = regex::Regex::new(pattern).ok()?;
3384 let captures = regex.captures(text)?;
3385 captures.get(1).map(|m| m.as_str().to_string())
3386}
3387
3388fn bool_label(value: bool) -> &'static str {
3389 if value {
3390 "yes"
3391 } else {
3392 "no"
3393 }
3394}
3395
3396fn collect_toolchains() -> ToolchainReport {
3397 let config = crate::agent::config::load_config();
3398 let mut python_probes = Vec::new();
3399 let _ = if let Some(ref path) = config.python_path {
3400 python_probes.push(CommandProbe::new(path, &["--version"]));
3401 } else {
3402 };
3403
3404 python_probes.extend([
3405 CommandProbe::new("python3", &["--version"]),
3406 CommandProbe::new("python", &["--version"]),
3407 CommandProbe::new("py", &["-3", "--version"]),
3408 CommandProbe::new("py", &["--version"]),
3409 ]);
3410
3411 let checks = [
3412 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3413 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3414 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3415 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3416 ToolCheck::new(
3417 "npm",
3418 &[
3419 CommandProbe::new("npm", &["--version"]),
3420 CommandProbe::new("npm.cmd", &["--version"]),
3421 ],
3422 ),
3423 ToolCheck::new(
3424 "pnpm",
3425 &[
3426 CommandProbe::new("pnpm", &["--version"]),
3427 CommandProbe::new("pnpm.cmd", &["--version"]),
3428 ],
3429 ),
3430 ToolCheck::new("python", &python_probes),
3431 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3432 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3433 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3434 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3435 ];
3436
3437 let mut found = Vec::new();
3438 let mut missing = Vec::new();
3439
3440 for check in checks {
3441 match check.detect() {
3442 Some(version) => found.push((check.label.to_string(), version)),
3443 None => missing.push(check.label.to_string()),
3444 }
3445 }
3446
3447 ToolchainReport { found, missing }
3448}
3449
3450fn collect_package_managers() -> PackageManagerReport {
3451 let config = crate::agent::config::load_config();
3452 let mut pip_probes = Vec::new();
3453 if let Some(ref path) = config.python_path {
3454 pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3455 }
3456 pip_probes.extend([
3457 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3458 CommandProbe::new("python", &["-m", "pip", "--version"]),
3459 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3460 CommandProbe::new("py", &["-m", "pip", "--version"]),
3461 CommandProbe::new("pip", &["--version"]),
3462 ]);
3463
3464 let checks = [
3465 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3466 ToolCheck::new(
3467 "npm",
3468 &[
3469 CommandProbe::new("npm", &["--version"]),
3470 CommandProbe::new("npm.cmd", &["--version"]),
3471 ],
3472 ),
3473 ToolCheck::new(
3474 "pnpm",
3475 &[
3476 CommandProbe::new("pnpm", &["--version"]),
3477 CommandProbe::new("pnpm.cmd", &["--version"]),
3478 ],
3479 ),
3480 ToolCheck::new("pip", &pip_probes),
3481 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3482 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3483 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3484 ToolCheck::new(
3485 "choco",
3486 &[
3487 CommandProbe::new("choco", &["--version"]),
3488 CommandProbe::new("choco.exe", &["--version"]),
3489 ],
3490 ),
3491 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3492 ];
3493
3494 let mut found = Vec::new();
3495 for check in checks {
3496 match check.detect() {
3497 Some(version) => found.push((check.label.to_string(), version)),
3498 None => {}
3499 }
3500 }
3501
3502 PackageManagerReport { found }
3503}
3504
3505#[derive(Clone)]
3506struct ToolCheck {
3507 label: &'static str,
3508 probes: Vec<CommandProbe>,
3509}
3510
3511impl ToolCheck {
3512 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3513 Self {
3514 label,
3515 probes: probes.to_vec(),
3516 }
3517 }
3518
3519 fn detect(&self) -> Option<String> {
3520 for probe in &self.probes {
3521 if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3522 return Some(output);
3523 }
3524 }
3525 None
3526 }
3527}
3528
3529#[derive(Clone)]
3530struct CommandProbe {
3531 program: String,
3532 args: Vec<String>,
3533}
3534
3535impl CommandProbe {
3536 fn new(program: &str, args: &[&str]) -> Self {
3537 Self {
3538 program: program.to_string(),
3539 args: args.iter().map(|s| s.to_string()).collect(),
3540 }
3541 }
3542}
3543
3544fn build_env_doctor_findings(
3545 toolchains: &ToolchainReport,
3546 package_managers: &PackageManagerReport,
3547 path_stats: &PathAnalysis,
3548) -> Vec<String> {
3549 let found_tools = toolchains
3550 .found
3551 .iter()
3552 .map(|(label, _)| label.as_str())
3553 .collect::<HashSet<_>>();
3554 let found_managers = package_managers
3555 .found
3556 .iter()
3557 .map(|(label, _)| label.as_str())
3558 .collect::<HashSet<_>>();
3559
3560 let mut findings = Vec::new();
3561
3562 if path_stats.duplicate_entries.len() > 0 {
3563 findings.push(format!(
3564 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3565 path_stats.duplicate_entries.len()
3566 ));
3567 }
3568 if path_stats.missing_entries.len() > 0 {
3569 findings.push(format!(
3570 "PATH contains {} entries that do not exist on disk.",
3571 path_stats.missing_entries.len()
3572 ));
3573 }
3574 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3575 findings.push(
3576 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3577 .to_string(),
3578 );
3579 }
3580 if found_tools.contains("node")
3581 && !found_managers.contains("npm")
3582 && !found_managers.contains("pnpm")
3583 {
3584 findings.push(
3585 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3586 .to_string(),
3587 );
3588 }
3589 if found_tools.contains("python")
3590 && !found_managers.contains("pip")
3591 && !found_managers.contains("uv")
3592 && !found_managers.contains("pipx")
3593 {
3594 findings.push(
3595 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3596 .to_string(),
3597 );
3598 }
3599 let windows_manager_count = ["winget", "choco", "scoop"]
3600 .iter()
3601 .filter(|label| found_managers.contains(**label))
3602 .count();
3603 if windows_manager_count > 1 {
3604 findings.push(
3605 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3606 .to_string(),
3607 );
3608 }
3609 if findings.is_empty() && !found_managers.is_empty() {
3610 findings.push(
3611 "Core package-manager coverage looks healthy for a normal developer workstation."
3612 .to_string(),
3613 );
3614 }
3615
3616 findings
3617}
3618
3619fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3620 let output = std::process::Command::new(program)
3621 .args(args.iter().map(|s| s.as_ref()))
3622 .output()
3623 .ok()?;
3624 if !output.status.success() {
3625 return None;
3626 }
3627
3628 let stdout = if output.stdout.is_empty() {
3629 String::from_utf8_lossy(&output.stderr).into_owned()
3630 } else {
3631 String::from_utf8_lossy(&output.stdout).into_owned()
3632 };
3633
3634 stdout
3635 .lines()
3636 .map(str::trim)
3637 .find(|line| !line.is_empty())
3638 .map(|line| line.to_string())
3639}
3640
3641fn human_bytes(bytes: u64) -> String {
3642 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3643 let mut value = bytes as f64;
3644 let mut unit_index = 0usize;
3645
3646 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3647 value /= 1024.0;
3648 unit_index += 1;
3649 }
3650
3651 if unit_index == 0 {
3652 format!("{} {}", bytes, UNITS[unit_index])
3653 } else {
3654 format!("{value:.1} {}", UNITS[unit_index])
3655 }
3656}
3657
3658#[cfg(target_os = "windows")]
3659fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3660 let mut adapters = Vec::new();
3661 let mut current: Option<NetworkAdapter> = None;
3662 let mut pending_dns = false;
3663
3664 for raw_line in text.lines() {
3665 let line = raw_line.trim_end();
3666 let trimmed = line.trim();
3667 if trimmed.is_empty() {
3668 pending_dns = false;
3669 continue;
3670 }
3671
3672 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3673 if let Some(adapter) = current.take() {
3674 adapters.push(adapter);
3675 }
3676 current = Some(NetworkAdapter {
3677 name: trimmed.trim_end_matches(':').to_string(),
3678 ..NetworkAdapter::default()
3679 });
3680 pending_dns = false;
3681 continue;
3682 }
3683
3684 let Some(adapter) = current.as_mut() else {
3685 continue;
3686 };
3687
3688 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3689 adapter.disconnected = true;
3690 }
3691
3692 if let Some(value) = value_after_colon(trimmed) {
3693 let normalized = normalize_ipconfig_value(value);
3694 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3695 adapter.ipv4.push(normalized);
3696 pending_dns = false;
3697 } else if trimmed.starts_with("IPv6 Address")
3698 || trimmed.starts_with("Temporary IPv6 Address")
3699 || trimmed.starts_with("Link-local IPv6 Address")
3700 {
3701 if !normalized.is_empty() {
3702 adapter.ipv6.push(normalized);
3703 }
3704 pending_dns = false;
3705 } else if trimmed.starts_with("Default Gateway") {
3706 if !normalized.is_empty() {
3707 adapter.gateways.push(normalized);
3708 }
3709 pending_dns = false;
3710 } else if trimmed.starts_with("DNS Servers") {
3711 if !normalized.is_empty() {
3712 adapter.dns_servers.push(normalized);
3713 }
3714 pending_dns = true;
3715 } else {
3716 pending_dns = false;
3717 }
3718 } else if pending_dns {
3719 let normalized = normalize_ipconfig_value(trimmed);
3720 if !normalized.is_empty() {
3721 adapter.dns_servers.push(normalized);
3722 }
3723 }
3724 }
3725
3726 if let Some(adapter) = current.take() {
3727 adapters.push(adapter);
3728 }
3729
3730 for adapter in &mut adapters {
3731 dedup_vec(&mut adapter.ipv4);
3732 dedup_vec(&mut adapter.ipv6);
3733 dedup_vec(&mut adapter.gateways);
3734 dedup_vec(&mut adapter.dns_servers);
3735 }
3736
3737 adapters
3738}
3739
3740#[cfg(not(target_os = "windows"))]
3741fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3742 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3743
3744 for line in text.lines() {
3745 let cols: Vec<&str> = line.split_whitespace().collect();
3746 if cols.len() < 4 {
3747 continue;
3748 }
3749 let name = cols[1].trim_end_matches(':').to_string();
3750 let family = cols[2];
3751 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3752 let entry = adapters
3753 .entry(name.clone())
3754 .or_insert_with(|| NetworkAdapter {
3755 name,
3756 ..NetworkAdapter::default()
3757 });
3758 match family {
3759 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3760 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3761 _ => {}
3762 }
3763 }
3764
3765 adapters.into_values().collect()
3766}
3767
3768#[cfg(not(target_os = "windows"))]
3769fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3770 for line in text.lines() {
3771 let cols: Vec<&str> = line.split_whitespace().collect();
3772 if cols.len() < 5 {
3773 continue;
3774 }
3775 let gateway = cols
3776 .windows(2)
3777 .find(|pair| pair[0] == "via")
3778 .map(|pair| pair[1].to_string());
3779 let dev = cols
3780 .windows(2)
3781 .find(|pair| pair[0] == "dev")
3782 .map(|pair| pair[1]);
3783 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3784 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3785 adapter.gateways.push(gateway);
3786 }
3787 }
3788 }
3789
3790 for adapter in adapters {
3791 dedup_vec(&mut adapter.gateways);
3792 }
3793}
3794
3795#[cfg(not(target_os = "windows"))]
3796fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3797 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3798 return;
3799 };
3800 let mut dns_servers = text
3801 .lines()
3802 .filter_map(|line| line.strip_prefix("nameserver "))
3803 .map(str::trim)
3804 .filter(|value| !value.is_empty())
3805 .map(|value| value.to_string())
3806 .collect::<Vec<_>>();
3807 dedup_vec(&mut dns_servers);
3808 if dns_servers.is_empty() {
3809 return;
3810 }
3811 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3812 adapter.dns_servers = dns_servers.clone();
3813 }
3814}
3815
3816#[cfg(target_os = "windows")]
3817fn value_after_colon(line: &str) -> Option<&str> {
3818 line.split_once(':').map(|(_, value)| value.trim())
3819}
3820
3821#[cfg(target_os = "windows")]
3822fn normalize_ipconfig_value(value: &str) -> String {
3823 value
3824 .trim()
3825 .trim_end_matches("(Preferred)")
3826 .trim_end_matches("(Deprecated)")
3827 .trim()
3828 .trim_matches(['(', ')'])
3829 .trim()
3830 .to_string()
3831}
3832
3833#[cfg(target_os = "windows")]
3834fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3835 let mac_upper = mac.to_ascii_uppercase();
3836 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3837 return true;
3838 }
3839
3840 ip == "255.255.255.255"
3841 || ip.starts_with("224.")
3842 || ip.starts_with("225.")
3843 || ip.starts_with("226.")
3844 || ip.starts_with("227.")
3845 || ip.starts_with("228.")
3846 || ip.starts_with("229.")
3847 || ip.starts_with("230.")
3848 || ip.starts_with("231.")
3849 || ip.starts_with("232.")
3850 || ip.starts_with("233.")
3851 || ip.starts_with("234.")
3852 || ip.starts_with("235.")
3853 || ip.starts_with("236.")
3854 || ip.starts_with("237.")
3855 || ip.starts_with("238.")
3856 || ip.starts_with("239.")
3857}
3858
3859fn dedup_vec(values: &mut Vec<String>) {
3860 let mut seen = HashSet::new();
3861 values.retain(|value| seen.insert(value.clone()));
3862}
3863
3864#[cfg(target_os = "windows")]
3865fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3866 let trimmed = text.trim();
3867 if trimmed.is_empty() {
3868 return Vec::new();
3869 }
3870
3871 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3872 return Vec::new();
3873 };
3874 let entries = match value {
3875 Value::Array(items) => items,
3876 other => vec![other],
3877 };
3878
3879 let mut neighbors = Vec::new();
3880 for entry in entries {
3881 let ip = entry
3882 .get("IPAddress")
3883 .and_then(|v| v.as_str())
3884 .unwrap_or("")
3885 .to_string();
3886 if ip.is_empty() {
3887 continue;
3888 }
3889 let mac = entry
3890 .get("LinkLayerAddress")
3891 .and_then(|v| v.as_str())
3892 .unwrap_or("unknown")
3893 .to_string();
3894 let state = entry
3895 .get("State")
3896 .and_then(|v| v.as_str())
3897 .unwrap_or("unknown")
3898 .to_string();
3899 let iface = entry
3900 .get("InterfaceAlias")
3901 .and_then(|v| v.as_str())
3902 .unwrap_or("unknown")
3903 .to_string();
3904 if is_noise_lan_neighbor(&ip, &mac) {
3905 continue;
3906 }
3907 neighbors.push((ip, mac, state, iface));
3908 }
3909
3910 neighbors
3911}
3912
3913#[cfg(target_os = "windows")]
3914fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3915 let trimmed = text.trim();
3916 if trimmed.is_empty() {
3917 return Ok(Vec::new());
3918 }
3919
3920 let value: Value = serde_json::from_str(trimmed)
3921 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3922 let entries = match value {
3923 Value::Array(items) => items,
3924 other => vec![other],
3925 };
3926
3927 let mut services = Vec::new();
3928 for entry in entries {
3929 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3930 continue;
3931 };
3932 services.push(ServiceEntry {
3933 name: name.to_string(),
3934 status: entry
3935 .get("State")
3936 .and_then(|v| v.as_str())
3937 .unwrap_or("unknown")
3938 .to_string(),
3939 startup: entry
3940 .get("StartMode")
3941 .and_then(|v| v.as_str())
3942 .map(|v| v.to_string()),
3943 display_name: entry
3944 .get("DisplayName")
3945 .and_then(|v| v.as_str())
3946 .map(|v| v.to_string()),
3947 start_name: entry
3948 .get("StartName")
3949 .and_then(|v| v.as_str())
3950 .map(|v| v.to_string()),
3951 });
3952 }
3953
3954 Ok(services)
3955}
3956
3957#[cfg(target_os = "windows")]
3958fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3959 match node.cloned() {
3960 Some(Value::Array(items)) => items,
3961 Some(other) => vec![other],
3962 None => Vec::new(),
3963 }
3964}
3965
3966#[cfg(target_os = "windows")]
3967fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3968 windows_json_entries(node)
3969 .into_iter()
3970 .filter_map(|entry| {
3971 let name = entry
3972 .get("FriendlyName")
3973 .and_then(|v| v.as_str())
3974 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3975 .unwrap_or("")
3976 .trim()
3977 .to_string();
3978 if name.is_empty() {
3979 return None;
3980 }
3981 Some(WindowsPnpDevice {
3982 name,
3983 status: entry
3984 .get("Status")
3985 .and_then(|v| v.as_str())
3986 .unwrap_or("Unknown")
3987 .trim()
3988 .to_string(),
3989 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3990 entry
3991 .get("Problem")
3992 .and_then(|v| v.as_i64())
3993 .map(|v| v as u64)
3994 }),
3995 class_name: entry
3996 .get("Class")
3997 .and_then(|v| v.as_str())
3998 .map(|v| v.trim().to_string()),
3999 instance_id: entry
4000 .get("InstanceId")
4001 .and_then(|v| v.as_str())
4002 .map(|v| v.trim().to_string()),
4003 })
4004 })
4005 .collect()
4006}
4007
4008#[cfg(target_os = "windows")]
4009fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4010 windows_json_entries(node)
4011 .into_iter()
4012 .filter_map(|entry| {
4013 let name = entry
4014 .get("Name")
4015 .and_then(|v| v.as_str())
4016 .unwrap_or("")
4017 .trim()
4018 .to_string();
4019 if name.is_empty() {
4020 return None;
4021 }
4022 Some(WindowsSoundDevice {
4023 name,
4024 status: entry
4025 .get("Status")
4026 .and_then(|v| v.as_str())
4027 .unwrap_or("Unknown")
4028 .trim()
4029 .to_string(),
4030 manufacturer: entry
4031 .get("Manufacturer")
4032 .and_then(|v| v.as_str())
4033 .map(|v| v.trim().to_string()),
4034 })
4035 })
4036 .collect()
4037}
4038
4039#[cfg(target_os = "windows")]
4040fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4041 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4042 || device.problem.unwrap_or(0) != 0
4043}
4044
4045#[cfg(target_os = "windows")]
4046fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4047 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4048}
4049
4050#[cfg(target_os = "windows")]
4051fn is_microphone_like_name(name: &str) -> bool {
4052 let lower = name.to_ascii_lowercase();
4053 lower.contains("microphone")
4054 || lower.contains("mic")
4055 || lower.contains("input")
4056 || lower.contains("array")
4057 || lower.contains("capture")
4058 || lower.contains("record")
4059}
4060
4061#[cfg(target_os = "windows")]
4062fn is_bluetooth_like_name(name: &str) -> bool {
4063 let lower = name.to_ascii_lowercase();
4064 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4065}
4066
4067#[cfg(target_os = "windows")]
4068fn service_is_running(service: &ServiceEntry) -> bool {
4069 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4070}
4071
4072#[cfg(not(target_os = "windows"))]
4073fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4074 let mut startup_modes = std::collections::HashMap::<String, String>::new();
4075 for line in startup_text.lines() {
4076 let cols: Vec<&str> = line.split_whitespace().collect();
4077 if cols.len() < 2 {
4078 continue;
4079 }
4080 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4081 }
4082
4083 let mut services = Vec::new();
4084 for line in status_text.lines() {
4085 let cols: Vec<&str> = line.split_whitespace().collect();
4086 if cols.len() < 4 {
4087 continue;
4088 }
4089 let unit = cols[0];
4090 let load = cols[1];
4091 let active = cols[2];
4092 let sub = cols[3];
4093 let description = if cols.len() > 4 {
4094 Some(cols[4..].join(" "))
4095 } else {
4096 None
4097 };
4098 services.push(ServiceEntry {
4099 name: unit.to_string(),
4100 status: format!("{}/{}", active, sub),
4101 startup: startup_modes
4102 .get(unit)
4103 .cloned()
4104 .or_else(|| Some(load.to_string())),
4105 display_name: description,
4106 start_name: None,
4107 });
4108 }
4109
4110 services
4111}
4112
4113fn inspect_health_report() -> Result<String, String> {
4119 let mut needs_fix: Vec<String> = Vec::new();
4120 let mut watch: Vec<String> = Vec::new();
4121 let mut good: Vec<String> = Vec::new();
4122 let mut tips: Vec<String> = Vec::new();
4123
4124 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4125 health_check_memory(&mut watch, &mut good);
4126 health_check_network(&mut needs_fix, &mut watch, &mut good);
4127 health_check_pending_reboot(&mut watch, &mut good);
4128 health_check_services(&mut needs_fix, &mut watch, &mut good);
4129 health_check_thermal(&mut watch, &mut good);
4130 health_check_tools(&mut watch, &mut good, &mut tips);
4131 health_check_recent_errors(&mut watch, &mut tips);
4132
4133 let overall = if !needs_fix.is_empty() {
4134 "ACTION REQUIRED"
4135 } else if !watch.is_empty() {
4136 "WORTH A LOOK"
4137 } else {
4138 "ALL GOOD"
4139 };
4140
4141 let mut out = format!("System Health Report — {overall}\n\n");
4142
4143 if !needs_fix.is_empty() {
4144 out.push_str("Needs fixing:\n");
4145 for item in &needs_fix {
4146 out.push_str(&format!(" [!] {item}\n"));
4147 }
4148 out.push('\n');
4149 }
4150 if !watch.is_empty() {
4151 out.push_str("Worth watching:\n");
4152 for item in &watch {
4153 out.push_str(&format!(" [-] {item}\n"));
4154 }
4155 out.push('\n');
4156 }
4157 if !good.is_empty() {
4158 out.push_str("Looking good:\n");
4159 for item in &good {
4160 out.push_str(&format!(" [+] {item}\n"));
4161 }
4162 out.push('\n');
4163 }
4164 if !tips.is_empty() {
4165 out.push_str("To dig deeper:\n");
4166 for tip in &tips {
4167 out.push_str(&format!(" {tip}\n"));
4168 }
4169 }
4170
4171 Ok(out.trim_end().to_string())
4172}
4173
4174fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4175 #[cfg(target_os = "windows")]
4176 {
4177 let script = r#"try {
4178 $d = Get-PSDrive C -ErrorAction Stop
4179 "$($d.Free)|$($d.Used)"
4180} catch { "ERR" }"#;
4181 if let Ok(out) = Command::new("powershell")
4182 .args(["-NoProfile", "-Command", script])
4183 .output()
4184 {
4185 let text = String::from_utf8_lossy(&out.stdout);
4186 let text = text.trim();
4187 if !text.starts_with("ERR") {
4188 let parts: Vec<&str> = text.split('|').collect();
4189 if parts.len() == 2 {
4190 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4191 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4192 let total = free_bytes + used_bytes;
4193 let free_gb = free_bytes / 1_073_741_824;
4194 let pct_free = if total > 0 {
4195 (free_bytes as f64 / total as f64 * 100.0) as u64
4196 } else {
4197 0
4198 };
4199 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4200 if free_gb < 5 {
4201 needs_fix.push(format!(
4202 "{msg} — very low. Free up space or your system may slow down or stop working."
4203 ));
4204 } else if free_gb < 15 {
4205 watch.push(format!("{msg} — getting low, consider cleaning up."));
4206 } else {
4207 good.push(msg);
4208 }
4209 return;
4210 }
4211 }
4212 }
4213 watch.push("Disk: could not read free space from C: drive.".to_string());
4214 }
4215
4216 #[cfg(not(target_os = "windows"))]
4217 {
4218 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4219 let text = String::from_utf8_lossy(&out.stdout);
4220 for line in text.lines().skip(1) {
4221 let cols: Vec<&str> = line.split_whitespace().collect();
4222 if cols.len() >= 5 {
4223 let avail_str = cols[3].trim_end_matches('G');
4224 let use_pct = cols[4].trim_end_matches('%');
4225 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4226 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4227 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4228 if avail_gb < 5 {
4229 needs_fix.push(format!(
4230 "{msg} — very low. Free up space to prevent system issues."
4231 ));
4232 } else if avail_gb < 15 {
4233 watch.push(format!("{msg} — getting low."));
4234 } else {
4235 good.push(msg);
4236 }
4237 return;
4238 }
4239 }
4240 }
4241 watch.push("Disk: could not determine free space.".to_string());
4242 }
4243}
4244
4245fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4246 #[cfg(target_os = "windows")]
4247 {
4248 let script = r#"try {
4249 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4250 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4251} catch { "ERR" }"#;
4252 if let Ok(out) = Command::new("powershell")
4253 .args(["-NoProfile", "-Command", script])
4254 .output()
4255 {
4256 let text = String::from_utf8_lossy(&out.stdout);
4257 let text = text.trim();
4258 if !text.starts_with("ERR") {
4259 let parts: Vec<&str> = text.split('|').collect();
4260 if parts.len() == 2 {
4261 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4262 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4263 if total_kb > 0 {
4264 let free_gb = free_kb / 1_048_576;
4265 let total_gb = total_kb / 1_048_576;
4266 let free_pct = free_kb * 100 / total_kb;
4267 let msg = format!(
4268 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4269 );
4270 if free_pct < 10 {
4271 watch.push(format!(
4272 "{msg} — very low. Close unused apps to free up memory."
4273 ));
4274 } else if free_pct < 25 {
4275 watch.push(format!("{msg} — running a bit low."));
4276 } else {
4277 good.push(msg);
4278 }
4279 return;
4280 }
4281 }
4282 }
4283 }
4284 }
4285
4286 #[cfg(not(target_os = "windows"))]
4287 {
4288 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4289 let mut total_kb = 0u64;
4290 let mut avail_kb = 0u64;
4291 for line in content.lines() {
4292 if line.starts_with("MemTotal:") {
4293 total_kb = line
4294 .split_whitespace()
4295 .nth(1)
4296 .and_then(|v| v.parse().ok())
4297 .unwrap_or(0);
4298 } else if line.starts_with("MemAvailable:") {
4299 avail_kb = line
4300 .split_whitespace()
4301 .nth(1)
4302 .and_then(|v| v.parse().ok())
4303 .unwrap_or(0);
4304 }
4305 }
4306 if total_kb > 0 {
4307 let free_gb = avail_kb / 1_048_576;
4308 let total_gb = total_kb / 1_048_576;
4309 let free_pct = avail_kb * 100 / total_kb;
4310 let msg =
4311 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4312 if free_pct < 10 {
4313 watch.push(format!("{msg} — very low. Close unused apps."));
4314 } else if free_pct < 25 {
4315 watch.push(format!("{msg} — running a bit low."));
4316 } else {
4317 good.push(msg);
4318 }
4319 }
4320 }
4321 }
4322}
4323
4324fn probe_tool(cmd: &str, arg: &str) -> bool {
4328 if Command::new(cmd)
4329 .arg(arg)
4330 .stdout(std::process::Stdio::null())
4331 .stderr(std::process::Stdio::null())
4332 .status()
4333 .map(|s| s.success())
4334 .unwrap_or(false)
4335 {
4336 return true;
4337 }
4338 #[cfg(windows)]
4340 {
4341 let home = std::env::var("USERPROFILE").unwrap_or_default();
4342 let fallback: Option<String> = match cmd {
4343 "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4344 "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4345 "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4346 _ => None,
4347 };
4348 if let Some(path) = fallback {
4349 return Command::new(&path)
4350 .arg(arg)
4351 .stdout(std::process::Stdio::null())
4352 .stderr(std::process::Stdio::null())
4353 .status()
4354 .map(|s| s.success())
4355 .unwrap_or(false);
4356 }
4357 }
4358 false
4359}
4360
4361fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4362 let tool_checks: &[(&str, &str, &str)] = &[
4363 ("git", "--version", "Git"),
4364 ("cargo", "--version", "Rust / Cargo"),
4365 ("node", "--version", "Node.js"),
4366 ("python", "--version", "Python"),
4367 ("python3", "--version", "Python 3"),
4368 ("npm", "--version", "npm"),
4369 ];
4370
4371 let mut found: Vec<String> = Vec::new();
4372 let mut missing: Vec<String> = Vec::new();
4373 let mut python_found = false;
4374
4375 for (cmd, arg, label) in tool_checks {
4376 if cmd.starts_with("python") && python_found {
4377 continue;
4378 }
4379 let ok = probe_tool(cmd, arg);
4380 if ok {
4381 found.push((*label).to_string());
4382 if cmd.starts_with("python") {
4383 python_found = true;
4384 }
4385 } else if !cmd.starts_with("python") || !python_found {
4386 missing.push((*label).to_string());
4387 }
4388 }
4389
4390 if !found.is_empty() {
4391 good.push(format!("Dev tools found: {}", found.join(", ")));
4392 }
4393 if !missing.is_empty() {
4394 watch.push(format!(
4395 "Not installed (or not on PATH): {} — only matters if you need them",
4396 missing.join(", ")
4397 ));
4398 tips.push(
4399 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4400 .to_string(),
4401 );
4402 }
4403}
4404
4405fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4406 #[cfg(target_os = "windows")]
4407 {
4408 let script = r#"try {
4409 $cutoff = (Get-Date).AddHours(-24)
4410 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4411 $count
4412} catch { "0" }"#;
4413 if let Ok(out) = Command::new("powershell")
4414 .args(["-NoProfile", "-Command", script])
4415 .output()
4416 {
4417 let text = String::from_utf8_lossy(&out.stdout);
4418 let count: u64 = text.trim().parse().unwrap_or(0);
4419 if count > 0 {
4420 watch.push(format!(
4421 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4422 if count == 1 { "" } else { "s" }
4423 ));
4424 tips.push(
4425 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4426 .to_string(),
4427 );
4428 }
4429 }
4430 }
4431
4432 #[cfg(not(target_os = "windows"))]
4433 {
4434 if let Ok(out) = Command::new("journalctl")
4435 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4436 .output()
4437 {
4438 let text = String::from_utf8_lossy(&out.stdout);
4439 if !text.trim().is_empty() {
4440 watch.push("Critical/error entries found in the system journal.".to_string());
4441 tips.push(
4442 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4443 );
4444 }
4445 }
4446 }
4447}
4448
4449fn health_check_network(
4450 needs_fix: &mut Vec<String>,
4451 watch: &mut Vec<String>,
4452 good: &mut Vec<String>,
4453) {
4454 #[cfg(target_os = "windows")]
4455 {
4456 let script = r#"try {
4458 $ping = New-Object System.Net.NetworkInformation.Ping
4459 $r = $ping.Send("1.1.1.1", 2000)
4460 if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4461} catch { "FAIL" }"#;
4462 if let Ok(out) = Command::new("powershell")
4463 .args(["-NoProfile", "-Command", script])
4464 .output()
4465 {
4466 let text = String::from_utf8_lossy(&out.stdout);
4467 let text = text.trim();
4468 if text.starts_with("OK") {
4469 let latency = text.split('|').nth(1).unwrap_or("?");
4470 let latency_ms: u64 = latency.parse().unwrap_or(0);
4471 let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4472 if latency_ms > 300 {
4473 watch.push(format!("{msg} — high latency, may indicate network issue."));
4474 } else {
4475 good.push(msg);
4476 }
4477 } else {
4478 needs_fix.push(
4479 "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4480 Check adapter, gateway, or DNS."
4481 .to_string(),
4482 );
4483 }
4484 return;
4485 }
4486 watch.push("Network: could not run connectivity check.".to_string());
4487 }
4488
4489 #[cfg(not(target_os = "windows"))]
4490 {
4491 let _ = watch;
4492 let ok = Command::new("ping")
4493 .args(["-c", "1", "-W", "2", "1.1.1.1"])
4494 .stdout(std::process::Stdio::null())
4495 .stderr(std::process::Stdio::null())
4496 .status()
4497 .map(|s| s.success())
4498 .unwrap_or(false);
4499 if ok {
4500 good.push("Internet connectivity: reachable.".to_string());
4501 } else {
4502 needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4503 }
4504 }
4505}
4506
4507fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4508 #[cfg(target_os = "windows")]
4509 {
4510 let script = r#"try {
4511 $pending = $false
4512 $reasons = @()
4513 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4514 $pending = $true; $reasons += 'CBS/component update'
4515 }
4516 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4517 $pending = $true; $reasons += 'Windows Update'
4518 }
4519 $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4520 if ($pfr -and $pfr.PendingFileRenameOperations) {
4521 $pending = $true; $reasons += 'file rename ops'
4522 }
4523 if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4524} catch { "OK" }"#;
4525 if let Ok(out) = Command::new("powershell")
4526 .args(["-NoProfile", "-Command", script])
4527 .output()
4528 {
4529 let text = String::from_utf8_lossy(&out.stdout);
4530 let text = text.trim();
4531 if text.starts_with("PENDING") {
4532 let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4533 watch.push(format!(
4534 "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4535 ));
4536 } else {
4537 good.push("No pending reboot.".to_string());
4538 }
4539 }
4540 }
4541
4542 #[cfg(not(target_os = "windows"))]
4543 {
4544 if std::path::Path::new("/var/run/reboot-required").exists() {
4546 watch.push(
4547 "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4548 );
4549 } else {
4550 good.push("No pending reboot.".to_string());
4551 }
4552 }
4553}
4554
4555fn health_check_services(
4556 needs_fix: &mut Vec<String>,
4557 watch: &mut Vec<String>,
4558 good: &mut Vec<String>,
4559) {
4560 #[cfg(not(target_os = "windows"))]
4561 let _ = (&needs_fix, &good);
4562 #[cfg(target_os = "windows")]
4563 let _ = &watch;
4564
4565 #[cfg(target_os = "windows")]
4566 {
4567 let script = r#"try {
4569 $names = @('EventLog','WinDefend','Dnscache')
4570 $stopped = @()
4571 foreach ($n in $names) {
4572 $s = Get-Service $n -ErrorAction SilentlyContinue
4573 if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4574 }
4575 if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4576} catch { "OK" }"#;
4577 if let Ok(out) = Command::new("powershell")
4578 .args(["-NoProfile", "-Command", script])
4579 .output()
4580 {
4581 let text = String::from_utf8_lossy(&out.stdout);
4582 let text = text.trim();
4583 if text.starts_with("STOPPED") {
4584 let names = text.split('|').nth(1).unwrap_or("unknown");
4585 needs_fix.push(format!(
4586 "Critical service(s) not running: {names} — these should always be active."
4587 ));
4588 } else {
4589 good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4590 }
4591 }
4592 }
4593
4594 #[cfg(not(target_os = "windows"))]
4595 {
4596 if let Ok(out) = Command::new("systemctl")
4598 .args(["--failed", "--no-legend", "--plain"])
4599 .output()
4600 {
4601 let text = String::from_utf8_lossy(&out.stdout);
4602 let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4603 if !failed.is_empty() {
4604 watch.push(format!(
4605 "{} failed systemd unit(s): {}",
4606 failed.len(),
4607 failed.join(", ")
4608 ));
4609 } else {
4610 good.push("No failed systemd units.".to_string());
4611 }
4612 }
4613 }
4614}
4615
4616fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4617 #[cfg(target_os = "windows")]
4618 {
4619 let script = r#"try {
4621 $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4622 $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4623 $max = ($temps | Measure-Object -Maximum).Maximum
4624 "$max"
4625} catch { "NA" }"#;
4626 if let Ok(out) = Command::new("powershell")
4627 .args(["-NoProfile", "-Command", script])
4628 .output()
4629 {
4630 let text = String::from_utf8_lossy(&out.stdout);
4631 let text = text.trim();
4632 if text != "NA" && !text.is_empty() {
4633 if let Ok(temp) = text.parse::<f64>() {
4634 let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4635 if temp >= 90.0 {
4636 watch.push(format!("{msg} — very high, check cooling and airflow."));
4637 } else if temp >= 75.0 {
4638 watch.push(format!(
4639 "{msg} — elevated under load, monitor for throttling."
4640 ));
4641 } else {
4642 good.push(format!("{msg} — normal."));
4643 }
4644 }
4645 }
4646 }
4648 }
4649
4650 #[cfg(not(target_os = "windows"))]
4651 {
4652 let paths = [
4654 "/sys/class/thermal/thermal_zone0/temp",
4655 "/sys/class/hwmon/hwmon0/temp1_input",
4656 ];
4657 for path in &paths {
4658 if let Ok(content) = std::fs::read_to_string(path) {
4659 if let Ok(raw) = content.trim().parse::<u64>() {
4660 let temp_c = raw / 1000;
4661 let msg = format!("CPU thermal: {temp_c}°C");
4662 if temp_c >= 90 {
4663 watch.push(format!("{msg} — very high, check cooling."));
4664 } else if temp_c >= 75 {
4665 watch.push(format!("{msg} — elevated under load."));
4666 } else {
4667 good.push(format!("{msg} — normal."));
4668 }
4669 return;
4670 }
4671 }
4672 }
4673 }
4674}
4675
4676fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4679 let mut out = String::from("Host inspection: log_check\n\n");
4680
4681 #[cfg(target_os = "windows")]
4682 {
4683 let hours = lookback_hours.unwrap_or(24);
4685 out.push_str(&format!(
4686 "Checking System/Application logs from the last {} hours...\n\n",
4687 hours
4688 ));
4689
4690 let n = max_entries.clamp(1, 50);
4691 let script = format!(
4692 r#"try {{
4693 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4694 if (-not $events) {{ "NO_EVENTS"; exit }}
4695 $events | Select-Object -First {n} | ForEach-Object {{
4696 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4697 $line
4698 }}
4699}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4700 hours = hours,
4701 n = n
4702 );
4703 let output = Command::new("powershell")
4704 .args(["-NoProfile", "-Command", &script])
4705 .output()
4706 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4707
4708 let raw = String::from_utf8_lossy(&output.stdout);
4709 let text = raw.trim();
4710
4711 if text.is_empty() || text == "NO_EVENTS" {
4712 out.push_str("No critical or error events found in Application/System logs.\n");
4713 return Ok(out.trim_end().to_string());
4714 }
4715 if text.starts_with("ERROR:") {
4716 out.push_str(&format!("Warning: event log query returned: {text}\n"));
4717 return Ok(out.trim_end().to_string());
4718 }
4719
4720 let mut count = 0usize;
4721 for line in text.lines() {
4722 let parts: Vec<&str> = line.splitn(4, '|').collect();
4723 if parts.len() == 4 {
4724 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4725 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4726 count += 1;
4727 }
4728 }
4729 out.push_str(&format!(
4730 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4731 ));
4732 }
4733
4734 #[cfg(not(target_os = "windows"))]
4735 {
4736 let _ = lookback_hours;
4737 let n = max_entries.clamp(1, 50).to_string();
4739 let output = Command::new("journalctl")
4740 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4741 .output();
4742
4743 match output {
4744 Ok(o) if o.status.success() => {
4745 let text = String::from_utf8_lossy(&o.stdout);
4746 let trimmed = text.trim();
4747 if trimmed.is_empty() || trimmed.contains("No entries") {
4748 out.push_str("No critical or error entries found in the system journal.\n");
4749 } else {
4750 out.push_str(trimmed);
4751 out.push('\n');
4752 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4753 }
4754 }
4755 _ => {
4756 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4758 let mut found = false;
4759 for log_path in &log_paths {
4760 if let Ok(content) = std::fs::read_to_string(log_path) {
4761 let lines: Vec<&str> = content.lines().collect();
4762 let tail: Vec<&str> = lines
4763 .iter()
4764 .rev()
4765 .filter(|l| {
4766 let l_lower = l.to_ascii_lowercase();
4767 l_lower.contains("error") || l_lower.contains("crit")
4768 })
4769 .take(max_entries)
4770 .copied()
4771 .collect::<Vec<_>>()
4772 .into_iter()
4773 .rev()
4774 .collect();
4775 if !tail.is_empty() {
4776 out.push_str(&format!("Source: {log_path}\n"));
4777 for l in &tail {
4778 out.push_str(l);
4779 out.push('\n');
4780 }
4781 found = true;
4782 break;
4783 }
4784 }
4785 }
4786 if !found {
4787 out.push_str(
4788 "journalctl not found and no readable syslog detected on this system.\n",
4789 );
4790 }
4791 }
4792 }
4793 }
4794
4795 Ok(out.trim_end().to_string())
4796}
4797
4798fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4801 let mut out = String::from("Host inspection: startup_items\n\n");
4802
4803 #[cfg(target_os = "windows")]
4804 {
4805 let script = r#"
4807$hives = @(
4808 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4809 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4810 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4811)
4812foreach ($h in $hives) {
4813 try {
4814 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4815 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4816 "$($h.Hive)|$($_.Name)|$($_.Value)"
4817 }
4818 } catch {}
4819}
4820"#;
4821 let output = Command::new("powershell")
4822 .args(["-NoProfile", "-Command", script])
4823 .output()
4824 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4825
4826 let raw = String::from_utf8_lossy(&output.stdout);
4827 let text = raw.trim();
4828
4829 let entries: Vec<(String, String, String)> = text
4830 .lines()
4831 .filter_map(|l| {
4832 let parts: Vec<&str> = l.splitn(3, '|').collect();
4833 if parts.len() == 3 {
4834 Some((
4835 parts[0].to_string(),
4836 parts[1].to_string(),
4837 parts[2].to_string(),
4838 ))
4839 } else {
4840 None
4841 }
4842 })
4843 .take(max_entries)
4844 .collect();
4845
4846 if entries.is_empty() {
4847 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4848 } else {
4849 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4850 let mut last_hive = String::new();
4851 for (hive, name, value) in &entries {
4852 if *hive != last_hive {
4853 out.push_str(&format!("[{}]\n", hive));
4854 last_hive = hive.clone();
4855 }
4856 let display = if value.len() > 100 {
4858 format!("{}…", &value[..100])
4859 } else {
4860 value.clone()
4861 };
4862 out.push_str(&format!(" {name}: {display}\n"));
4863 }
4864 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4865 }
4866
4867 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4869 if let Ok(unified_out) = Command::new("powershell")
4870 .args(["-NoProfile", "-Command", unified_script])
4871 .output()
4872 {
4873 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4874 let trimmed = unified_text.trim();
4875 if !trimmed.is_empty() {
4876 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4877 out.push_str(trimmed);
4878 out.push('\n');
4879 }
4880 }
4881 }
4882
4883 #[cfg(not(target_os = "windows"))]
4884 {
4885 let output = Command::new("systemctl")
4887 .args([
4888 "list-unit-files",
4889 "--type=service",
4890 "--state=enabled",
4891 "--no-legend",
4892 "--no-pager",
4893 "--plain",
4894 ])
4895 .output();
4896
4897 match output {
4898 Ok(o) if o.status.success() => {
4899 let text = String::from_utf8_lossy(&o.stdout);
4900 let services: Vec<&str> = text
4901 .lines()
4902 .filter(|l| !l.trim().is_empty())
4903 .take(max_entries)
4904 .collect();
4905 if services.is_empty() {
4906 out.push_str("No enabled systemd services found.\n");
4907 } else {
4908 out.push_str("Enabled systemd services (run at boot):\n\n");
4909 for s in &services {
4910 out.push_str(&format!(" {s}\n"));
4911 }
4912 out.push_str(&format!(
4913 "\nShowing {} of enabled services.\n",
4914 services.len()
4915 ));
4916 }
4917 }
4918 _ => {
4919 out.push_str(
4920 "systemctl not found on this system. Cannot enumerate startup services.\n",
4921 );
4922 }
4923 }
4924
4925 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4927 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4928 let reboot_entries: Vec<&str> = cron_text
4929 .lines()
4930 .filter(|l| l.trim_start().starts_with("@reboot"))
4931 .collect();
4932 if !reboot_entries.is_empty() {
4933 out.push_str("\nCron @reboot entries:\n");
4934 for e in reboot_entries {
4935 out.push_str(&format!(" {e}\n"));
4936 }
4937 }
4938 }
4939 }
4940
4941 Ok(out.trim_end().to_string())
4942}
4943
4944fn inspect_os_config() -> Result<String, String> {
4945 let mut out = String::from("Host inspection: OS Configuration\n\n");
4946
4947 #[cfg(target_os = "windows")]
4948 {
4949 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4951 let power_str = String::from_utf8_lossy(&power_out.stdout);
4952 out.push_str("=== Power Plan ===\n");
4953 out.push_str(power_str.trim());
4954 out.push_str("\n\n");
4955 }
4956
4957 let fw_script =
4959 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4960 if let Ok(fw_out) = Command::new("powershell")
4961 .args(["-NoProfile", "-Command", fw_script])
4962 .output()
4963 {
4964 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4965 out.push_str("=== Firewall Profiles ===\n");
4966 out.push_str(fw_str.trim());
4967 out.push_str("\n\n");
4968 }
4969
4970 let uptime_script =
4972 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4973 if let Ok(uptime_out) = Command::new("powershell")
4974 .args(["-NoProfile", "-Command", uptime_script])
4975 .output()
4976 {
4977 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4978 out.push_str("=== System Uptime (Last Boot) ===\n");
4979 out.push_str(uptime_str.trim());
4980 out.push_str("\n\n");
4981 }
4982 }
4983
4984 #[cfg(not(target_os = "windows"))]
4985 {
4986 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4988 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4989 out.push_str("=== System Uptime ===\n");
4990 out.push_str(uptime_str.trim());
4991 out.push_str("\n\n");
4992 }
4993
4994 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4996 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4997 if !ufw_str.trim().is_empty() {
4998 out.push_str("=== Firewall (UFW) ===\n");
4999 out.push_str(ufw_str.trim());
5000 out.push_str("\n\n");
5001 }
5002 }
5003 }
5004 Ok(out.trim_end().to_string())
5005}
5006
5007pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5008 let action = args
5009 .get("action")
5010 .and_then(|v| v.as_str())
5011 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5012
5013 let target = args
5014 .get("target")
5015 .and_then(|v| v.as_str())
5016 .unwrap_or("")
5017 .trim();
5018
5019 if target.is_empty() && action != "clear_temp" {
5020 return Err("Missing required argument: 'target' for this action".to_string());
5021 }
5022
5023 match action {
5024 "install_package" => {
5025 #[cfg(target_os = "windows")]
5026 {
5027 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5028 match Command::new("powershell")
5029 .args(["-NoProfile", "-Command", &cmd])
5030 .output()
5031 {
5032 Ok(out) => Ok(format!(
5033 "Executed remediation (winget install):\n{}",
5034 String::from_utf8_lossy(&out.stdout)
5035 )),
5036 Err(e) => Err(format!("Failed to run winget: {}", e)),
5037 }
5038 }
5039 #[cfg(not(target_os = "windows"))]
5040 {
5041 Err(
5042 "install_package via wrapper is only supported on Windows currently (winget)"
5043 .to_string(),
5044 )
5045 }
5046 }
5047 "restart_service" => {
5048 #[cfg(target_os = "windows")]
5049 {
5050 let cmd = format!("Restart-Service -Name {} -Force", target);
5051 match Command::new("powershell")
5052 .args(["-NoProfile", "-Command", &cmd])
5053 .output()
5054 {
5055 Ok(out) => {
5056 let err_str = String::from_utf8_lossy(&out.stderr);
5057 if !err_str.is_empty() {
5058 return Err(format!("Error restarting service:\n{}", err_str));
5059 }
5060 Ok(format!("Successfully restarted service: {}", target))
5061 }
5062 Err(e) => Err(format!("Failed to restart service: {}", e)),
5063 }
5064 }
5065 #[cfg(not(target_os = "windows"))]
5066 {
5067 Err(
5068 "restart_service via wrapper is only supported on Windows currently"
5069 .to_string(),
5070 )
5071 }
5072 }
5073 "clear_temp" => {
5074 #[cfg(target_os = "windows")]
5075 {
5076 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5077 match Command::new("powershell")
5078 .args(["-NoProfile", "-Command", cmd])
5079 .output()
5080 {
5081 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5082 Err(e) => Err(format!("Failed to clear temp: {}", e)),
5083 }
5084 }
5085 #[cfg(not(target_os = "windows"))]
5086 {
5087 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5088 }
5089 }
5090 other => Err(format!("Unknown remediation action: {}", other)),
5091 }
5092}
5093
5094fn inspect_storage(max_entries: usize) -> Result<String, String> {
5097 let mut out = String::from("Host inspection: storage\n\n");
5098 let _ = max_entries; out.push_str("Drives:\n");
5102
5103 #[cfg(target_os = "windows")]
5104 {
5105 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5106 $free = $_.Free
5107 $used = $_.Used
5108 if ($free -eq $null) { $free = 0 }
5109 if ($used -eq $null) { $used = 0 }
5110 $total = $free + $used
5111 "$($_.Name)|$free|$used|$total"
5112}"#;
5113 match Command::new("powershell")
5114 .args(["-NoProfile", "-Command", script])
5115 .output()
5116 {
5117 Ok(o) => {
5118 let text = String::from_utf8_lossy(&o.stdout);
5119 let mut drive_count = 0usize;
5120 for line in text.lines() {
5121 let parts: Vec<&str> = line.trim().split('|').collect();
5122 if parts.len() == 4 {
5123 let name = parts[0];
5124 let free: u64 = parts[1].parse().unwrap_or(0);
5125 let total: u64 = parts[3].parse().unwrap_or(0);
5126 if total == 0 {
5127 continue;
5128 }
5129 let free_gb = free / 1_073_741_824;
5130 let total_gb = total / 1_073_741_824;
5131 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5132 let bar_len = 20usize;
5133 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5134 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5135 let warn = if free_gb < 5 {
5136 " [!] CRITICALLY LOW"
5137 } else if free_gb < 15 {
5138 " [-] LOW"
5139 } else {
5140 ""
5141 };
5142 out.push_str(&format!(
5143 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
5144 ));
5145 drive_count += 1;
5146 }
5147 }
5148 if drive_count == 0 {
5149 out.push_str(" (could not enumerate drives)\n");
5150 }
5151 }
5152 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
5153 }
5154
5155 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5157 match Command::new("powershell")
5158 .args(["-NoProfile", "-Command", latency_script])
5159 .output()
5160 {
5161 Ok(o) => {
5162 out.push_str("\nReal-time Disk Intensity:\n");
5163 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5164 if !text.is_empty() {
5165 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
5166 if let Ok(q) = text.parse::<f64>() {
5167 if q > 2.0 {
5168 out.push_str(
5169 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5170 );
5171 } else {
5172 out.push_str(" [~] Disk latency is within healthy bounds.\n");
5173 }
5174 }
5175 } else {
5176 out.push_str(" Average Disk Queue Length: unavailable\n");
5177 }
5178 }
5179 Err(_) => {
5180 out.push_str("\nReal-time Disk Intensity:\n");
5181 out.push_str(" Average Disk Queue Length: unavailable\n");
5182 }
5183 }
5184 }
5185
5186 #[cfg(not(target_os = "windows"))]
5187 {
5188 match Command::new("df")
5189 .args(["-h", "--output=target,size,avail,pcent"])
5190 .output()
5191 {
5192 Ok(o) => {
5193 let text = String::from_utf8_lossy(&o.stdout);
5194 let mut count = 0usize;
5195 for line in text.lines().skip(1) {
5196 let cols: Vec<&str> = line.split_whitespace().collect();
5197 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
5198 out.push_str(&format!(
5199 " {} size: {} avail: {} used: {}\n",
5200 cols[0], cols[1], cols[2], cols[3]
5201 ));
5202 count += 1;
5203 if count >= max_entries {
5204 break;
5205 }
5206 }
5207 }
5208 }
5209 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
5210 }
5211 }
5212
5213 out.push_str("\nLarge developer cache directories (if present):\n");
5215
5216 #[cfg(target_os = "windows")]
5217 {
5218 let home = std::env::var("USERPROFILE").unwrap_or_default();
5219 let check_dirs: &[(&str, &str)] = &[
5220 ("Temp", r"AppData\Local\Temp"),
5221 ("npm cache", r"AppData\Roaming\npm-cache"),
5222 ("Cargo registry", r".cargo\registry"),
5223 ("Cargo git", r".cargo\git"),
5224 ("pip cache", r"AppData\Local\pip\cache"),
5225 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5226 (".rustup toolchains", r".rustup\toolchains"),
5227 ("node_modules (home)", r"node_modules"),
5228 ];
5229
5230 let mut found_any = false;
5231 for (label, rel) in check_dirs {
5232 let full = format!(r"{}\{}", home, rel);
5233 let path = std::path::Path::new(&full);
5234 if path.exists() {
5235 let size_script = format!(
5237 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5238 full.replace('\'', "''")
5239 );
5240 let size_mb = Command::new("powershell")
5241 .args(["-NoProfile", "-Command", &size_script])
5242 .output()
5243 .ok()
5244 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5245 .unwrap_or_else(|| "?".to_string());
5246 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
5247 found_any = true;
5248 }
5249 }
5250 if !found_any {
5251 out.push_str(" (none of the common cache directories found)\n");
5252 }
5253
5254 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5255 }
5256
5257 #[cfg(not(target_os = "windows"))]
5258 {
5259 let home = std::env::var("HOME").unwrap_or_default();
5260 let check_dirs: &[(&str, &str)] = &[
5261 ("npm cache", ".npm"),
5262 ("Cargo registry", ".cargo/registry"),
5263 ("pip cache", ".cache/pip"),
5264 (".rustup toolchains", ".rustup/toolchains"),
5265 ("Yarn cache", ".cache/yarn"),
5266 ];
5267 let mut found_any = false;
5268 for (label, rel) in check_dirs {
5269 let full = format!("{}/{}", home, rel);
5270 if std::path::Path::new(&full).exists() {
5271 let size = Command::new("du")
5272 .args(["-sh", &full])
5273 .output()
5274 .ok()
5275 .map(|o| {
5276 let s = String::from_utf8_lossy(&o.stdout);
5277 s.split_whitespace().next().unwrap_or("?").to_string()
5278 })
5279 .unwrap_or_else(|| "?".to_string());
5280 out.push_str(&format!(" {label}: {size} ({full})\n"));
5281 found_any = true;
5282 }
5283 }
5284 if !found_any {
5285 out.push_str(" (none of the common cache directories found)\n");
5286 }
5287 }
5288
5289 Ok(out.trim_end().to_string())
5290}
5291
5292fn inspect_hardware() -> Result<String, String> {
5295 let mut out = String::from("Host inspection: hardware\n\n");
5296
5297 #[cfg(target_os = "windows")]
5298 {
5299 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5301 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5302} | Select-Object -First 1"#;
5303 if let Ok(o) = Command::new("powershell")
5304 .args(["-NoProfile", "-Command", cpu_script])
5305 .output()
5306 {
5307 let text = String::from_utf8_lossy(&o.stdout);
5308 let text = text.trim();
5309 let parts: Vec<&str> = text.split('|').collect();
5310 if parts.len() == 4 {
5311 out.push_str(&format!(
5312 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
5313 parts[0],
5314 parts[1],
5315 parts[2],
5316 parts[3].parse::<f32>().unwrap_or(0.0)
5317 ));
5318 } else {
5319 out.push_str(&format!("CPU: {text}\n\n"));
5320 }
5321 }
5322
5323 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5325$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5326$speed = ($sticks | Select-Object -First 1).Speed
5327"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5328 if let Ok(o) = Command::new("powershell")
5329 .args(["-NoProfile", "-Command", ram_script])
5330 .output()
5331 {
5332 let text = String::from_utf8_lossy(&o.stdout);
5333 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5334 }
5335
5336 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5338 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5339}"#;
5340 if let Ok(o) = Command::new("powershell")
5341 .args(["-NoProfile", "-Command", gpu_script])
5342 .output()
5343 {
5344 let text = String::from_utf8_lossy(&o.stdout);
5345 let lines: Vec<&str> = text.lines().collect();
5346 if !lines.is_empty() {
5347 out.push_str("GPU(s):\n");
5348 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5349 let parts: Vec<&str> = line.trim().split('|').collect();
5350 if parts.len() == 3 {
5351 let res = if parts[2] == "x" || parts[2].starts_with('0') {
5352 String::new()
5353 } else {
5354 format!(" — {}@display", parts[2])
5355 };
5356 out.push_str(&format!(
5357 " {}\n Driver: {}{}\n",
5358 parts[0], parts[1], res
5359 ));
5360 } else {
5361 out.push_str(&format!(" {}\n", line.trim()));
5362 }
5363 }
5364 out.push('\n');
5365 }
5366 }
5367
5368 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5370$bios = Get-CimInstance Win32_BIOS
5371$cs = Get-CimInstance Win32_ComputerSystem
5372$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5373$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5374"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5375 if let Ok(o) = Command::new("powershell")
5376 .args(["-NoProfile", "-Command", mb_script])
5377 .output()
5378 {
5379 let text = String::from_utf8_lossy(&o.stdout);
5380 let text = text.trim().trim_matches('"');
5381 let parts: Vec<&str> = text.split('|').collect();
5382 if parts.len() == 4 {
5383 out.push_str(&format!(
5384 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5385 parts[0].trim(),
5386 parts[1].trim(),
5387 parts[2].trim(),
5388 parts[3].trim()
5389 ));
5390 }
5391 }
5392
5393 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5395 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5396}"#;
5397 if let Ok(o) = Command::new("powershell")
5398 .args(["-NoProfile", "-Command", disp_script])
5399 .output()
5400 {
5401 let text = String::from_utf8_lossy(&o.stdout);
5402 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5403 if !lines.is_empty() {
5404 out.push_str("Display(s):\n");
5405 for line in &lines {
5406 let parts: Vec<&str> = line.trim().split('|').collect();
5407 if parts.len() == 2 {
5408 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
5409 }
5410 }
5411 }
5412 }
5413 }
5414
5415 #[cfg(not(target_os = "windows"))]
5416 {
5417 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5419 let model = content
5420 .lines()
5421 .find(|l| l.starts_with("model name"))
5422 .and_then(|l| l.split(':').nth(1))
5423 .map(str::trim)
5424 .unwrap_or("unknown");
5425 let cores = content
5426 .lines()
5427 .filter(|l| l.starts_with("processor"))
5428 .count();
5429 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
5430 }
5431
5432 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5434 let total_kb: u64 = content
5435 .lines()
5436 .find(|l| l.starts_with("MemTotal:"))
5437 .and_then(|l| l.split_whitespace().nth(1))
5438 .and_then(|v| v.parse().ok())
5439 .unwrap_or(0);
5440 let total_gb = total_kb / 1_048_576;
5441 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5442 }
5443
5444 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5446 let text = String::from_utf8_lossy(&o.stdout);
5447 let gpu_lines: Vec<&str> = text
5448 .lines()
5449 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5450 .collect();
5451 if !gpu_lines.is_empty() {
5452 out.push_str("GPU(s):\n");
5453 for l in gpu_lines {
5454 out.push_str(&format!(" {l}\n"));
5455 }
5456 out.push('\n');
5457 }
5458 }
5459
5460 if let Ok(o) = Command::new("dmidecode")
5462 .args(["-t", "baseboard", "-t", "bios"])
5463 .output()
5464 {
5465 let text = String::from_utf8_lossy(&o.stdout);
5466 out.push_str("Motherboard/BIOS:\n");
5467 for line in text
5468 .lines()
5469 .filter(|l| {
5470 l.contains("Manufacturer:")
5471 || l.contains("Product Name:")
5472 || l.contains("Version:")
5473 })
5474 .take(6)
5475 {
5476 out.push_str(&format!(" {}\n", line.trim()));
5477 }
5478 }
5479 }
5480
5481 Ok(out.trim_end().to_string())
5482}
5483
5484fn inspect_updates() -> Result<String, String> {
5487 let mut out = String::from("Host inspection: updates\n\n");
5488
5489 #[cfg(target_os = "windows")]
5490 {
5491 let script = r#"
5493try {
5494 $sess = New-Object -ComObject Microsoft.Update.Session
5495 $searcher = $sess.CreateUpdateSearcher()
5496 $count = $searcher.GetTotalHistoryCount()
5497 if ($count -gt 0) {
5498 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5499 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5500 } else { "NONE|LAST_INSTALL" }
5501} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5502"#;
5503 if let Ok(o) = Command::new("powershell")
5504 .args(["-NoProfile", "-Command", script])
5505 .output()
5506 {
5507 let raw = String::from_utf8_lossy(&o.stdout);
5508 let text = raw.trim();
5509 if text.starts_with("ERROR:") {
5510 out.push_str("Last update install: (unable to query)\n");
5511 } else if text.contains("NONE") {
5512 out.push_str("Last update install: No update history found\n");
5513 } else {
5514 let date = text.replace("|LAST_INSTALL", "");
5515 out.push_str(&format!("Last update install: {date}\n"));
5516 }
5517 }
5518
5519 let pending_script = r#"
5521try {
5522 $sess = New-Object -ComObject Microsoft.Update.Session
5523 $searcher = $sess.CreateUpdateSearcher()
5524 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5525 $results.Updates.Count.ToString() + "|PENDING"
5526} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5527"#;
5528 if let Ok(o) = Command::new("powershell")
5529 .args(["-NoProfile", "-Command", pending_script])
5530 .output()
5531 {
5532 let raw = String::from_utf8_lossy(&o.stdout);
5533 let text = raw.trim();
5534 if text.starts_with("ERROR:") {
5535 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5536 } else {
5537 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5538 if count == 0 {
5539 out.push_str("Pending updates: Up to date — no updates waiting\n");
5540 } else if count > 0 {
5541 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5542 out.push_str(
5543 " → Open Windows Update (Settings > Windows Update) to install\n",
5544 );
5545 }
5546 }
5547 }
5548
5549 let svc_script = r#"
5551$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5552if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5553"#;
5554 if let Ok(o) = Command::new("powershell")
5555 .args(["-NoProfile", "-Command", svc_script])
5556 .output()
5557 {
5558 let raw = String::from_utf8_lossy(&o.stdout);
5559 let status = raw.trim();
5560 out.push_str(&format!("Windows Update service: {status}\n"));
5561 }
5562 }
5563
5564 #[cfg(not(target_os = "windows"))]
5565 {
5566 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5567 let mut found = false;
5568 if let Ok(o) = apt_out {
5569 let text = String::from_utf8_lossy(&o.stdout);
5570 let lines: Vec<&str> = text
5571 .lines()
5572 .filter(|l| l.contains('/') && !l.contains("Listing"))
5573 .collect();
5574 if !lines.is_empty() {
5575 out.push_str(&format!(
5576 "{} package(s) can be upgraded (apt)\n",
5577 lines.len()
5578 ));
5579 out.push_str(" → Run: sudo apt upgrade\n");
5580 found = true;
5581 }
5582 }
5583 if !found {
5584 if let Ok(o) = Command::new("dnf")
5585 .args(["check-update", "--quiet"])
5586 .output()
5587 {
5588 let text = String::from_utf8_lossy(&o.stdout);
5589 let count = text
5590 .lines()
5591 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5592 .count();
5593 if count > 0 {
5594 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5595 out.push_str(" → Run: sudo dnf upgrade\n");
5596 } else {
5597 out.push_str("System is up to date.\n");
5598 }
5599 } else {
5600 out.push_str("Could not query package manager for updates.\n");
5601 }
5602 }
5603 }
5604
5605 Ok(out.trim_end().to_string())
5606}
5607
5608fn inspect_security() -> Result<String, String> {
5611 let mut out = String::from("Host inspection: security\n\n");
5612
5613 #[cfg(target_os = "windows")]
5614 {
5615 let defender_script = r#"
5617try {
5618 $status = Get-MpComputerStatus -ErrorAction Stop
5619 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5620} catch { "ERROR:" + $_.Exception.Message }
5621"#;
5622 if let Ok(o) = Command::new("powershell")
5623 .args(["-NoProfile", "-Command", defender_script])
5624 .output()
5625 {
5626 let raw = String::from_utf8_lossy(&o.stdout);
5627 let text = raw.trim();
5628 if text.starts_with("ERROR:") {
5629 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5630 } else {
5631 let get = |key: &str| -> String {
5632 text.split('|')
5633 .find(|s| s.starts_with(key))
5634 .and_then(|s| s.splitn(2, ':').nth(1))
5635 .unwrap_or("unknown")
5636 .to_string()
5637 };
5638 let rtp = get("RTP");
5639 let last_scan = {
5640 text.split('|')
5642 .find(|s| s.starts_with("SCAN:"))
5643 .and_then(|s| s.get(5..))
5644 .unwrap_or("unknown")
5645 .to_string()
5646 };
5647 let def_ver = get("VER");
5648 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5649
5650 let rtp_label = if rtp == "True" {
5651 "ENABLED"
5652 } else {
5653 "DISABLED [!]"
5654 };
5655 out.push_str(&format!(
5656 "Windows Defender real-time protection: {rtp_label}\n"
5657 ));
5658 out.push_str(&format!("Last quick scan: {last_scan}\n"));
5659 out.push_str(&format!("Signature version: {def_ver}\n"));
5660 if age_days >= 0 {
5661 let freshness = if age_days == 0 {
5662 "up to date".to_string()
5663 } else if age_days <= 3 {
5664 format!("{age_days} day(s) old — OK")
5665 } else if age_days <= 7 {
5666 format!("{age_days} day(s) old — consider updating")
5667 } else {
5668 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5669 };
5670 out.push_str(&format!("Signature age: {freshness}\n"));
5671 }
5672 if rtp != "True" {
5673 out.push_str(
5674 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5675 );
5676 out.push_str(
5677 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5678 );
5679 }
5680 }
5681 }
5682
5683 out.push('\n');
5684
5685 let fw_script = r#"
5687try {
5688 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5689} catch { "ERROR:" + $_.Exception.Message }
5690"#;
5691 if let Ok(o) = Command::new("powershell")
5692 .args(["-NoProfile", "-Command", fw_script])
5693 .output()
5694 {
5695 let raw = String::from_utf8_lossy(&o.stdout);
5696 let text = raw.trim();
5697 if !text.starts_with("ERROR:") && !text.is_empty() {
5698 out.push_str("Windows Firewall:\n");
5699 for line in text.lines() {
5700 if let Some((name, enabled)) = line.split_once(':') {
5701 let state = if enabled.trim() == "True" {
5702 "ON"
5703 } else {
5704 "OFF [!]"
5705 };
5706 out.push_str(&format!(" {name}: {state}\n"));
5707 }
5708 }
5709 out.push('\n');
5710 }
5711 }
5712
5713 let act_script = r#"
5715try {
5716 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5717 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5718} catch { "UNKNOWN" }
5719"#;
5720 if let Ok(o) = Command::new("powershell")
5721 .args(["-NoProfile", "-Command", act_script])
5722 .output()
5723 {
5724 let raw = String::from_utf8_lossy(&o.stdout);
5725 match raw.trim() {
5726 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5727 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5728 _ => out.push_str("Windows activation: Unable to determine\n"),
5729 }
5730 }
5731
5732 let uac_script = r#"
5734$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5735if ($val -eq 1) { "ON" } else { "OFF" }
5736"#;
5737 if let Ok(o) = Command::new("powershell")
5738 .args(["-NoProfile", "-Command", uac_script])
5739 .output()
5740 {
5741 let raw = String::from_utf8_lossy(&o.stdout);
5742 let state = raw.trim();
5743 let label = if state == "ON" {
5744 "Enabled"
5745 } else {
5746 "DISABLED [!] — recommended to re-enable via secpol.msc"
5747 };
5748 out.push_str(&format!("UAC (User Account Control): {label}\n"));
5749 }
5750 }
5751
5752 #[cfg(not(target_os = "windows"))]
5753 {
5754 if let Ok(o) = Command::new("ufw").arg("status").output() {
5755 let text = String::from_utf8_lossy(&o.stdout);
5756 out.push_str(&format!(
5757 "UFW: {}\n",
5758 text.lines().next().unwrap_or("unknown")
5759 ));
5760 }
5761 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5762 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5763 out.push_str(&format!("{line}\n"));
5764 }
5765 }
5766 }
5767
5768 Ok(out.trim_end().to_string())
5769}
5770
5771fn inspect_pending_reboot() -> Result<String, String> {
5774 let mut out = String::from("Host inspection: pending_reboot\n\n");
5775
5776 #[cfg(target_os = "windows")]
5777 {
5778 let script = r#"
5779$reasons = @()
5780if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5781 $reasons += "Windows Update requires a restart"
5782}
5783if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5784 $reasons += "Windows component install/update requires a restart"
5785}
5786$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5787if ($pfro -and $pfro.PendingFileRenameOperations) {
5788 $reasons += "Pending file rename operations (driver or system file replacement)"
5789}
5790if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5791"#;
5792 let output = Command::new("powershell")
5793 .args(["-NoProfile", "-Command", script])
5794 .output()
5795 .map_err(|e| format!("pending_reboot: {e}"))?;
5796
5797 let raw = String::from_utf8_lossy(&output.stdout);
5798 let text = raw.trim();
5799
5800 if text == "NO_REBOOT_NEEDED" {
5801 out.push_str("No restart required — system is up to date and stable.\n");
5802 } else if text.is_empty() {
5803 out.push_str("Could not determine reboot status.\n");
5804 } else {
5805 out.push_str("[!] A system restart is pending:\n\n");
5806 for reason in text.split("|REASON|") {
5807 out.push_str(&format!(" • {}\n", reason.trim()));
5808 }
5809 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5810 }
5811 }
5812
5813 #[cfg(not(target_os = "windows"))]
5814 {
5815 if std::path::Path::new("/var/run/reboot-required").exists() {
5816 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5817 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5818 out.push_str("Packages requiring restart:\n");
5819 for p in pkgs.lines().take(10) {
5820 out.push_str(&format!(" • {p}\n"));
5821 }
5822 }
5823 } else {
5824 out.push_str("No restart required.\n");
5825 }
5826 }
5827
5828 Ok(out.trim_end().to_string())
5829}
5830
5831fn inspect_disk_health() -> Result<String, String> {
5834 let mut out = String::from("Host inspection: disk_health\n\n");
5835
5836 #[cfg(target_os = "windows")]
5837 {
5838 let script = r#"
5839try {
5840 $disks = Get-PhysicalDisk -ErrorAction Stop
5841 foreach ($d in $disks) {
5842 $size_gb = [math]::Round($d.Size / 1GB, 0)
5843 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5844 }
5845} catch { "ERROR:" + $_.Exception.Message }
5846"#;
5847 let output = Command::new("powershell")
5848 .args(["-NoProfile", "-Command", script])
5849 .output()
5850 .map_err(|e| format!("disk_health: {e}"))?;
5851
5852 let raw = String::from_utf8_lossy(&output.stdout);
5853 let text = raw.trim();
5854
5855 if text.starts_with("ERROR:") {
5856 out.push_str(&format!("Unable to query disk health: {text}\n"));
5857 out.push_str("This may require running as administrator.\n");
5858 } else if text.is_empty() {
5859 out.push_str("No physical disks found.\n");
5860 } else {
5861 out.push_str("Physical Drive Health:\n\n");
5862 for line in text.lines() {
5863 let parts: Vec<&str> = line.splitn(5, '|').collect();
5864 if parts.len() >= 4 {
5865 let name = parts[0];
5866 let media = parts[1];
5867 let size = parts[2];
5868 let health = parts[3];
5869 let op_status = parts.get(4).unwrap_or(&"");
5870 let health_label = match health.trim() {
5871 "Healthy" => "OK",
5872 "Warning" => "[!] WARNING",
5873 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5874 other => other,
5875 };
5876 out.push_str(&format!(" {name}\n"));
5877 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
5878 out.push_str(&format!(" Health: {health_label}\n"));
5879 if !op_status.is_empty() {
5880 out.push_str(&format!(" Status: {op_status}\n"));
5881 }
5882 out.push('\n');
5883 }
5884 }
5885 }
5886
5887 let smart_script = r#"
5889try {
5890 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5891 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5892} catch { "" }
5893"#;
5894 if let Ok(o) = Command::new("powershell")
5895 .args(["-NoProfile", "-Command", smart_script])
5896 .output()
5897 {
5898 let raw2 = String::from_utf8_lossy(&o.stdout);
5899 let text2 = raw2.trim();
5900 if !text2.is_empty() {
5901 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5902 if failures.is_empty() {
5903 out.push_str("SMART failure prediction: No failures predicted\n");
5904 } else {
5905 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5906 for f in failures {
5907 let name = f.split('|').next().unwrap_or(f);
5908 out.push_str(&format!(" • {name}\n"));
5909 }
5910 out.push_str(
5911 "\nBack up your data immediately and replace the failing drive.\n",
5912 );
5913 }
5914 }
5915 }
5916 }
5917
5918 #[cfg(not(target_os = "windows"))]
5919 {
5920 if let Ok(o) = Command::new("lsblk")
5921 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5922 .output()
5923 {
5924 let text = String::from_utf8_lossy(&o.stdout);
5925 out.push_str("Block devices:\n");
5926 out.push_str(text.trim());
5927 out.push('\n');
5928 }
5929 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5930 let devices = String::from_utf8_lossy(&scan.stdout);
5931 for dev_line in devices.lines().take(4) {
5932 let dev = dev_line.split_whitespace().next().unwrap_or("");
5933 if dev.is_empty() {
5934 continue;
5935 }
5936 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5937 let health = String::from_utf8_lossy(&o.stdout);
5938 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5939 {
5940 out.push_str(&format!("{dev}: {}\n", line.trim()));
5941 }
5942 }
5943 }
5944 } else {
5945 out.push_str("(install smartmontools for SMART health data)\n");
5946 }
5947 }
5948
5949 Ok(out.trim_end().to_string())
5950}
5951
5952fn inspect_battery() -> Result<String, String> {
5955 let mut out = String::from("Host inspection: battery\n\n");
5956
5957 #[cfg(target_os = "windows")]
5958 {
5959 let script = r#"
5960try {
5961 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5962 if (-not $bats) { "NO_BATTERY"; exit }
5963
5964 # Modern Battery Health (Cycle count + Capacity health)
5965 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5966 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
5967 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5968
5969 foreach ($b in $bats) {
5970 $state = switch ($b.BatteryStatus) {
5971 1 { "Discharging" }
5972 2 { "AC Power (Fully Charged)" }
5973 3 { "AC Power (Charging)" }
5974 default { "Status $($b.BatteryStatus)" }
5975 }
5976
5977 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5978 $health = if ($static -and $full) {
5979 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5980 } else { "unknown" }
5981
5982 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5983 }
5984} catch { "ERROR:" + $_.Exception.Message }
5985"#;
5986 let output = Command::new("powershell")
5987 .args(["-NoProfile", "-Command", script])
5988 .output()
5989 .map_err(|e| format!("battery: {e}"))?;
5990
5991 let raw = String::from_utf8_lossy(&output.stdout);
5992 let text = raw.trim();
5993
5994 if text == "NO_BATTERY" {
5995 out.push_str("No battery detected — desktop or AC-only system.\n");
5996 return Ok(out.trim_end().to_string());
5997 }
5998 if text.starts_with("ERROR:") {
5999 out.push_str(&format!("Unable to query battery: {text}\n"));
6000 return Ok(out.trim_end().to_string());
6001 }
6002
6003 for line in text.lines() {
6004 let parts: Vec<&str> = line.split('|').collect();
6005 if parts.len() == 5 {
6006 let name = parts[0];
6007 let charge: i64 = parts[1].parse().unwrap_or(-1);
6008 let state = parts[2];
6009 let cycles = parts[3];
6010 let health = parts[4];
6011
6012 out.push_str(&format!("Battery: {name}\n"));
6013 if charge >= 0 {
6014 let bar_filled = (charge as usize * 20) / 100;
6015 out.push_str(&format!(
6016 " Charge: [{}{}] {}%\n",
6017 "#".repeat(bar_filled),
6018 ".".repeat(20 - bar_filled),
6019 charge
6020 ));
6021 }
6022 out.push_str(&format!(" Status: {state}\n"));
6023 out.push_str(&format!(" Cycles: {cycles}\n"));
6024 out.push_str(&format!(
6025 " Health: {health}% (Actual vs Design Capacity)\n\n"
6026 ));
6027 }
6028 }
6029 }
6030
6031 #[cfg(not(target_os = "windows"))]
6032 {
6033 let power_path = std::path::Path::new("/sys/class/power_supply");
6034 let mut found = false;
6035 if power_path.exists() {
6036 if let Ok(entries) = std::fs::read_dir(power_path) {
6037 for entry in entries.flatten() {
6038 let p = entry.path();
6039 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6040 if t.trim() == "Battery" {
6041 found = true;
6042 let name = p
6043 .file_name()
6044 .unwrap_or_default()
6045 .to_string_lossy()
6046 .to_string();
6047 out.push_str(&format!("Battery: {name}\n"));
6048 let read = |f: &str| {
6049 std::fs::read_to_string(p.join(f))
6050 .ok()
6051 .map(|s| s.trim().to_string())
6052 };
6053 if let Some(cap) = read("capacity") {
6054 out.push_str(&format!(" Charge: {cap}%\n"));
6055 }
6056 if let Some(status) = read("status") {
6057 out.push_str(&format!(" Status: {status}\n"));
6058 }
6059 if let (Some(full), Some(design)) =
6060 (read("energy_full"), read("energy_full_design"))
6061 {
6062 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6063 {
6064 if d > 0.0 {
6065 out.push_str(&format!(
6066 " Wear level: {:.1}% of design capacity\n",
6067 (f / d) * 100.0
6068 ));
6069 }
6070 }
6071 }
6072 }
6073 }
6074 }
6075 }
6076 }
6077 if !found {
6078 out.push_str("No battery found.\n");
6079 }
6080 }
6081
6082 Ok(out.trim_end().to_string())
6083}
6084
6085fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6088 let mut out = String::from("Host inspection: recent_crashes\n\n");
6089 let n = max_entries.clamp(1, 30);
6090
6091 #[cfg(target_os = "windows")]
6092 {
6093 let bsod_script = format!(
6095 r#"
6096try {{
6097 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6098 if ($events) {{
6099 $events | ForEach-Object {{
6100 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6101 }}
6102 }} else {{ "NO_BSOD" }}
6103}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6104 );
6105
6106 if let Ok(o) = Command::new("powershell")
6107 .args(["-NoProfile", "-Command", &bsod_script])
6108 .output()
6109 {
6110 let raw = String::from_utf8_lossy(&o.stdout);
6111 let text = raw.trim();
6112 if text == "NO_BSOD" {
6113 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6114 } else if text.starts_with("ERROR:") {
6115 out.push_str("System crashes: unable to query\n");
6116 } else {
6117 out.push_str("System crashes / unexpected shutdowns:\n");
6118 for line in text.lines() {
6119 let parts: Vec<&str> = line.splitn(3, '|').collect();
6120 if parts.len() >= 3 {
6121 let time = parts[0];
6122 let id = parts[1];
6123 let msg = parts[2];
6124 let label = if id == "41" {
6125 "Unexpected shutdown"
6126 } else {
6127 "BSOD (BugCheck)"
6128 };
6129 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
6130 }
6131 }
6132 out.push('\n');
6133 }
6134 }
6135
6136 let app_script = format!(
6138 r#"
6139try {{
6140 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6141 if ($crashes) {{
6142 $crashes | ForEach-Object {{
6143 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6144 }}
6145 }} else {{ "NO_CRASHES" }}
6146}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6147 );
6148
6149 if let Ok(o) = Command::new("powershell")
6150 .args(["-NoProfile", "-Command", &app_script])
6151 .output()
6152 {
6153 let raw = String::from_utf8_lossy(&o.stdout);
6154 let text = raw.trim();
6155 if text == "NO_CRASHES" {
6156 out.push_str("Application crashes: None in recent history\n");
6157 } else if text.starts_with("ERROR_APP:") {
6158 out.push_str("Application crashes: unable to query\n");
6159 } else {
6160 out.push_str("Application crashes:\n");
6161 for line in text.lines().take(n) {
6162 let parts: Vec<&str> = line.splitn(2, '|').collect();
6163 if parts.len() >= 2 {
6164 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
6165 }
6166 }
6167 }
6168 }
6169 }
6170
6171 #[cfg(not(target_os = "windows"))]
6172 {
6173 let n_str = n.to_string();
6174 if let Ok(o) = Command::new("journalctl")
6175 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6176 .output()
6177 {
6178 let text = String::from_utf8_lossy(&o.stdout);
6179 let trimmed = text.trim();
6180 if trimmed.is_empty() || trimmed.contains("No entries") {
6181 out.push_str("No kernel panics or critical crashes found.\n");
6182 } else {
6183 out.push_str("Kernel critical events:\n");
6184 out.push_str(trimmed);
6185 out.push('\n');
6186 }
6187 }
6188 if let Ok(o) = Command::new("coredumpctl")
6189 .args(["list", "--no-pager"])
6190 .output()
6191 {
6192 let text = String::from_utf8_lossy(&o.stdout);
6193 let count = text
6194 .lines()
6195 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6196 .count();
6197 if count > 0 {
6198 out.push_str(&format!(
6199 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
6200 ));
6201 }
6202 }
6203 }
6204
6205 Ok(out.trim_end().to_string())
6206}
6207
6208fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6211 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6212 let n = max_entries.clamp(1, 30);
6213
6214 #[cfg(target_os = "windows")]
6215 {
6216 let script = format!(
6217 r#"
6218try {{
6219 $tasks = Get-ScheduledTask -ErrorAction Stop |
6220 Where-Object {{ $_.State -ne 'Disabled' }} |
6221 ForEach-Object {{
6222 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6223 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6224 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6225 }} else {{ "never" }}
6226 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6227 $exec = ($_.Actions | Select-Object -First 1).Execute
6228 if (-not $exec) {{ $exec = "(no exec)" }}
6229 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6230 }}
6231 $tasks | Select-Object -First {n}
6232}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6233 );
6234
6235 let output = Command::new("powershell")
6236 .args(["-NoProfile", "-Command", &script])
6237 .output()
6238 .map_err(|e| format!("scheduled_tasks: {e}"))?;
6239
6240 let raw = String::from_utf8_lossy(&output.stdout);
6241 let text = raw.trim();
6242
6243 if text.starts_with("ERROR:") {
6244 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
6245 } else if text.is_empty() {
6246 out.push_str("No active scheduled tasks found.\n");
6247 } else {
6248 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
6249 for line in text.lines() {
6250 let parts: Vec<&str> = line.splitn(6, '|').collect();
6251 if parts.len() >= 5 {
6252 let name = parts[0];
6253 let path = parts[1];
6254 let state = parts[2];
6255 let last = parts[3];
6256 let res = parts[4];
6257 let exec = parts.get(5).unwrap_or(&"").trim();
6258 let display_path = path.trim_matches('\\');
6259 let display_path = if display_path.is_empty() {
6260 "Root"
6261 } else {
6262 display_path
6263 };
6264 out.push_str(&format!(" {name} [{display_path}]\n"));
6265 out.push_str(&format!(
6266 " State: {state} | Last run: {last} | Result: {res}\n"
6267 ));
6268 if !exec.is_empty() && exec != "(no exec)" {
6269 let short = if exec.len() > 80 { &exec[..80] } else { exec };
6270 out.push_str(&format!(" Runs: {short}\n"));
6271 }
6272 }
6273 }
6274 }
6275 }
6276
6277 #[cfg(not(target_os = "windows"))]
6278 {
6279 if let Ok(o) = Command::new("systemctl")
6280 .args(["list-timers", "--no-pager", "--all"])
6281 .output()
6282 {
6283 let text = String::from_utf8_lossy(&o.stdout);
6284 out.push_str("Systemd timers:\n");
6285 for l in text
6286 .lines()
6287 .filter(|l| {
6288 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6289 })
6290 .take(n)
6291 {
6292 out.push_str(&format!(" {l}\n"));
6293 }
6294 out.push('\n');
6295 }
6296 if let Ok(o) = Command::new("crontab").arg("-l").output() {
6297 let text = String::from_utf8_lossy(&o.stdout);
6298 let jobs: Vec<&str> = text
6299 .lines()
6300 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6301 .collect();
6302 if !jobs.is_empty() {
6303 out.push_str("User crontab:\n");
6304 for j in jobs.iter().take(n) {
6305 out.push_str(&format!(" {j}\n"));
6306 }
6307 }
6308 }
6309 }
6310
6311 Ok(out.trim_end().to_string())
6312}
6313
6314fn inspect_dev_conflicts() -> Result<String, String> {
6317 let mut out = String::from("Host inspection: dev_conflicts\n\n");
6318 let mut conflicts: Vec<String> = Vec::new();
6319 let mut notes: Vec<String> = Vec::new();
6320
6321 {
6323 let node_ver = Command::new("node")
6324 .arg("--version")
6325 .output()
6326 .ok()
6327 .and_then(|o| String::from_utf8(o.stdout).ok())
6328 .map(|s| s.trim().to_string());
6329 let nvm_active = Command::new("nvm")
6330 .arg("current")
6331 .output()
6332 .ok()
6333 .and_then(|o| String::from_utf8(o.stdout).ok())
6334 .map(|s| s.trim().to_string())
6335 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6336 let fnm_active = Command::new("fnm")
6337 .arg("current")
6338 .output()
6339 .ok()
6340 .and_then(|o| String::from_utf8(o.stdout).ok())
6341 .map(|s| s.trim().to_string())
6342 .filter(|s| !s.is_empty() && !s.contains("none"));
6343 let volta_active = Command::new("volta")
6344 .args(["which", "node"])
6345 .output()
6346 .ok()
6347 .and_then(|o| String::from_utf8(o.stdout).ok())
6348 .map(|s| s.trim().to_string())
6349 .filter(|s| !s.is_empty());
6350
6351 out.push_str("Node.js:\n");
6352 if let Some(ref v) = node_ver {
6353 out.push_str(&format!(" Active: {v}\n"));
6354 } else {
6355 out.push_str(" Not installed\n");
6356 }
6357 let managers: Vec<&str> = [
6358 nvm_active.as_deref(),
6359 fnm_active.as_deref(),
6360 volta_active.as_deref(),
6361 ]
6362 .iter()
6363 .filter_map(|x| *x)
6364 .collect();
6365 if managers.len() > 1 {
6366 conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6367 } else if !managers.is_empty() {
6368 out.push_str(&format!(" Version manager: {}\n", managers[0]));
6369 }
6370 out.push('\n');
6371 }
6372
6373 {
6375 let py3 = Command::new("python3")
6376 .arg("--version")
6377 .output()
6378 .ok()
6379 .and_then(|o| {
6380 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6381 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6382 let v = if stdout.is_empty() { stderr } else { stdout };
6383 if v.is_empty() {
6384 None
6385 } else {
6386 Some(v)
6387 }
6388 });
6389 let py = Command::new("python")
6390 .arg("--version")
6391 .output()
6392 .ok()
6393 .and_then(|o| {
6394 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6395 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6396 let v = if stdout.is_empty() { stderr } else { stdout };
6397 if v.is_empty() {
6398 None
6399 } else {
6400 Some(v)
6401 }
6402 });
6403 let pyenv = Command::new("pyenv")
6404 .arg("version")
6405 .output()
6406 .ok()
6407 .and_then(|o| String::from_utf8(o.stdout).ok())
6408 .map(|s| s.trim().to_string())
6409 .filter(|s| !s.is_empty());
6410 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6411
6412 out.push_str("Python:\n");
6413 match (&py3, &py) {
6414 (Some(v3), Some(v)) if v3 != v => {
6415 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
6416 if v.contains("2.") {
6417 conflicts.push(
6418 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6419 );
6420 } else {
6421 notes.push(
6422 "python and python3 resolve to different minor versions.".to_string(),
6423 );
6424 }
6425 }
6426 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
6427 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
6428 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
6429 (None, None) => out.push_str(" Not installed\n"),
6430 }
6431 if let Some(ref pe) = pyenv {
6432 out.push_str(&format!(" pyenv: {pe}\n"));
6433 }
6434 if let Some(env) = conda_env {
6435 if env == "base" {
6436 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6437 } else {
6438 out.push_str(&format!(" conda env: {env}\n"));
6439 }
6440 }
6441 out.push('\n');
6442 }
6443
6444 {
6446 let toolchain = Command::new("rustup")
6447 .args(["show", "active-toolchain"])
6448 .output()
6449 .ok()
6450 .and_then(|o| String::from_utf8(o.stdout).ok())
6451 .map(|s| s.trim().to_string())
6452 .filter(|s| !s.is_empty());
6453 let cargo_ver = Command::new("cargo")
6454 .arg("--version")
6455 .output()
6456 .ok()
6457 .and_then(|o| String::from_utf8(o.stdout).ok())
6458 .map(|s| s.trim().to_string());
6459 let rustc_ver = Command::new("rustc")
6460 .arg("--version")
6461 .output()
6462 .ok()
6463 .and_then(|o| String::from_utf8(o.stdout).ok())
6464 .map(|s| s.trim().to_string());
6465
6466 out.push_str("Rust:\n");
6467 if let Some(ref t) = toolchain {
6468 out.push_str(&format!(" Active toolchain: {t}\n"));
6469 }
6470 if let Some(ref c) = cargo_ver {
6471 out.push_str(&format!(" {c}\n"));
6472 }
6473 if let Some(ref r) = rustc_ver {
6474 out.push_str(&format!(" {r}\n"));
6475 }
6476 if cargo_ver.is_none() && rustc_ver.is_none() {
6477 out.push_str(" Not installed\n");
6478 }
6479
6480 #[cfg(not(target_os = "windows"))]
6482 if let Ok(o) = Command::new("which").arg("rustc").output() {
6483 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6484 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6485 conflicts.push(format!(
6486 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6487 ));
6488 }
6489 }
6490 out.push('\n');
6491 }
6492
6493 {
6495 let git_ver = Command::new("git")
6496 .arg("--version")
6497 .output()
6498 .ok()
6499 .and_then(|o| String::from_utf8(o.stdout).ok())
6500 .map(|s| s.trim().to_string());
6501 out.push_str("Git:\n");
6502 if let Some(ref v) = git_ver {
6503 out.push_str(&format!(" {v}\n"));
6504 let email = Command::new("git")
6505 .args(["config", "--global", "user.email"])
6506 .output()
6507 .ok()
6508 .and_then(|o| String::from_utf8(o.stdout).ok())
6509 .map(|s| s.trim().to_string());
6510 if let Some(ref e) = email {
6511 if e.is_empty() {
6512 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6513 } else {
6514 out.push_str(&format!(" user.email: {e}\n"));
6515 }
6516 }
6517 let gpg_sign = Command::new("git")
6518 .args(["config", "--global", "commit.gpgsign"])
6519 .output()
6520 .ok()
6521 .and_then(|o| String::from_utf8(o.stdout).ok())
6522 .map(|s| s.trim().to_string());
6523 if gpg_sign.as_deref() == Some("true") {
6524 let key = Command::new("git")
6525 .args(["config", "--global", "user.signingkey"])
6526 .output()
6527 .ok()
6528 .and_then(|o| String::from_utf8(o.stdout).ok())
6529 .map(|s| s.trim().to_string());
6530 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6531 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6532 }
6533 }
6534 } else {
6535 out.push_str(" Not installed\n");
6536 }
6537 out.push('\n');
6538 }
6539
6540 {
6542 let path_env = std::env::var("PATH").unwrap_or_default();
6543 let sep = if cfg!(windows) { ';' } else { ':' };
6544 let mut seen = HashSet::new();
6545 let mut dupes: Vec<String> = Vec::new();
6546 for p in path_env.split(sep) {
6547 let norm = p.trim().to_lowercase();
6548 if !norm.is_empty() && !seen.insert(norm) {
6549 dupes.push(p.to_string());
6550 }
6551 }
6552 if !dupes.is_empty() {
6553 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6554 notes.push(format!(
6555 "Duplicate PATH entries: {} {}",
6556 shown.join(", "),
6557 if dupes.len() > 3 {
6558 format!("+{} more", dupes.len() - 3)
6559 } else {
6560 String::new()
6561 }
6562 ));
6563 }
6564 }
6565
6566 if conflicts.is_empty() && notes.is_empty() {
6568 out.push_str("No conflicts detected — dev environment looks clean.\n");
6569 } else {
6570 if !conflicts.is_empty() {
6571 out.push_str("CONFLICTS:\n");
6572 for c in &conflicts {
6573 out.push_str(&format!(" [!] {c}\n"));
6574 }
6575 out.push('\n');
6576 }
6577 if !notes.is_empty() {
6578 out.push_str("NOTES:\n");
6579 for n in ¬es {
6580 out.push_str(&format!(" [-] {n}\n"));
6581 }
6582 }
6583 }
6584
6585 Ok(out.trim_end().to_string())
6586}
6587
6588async fn inspect_public_ip() -> Result<String, String> {
6591 let mut out = String::from("Host inspection: public_ip\n\n");
6592
6593 let client = reqwest::Client::builder()
6594 .timeout(std::time::Duration::from_secs(5))
6595 .build()
6596 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
6597
6598 match client.get("https://api.ipify.org?format=json").send().await {
6599 Ok(resp) => {
6600 if let Ok(json) = resp.json::<serde_json::Value>().await {
6601 let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
6602 out.push_str(&format!("Public IP: {}\n", ip));
6603
6604 if let Ok(geo_resp) = client
6606 .get(format!("http://ip-api.com/json/{}", ip))
6607 .send()
6608 .await
6609 {
6610 if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
6611 if let (Some(city), Some(region), Some(country), Some(isp)) = (
6612 geo_json.get("city").and_then(|v| v.as_str()),
6613 geo_json.get("regionName").and_then(|v| v.as_str()),
6614 geo_json.get("country").and_then(|v| v.as_str()),
6615 geo_json.get("isp").and_then(|v| v.as_str()),
6616 ) {
6617 out.push_str(&format!(
6618 "Location: {}, {} ({})\n",
6619 city, region, country
6620 ));
6621 out.push_str(&format!("ISP: {}\n", isp));
6622 }
6623 }
6624 }
6625 } else {
6626 out.push_str("Error: Failed to parse public IP response.\n");
6627 }
6628 }
6629 Err(e) => {
6630 out.push_str(&format!(
6631 "Error: Failed to fetch public IP ({}). Check internet connectivity.\n",
6632 e
6633 ));
6634 }
6635 }
6636
6637 Ok(out)
6638}
6639
6640fn inspect_ssl_cert(host: &str) -> Result<String, String> {
6641 let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
6642
6643 #[cfg(target_os = "windows")]
6644 {
6645 use std::process::Command;
6646 let script = format!(
6647 r#"$domain = "{host}"
6648try {{
6649 $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
6650 $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
6651 $sslStream.AuthenticateAsClient($domain)
6652 $cert = $sslStream.RemoteCertificate
6653 $tcpClient.Close()
6654 if ($cert) {{
6655 $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
6656 $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
6657 }} else {{
6658 "null"
6659 }}
6660}} catch {{
6661 "ERROR:" + $_.Exception.Message
6662}}"#
6663 );
6664
6665 let ps_out = Command::new("powershell")
6666 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
6667 .output()
6668 .map_err(|e| format!("powershell launch failed: {e}"))?;
6669
6670 let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
6671 if text.starts_with("ERROR:") {
6672 out.push_str(&format!("Error: {}\n", text.trim_start_matches("ERROR:")));
6673 } else if text == "null" || text.is_empty() {
6674 out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
6675 } else {
6676 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
6677 if let Some(obj) = json.as_object() {
6678 for (k, v) in obj {
6679 let val_str = v.as_str().unwrap_or("");
6680 out.push_str(&format!("{:<12}: {}\n", k, val_str));
6681 }
6682
6683 if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
6684 if not_after_raw.starts_with("/Date(") {
6685 let ts = not_after_raw
6686 .trim_start_matches("/Date(")
6687 .trim_end_matches(")/")
6688 .parse::<i64>()
6689 .unwrap_or(0);
6690 let expiry =
6691 chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
6692 let now = chrono::Utc::now();
6693 let days_left = expiry.signed_duration_since(now).num_days();
6694 if days_left < 0 {
6695 out.push_str("\nSTATUS: [!!] EXPIRED\n");
6696 } else if days_left < 30 {
6697 out.push_str(&format!(
6698 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6699 days_left
6700 ));
6701 } else {
6702 out.push_str(&format!(
6703 "\nSTATUS: Valid ({} days left)\n",
6704 days_left
6705 ));
6706 }
6707 } else {
6708 if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw)
6709 {
6710 let now = chrono::Utc::now();
6711 let days_left = expiry.signed_duration_since(now).num_days();
6712 if days_left < 0 {
6713 out.push_str("\nSTATUS: [!!] EXPIRED\n");
6714 } else if days_left < 30 {
6715 out.push_str(&format!(
6716 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6717 days_left
6718 ));
6719 } else {
6720 out.push_str(&format!(
6721 "\nSTATUS: Valid ({} days left)\n",
6722 days_left
6723 ));
6724 }
6725 }
6726 }
6727 }
6728 }
6729 } else {
6730 out.push_str(&format!("Raw Output: {}\n", text));
6731 }
6732 }
6733 }
6734
6735 #[cfg(not(target_os = "windows"))]
6736 {
6737 out.push_str(
6738 "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
6739 );
6740 }
6741
6742 Ok(out)
6743}
6744
6745async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
6746 let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
6747
6748 if !path.exists() {
6749 return Err(format!("File not found: {:?}", path));
6750 }
6751 if !path.is_file() {
6752 return Err(format!("Not a file: {:?}", path));
6753 }
6754
6755 let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6756 out.push_str(&format!(
6757 "File Size: {} bytes ({:.2} MB)\n",
6758 file_size,
6759 file_size as f64 / 1_048_576.0
6760 ));
6761
6762 let ext = path
6763 .extension()
6764 .and_then(|s| s.to_str())
6765 .unwrap_or("")
6766 .to_lowercase();
6767 out.push_str(&format!("Format: {}\n\n", ext.to_uppercase()));
6768
6769 match ext.as_str() {
6770 "csv" | "tsv" | "txt" | "log" => {
6771 let content = std::fs::read_to_string(&path)
6772 .map_err(|e| format!("Failed to read file: {}", e))?;
6773 let lines: Vec<&str> = content.lines().collect();
6774 out.push_str(&format!("Row Count: {} (total lines)\n", lines.len()));
6775
6776 if let Some(header) = lines.get(0) {
6777 out.push_str("Columns (Guessed from header):\n");
6778 let delimiter = if ext == "tsv" {
6779 "\t"
6780 } else if header.contains(',') {
6781 ","
6782 } else {
6783 " "
6784 };
6785 let cols: Vec<&str> = header.split(delimiter).map(|s| s.trim()).collect();
6786 for (i, col) in cols.iter().enumerate() {
6787 out.push_str(&format!(" {}. {}\n", i + 1, col));
6788 }
6789 }
6790
6791 out.push_str("\nSample Data (First 5 rows):\n");
6792 for line in lines.iter().take(6) {
6793 out.push_str(&format!(" {}\n", line));
6794 }
6795 }
6796 "json" => {
6797 let content = std::fs::read_to_string(&path)
6798 .map_err(|e| format!("Failed to read file: {}", e))?;
6799 if let Ok(json) = serde_json::from_str::<Value>(&content) {
6800 if let Some(arr) = json.as_array() {
6801 out.push_str(&format!("Record Count: {}\n", arr.len()));
6802 if let Some(first) = arr.get(0) {
6803 if let Some(obj) = first.as_object() {
6804 out.push_str("Fields (from first record):\n");
6805 for k in obj.keys() {
6806 out.push_str(&format!(" - {}\n", k));
6807 }
6808 }
6809 }
6810 out.push_str("\nSample Record:\n");
6811 out.push_str(&serde_json::to_string_pretty(&arr.get(0)).unwrap_or_default());
6812 } else if let Some(obj) = json.as_object() {
6813 out.push_str("Top-level Keys:\n");
6814 for k in obj.keys() {
6815 out.push_str(&format!(" - {}\n", k));
6816 }
6817 }
6818 } else {
6819 out.push_str("Error: Failed to parse as JSON.\n");
6820 }
6821 }
6822 "db" | "sqlite" | "sqlite3" => {
6823 out.push_str("SQLite Database detected.\n");
6824 out.push_str("Use `query_data` to execute SQL against this database.\n");
6825 }
6826 _ => {
6827 out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
6828 let content = std::fs::read_to_string(&path)
6829 .map_err(|e| format!("Failed to read file: {}", e))?;
6830 for line in content.lines().take(10) {
6831 out.push_str(&format!(" {}\n", line));
6832 }
6833 }
6834 }
6835
6836 Ok(out)
6837}
6838
6839fn inspect_connectivity() -> Result<String, String> {
6840 let mut out = String::from("Host inspection: connectivity\n\n");
6841
6842 #[cfg(target_os = "windows")]
6843 {
6844 let inet_script = r#"
6845try {
6846 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6847 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6848} catch { "ERROR:" + $_.Exception.Message }
6849"#;
6850 if let Ok(o) = Command::new("powershell")
6851 .args(["-NoProfile", "-Command", inet_script])
6852 .output()
6853 {
6854 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6855 match text.as_str() {
6856 "REACHABLE" => out.push_str("Internet: reachable\n"),
6857 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6858 _ => out.push_str(&format!(
6859 "Internet: {}\n",
6860 text.trim_start_matches("ERROR:").trim()
6861 )),
6862 }
6863 }
6864
6865 let dns_script = r#"
6866try {
6867 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6868 "DNS:ok"
6869} catch { "DNS:fail:" + $_.Exception.Message }
6870"#;
6871 if let Ok(o) = Command::new("powershell")
6872 .args(["-NoProfile", "-Command", dns_script])
6873 .output()
6874 {
6875 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6876 if text == "DNS:ok" {
6877 out.push_str("DNS: resolving correctly\n");
6878 } else {
6879 let detail = text.trim_start_matches("DNS:fail:").trim();
6880 out.push_str(&format!("DNS: failed — {}\n", detail));
6881 }
6882 }
6883
6884 let gw_script = r#"
6885(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6886"#;
6887 if let Ok(o) = Command::new("powershell")
6888 .args(["-NoProfile", "-Command", gw_script])
6889 .output()
6890 {
6891 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6892 if !gw.is_empty() && gw != "0.0.0.0" {
6893 out.push_str(&format!("Default gateway: {}\n", gw));
6894 }
6895 }
6896 }
6897
6898 #[cfg(not(target_os = "windows"))]
6899 {
6900 let reachable = Command::new("ping")
6901 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6902 .output()
6903 .map(|o| o.status.success())
6904 .unwrap_or(false);
6905 out.push_str(if reachable {
6906 "Internet: reachable\n"
6907 } else {
6908 "Internet: unreachable\n"
6909 });
6910 let dns_ok = Command::new("getent")
6911 .args(["hosts", "dns.google"])
6912 .output()
6913 .map(|o| o.status.success())
6914 .unwrap_or(false);
6915 out.push_str(if dns_ok {
6916 "DNS: resolving correctly\n"
6917 } else {
6918 "DNS: failed\n"
6919 });
6920 if let Ok(o) = Command::new("ip")
6921 .args(["route", "show", "default"])
6922 .output()
6923 {
6924 let text = String::from_utf8_lossy(&o.stdout);
6925 if let Some(line) = text.lines().next() {
6926 out.push_str(&format!("Default gateway: {}\n", line.trim()));
6927 }
6928 }
6929 }
6930
6931 Ok(out.trim_end().to_string())
6932}
6933
6934fn inspect_wifi() -> Result<String, String> {
6937 let mut out = String::from("Host inspection: wifi\n\n");
6938
6939 #[cfg(target_os = "windows")]
6940 {
6941 let output = Command::new("netsh")
6942 .args(["wlan", "show", "interfaces"])
6943 .output()
6944 .map_err(|e| format!("wifi: {e}"))?;
6945 let text = String::from_utf8_lossy(&output.stdout).to_string();
6946
6947 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6948 out.push_str("No wireless interface detected on this machine.\n");
6949 return Ok(out.trim_end().to_string());
6950 }
6951
6952 let fields = [
6953 ("SSID", "SSID"),
6954 ("State", "State"),
6955 ("Signal", "Signal"),
6956 ("Radio type", "Radio type"),
6957 ("Channel", "Channel"),
6958 ("Receive rate (Mbps)", "Download speed (Mbps)"),
6959 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6960 ("Authentication", "Authentication"),
6961 ("Network type", "Network type"),
6962 ];
6963
6964 let mut any = false;
6965 for line in text.lines() {
6966 let trimmed = line.trim();
6967 for (key, label) in &fields {
6968 if trimmed.starts_with(key) && trimmed.contains(':') {
6969 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6970 if !val.is_empty() {
6971 out.push_str(&format!(" {label}: {val}\n"));
6972 any = true;
6973 }
6974 }
6975 }
6976 }
6977 if !any {
6978 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
6979 }
6980 }
6981
6982 #[cfg(not(target_os = "windows"))]
6983 {
6984 if let Ok(o) = Command::new("nmcli")
6985 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6986 .output()
6987 {
6988 let text = String::from_utf8_lossy(&o.stdout).to_string();
6989 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6990 if lines.is_empty() {
6991 out.push_str("No Wi-Fi devices found.\n");
6992 } else {
6993 for l in lines {
6994 out.push_str(&format!(" {l}\n"));
6995 }
6996 }
6997 } else if let Ok(o) = Command::new("iwconfig").output() {
6998 let text = String::from_utf8_lossy(&o.stdout).to_string();
6999 if !text.trim().is_empty() {
7000 out.push_str(text.trim());
7001 out.push('\n');
7002 }
7003 } else {
7004 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7005 }
7006 }
7007
7008 Ok(out.trim_end().to_string())
7009}
7010
7011fn inspect_connections(max_entries: usize) -> Result<String, String> {
7014 let mut out = String::from("Host inspection: connections\n\n");
7015 let n = max_entries.clamp(1, 25);
7016
7017 #[cfg(target_os = "windows")]
7018 {
7019 let script = format!(
7020 r#"
7021try {{
7022 $procs = @{{}}
7023 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7024 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7025 Sort-Object OwningProcess
7026 "TOTAL:" + $all.Count
7027 $all | Select-Object -First {n} | ForEach-Object {{
7028 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7029 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7030 }}
7031}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7032 );
7033
7034 let output = Command::new("powershell")
7035 .args(["-NoProfile", "-Command", &script])
7036 .output()
7037 .map_err(|e| format!("connections: {e}"))?;
7038
7039 let raw = String::from_utf8_lossy(&output.stdout);
7040 let text = raw.trim();
7041
7042 if text.starts_with("ERROR:") {
7043 out.push_str(&format!("Unable to query connections: {text}\n"));
7044 } else {
7045 let mut total = 0usize;
7046 let mut rows = Vec::new();
7047 for line in text.lines() {
7048 if let Some(rest) = line.strip_prefix("TOTAL:") {
7049 total = rest.trim().parse().unwrap_or(0);
7050 } else {
7051 rows.push(line);
7052 }
7053 }
7054 out.push_str(&format!("Established TCP connections: {total}\n\n"));
7055 for row in &rows {
7056 let parts: Vec<&str> = row.splitn(4, '|').collect();
7057 if parts.len() == 4 {
7058 out.push_str(&format!(
7059 " {:<15} (pid {:<5}) | {} → {}\n",
7060 parts[0], parts[1], parts[2], parts[3]
7061 ));
7062 }
7063 }
7064 if total > n {
7065 out.push_str(&format!(
7066 "\n ... {} more connections not shown\n",
7067 total.saturating_sub(n)
7068 ));
7069 }
7070 }
7071 }
7072
7073 #[cfg(not(target_os = "windows"))]
7074 {
7075 if let Ok(o) = Command::new("ss")
7076 .args(["-tnp", "state", "established"])
7077 .output()
7078 {
7079 let text = String::from_utf8_lossy(&o.stdout);
7080 let lines: Vec<&str> = text
7081 .lines()
7082 .skip(1)
7083 .filter(|l| !l.trim().is_empty())
7084 .collect();
7085 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
7086 for line in lines.iter().take(n) {
7087 out.push_str(&format!(" {}\n", line.trim()));
7088 }
7089 if lines.len() > n {
7090 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
7091 }
7092 } else {
7093 out.push_str("ss not available — install iproute2\n");
7094 }
7095 }
7096
7097 Ok(out.trim_end().to_string())
7098}
7099
7100fn inspect_vpn() -> Result<String, String> {
7103 let mut out = String::from("Host inspection: vpn\n\n");
7104
7105 #[cfg(target_os = "windows")]
7106 {
7107 let script = r#"
7108try {
7109 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7110 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7111 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7112 }
7113 if ($vpn) {
7114 foreach ($a in $vpn) {
7115 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7116 }
7117 } else { "NONE" }
7118} catch { "ERROR:" + $_.Exception.Message }
7119"#;
7120 let output = Command::new("powershell")
7121 .args(["-NoProfile", "-Command", script])
7122 .output()
7123 .map_err(|e| format!("vpn: {e}"))?;
7124
7125 let raw = String::from_utf8_lossy(&output.stdout);
7126 let text = raw.trim();
7127
7128 if text == "NONE" {
7129 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7130 } else if text.starts_with("ERROR:") {
7131 out.push_str(&format!("Unable to query adapters: {text}\n"));
7132 } else {
7133 out.push_str("VPN adapters:\n\n");
7134 for line in text.lines() {
7135 let parts: Vec<&str> = line.splitn(4, '|').collect();
7136 if parts.len() >= 3 {
7137 let name = parts[0];
7138 let desc = parts[1];
7139 let status = parts[2];
7140 let media = parts.get(3).unwrap_or(&"unknown");
7141 let label = if status.trim() == "Up" {
7142 "CONNECTED"
7143 } else {
7144 "disconnected"
7145 };
7146 out.push_str(&format!(
7147 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
7148 ));
7149 }
7150 }
7151 }
7152
7153 let ras_script = r#"
7155try {
7156 $c = Get-VpnConnection -ErrorAction Stop
7157 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7158 else { "NO_RAS" }
7159} catch { "NO_RAS" }
7160"#;
7161 if let Ok(o) = Command::new("powershell")
7162 .args(["-NoProfile", "-Command", ras_script])
7163 .output()
7164 {
7165 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7166 if t != "NO_RAS" && !t.is_empty() {
7167 out.push_str("Windows VPN connections:\n");
7168 for line in t.lines() {
7169 let parts: Vec<&str> = line.splitn(3, '|').collect();
7170 if parts.len() >= 2 {
7171 let name = parts[0];
7172 let status = parts[1];
7173 let server = parts.get(2).unwrap_or(&"");
7174 out.push_str(&format!(" {name} → {server} [{status}]\n"));
7175 }
7176 }
7177 }
7178 }
7179 }
7180
7181 #[cfg(not(target_os = "windows"))]
7182 {
7183 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7184 let text = String::from_utf8_lossy(&o.stdout);
7185 let vpn_ifaces: Vec<&str> = text
7186 .lines()
7187 .filter(|l| {
7188 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7189 })
7190 .collect();
7191 if vpn_ifaces.is_empty() {
7192 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7193 } else {
7194 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
7195 for l in vpn_ifaces {
7196 out.push_str(&format!(" {}\n", l.trim()));
7197 }
7198 }
7199 }
7200 }
7201
7202 Ok(out.trim_end().to_string())
7203}
7204
7205fn inspect_proxy() -> Result<String, String> {
7208 let mut out = String::from("Host inspection: proxy\n\n");
7209
7210 #[cfg(target_os = "windows")]
7211 {
7212 let script = r#"
7213$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7214if ($ie) {
7215 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7216} else { "NONE" }
7217"#;
7218 if let Ok(o) = Command::new("powershell")
7219 .args(["-NoProfile", "-Command", script])
7220 .output()
7221 {
7222 let raw = String::from_utf8_lossy(&o.stdout);
7223 let text = raw.trim();
7224 if text != "NONE" && !text.is_empty() {
7225 let get = |key: &str| -> &str {
7226 text.split('|')
7227 .find(|s| s.starts_with(key))
7228 .and_then(|s| s.splitn(2, ':').nth(1))
7229 .unwrap_or("")
7230 };
7231 let enabled = get("ENABLE");
7232 let server = get("SERVER");
7233 let overrides = get("OVERRIDE");
7234 out.push_str("WinINET / IE proxy:\n");
7235 out.push_str(&format!(
7236 " Enabled: {}\n",
7237 if enabled == "1" { "yes" } else { "no" }
7238 ));
7239 if !server.is_empty() && server != "None" {
7240 out.push_str(&format!(" Proxy server: {server}\n"));
7241 }
7242 if !overrides.is_empty() && overrides != "None" {
7243 out.push_str(&format!(" Bypass list: {overrides}\n"));
7244 }
7245 out.push('\n');
7246 }
7247 }
7248
7249 if let Ok(o) = Command::new("netsh")
7250 .args(["winhttp", "show", "proxy"])
7251 .output()
7252 {
7253 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7254 out.push_str("WinHTTP proxy:\n");
7255 for line in text.lines() {
7256 let l = line.trim();
7257 if !l.is_empty() {
7258 out.push_str(&format!(" {l}\n"));
7259 }
7260 }
7261 out.push('\n');
7262 }
7263
7264 let mut env_found = false;
7265 for var in &[
7266 "http_proxy",
7267 "https_proxy",
7268 "HTTP_PROXY",
7269 "HTTPS_PROXY",
7270 "no_proxy",
7271 "NO_PROXY",
7272 ] {
7273 if let Ok(val) = std::env::var(var) {
7274 if !env_found {
7275 out.push_str("Environment proxy variables:\n");
7276 env_found = true;
7277 }
7278 out.push_str(&format!(" {var}: {val}\n"));
7279 }
7280 }
7281 if !env_found {
7282 out.push_str("No proxy environment variables set.\n");
7283 }
7284 }
7285
7286 #[cfg(not(target_os = "windows"))]
7287 {
7288 let mut found = false;
7289 for var in &[
7290 "http_proxy",
7291 "https_proxy",
7292 "HTTP_PROXY",
7293 "HTTPS_PROXY",
7294 "no_proxy",
7295 "NO_PROXY",
7296 "ALL_PROXY",
7297 "all_proxy",
7298 ] {
7299 if let Ok(val) = std::env::var(var) {
7300 if !found {
7301 out.push_str("Proxy environment variables:\n");
7302 found = true;
7303 }
7304 out.push_str(&format!(" {var}: {val}\n"));
7305 }
7306 }
7307 if !found {
7308 out.push_str("No proxy environment variables set.\n");
7309 }
7310 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7311 let proxy_lines: Vec<&str> = content
7312 .lines()
7313 .filter(|l| l.to_lowercase().contains("proxy"))
7314 .collect();
7315 if !proxy_lines.is_empty() {
7316 out.push_str("\nSystem proxy (/etc/environment):\n");
7317 for l in proxy_lines {
7318 out.push_str(&format!(" {l}\n"));
7319 }
7320 }
7321 }
7322 }
7323
7324 Ok(out.trim_end().to_string())
7325}
7326
7327fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7330 let mut out = String::from("Host inspection: firewall_rules\n\n");
7331 let n = max_entries.clamp(1, 20);
7332
7333 #[cfg(target_os = "windows")]
7334 {
7335 let script = format!(
7336 r#"
7337try {{
7338 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7339 Where-Object {{
7340 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7341 $_.Owner -eq $null
7342 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7343 "TOTAL:" + $rules.Count
7344 $rules | ForEach-Object {{
7345 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7346 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7347 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7348 }}
7349}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7350 );
7351
7352 let output = Command::new("powershell")
7353 .args(["-NoProfile", "-Command", &script])
7354 .output()
7355 .map_err(|e| format!("firewall_rules: {e}"))?;
7356
7357 let raw = String::from_utf8_lossy(&output.stdout);
7358 let text = raw.trim();
7359
7360 if text.starts_with("ERROR:") {
7361 out.push_str(&format!(
7362 "Unable to query firewall rules: {}\n",
7363 text.trim_start_matches("ERROR:").trim()
7364 ));
7365 out.push_str("This query may require running as administrator.\n");
7366 } else if text.is_empty() {
7367 out.push_str("No non-default enabled firewall rules found.\n");
7368 } else {
7369 let mut total = 0usize;
7370 for line in text.lines() {
7371 if let Some(rest) = line.strip_prefix("TOTAL:") {
7372 total = rest.trim().parse().unwrap_or(0);
7373 out.push_str(&format!(
7374 "Non-default enabled rules (showing up to {n}):\n\n"
7375 ));
7376 } else {
7377 let parts: Vec<&str> = line.splitn(4, '|').collect();
7378 if parts.len() >= 3 {
7379 let name = parts[0];
7380 let dir = parts[1];
7381 let action = parts[2];
7382 let profile = parts.get(3).unwrap_or(&"Any");
7383 let icon = if action == "Block" { "[!]" } else { " " };
7384 out.push_str(&format!(
7385 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
7386 ));
7387 }
7388 }
7389 }
7390 if total == 0 {
7391 out.push_str("No non-default enabled rules found.\n");
7392 }
7393 }
7394 }
7395
7396 #[cfg(not(target_os = "windows"))]
7397 {
7398 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7399 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7400 if !text.is_empty() {
7401 out.push_str(&text);
7402 out.push('\n');
7403 }
7404 } else if let Ok(o) = Command::new("iptables")
7405 .args(["-L", "-n", "--line-numbers"])
7406 .output()
7407 {
7408 let text = String::from_utf8_lossy(&o.stdout);
7409 for l in text.lines().take(n * 2) {
7410 out.push_str(&format!(" {l}\n"));
7411 }
7412 } else {
7413 out.push_str("ufw and iptables not available or insufficient permissions.\n");
7414 }
7415 }
7416
7417 Ok(out.trim_end().to_string())
7418}
7419
7420fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7423 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7424 let hops = max_entries.clamp(5, 30);
7425
7426 #[cfg(target_os = "windows")]
7427 {
7428 let output = Command::new("tracert")
7429 .args(["-d", "-h", &hops.to_string(), host])
7430 .output()
7431 .map_err(|e| format!("tracert: {e}"))?;
7432 let raw = String::from_utf8_lossy(&output.stdout);
7433 let mut hop_count = 0usize;
7434 for line in raw.lines() {
7435 let trimmed = line.trim();
7436 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7437 hop_count += 1;
7438 out.push_str(&format!(" {trimmed}\n"));
7439 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7440 out.push_str(&format!("{trimmed}\n"));
7441 }
7442 }
7443 if hop_count == 0 {
7444 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7445 }
7446 }
7447
7448 #[cfg(not(target_os = "windows"))]
7449 {
7450 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7451 || std::path::Path::new("/usr/sbin/traceroute").exists()
7452 {
7453 "traceroute"
7454 } else {
7455 "tracepath"
7456 };
7457 let output = Command::new(cmd)
7458 .args(["-m", &hops.to_string(), "-n", host])
7459 .output()
7460 .map_err(|e| format!("{cmd}: {e}"))?;
7461 let raw = String::from_utf8_lossy(&output.stdout);
7462 let mut hop_count = 0usize;
7463 for line in raw.lines().take(hops + 2) {
7464 let trimmed = line.trim();
7465 if !trimmed.is_empty() {
7466 hop_count += 1;
7467 out.push_str(&format!(" {trimmed}\n"));
7468 }
7469 }
7470 if hop_count == 0 {
7471 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7472 }
7473 }
7474
7475 Ok(out.trim_end().to_string())
7476}
7477
7478fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7481 let mut out = String::from("Host inspection: dns_cache\n\n");
7482 let n = max_entries.clamp(10, 100);
7483
7484 #[cfg(target_os = "windows")]
7485 {
7486 let output = Command::new("powershell")
7487 .args([
7488 "-NoProfile",
7489 "-Command",
7490 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7491 ])
7492 .output()
7493 .map_err(|e| format!("dns_cache: {e}"))?;
7494
7495 let raw = String::from_utf8_lossy(&output.stdout);
7496 let lines: Vec<&str> = raw.lines().skip(1).collect();
7497 let total = lines.len();
7498
7499 if total == 0 {
7500 out.push_str("DNS cache is empty or could not be read.\n");
7501 } else {
7502 out.push_str(&format!(
7503 "DNS cache entries (showing up to {n} of {total}):\n\n"
7504 ));
7505 let mut shown = 0usize;
7506 for line in lines.iter().take(n) {
7507 let cols: Vec<&str> = line.splitn(4, ',').collect();
7508 if cols.len() >= 3 {
7509 let entry = cols[0].trim_matches('"');
7510 let rtype = cols[1].trim_matches('"');
7511 let data = cols[2].trim_matches('"');
7512 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
7513 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
7514 shown += 1;
7515 }
7516 }
7517 if total > shown {
7518 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
7519 }
7520 }
7521 }
7522
7523 #[cfg(not(target_os = "windows"))]
7524 {
7525 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7526 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7527 if !text.is_empty() {
7528 out.push_str("systemd-resolved statistics:\n");
7529 for line in text.lines().take(n) {
7530 out.push_str(&format!(" {line}\n"));
7531 }
7532 out.push('\n');
7533 }
7534 }
7535 if let Ok(o) = Command::new("dscacheutil")
7536 .args(["-cachedump", "-entries", "Host"])
7537 .output()
7538 {
7539 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7540 if !text.is_empty() {
7541 out.push_str("DNS cache (macOS dscacheutil):\n");
7542 for line in text.lines().take(n) {
7543 out.push_str(&format!(" {line}\n"));
7544 }
7545 } else {
7546 out.push_str("DNS cache is empty or not accessible on this platform.\n");
7547 }
7548 } else {
7549 out.push_str(
7550 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7551 );
7552 }
7553 }
7554
7555 Ok(out.trim_end().to_string())
7556}
7557
7558fn inspect_arp() -> Result<String, String> {
7561 let mut out = String::from("Host inspection: arp\n\n");
7562
7563 #[cfg(target_os = "windows")]
7564 {
7565 let output = Command::new("arp")
7566 .args(["-a"])
7567 .output()
7568 .map_err(|e| format!("arp: {e}"))?;
7569 let raw = String::from_utf8_lossy(&output.stdout);
7570 let mut count = 0usize;
7571 for line in raw.lines() {
7572 let t = line.trim();
7573 if t.is_empty() {
7574 continue;
7575 }
7576 out.push_str(&format!(" {t}\n"));
7577 if t.contains("dynamic") || t.contains("static") {
7578 count += 1;
7579 }
7580 }
7581 out.push_str(&format!("\nTotal entries: {count}\n"));
7582 }
7583
7584 #[cfg(not(target_os = "windows"))]
7585 {
7586 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7587 let raw = String::from_utf8_lossy(&o.stdout);
7588 let mut count = 0usize;
7589 for line in raw.lines() {
7590 let t = line.trim();
7591 if !t.is_empty() {
7592 out.push_str(&format!(" {t}\n"));
7593 count += 1;
7594 }
7595 }
7596 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7597 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7598 let raw = String::from_utf8_lossy(&o.stdout);
7599 let mut count = 0usize;
7600 for line in raw.lines() {
7601 let t = line.trim();
7602 if !t.is_empty() {
7603 out.push_str(&format!(" {t}\n"));
7604 count += 1;
7605 }
7606 }
7607 out.push_str(&format!("\nTotal entries: {count}\n"));
7608 } else {
7609 out.push_str("arp and ip neigh not available.\n");
7610 }
7611 }
7612
7613 Ok(out.trim_end().to_string())
7614}
7615
7616fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7619 let mut out = String::from("Host inspection: route_table\n\n");
7620 let n = max_entries.clamp(10, 50);
7621
7622 #[cfg(target_os = "windows")]
7623 {
7624 let script = r#"
7625try {
7626 $routes = Get-NetRoute -ErrorAction Stop |
7627 Where-Object { $_.RouteMetric -lt 9000 } |
7628 Sort-Object RouteMetric |
7629 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7630 "TOTAL:" + $routes.Count
7631 $routes | ForEach-Object {
7632 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7633 }
7634} catch { "ERROR:" + $_.Exception.Message }
7635"#;
7636 let output = Command::new("powershell")
7637 .args(["-NoProfile", "-Command", script])
7638 .output()
7639 .map_err(|e| format!("route_table: {e}"))?;
7640 let raw = String::from_utf8_lossy(&output.stdout);
7641 let text = raw.trim();
7642
7643 if text.starts_with("ERROR:") {
7644 out.push_str(&format!(
7645 "Unable to read route table: {}\n",
7646 text.trim_start_matches("ERROR:").trim()
7647 ));
7648 } else {
7649 let mut shown = 0usize;
7650 for line in text.lines() {
7651 if let Some(rest) = line.strip_prefix("TOTAL:") {
7652 let total: usize = rest.trim().parse().unwrap_or(0);
7653 out.push_str(&format!(
7654 "Routing table (showing up to {n} of {total} routes):\n\n"
7655 ));
7656 out.push_str(&format!(
7657 " {:<22} {:<18} {:>8} Interface\n",
7658 "Destination", "Next Hop", "Metric"
7659 ));
7660 out.push_str(&format!(" {}\n", "-".repeat(70)));
7661 } else if shown < n {
7662 let parts: Vec<&str> = line.splitn(4, '|').collect();
7663 if parts.len() == 4 {
7664 let dest = parts[0];
7665 let hop =
7666 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7667 "on-link"
7668 } else {
7669 parts[1]
7670 };
7671 let metric = parts[2];
7672 let iface = parts[3];
7673 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
7674 shown += 1;
7675 }
7676 }
7677 }
7678 }
7679 }
7680
7681 #[cfg(not(target_os = "windows"))]
7682 {
7683 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7684 let raw = String::from_utf8_lossy(&o.stdout);
7685 let lines: Vec<&str> = raw.lines().collect();
7686 let total = lines.len();
7687 out.push_str(&format!(
7688 "Routing table (showing up to {n} of {total} routes):\n\n"
7689 ));
7690 for line in lines.iter().take(n) {
7691 out.push_str(&format!(" {line}\n"));
7692 }
7693 if total > n {
7694 out.push_str(&format!("\n ... and {} more routes\n", total - n));
7695 }
7696 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7697 let raw = String::from_utf8_lossy(&o.stdout);
7698 for line in raw.lines().take(n) {
7699 out.push_str(&format!(" {line}\n"));
7700 }
7701 } else {
7702 out.push_str("ip route and netstat not available.\n");
7703 }
7704 }
7705
7706 Ok(out.trim_end().to_string())
7707}
7708
7709fn inspect_env(max_entries: usize) -> Result<String, String> {
7712 let mut out = String::from("Host inspection: env\n\n");
7713 let n = max_entries.clamp(10, 50);
7714
7715 fn looks_like_secret(name: &str) -> bool {
7716 let n = name.to_uppercase();
7717 n.contains("KEY")
7718 || n.contains("SECRET")
7719 || n.contains("TOKEN")
7720 || n.contains("PASSWORD")
7721 || n.contains("PASSWD")
7722 || n.contains("CREDENTIAL")
7723 || n.contains("AUTH")
7724 || n.contains("CERT")
7725 || n.contains("PRIVATE")
7726 }
7727
7728 let known_dev_vars: &[&str] = &[
7729 "CARGO_HOME",
7730 "RUSTUP_HOME",
7731 "GOPATH",
7732 "GOROOT",
7733 "GOBIN",
7734 "JAVA_HOME",
7735 "ANDROID_HOME",
7736 "ANDROID_SDK_ROOT",
7737 "PYTHONPATH",
7738 "PYTHONHOME",
7739 "VIRTUAL_ENV",
7740 "CONDA_DEFAULT_ENV",
7741 "CONDA_PREFIX",
7742 "NODE_PATH",
7743 "NVM_DIR",
7744 "NVM_BIN",
7745 "PNPM_HOME",
7746 "DENO_INSTALL",
7747 "DENO_DIR",
7748 "DOTNET_ROOT",
7749 "NUGET_PACKAGES",
7750 "CMAKE_HOME",
7751 "VCPKG_ROOT",
7752 "AWS_PROFILE",
7753 "AWS_REGION",
7754 "AWS_DEFAULT_REGION",
7755 "GCP_PROJECT",
7756 "GOOGLE_CLOUD_PROJECT",
7757 "GOOGLE_APPLICATION_CREDENTIALS",
7758 "AZURE_SUBSCRIPTION_ID",
7759 "DATABASE_URL",
7760 "REDIS_URL",
7761 "MONGO_URI",
7762 "EDITOR",
7763 "VISUAL",
7764 "SHELL",
7765 "TERM",
7766 "XDG_CONFIG_HOME",
7767 "XDG_DATA_HOME",
7768 "XDG_CACHE_HOME",
7769 "HOME",
7770 "USERPROFILE",
7771 "APPDATA",
7772 "LOCALAPPDATA",
7773 "TEMP",
7774 "TMP",
7775 "COMPUTERNAME",
7776 "USERNAME",
7777 "USERDOMAIN",
7778 "PROCESSOR_ARCHITECTURE",
7779 "NUMBER_OF_PROCESSORS",
7780 "OS",
7781 "HOMEDRIVE",
7782 "HOMEPATH",
7783 "HTTP_PROXY",
7784 "HTTPS_PROXY",
7785 "NO_PROXY",
7786 "ALL_PROXY",
7787 "http_proxy",
7788 "https_proxy",
7789 "no_proxy",
7790 "DOCKER_HOST",
7791 "DOCKER_BUILDKIT",
7792 "COMPOSE_PROJECT_NAME",
7793 "KUBECONFIG",
7794 "KUBE_CONTEXT",
7795 "CI",
7796 "GITHUB_ACTIONS",
7797 "GITLAB_CI",
7798 "LMSTUDIO_HOME",
7799 "HEMATITE_URL",
7800 ];
7801
7802 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7803 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7804 let total = all_vars.len();
7805
7806 let mut dev_found: Vec<String> = Vec::new();
7807 let mut secret_found: Vec<String> = Vec::new();
7808
7809 for (k, v) in &all_vars {
7810 if k == "PATH" {
7811 continue;
7812 }
7813 if looks_like_secret(k) {
7814 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7815 } else {
7816 let k_upper = k.to_uppercase();
7817 let is_known = known_dev_vars
7818 .iter()
7819 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7820 if is_known {
7821 let display = if v.len() > 120 {
7822 format!("{k} = {}…", &v[..117])
7823 } else {
7824 format!("{k} = {v}")
7825 };
7826 dev_found.push(display);
7827 }
7828 }
7829 }
7830
7831 out.push_str(&format!("Total environment variables: {total}\n\n"));
7832
7833 if let Ok(p) = std::env::var("PATH") {
7834 let sep = if cfg!(target_os = "windows") {
7835 ';'
7836 } else {
7837 ':'
7838 };
7839 let count = p.split(sep).count();
7840 out.push_str(&format!(
7841 "PATH: {count} entries (use topic=path for full audit)\n\n"
7842 ));
7843 }
7844
7845 if !secret_found.is_empty() {
7846 out.push_str(&format!(
7847 "=== Secret/credential variables ({} detected, values hidden) ===\n",
7848 secret_found.len()
7849 ));
7850 for s in secret_found.iter().take(n) {
7851 out.push_str(&format!(" {s}\n"));
7852 }
7853 out.push('\n');
7854 }
7855
7856 if !dev_found.is_empty() {
7857 out.push_str(&format!(
7858 "=== Developer & tool variables ({}) ===\n",
7859 dev_found.len()
7860 ));
7861 for d in dev_found.iter().take(n) {
7862 out.push_str(&format!(" {d}\n"));
7863 }
7864 out.push('\n');
7865 }
7866
7867 let other_count = all_vars
7868 .iter()
7869 .filter(|(k, _)| {
7870 k != "PATH"
7871 && !looks_like_secret(k)
7872 && !known_dev_vars
7873 .iter()
7874 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7875 })
7876 .count();
7877 if other_count > 0 {
7878 out.push_str(&format!(
7879 "Other variables: {other_count} (use 'env' in shell to see all)\n"
7880 ));
7881 }
7882
7883 Ok(out.trim_end().to_string())
7884}
7885
7886fn inspect_hosts_file() -> Result<String, String> {
7889 let mut out = String::from("Host inspection: hosts_file\n\n");
7890
7891 let hosts_path = if cfg!(target_os = "windows") {
7892 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7893 } else {
7894 std::path::PathBuf::from("/etc/hosts")
7895 };
7896
7897 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7898
7899 match fs::read_to_string(&hosts_path) {
7900 Ok(content) => {
7901 let mut active_entries: Vec<String> = Vec::new();
7902 let mut comment_lines = 0usize;
7903 let mut blank_lines = 0usize;
7904
7905 for line in content.lines() {
7906 let t = line.trim();
7907 if t.is_empty() {
7908 blank_lines += 1;
7909 } else if t.starts_with('#') {
7910 comment_lines += 1;
7911 } else {
7912 active_entries.push(line.to_string());
7913 }
7914 }
7915
7916 out.push_str(&format!(
7917 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7918 active_entries.len(),
7919 comment_lines,
7920 blank_lines
7921 ));
7922
7923 if active_entries.is_empty() {
7924 out.push_str(
7925 "No active host entries (file contains only comments/blanks — standard default state).\n",
7926 );
7927 } else {
7928 out.push_str("=== Active entries ===\n");
7929 for entry in &active_entries {
7930 out.push_str(&format!(" {entry}\n"));
7931 }
7932 out.push('\n');
7933
7934 let custom: Vec<&String> = active_entries
7935 .iter()
7936 .filter(|e| {
7937 let t = e.trim_start();
7938 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7939 })
7940 .collect();
7941 if !custom.is_empty() {
7942 out.push_str(&format!(
7943 "[!] Custom (non-loopback) entries: {}\n",
7944 custom.len()
7945 ));
7946 for e in &custom {
7947 out.push_str(&format!(" {e}\n"));
7948 }
7949 } else {
7950 out.push_str("All active entries are standard loopback or block entries.\n");
7951 }
7952 }
7953
7954 out.push_str("\n=== Full file ===\n");
7955 for line in content.lines() {
7956 out.push_str(&format!(" {line}\n"));
7957 }
7958 }
7959 Err(e) => {
7960 out.push_str(&format!("Could not read hosts file: {e}\n"));
7961 if cfg!(target_os = "windows") {
7962 out.push_str(
7963 "On Windows, run Hematite as Administrator if permission is denied.\n",
7964 );
7965 }
7966 }
7967 }
7968
7969 Ok(out.trim_end().to_string())
7970}
7971
7972struct AuditFinding {
7975 finding: String,
7976 impact: String,
7977 fix: String,
7978}
7979
7980#[cfg(target_os = "windows")]
7981#[derive(Debug, Clone)]
7982struct WindowsPnpDevice {
7983 name: String,
7984 status: String,
7985 problem: Option<u64>,
7986 class_name: Option<String>,
7987 instance_id: Option<String>,
7988}
7989
7990#[cfg(target_os = "windows")]
7991#[derive(Debug, Clone)]
7992struct WindowsSoundDevice {
7993 name: String,
7994 status: String,
7995 manufacturer: Option<String>,
7996}
7997
7998struct DockerMountAudit {
7999 mount_type: String,
8000 source: Option<String>,
8001 destination: String,
8002 name: Option<String>,
8003 read_write: Option<bool>,
8004 driver: Option<String>,
8005 exists_on_host: Option<bool>,
8006}
8007
8008struct DockerContainerAudit {
8009 name: String,
8010 image: String,
8011 status: String,
8012 mounts: Vec<DockerMountAudit>,
8013}
8014
8015struct DockerVolumeAudit {
8016 name: String,
8017 driver: String,
8018 mountpoint: Option<String>,
8019 scope: Option<String>,
8020}
8021
8022#[cfg(target_os = "windows")]
8023struct WslDistroAudit {
8024 name: String,
8025 state: String,
8026 version: String,
8027}
8028
8029#[cfg(target_os = "windows")]
8030struct WslRootUsage {
8031 total_kb: u64,
8032 used_kb: u64,
8033 avail_kb: u64,
8034 use_percent: String,
8035 mnt_c_present: Option<bool>,
8036}
8037
8038fn docker_engine_version() -> Result<String, String> {
8039 let version_output = Command::new("docker")
8040 .args(["version", "--format", "{{.Server.Version}}"])
8041 .output();
8042
8043 match version_output {
8044 Err(_) => Err(
8045 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8046 ),
8047 Ok(o) if !o.status.success() => {
8048 let stderr = String::from_utf8_lossy(&o.stderr);
8049 if stderr.contains("cannot connect")
8050 || stderr.contains("Is the docker daemon running")
8051 || stderr.contains("pipe")
8052 || stderr.contains("socket")
8053 {
8054 Err(
8055 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8056 )
8057 } else {
8058 Err(format!("Docker: error - {}", stderr.trim()))
8059 }
8060 }
8061 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8062 }
8063}
8064
8065fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8066 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8067 return Vec::new();
8068 };
8069 let Value::Array(entries) = value else {
8070 return Vec::new();
8071 };
8072
8073 let mut mounts = Vec::new();
8074 for entry in entries {
8075 let mount_type = entry
8076 .get("Type")
8077 .and_then(|v| v.as_str())
8078 .unwrap_or("unknown")
8079 .to_string();
8080 let source = entry
8081 .get("Source")
8082 .and_then(|v| v.as_str())
8083 .map(|v| v.to_string());
8084 let destination = entry
8085 .get("Destination")
8086 .and_then(|v| v.as_str())
8087 .unwrap_or("?")
8088 .to_string();
8089 let name = entry
8090 .get("Name")
8091 .and_then(|v| v.as_str())
8092 .map(|v| v.to_string());
8093 let read_write = entry.get("RW").and_then(|v| v.as_bool());
8094 let driver = entry
8095 .get("Driver")
8096 .and_then(|v| v.as_str())
8097 .map(|v| v.to_string());
8098 let exists_on_host = if mount_type == "bind" {
8099 source.as_deref().map(|path| Path::new(path).exists())
8100 } else {
8101 None
8102 };
8103 mounts.push(DockerMountAudit {
8104 mount_type,
8105 source,
8106 destination,
8107 name,
8108 read_write,
8109 driver,
8110 exists_on_host,
8111 });
8112 }
8113
8114 mounts
8115}
8116
8117fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8118 let mut audit = DockerVolumeAudit {
8119 name: name.to_string(),
8120 driver: "unknown".to_string(),
8121 mountpoint: None,
8122 scope: None,
8123 };
8124
8125 if let Ok(output) = Command::new("docker")
8126 .args(["volume", "inspect", name, "--format", "{{json .}}"])
8127 .output()
8128 {
8129 if output.status.success() {
8130 if let Ok(value) =
8131 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8132 {
8133 audit.driver = value
8134 .get("Driver")
8135 .and_then(|v| v.as_str())
8136 .unwrap_or("unknown")
8137 .to_string();
8138 audit.mountpoint = value
8139 .get("Mountpoint")
8140 .and_then(|v| v.as_str())
8141 .map(|v| v.to_string());
8142 audit.scope = value
8143 .get("Scope")
8144 .and_then(|v| v.as_str())
8145 .map(|v| v.to_string());
8146 }
8147 }
8148 }
8149
8150 audit
8151}
8152
8153#[cfg(target_os = "windows")]
8154fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8155 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8156 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8157 let path = local_app_data
8158 .join("Docker")
8159 .join("wsl")
8160 .join("disk")
8161 .join(file_name);
8162 if let Ok(metadata) = fs::metadata(&path) {
8163 return Some((path, metadata.len()));
8164 }
8165 }
8166 None
8167}
8168
8169#[cfg(target_os = "windows")]
8170fn clean_wsl_text(raw: &[u8]) -> String {
8171 String::from_utf8_lossy(raw)
8172 .chars()
8173 .filter(|c| *c != '\0')
8174 .collect()
8175}
8176
8177#[cfg(target_os = "windows")]
8178fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8179 let mut distros = Vec::new();
8180 for line in raw.lines() {
8181 let trimmed = line.trim();
8182 if trimmed.is_empty()
8183 || trimmed.to_uppercase().starts_with("NAME")
8184 || trimmed.starts_with("---")
8185 {
8186 continue;
8187 }
8188 let normalized = trimmed.trim_start_matches('*').trim();
8189 let cols: Vec<&str> = normalized.split_whitespace().collect();
8190 if cols.len() < 3 {
8191 continue;
8192 }
8193 let version = cols[cols.len() - 1].to_string();
8194 let state = cols[cols.len() - 2].to_string();
8195 let name = cols[..cols.len() - 2].join(" ");
8196 if !name.is_empty() {
8197 distros.push(WslDistroAudit {
8198 name,
8199 state,
8200 version,
8201 });
8202 }
8203 }
8204 distros
8205}
8206
8207#[cfg(target_os = "windows")]
8208fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8209 let output = Command::new("wsl")
8210 .args([
8211 "-d",
8212 distro_name,
8213 "--",
8214 "sh",
8215 "-lc",
8216 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8217 ])
8218 .output()
8219 .ok()?;
8220 if !output.status.success() {
8221 return None;
8222 }
8223
8224 let text = clean_wsl_text(&output.stdout);
8225 let mut total_kb = 0;
8226 let mut used_kb = 0;
8227 let mut avail_kb = 0;
8228 let mut use_percent = String::from("unknown");
8229 let mut mnt_c_present = None;
8230
8231 for line in text.lines() {
8232 let trimmed = line.trim();
8233 if trimmed.starts_with("__MNTC__:") {
8234 mnt_c_present = Some(trimmed.ends_with("ok"));
8235 continue;
8236 }
8237 let cols: Vec<&str> = trimmed.split_whitespace().collect();
8238 if cols.len() >= 6 {
8239 total_kb = cols[1].parse::<u64>().unwrap_or(0);
8240 used_kb = cols[2].parse::<u64>().unwrap_or(0);
8241 avail_kb = cols[3].parse::<u64>().unwrap_or(0);
8242 use_percent = cols[4].to_string();
8243 }
8244 }
8245
8246 Some(WslRootUsage {
8247 total_kb,
8248 used_kb,
8249 avail_kb,
8250 use_percent,
8251 mnt_c_present,
8252 })
8253}
8254
8255#[cfg(target_os = "windows")]
8256fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8257 let mut vhds = Vec::new();
8258 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8259 return vhds;
8260 };
8261 let packages_dir = local_app_data.join("Packages");
8262 let Ok(entries) = fs::read_dir(packages_dir) else {
8263 return vhds;
8264 };
8265
8266 for entry in entries.flatten() {
8267 let path = entry.path().join("LocalState").join("ext4.vhdx");
8268 if let Ok(metadata) = fs::metadata(&path) {
8269 vhds.push((path, metadata.len()));
8270 }
8271 }
8272 vhds.sort_by(|a, b| b.1.cmp(&a.1));
8273 vhds
8274}
8275
8276fn inspect_docker(max_entries: usize) -> Result<String, String> {
8277 let mut out = String::from("Host inspection: docker\n\n");
8278 let n = max_entries.clamp(5, 25);
8279
8280 let version_output = Command::new("docker")
8281 .args(["version", "--format", "{{.Server.Version}}"])
8282 .output();
8283
8284 match version_output {
8285 Err(_) => {
8286 out.push_str("Docker: not found on PATH.\n");
8287 out.push_str(
8288 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8289 );
8290 return Ok(out.trim_end().to_string());
8291 }
8292 Ok(o) if !o.status.success() => {
8293 let stderr = String::from_utf8_lossy(&o.stderr);
8294 if stderr.contains("cannot connect")
8295 || stderr.contains("Is the docker daemon running")
8296 || stderr.contains("pipe")
8297 || stderr.contains("socket")
8298 {
8299 out.push_str("Docker: installed but daemon is NOT running.\n");
8300 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8301 } else {
8302 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
8303 }
8304 return Ok(out.trim_end().to_string());
8305 }
8306 Ok(o) => {
8307 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8308 out.push_str(&format!("Docker Engine: {version}\n"));
8309 }
8310 }
8311
8312 if let Ok(o) = Command::new("docker")
8313 .args([
8314 "info",
8315 "--format",
8316 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8317 ])
8318 .output()
8319 {
8320 let info = String::from_utf8_lossy(&o.stdout);
8321 for line in info.lines() {
8322 let t = line.trim();
8323 if !t.is_empty() {
8324 out.push_str(&format!(" {t}\n"));
8325 }
8326 }
8327 out.push('\n');
8328 }
8329
8330 if let Ok(o) = Command::new("docker")
8331 .args([
8332 "ps",
8333 "--format",
8334 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8335 ])
8336 .output()
8337 {
8338 let raw = String::from_utf8_lossy(&o.stdout);
8339 let lines: Vec<&str> = raw.lines().collect();
8340 if lines.len() <= 1 {
8341 out.push_str("Running containers: none\n\n");
8342 } else {
8343 out.push_str(&format!(
8344 "=== Running containers ({}) ===\n",
8345 lines.len().saturating_sub(1)
8346 ));
8347 for line in lines.iter().take(n + 1) {
8348 out.push_str(&format!(" {line}\n"));
8349 }
8350 if lines.len() > n + 1 {
8351 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
8352 }
8353 out.push('\n');
8354 }
8355 }
8356
8357 if let Ok(o) = Command::new("docker")
8358 .args([
8359 "images",
8360 "--format",
8361 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8362 ])
8363 .output()
8364 {
8365 let raw = String::from_utf8_lossy(&o.stdout);
8366 let lines: Vec<&str> = raw.lines().collect();
8367 if lines.len() > 1 {
8368 out.push_str(&format!(
8369 "=== Local images ({}) ===\n",
8370 lines.len().saturating_sub(1)
8371 ));
8372 for line in lines.iter().take(n + 1) {
8373 out.push_str(&format!(" {line}\n"));
8374 }
8375 if lines.len() > n + 1 {
8376 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
8377 }
8378 out.push('\n');
8379 }
8380 }
8381
8382 if let Ok(o) = Command::new("docker")
8383 .args([
8384 "compose",
8385 "ls",
8386 "--format",
8387 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8388 ])
8389 .output()
8390 {
8391 let raw = String::from_utf8_lossy(&o.stdout);
8392 let lines: Vec<&str> = raw.lines().collect();
8393 if lines.len() > 1 {
8394 out.push_str(&format!(
8395 "=== Compose projects ({}) ===\n",
8396 lines.len().saturating_sub(1)
8397 ));
8398 for line in lines.iter().take(n + 1) {
8399 out.push_str(&format!(" {line}\n"));
8400 }
8401 out.push('\n');
8402 }
8403 }
8404
8405 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8406 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8407 if !ctx.is_empty() {
8408 out.push_str(&format!("Active context: {ctx}\n"));
8409 }
8410 }
8411
8412 Ok(out.trim_end().to_string())
8413}
8414
8415fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8418 let mut out = String::from("Host inspection: docker_filesystems\n\n");
8419 let n = max_entries.clamp(3, 12);
8420
8421 match docker_engine_version() {
8422 Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
8423 Err(message) => {
8424 out.push_str(&message);
8425 return Ok(out.trim_end().to_string());
8426 }
8427 }
8428
8429 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8430 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8431 if !ctx.is_empty() {
8432 out.push_str(&format!("Active context: {ctx}\n"));
8433 }
8434 }
8435 out.push('\n');
8436
8437 let mut containers = Vec::new();
8438 if let Ok(o) = Command::new("docker")
8439 .args([
8440 "ps",
8441 "-a",
8442 "--format",
8443 "{{.Names}}\t{{.Image}}\t{{.Status}}",
8444 ])
8445 .output()
8446 {
8447 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8448 let cols: Vec<&str> = line.split('\t').collect();
8449 if cols.len() < 3 {
8450 continue;
8451 }
8452 let name = cols[0].trim().to_string();
8453 if name.is_empty() {
8454 continue;
8455 }
8456 let inspect_output = Command::new("docker")
8457 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8458 .output();
8459 let mounts = match inspect_output {
8460 Ok(result) if result.status.success() => {
8461 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8462 }
8463 _ => Vec::new(),
8464 };
8465 containers.push(DockerContainerAudit {
8466 name,
8467 image: cols[1].trim().to_string(),
8468 status: cols[2].trim().to_string(),
8469 mounts,
8470 });
8471 }
8472 }
8473
8474 let mut volumes = Vec::new();
8475 if let Ok(o) = Command::new("docker")
8476 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8477 .output()
8478 {
8479 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8480 let cols: Vec<&str> = line.split('\t').collect();
8481 let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8482 continue;
8483 };
8484 let mut audit = inspect_docker_volume(name);
8485 if audit.driver == "unknown" {
8486 audit.driver = cols
8487 .get(1)
8488 .map(|v| v.trim())
8489 .filter(|v| !v.is_empty())
8490 .unwrap_or("unknown")
8491 .to_string();
8492 }
8493 volumes.push(audit);
8494 }
8495 }
8496
8497 let mut findings = Vec::new();
8498 for container in &containers {
8499 for mount in &container.mounts {
8500 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8501 let source = mount.source.as_deref().unwrap_or("<unknown>");
8502 findings.push(AuditFinding {
8503 finding: format!(
8504 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8505 container.name, source, mount.destination
8506 ),
8507 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8508 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8509 });
8510 }
8511 }
8512 }
8513
8514 #[cfg(target_os = "windows")]
8515 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8516 if size_bytes >= 20 * 1024 * 1024 * 1024 {
8517 findings.push(AuditFinding {
8518 finding: format!(
8519 "Docker Desktop disk image is large: {} at {}",
8520 human_bytes(size_bytes),
8521 path.display()
8522 ),
8523 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8524 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(),
8525 });
8526 }
8527 }
8528
8529 out.push_str("=== Findings ===\n");
8530 if findings.is_empty() {
8531 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8532 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8533 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8534 } else {
8535 for finding in &findings {
8536 out.push_str(&format!("- Finding: {}\n", finding.finding));
8537 out.push_str(&format!(" Impact: {}\n", finding.impact));
8538 out.push_str(&format!(" Fix: {}\n", finding.fix));
8539 }
8540 }
8541
8542 out.push_str("\n=== Container mount summary ===\n");
8543 if containers.is_empty() {
8544 out.push_str("- No containers found.\n");
8545 } else {
8546 for container in &containers {
8547 out.push_str(&format!(
8548 "- {} ({}) [{}]\n",
8549 container.name, container.image, container.status
8550 ));
8551 if container.mounts.is_empty() {
8552 out.push_str(" - no mounts reported\n");
8553 continue;
8554 }
8555 for mount in &container.mounts {
8556 let mut source = mount
8557 .name
8558 .clone()
8559 .or_else(|| mount.source.clone())
8560 .unwrap_or_else(|| "<unknown>".to_string());
8561 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8562 source.push_str(" [missing]");
8563 }
8564 let mut extras = Vec::new();
8565 if let Some(rw) = mount.read_write {
8566 extras.push(if rw { "rw" } else { "ro" }.to_string());
8567 }
8568 if let Some(driver) = &mount.driver {
8569 extras.push(format!("driver={driver}"));
8570 }
8571 let extra_suffix = if extras.is_empty() {
8572 String::new()
8573 } else {
8574 format!(" ({})", extras.join(", "))
8575 };
8576 out.push_str(&format!(
8577 " - {}: {} -> {}{}\n",
8578 mount.mount_type, source, mount.destination, extra_suffix
8579 ));
8580 }
8581 }
8582 }
8583
8584 out.push_str("\n=== Named volumes ===\n");
8585 if volumes.is_empty() {
8586 out.push_str("- No named volumes found.\n");
8587 } else {
8588 for volume in &volumes {
8589 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8590 if let Some(scope) = &volume.scope {
8591 detail.push_str(&format!(", scope: {scope}"));
8592 }
8593 if let Some(mountpoint) = &volume.mountpoint {
8594 detail.push_str(&format!(", mountpoint: {mountpoint}"));
8595 }
8596 out.push_str(&format!("{detail}\n"));
8597 }
8598 }
8599
8600 #[cfg(target_os = "windows")]
8601 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8602 out.push_str("\n=== Docker Desktop disk ===\n");
8603 out.push_str(&format!(
8604 "- {} at {}\n",
8605 human_bytes(size_bytes),
8606 path.display()
8607 ));
8608 }
8609
8610 Ok(out.trim_end().to_string())
8611}
8612
8613fn inspect_wsl() -> Result<String, String> {
8614 let mut out = String::from("Host inspection: wsl\n\n");
8615
8616 #[cfg(target_os = "windows")]
8617 {
8618 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8619 let raw = String::from_utf8_lossy(&o.stdout);
8620 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8621 for line in cleaned.lines().take(4) {
8622 let t = line.trim();
8623 if !t.is_empty() {
8624 out.push_str(&format!(" {t}\n"));
8625 }
8626 }
8627 out.push('\n');
8628 }
8629
8630 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8631 match list_output {
8632 Err(e) => {
8633 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8634 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8635 }
8636 Ok(o) if !o.status.success() => {
8637 let stderr = String::from_utf8_lossy(&o.stderr);
8638 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8639 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8640 out.push_str("Run: wsl --install\n");
8641 }
8642 Ok(o) => {
8643 let raw = String::from_utf8_lossy(&o.stdout);
8644 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8645 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8646 let distro_lines: Vec<&str> = lines
8647 .iter()
8648 .filter(|l| {
8649 let t = l.trim();
8650 !t.is_empty()
8651 && !t.to_uppercase().starts_with("NAME")
8652 && !t.starts_with("---")
8653 })
8654 .copied()
8655 .collect();
8656
8657 if distro_lines.is_empty() {
8658 out.push_str("WSL: installed but no distributions found.\n");
8659 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8660 } else {
8661 out.push_str("=== WSL Distributions ===\n");
8662 for line in &lines {
8663 out.push_str(&format!(" {}\n", line.trim()));
8664 }
8665 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8666 }
8667 }
8668 }
8669
8670 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8671 let raw = String::from_utf8_lossy(&o.stdout);
8672 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8673 let status_lines: Vec<&str> = cleaned
8674 .lines()
8675 .filter(|l| !l.trim().is_empty())
8676 .take(8)
8677 .collect();
8678 if !status_lines.is_empty() {
8679 out.push_str("\n=== WSL status ===\n");
8680 for line in status_lines {
8681 out.push_str(&format!(" {}\n", line.trim()));
8682 }
8683 }
8684 }
8685 }
8686
8687 #[cfg(not(target_os = "windows"))]
8688 {
8689 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8690 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8691 }
8692
8693 Ok(out.trim_end().to_string())
8694}
8695
8696fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8699 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8700
8701 #[cfg(target_os = "windows")]
8702 {
8703 let n = max_entries.clamp(3, 12);
8704 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8705 let distros = match list_output {
8706 Err(e) => {
8707 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8708 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8709 return Ok(out.trim_end().to_string());
8710 }
8711 Ok(o) if !o.status.success() => {
8712 let cleaned = clean_wsl_text(&o.stderr);
8713 out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8714 out.push_str("Run: wsl --install\n");
8715 return Ok(out.trim_end().to_string());
8716 }
8717 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8718 };
8719
8720 out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8721
8722 let vhdx_files = collect_wsl_vhdx_files();
8723 let mut findings = Vec::new();
8724 let mut live_usage = Vec::new();
8725
8726 for distro in distros.iter().take(n) {
8727 if distro.state.eq_ignore_ascii_case("Running") {
8728 if let Some(usage) = wsl_root_usage(&distro.name) {
8729 if let Some(false) = usage.mnt_c_present {
8730 findings.push(AuditFinding {
8731 finding: format!(
8732 "Distro '{}' is running without /mnt/c available",
8733 distro.name
8734 ),
8735 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8736 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8737 });
8738 }
8739
8740 let percent_num = usage
8741 .use_percent
8742 .trim_end_matches('%')
8743 .parse::<u32>()
8744 .unwrap_or(0);
8745 if percent_num >= 85 {
8746 findings.push(AuditFinding {
8747 finding: format!(
8748 "Distro '{}' root filesystem is {} full",
8749 distro.name, usage.use_percent
8750 ),
8751 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8752 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8753 });
8754 }
8755 live_usage.push((distro.name.clone(), usage));
8756 }
8757 }
8758 }
8759
8760 for (path, size_bytes) in vhdx_files.iter().take(n) {
8761 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8762 findings.push(AuditFinding {
8763 finding: format!(
8764 "Host-side WSL disk image is large: {} at {}",
8765 human_bytes(*size_bytes),
8766 path.display()
8767 ),
8768 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8769 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8770 });
8771 }
8772 }
8773
8774 out.push_str("=== Findings ===\n");
8775 if findings.is_empty() {
8776 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8777 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8778 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8779 } else {
8780 for finding in &findings {
8781 out.push_str(&format!("- Finding: {}\n", finding.finding));
8782 out.push_str(&format!(" Impact: {}\n", finding.impact));
8783 out.push_str(&format!(" Fix: {}\n", finding.fix));
8784 }
8785 }
8786
8787 out.push_str("\n=== Distro bridge and root usage ===\n");
8788 if distros.is_empty() {
8789 out.push_str("- No WSL distributions found.\n");
8790 } else {
8791 for distro in distros.iter().take(n) {
8792 out.push_str(&format!(
8793 "- {} [state: {}, version: {}]\n",
8794 distro.name, distro.state, distro.version
8795 ));
8796 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8797 out.push_str(&format!(
8798 " - rootfs: {} used / {} total ({}), free: {}\n",
8799 human_bytes(usage.used_kb * 1024),
8800 human_bytes(usage.total_kb * 1024),
8801 usage.use_percent,
8802 human_bytes(usage.avail_kb * 1024)
8803 ));
8804 match usage.mnt_c_present {
8805 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8806 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8807 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8808 }
8809 } else if distro.state.eq_ignore_ascii_case("Running") {
8810 out.push_str(" - live rootfs check: unavailable\n");
8811 } else {
8812 out.push_str(
8813 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8814 );
8815 }
8816 }
8817 }
8818
8819 out.push_str("\n=== Host-side VHDX files ===\n");
8820 if vhdx_files.is_empty() {
8821 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8822 } else {
8823 for (path, size_bytes) in vhdx_files.iter().take(n) {
8824 out.push_str(&format!(
8825 "- {} at {}\n",
8826 human_bytes(*size_bytes),
8827 path.display()
8828 ));
8829 }
8830 }
8831 }
8832
8833 #[cfg(not(target_os = "windows"))]
8834 {
8835 let _ = max_entries;
8836 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8837 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8838 }
8839
8840 Ok(out.trim_end().to_string())
8841}
8842
8843fn dirs_home() -> Option<PathBuf> {
8844 std::env::var("HOME")
8845 .ok()
8846 .map(PathBuf::from)
8847 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8848}
8849
8850fn inspect_ssh() -> Result<String, String> {
8851 let mut out = String::from("Host inspection: ssh\n\n");
8852
8853 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8854 let ver = if o.stdout.is_empty() {
8855 String::from_utf8_lossy(&o.stderr).trim().to_string()
8856 } else {
8857 String::from_utf8_lossy(&o.stdout).trim().to_string()
8858 };
8859 if !ver.is_empty() {
8860 out.push_str(&format!("SSH client: {ver}\n"));
8861 }
8862 } else {
8863 out.push_str("SSH client: not found on PATH.\n");
8864 }
8865
8866 #[cfg(target_os = "windows")]
8867 {
8868 let script = r#"
8869$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8870if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8871else { "SSHD:not_installed" }
8872"#;
8873 if let Ok(o) = Command::new("powershell")
8874 .args(["-NoProfile", "-Command", script])
8875 .output()
8876 {
8877 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8878 if text.contains("not_installed") {
8879 out.push_str("SSH server (sshd): not installed\n");
8880 } else {
8881 out.push_str(&format!(
8882 "SSH server (sshd): {}\n",
8883 text.trim_start_matches("SSHD:")
8884 ));
8885 }
8886 }
8887 }
8888
8889 #[cfg(not(target_os = "windows"))]
8890 {
8891 if let Ok(o) = Command::new("systemctl")
8892 .args(["is-active", "sshd"])
8893 .output()
8894 {
8895 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8896 out.push_str(&format!("SSH server (sshd): {status}\n"));
8897 } else if let Ok(o) = Command::new("systemctl")
8898 .args(["is-active", "ssh"])
8899 .output()
8900 {
8901 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8902 out.push_str(&format!("SSH server (ssh): {status}\n"));
8903 }
8904 }
8905
8906 out.push('\n');
8907
8908 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8909 if ssh_dir.exists() {
8910 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8911
8912 let kh = ssh_dir.join("known_hosts");
8913 if kh.exists() {
8914 let count = fs::read_to_string(&kh)
8915 .map(|c| {
8916 c.lines()
8917 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8918 .count()
8919 })
8920 .unwrap_or(0);
8921 out.push_str(&format!(" known_hosts: {count} entries\n"));
8922 } else {
8923 out.push_str(" known_hosts: not present\n");
8924 }
8925
8926 let ak = ssh_dir.join("authorized_keys");
8927 if ak.exists() {
8928 let count = fs::read_to_string(&ak)
8929 .map(|c| {
8930 c.lines()
8931 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8932 .count()
8933 })
8934 .unwrap_or(0);
8935 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
8936 } else {
8937 out.push_str(" authorized_keys: not present\n");
8938 }
8939
8940 let key_names = [
8941 "id_rsa",
8942 "id_ed25519",
8943 "id_ecdsa",
8944 "id_dsa",
8945 "id_ecdsa_sk",
8946 "id_ed25519_sk",
8947 ];
8948 let found_keys: Vec<&str> = key_names
8949 .iter()
8950 .filter(|k| ssh_dir.join(k).exists())
8951 .copied()
8952 .collect();
8953 if !found_keys.is_empty() {
8954 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
8955 } else {
8956 out.push_str(" Private keys: none found\n");
8957 }
8958
8959 let config_path = ssh_dir.join("config");
8960 if config_path.exists() {
8961 out.push_str("\n=== SSH config hosts ===\n");
8962 match fs::read_to_string(&config_path) {
8963 Ok(content) => {
8964 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8965 let mut current: Option<(String, Vec<String>)> = None;
8966 for line in content.lines() {
8967 let t = line.trim();
8968 if t.is_empty() || t.starts_with('#') {
8969 continue;
8970 }
8971 if let Some(host) = t.strip_prefix("Host ") {
8972 if let Some(prev) = current.take() {
8973 hosts.push(prev);
8974 }
8975 current = Some((host.trim().to_string(), Vec::new()));
8976 } else if let Some((_, ref mut details)) = current {
8977 let tu = t.to_uppercase();
8978 if tu.starts_with("HOSTNAME ")
8979 || tu.starts_with("USER ")
8980 || tu.starts_with("PORT ")
8981 || tu.starts_with("IDENTITYFILE ")
8982 {
8983 details.push(t.to_string());
8984 }
8985 }
8986 }
8987 if let Some(prev) = current {
8988 hosts.push(prev);
8989 }
8990
8991 if hosts.is_empty() {
8992 out.push_str(" No Host entries found.\n");
8993 } else {
8994 for (h, details) in &hosts {
8995 if details.is_empty() {
8996 out.push_str(&format!(" Host {h}\n"));
8997 } else {
8998 out.push_str(&format!(
8999 " Host {h} [{}]\n",
9000 details.join(", ")
9001 ));
9002 }
9003 }
9004 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
9005 }
9006 }
9007 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
9008 }
9009 } else {
9010 out.push_str(" SSH config: not present\n");
9011 }
9012 } else {
9013 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9014 }
9015 }
9016
9017 Ok(out.trim_end().to_string())
9018}
9019
9020fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9023 let mut out = String::from("Host inspection: installed_software\n\n");
9024 let n = max_entries.clamp(10, 50);
9025
9026 #[cfg(target_os = "windows")]
9027 {
9028 let winget_out = Command::new("winget")
9029 .args(["list", "--accept-source-agreements"])
9030 .output();
9031
9032 if let Ok(o) = winget_out {
9033 if o.status.success() {
9034 let raw = String::from_utf8_lossy(&o.stdout);
9035 let mut header_done = false;
9036 let mut packages: Vec<&str> = Vec::new();
9037 for line in raw.lines() {
9038 let t = line.trim();
9039 if t.starts_with("---") {
9040 header_done = true;
9041 continue;
9042 }
9043 if header_done && !t.is_empty() {
9044 packages.push(line);
9045 }
9046 }
9047 let total = packages.len();
9048 out.push_str(&format!(
9049 "=== Installed software via winget ({total} packages) ===\n\n"
9050 ));
9051 for line in packages.iter().take(n) {
9052 out.push_str(&format!(" {line}\n"));
9053 }
9054 if total > n {
9055 out.push_str(&format!("\n ... and {} more packages\n", total - n));
9056 }
9057 out.push_str("\nFor full list: winget list\n");
9058 return Ok(out.trim_end().to_string());
9059 }
9060 }
9061
9062 let script = format!(
9064 r#"
9065$apps = @()
9066$reg_paths = @(
9067 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9068 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9069 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9070)
9071foreach ($p in $reg_paths) {{
9072 try {{
9073 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9074 Where-Object {{ $_.DisplayName }} |
9075 Select-Object DisplayName, DisplayVersion, Publisher
9076 }} catch {{}}
9077}}
9078$sorted = $apps | Sort-Object DisplayName -Unique
9079"TOTAL:" + $sorted.Count
9080$sorted | Select-Object -First {n} | ForEach-Object {{
9081 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9082}}
9083"#
9084 );
9085 if let Ok(o) = Command::new("powershell")
9086 .args(["-NoProfile", "-Command", &script])
9087 .output()
9088 {
9089 let raw = String::from_utf8_lossy(&o.stdout);
9090 out.push_str("=== Installed software (registry scan) ===\n");
9091 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
9092 out.push_str(&format!(" {}\n", "-".repeat(90)));
9093 for line in raw.lines() {
9094 if let Some(rest) = line.strip_prefix("TOTAL:") {
9095 let total: usize = rest.trim().parse().unwrap_or(0);
9096 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
9097 } else if !line.trim().is_empty() {
9098 let parts: Vec<&str> = line.splitn(3, '|').collect();
9099 let name = parts.first().map(|s| s.trim()).unwrap_or("");
9100 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
9101 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
9102 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
9103 }
9104 }
9105 } else {
9106 out.push_str(
9107 "Could not query installed software (winget and registry scan both failed).\n",
9108 );
9109 }
9110 }
9111
9112 #[cfg(target_os = "linux")]
9113 {
9114 let mut found = false;
9115 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9116 if o.status.success() {
9117 let raw = String::from_utf8_lossy(&o.stdout);
9118 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9119 let total = installed.len();
9120 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
9121 for line in installed.iter().take(n) {
9122 out.push_str(&format!(" {}\n", line.trim()));
9123 }
9124 if total > n {
9125 out.push_str(&format!(" ... and {} more\n", total - n));
9126 }
9127 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9128 found = true;
9129 }
9130 }
9131 if !found {
9132 if let Ok(o) = Command::new("rpm")
9133 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9134 .output()
9135 {
9136 if o.status.success() {
9137 let raw = String::from_utf8_lossy(&o.stdout);
9138 let lines: Vec<&str> = raw.lines().collect();
9139 let total = lines.len();
9140 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
9141 for line in lines.iter().take(n) {
9142 out.push_str(&format!(" {line}\n"));
9143 }
9144 if total > n {
9145 out.push_str(&format!(" ... and {} more\n", total - n));
9146 }
9147 found = true;
9148 }
9149 }
9150 }
9151 if !found {
9152 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9153 if o.status.success() {
9154 let raw = String::from_utf8_lossy(&o.stdout);
9155 let lines: Vec<&str> = raw.lines().collect();
9156 let total = lines.len();
9157 out.push_str(&format!(
9158 "=== Installed packages via pacman ({total}) ===\n"
9159 ));
9160 for line in lines.iter().take(n) {
9161 out.push_str(&format!(" {line}\n"));
9162 }
9163 if total > n {
9164 out.push_str(&format!(" ... and {} more\n", total - n));
9165 }
9166 found = true;
9167 }
9168 }
9169 }
9170 if !found {
9171 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9172 }
9173 }
9174
9175 #[cfg(target_os = "macos")]
9176 {
9177 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9178 if o.status.success() {
9179 let raw = String::from_utf8_lossy(&o.stdout);
9180 let lines: Vec<&str> = raw.lines().collect();
9181 let total = lines.len();
9182 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
9183 for line in lines.iter().take(n) {
9184 out.push_str(&format!(" {line}\n"));
9185 }
9186 if total > n {
9187 out.push_str(&format!(" ... and {} more\n", total - n));
9188 }
9189 out.push_str("\nFor full list: brew list --versions\n");
9190 }
9191 } else {
9192 out.push_str("Homebrew not found.\n");
9193 }
9194 if let Ok(o) = Command::new("mas").args(["list"]).output() {
9195 if o.status.success() {
9196 let raw = String::from_utf8_lossy(&o.stdout);
9197 let lines: Vec<&str> = raw.lines().collect();
9198 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
9199 for line in lines.iter().take(n) {
9200 out.push_str(&format!(" {line}\n"));
9201 }
9202 }
9203 }
9204 }
9205
9206 Ok(out.trim_end().to_string())
9207}
9208
9209fn inspect_git_config() -> Result<String, String> {
9212 let mut out = String::from("Host inspection: git_config\n\n");
9213
9214 if let Ok(o) = Command::new("git").args(["--version"]).output() {
9215 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9216 out.push_str(&format!("Git: {ver}\n\n"));
9217 } else {
9218 out.push_str("Git: not found on PATH.\n");
9219 return Ok(out.trim_end().to_string());
9220 }
9221
9222 if let Ok(o) = Command::new("git")
9223 .args(["config", "--global", "--list"])
9224 .output()
9225 {
9226 if o.status.success() {
9227 let raw = String::from_utf8_lossy(&o.stdout);
9228 let mut pairs: Vec<(String, String)> = raw
9229 .lines()
9230 .filter_map(|l| {
9231 let mut parts = l.splitn(2, '=');
9232 let k = parts.next()?.trim().to_string();
9233 let v = parts.next().unwrap_or("").trim().to_string();
9234 Some((k, v))
9235 })
9236 .collect();
9237 pairs.sort_by(|a, b| a.0.cmp(&b.0));
9238
9239 out.push_str("=== Global git config ===\n");
9240
9241 let sections: &[(&str, &[&str])] = &[
9242 ("Identity", &["user.name", "user.email", "user.signingkey"]),
9243 (
9244 "Core",
9245 &[
9246 "core.editor",
9247 "core.autocrlf",
9248 "core.eol",
9249 "core.ignorecase",
9250 "core.filemode",
9251 ],
9252 ),
9253 (
9254 "Commit/Signing",
9255 &[
9256 "commit.gpgsign",
9257 "tag.gpgsign",
9258 "gpg.format",
9259 "gpg.ssh.allowedsignersfile",
9260 ],
9261 ),
9262 (
9263 "Push/Pull",
9264 &[
9265 "push.default",
9266 "push.autosetupremote",
9267 "pull.rebase",
9268 "pull.ff",
9269 ],
9270 ),
9271 ("Credential", &["credential.helper"]),
9272 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9273 ];
9274
9275 let mut shown_keys: HashSet<String> = HashSet::new();
9276 for (section, keys) in sections {
9277 let mut section_lines: Vec<String> = Vec::new();
9278 for key in *keys {
9279 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9280 section_lines.push(format!(" {k} = {v}"));
9281 shown_keys.insert(k.clone());
9282 }
9283 }
9284 if !section_lines.is_empty() {
9285 out.push_str(&format!("\n[{section}]\n"));
9286 for line in section_lines {
9287 out.push_str(&format!("{line}\n"));
9288 }
9289 }
9290 }
9291
9292 let other: Vec<&(String, String)> = pairs
9293 .iter()
9294 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9295 .collect();
9296 if !other.is_empty() {
9297 out.push_str("\n[Other]\n");
9298 for (k, v) in other.iter().take(20) {
9299 out.push_str(&format!(" {k} = {v}\n"));
9300 }
9301 if other.len() > 20 {
9302 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
9303 }
9304 }
9305
9306 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
9307 } else {
9308 out.push_str("No global git config found.\n");
9309 out.push_str("Set up with:\n");
9310 out.push_str(" git config --global user.name \"Your Name\"\n");
9311 out.push_str(" git config --global user.email \"you@example.com\"\n");
9312 }
9313 }
9314
9315 if let Ok(o) = Command::new("git")
9316 .args(["config", "--local", "--list"])
9317 .output()
9318 {
9319 if o.status.success() {
9320 let raw = String::from_utf8_lossy(&o.stdout);
9321 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9322 if !lines.is_empty() {
9323 out.push_str(&format!(
9324 "\n=== Local repo config ({} keys) ===\n",
9325 lines.len()
9326 ));
9327 for line in lines.iter().take(15) {
9328 out.push_str(&format!(" {line}\n"));
9329 }
9330 if lines.len() > 15 {
9331 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
9332 }
9333 }
9334 }
9335 }
9336
9337 if let Ok(o) = Command::new("git")
9338 .args(["config", "--global", "--get-regexp", r"alias\."])
9339 .output()
9340 {
9341 if o.status.success() {
9342 let raw = String::from_utf8_lossy(&o.stdout);
9343 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9344 if !aliases.is_empty() {
9345 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
9346 for a in aliases.iter().take(20) {
9347 out.push_str(&format!(" {a}\n"));
9348 }
9349 if aliases.len() > 20 {
9350 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
9351 }
9352 }
9353 }
9354 }
9355
9356 Ok(out.trim_end().to_string())
9357}
9358
9359fn inspect_databases() -> Result<String, String> {
9362 let mut out = String::from("Host inspection: databases\n\n");
9363 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9364
9365 struct DbEngine {
9366 name: &'static str,
9367 service_names: &'static [&'static str],
9368 default_port: u16,
9369 cli_name: &'static str,
9370 cli_version_args: &'static [&'static str],
9371 }
9372
9373 let engines: &[DbEngine] = &[
9374 DbEngine {
9375 name: "PostgreSQL",
9376 service_names: &[
9377 "postgresql",
9378 "postgresql-x64-14",
9379 "postgresql-x64-15",
9380 "postgresql-x64-16",
9381 "postgresql-x64-17",
9382 ],
9383
9384 default_port: 5432,
9385 cli_name: "psql",
9386 cli_version_args: &["--version"],
9387 },
9388 DbEngine {
9389 name: "MySQL",
9390 service_names: &["mysql", "mysql80", "mysql57"],
9391
9392 default_port: 3306,
9393 cli_name: "mysql",
9394 cli_version_args: &["--version"],
9395 },
9396 DbEngine {
9397 name: "MariaDB",
9398 service_names: &["mariadb", "mariadb.exe"],
9399
9400 default_port: 3306,
9401 cli_name: "mariadb",
9402 cli_version_args: &["--version"],
9403 },
9404 DbEngine {
9405 name: "MongoDB",
9406 service_names: &["mongodb", "mongod"],
9407
9408 default_port: 27017,
9409 cli_name: "mongod",
9410 cli_version_args: &["--version"],
9411 },
9412 DbEngine {
9413 name: "Redis",
9414 service_names: &["redis", "redis-server"],
9415
9416 default_port: 6379,
9417 cli_name: "redis-server",
9418 cli_version_args: &["--version"],
9419 },
9420 DbEngine {
9421 name: "SQL Server",
9422 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9423
9424 default_port: 1433,
9425 cli_name: "sqlcmd",
9426 cli_version_args: &["-?"],
9427 },
9428 DbEngine {
9429 name: "SQLite",
9430 service_names: &[], default_port: 0, cli_name: "sqlite3",
9434 cli_version_args: &["--version"],
9435 },
9436 DbEngine {
9437 name: "CouchDB",
9438 service_names: &["couchdb", "apache-couchdb"],
9439
9440 default_port: 5984,
9441 cli_name: "couchdb",
9442 cli_version_args: &["--version"],
9443 },
9444 DbEngine {
9445 name: "Cassandra",
9446 service_names: &["cassandra"],
9447
9448 default_port: 9042,
9449 cli_name: "cqlsh",
9450 cli_version_args: &["--version"],
9451 },
9452 DbEngine {
9453 name: "Elasticsearch",
9454 service_names: &["elasticsearch-service-x64", "elasticsearch"],
9455
9456 default_port: 9200,
9457 cli_name: "elasticsearch",
9458 cli_version_args: &["--version"],
9459 },
9460 ];
9461
9462 fn port_listening(port: u16) -> bool {
9464 if port == 0 {
9465 return false;
9466 }
9467 std::net::TcpStream::connect_timeout(
9469 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9470 std::time::Duration::from_millis(150),
9471 )
9472 .is_ok()
9473 }
9474
9475 let mut found_any = false;
9476
9477 for engine in engines {
9478 let mut status_parts: Vec<String> = Vec::new();
9479 let mut detected = false;
9480
9481 let version = Command::new(engine.cli_name)
9483 .args(engine.cli_version_args)
9484 .output()
9485 .ok()
9486 .and_then(|o| {
9487 let combined = if o.stdout.is_empty() {
9488 String::from_utf8_lossy(&o.stderr).trim().to_string()
9489 } else {
9490 String::from_utf8_lossy(&o.stdout).trim().to_string()
9491 };
9492 combined.lines().next().map(|l| l.trim().to_string())
9494 });
9495
9496 if let Some(ref ver) = version {
9497 if !ver.is_empty() {
9498 status_parts.push(format!("version: {ver}"));
9499 detected = true;
9500 }
9501 }
9502
9503 if engine.default_port > 0 && port_listening(engine.default_port) {
9505 status_parts.push(format!("listening on :{}", engine.default_port));
9506 detected = true;
9507 } else if engine.default_port > 0 && detected {
9508 status_parts.push(format!("not listening on :{}", engine.default_port));
9509 }
9510
9511 #[cfg(target_os = "windows")]
9513 {
9514 if !engine.service_names.is_empty() {
9515 let service_list = engine.service_names.join("','");
9516 let script = format!(
9517 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9518 service_list
9519 );
9520 if let Ok(o) = Command::new("powershell")
9521 .args(["-NoProfile", "-Command", &script])
9522 .output()
9523 {
9524 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9525 if !text.is_empty() {
9526 let parts: Vec<&str> = text.splitn(2, ':').collect();
9527 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
9528 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
9529 status_parts.push(format!("service '{svc_name}': {svc_state}"));
9530 detected = true;
9531 }
9532 }
9533 }
9534 }
9535
9536 #[cfg(not(target_os = "windows"))]
9538 {
9539 for svc in engine.service_names {
9540 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9541 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9542 if !state.is_empty() && state != "inactive" {
9543 status_parts.push(format!("systemd '{svc}': {state}"));
9544 detected = true;
9545 break;
9546 }
9547 }
9548 }
9549 }
9550
9551 if detected {
9552 found_any = true;
9553 let label = if engine.default_port > 0 {
9554 format!("{} (default port: {})", engine.name, engine.default_port)
9555 } else {
9556 format!("{} (file-based, no port)", engine.name)
9557 };
9558 out.push_str(&format!("[FOUND] {label}\n"));
9559 for part in &status_parts {
9560 out.push_str(&format!(" {part}\n"));
9561 }
9562 out.push('\n');
9563 }
9564 }
9565
9566 if !found_any {
9567 out.push_str("No local database engines detected.\n");
9568 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9569 out.push_str(
9570 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9571 );
9572 } else {
9573 out.push_str("---\n");
9574 out.push_str(
9575 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9576 );
9577 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9578 }
9579
9580 Ok(out.trim_end().to_string())
9581}
9582
9583fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9586 let mut out = String::from("Host inspection: user_accounts\n\n");
9587
9588 #[cfg(target_os = "windows")]
9589 {
9590 let users_out = Command::new("powershell")
9591 .args([
9592 "-NoProfile", "-NonInteractive", "-Command",
9593 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9594 ])
9595 .output()
9596 .ok()
9597 .and_then(|o| String::from_utf8(o.stdout).ok())
9598 .unwrap_or_default();
9599
9600 out.push_str("=== Local User Accounts ===\n");
9601 if users_out.trim().is_empty() {
9602 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
9603 } else {
9604 for line in users_out.lines().take(max_entries) {
9605 if !line.trim().is_empty() {
9606 out.push_str(line);
9607 out.push('\n');
9608 }
9609 }
9610 }
9611
9612 let admins_out = Command::new("powershell")
9613 .args([
9614 "-NoProfile", "-NonInteractive", "-Command",
9615 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
9616 ])
9617 .output()
9618 .ok()
9619 .and_then(|o| String::from_utf8(o.stdout).ok())
9620 .unwrap_or_default();
9621
9622 out.push_str("\n=== Administrators Group Members ===\n");
9623 if admins_out.trim().is_empty() {
9624 out.push_str(" (unable to retrieve)\n");
9625 } else {
9626 out.push_str(admins_out.trim());
9627 out.push('\n');
9628 }
9629
9630 let sessions_out = Command::new("powershell")
9631 .args([
9632 "-NoProfile",
9633 "-NonInteractive",
9634 "-Command",
9635 "query user 2>$null",
9636 ])
9637 .output()
9638 .ok()
9639 .and_then(|o| String::from_utf8(o.stdout).ok())
9640 .unwrap_or_default();
9641
9642 out.push_str("\n=== Active Logon Sessions ===\n");
9643 if sessions_out.trim().is_empty() {
9644 out.push_str(" (none or requires elevation)\n");
9645 } else {
9646 for line in sessions_out.lines().take(max_entries) {
9647 if !line.trim().is_empty() {
9648 out.push_str(&format!(" {}\n", line));
9649 }
9650 }
9651 }
9652
9653 let is_admin = Command::new("powershell")
9654 .args([
9655 "-NoProfile", "-NonInteractive", "-Command",
9656 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9657 ])
9658 .output()
9659 .ok()
9660 .and_then(|o| String::from_utf8(o.stdout).ok())
9661 .map(|s| s.trim().to_lowercase())
9662 .unwrap_or_default();
9663
9664 out.push_str("\n=== Current Session Elevation ===\n");
9665 out.push_str(&format!(
9666 " Running as Administrator: {}\n",
9667 if is_admin.contains("true") {
9668 "YES"
9669 } else {
9670 "no"
9671 }
9672 ));
9673 }
9674
9675 #[cfg(not(target_os = "windows"))]
9676 {
9677 let who_out = Command::new("who")
9678 .output()
9679 .ok()
9680 .and_then(|o| String::from_utf8(o.stdout).ok())
9681 .unwrap_or_default();
9682 out.push_str("=== Active Sessions ===\n");
9683 if who_out.trim().is_empty() {
9684 out.push_str(" (none)\n");
9685 } else {
9686 for line in who_out.lines().take(max_entries) {
9687 out.push_str(&format!(" {}\n", line));
9688 }
9689 }
9690 let id_out = Command::new("id")
9691 .output()
9692 .ok()
9693 .and_then(|o| String::from_utf8(o.stdout).ok())
9694 .unwrap_or_default();
9695 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
9696 }
9697
9698 Ok(out.trim_end().to_string())
9699}
9700
9701fn inspect_audit_policy() -> Result<String, String> {
9704 let mut out = String::from("Host inspection: audit_policy\n\n");
9705
9706 #[cfg(target_os = "windows")]
9707 {
9708 let auditpol_out = Command::new("auditpol")
9709 .args(["/get", "/category:*"])
9710 .output()
9711 .ok()
9712 .and_then(|o| String::from_utf8(o.stdout).ok())
9713 .unwrap_or_default();
9714
9715 if auditpol_out.trim().is_empty()
9716 || auditpol_out.to_lowercase().contains("access is denied")
9717 {
9718 out.push_str("Audit policy requires Administrator elevation to read.\n");
9719 out.push_str(
9720 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9721 );
9722 } else {
9723 out.push_str("=== Windows Audit Policy ===\n");
9724 let mut any_enabled = false;
9725 for line in auditpol_out.lines() {
9726 let trimmed = line.trim();
9727 if trimmed.is_empty() {
9728 continue;
9729 }
9730 if trimmed.contains("Success") || trimmed.contains("Failure") {
9731 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
9732 any_enabled = true;
9733 } else {
9734 out.push_str(&format!(" {}\n", trimmed));
9735 }
9736 }
9737 if !any_enabled {
9738 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9739 out.push_str(
9740 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9741 );
9742 }
9743 }
9744
9745 let evtlog = Command::new("powershell")
9746 .args([
9747 "-NoProfile", "-NonInteractive", "-Command",
9748 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9749 ])
9750 .output()
9751 .ok()
9752 .and_then(|o| String::from_utf8(o.stdout).ok())
9753 .map(|s| s.trim().to_string())
9754 .unwrap_or_default();
9755
9756 out.push_str(&format!(
9757 "\n=== Windows Event Log Service ===\n Status: {}\n",
9758 if evtlog.is_empty() {
9759 "unknown".to_string()
9760 } else {
9761 evtlog
9762 }
9763 ));
9764 }
9765
9766 #[cfg(not(target_os = "windows"))]
9767 {
9768 let auditd_status = Command::new("systemctl")
9769 .args(["is-active", "auditd"])
9770 .output()
9771 .ok()
9772 .and_then(|o| String::from_utf8(o.stdout).ok())
9773 .map(|s| s.trim().to_string())
9774 .unwrap_or_else(|| "not found".to_string());
9775
9776 out.push_str(&format!(
9777 "=== auditd service ===\n Status: {}\n",
9778 auditd_status
9779 ));
9780
9781 if auditd_status == "active" {
9782 let rules = Command::new("auditctl")
9783 .args(["-l"])
9784 .output()
9785 .ok()
9786 .and_then(|o| String::from_utf8(o.stdout).ok())
9787 .unwrap_or_default();
9788 out.push_str("\n=== Active Audit Rules ===\n");
9789 if rules.trim().is_empty() || rules.contains("No rules") {
9790 out.push_str(" No rules configured.\n");
9791 } else {
9792 for line in rules.lines() {
9793 out.push_str(&format!(" {}\n", line));
9794 }
9795 }
9796 }
9797 }
9798
9799 Ok(out.trim_end().to_string())
9800}
9801
9802fn inspect_shares(max_entries: usize) -> Result<String, String> {
9805 let mut out = String::from("Host inspection: shares\n\n");
9806
9807 #[cfg(target_os = "windows")]
9808 {
9809 let smb_out = Command::new("powershell")
9810 .args([
9811 "-NoProfile", "-NonInteractive", "-Command",
9812 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9813 ])
9814 .output()
9815 .ok()
9816 .and_then(|o| String::from_utf8(o.stdout).ok())
9817 .unwrap_or_default();
9818
9819 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9820 let smb_lines: Vec<&str> = smb_out
9821 .lines()
9822 .filter(|l| !l.trim().is_empty())
9823 .take(max_entries)
9824 .collect();
9825 if smb_lines.is_empty() {
9826 out.push_str(" No SMB shares or unable to retrieve.\n");
9827 } else {
9828 for line in &smb_lines {
9829 let name = line.trim().split('|').next().unwrap_or("").trim();
9830 if name.ends_with('$') {
9831 out.push_str(&format!(" {}\n", line.trim()));
9832 } else {
9833 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
9834 }
9835 }
9836 }
9837
9838 let smb_security = Command::new("powershell")
9839 .args([
9840 "-NoProfile", "-NonInteractive", "-Command",
9841 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9842 ])
9843 .output()
9844 .ok()
9845 .and_then(|o| String::from_utf8(o.stdout).ok())
9846 .unwrap_or_default();
9847
9848 out.push_str("\n=== SMB Server Security Settings ===\n");
9849 if smb_security.trim().is_empty() {
9850 out.push_str(" (unable to retrieve)\n");
9851 } else {
9852 out.push_str(smb_security.trim());
9853 out.push('\n');
9854 if smb_security.to_lowercase().contains("smb1: true") {
9855 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9856 }
9857 }
9858
9859 let drives_out = Command::new("powershell")
9860 .args([
9861 "-NoProfile", "-NonInteractive", "-Command",
9862 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9863 ])
9864 .output()
9865 .ok()
9866 .and_then(|o| String::from_utf8(o.stdout).ok())
9867 .unwrap_or_default();
9868
9869 out.push_str("\n=== Mapped Network Drives ===\n");
9870 if drives_out.trim().is_empty() {
9871 out.push_str(" None.\n");
9872 } else {
9873 for line in drives_out.lines().take(max_entries) {
9874 if !line.trim().is_empty() {
9875 out.push_str(line);
9876 out.push('\n');
9877 }
9878 }
9879 }
9880 }
9881
9882 #[cfg(not(target_os = "windows"))]
9883 {
9884 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9885 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9886 if smb_conf.is_empty() {
9887 out.push_str(" Not found or Samba not installed.\n");
9888 } else {
9889 for line in smb_conf.lines().take(max_entries) {
9890 out.push_str(&format!(" {}\n", line));
9891 }
9892 }
9893 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9894 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9895 if nfs_exports.is_empty() {
9896 out.push_str(" Not configured.\n");
9897 } else {
9898 for line in nfs_exports.lines().take(max_entries) {
9899 out.push_str(&format!(" {}\n", line));
9900 }
9901 }
9902 }
9903
9904 Ok(out.trim_end().to_string())
9905}
9906
9907fn inspect_dns_servers() -> Result<String, String> {
9910 let mut out = String::from("Host inspection: dns_servers\n\n");
9911
9912 #[cfg(target_os = "windows")]
9913 {
9914 let dns_out = Command::new("powershell")
9915 .args([
9916 "-NoProfile", "-NonInteractive", "-Command",
9917 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9918 ])
9919 .output()
9920 .ok()
9921 .and_then(|o| String::from_utf8(o.stdout).ok())
9922 .unwrap_or_default();
9923
9924 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9925 if dns_out.trim().is_empty() {
9926 out.push_str(" (unable to retrieve)\n");
9927 } else {
9928 for line in dns_out.lines() {
9929 if line.trim().is_empty() {
9930 continue;
9931 }
9932 let mut annotation = "";
9933 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9934 annotation = " <- Google Public DNS";
9935 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9936 annotation = " <- Cloudflare DNS";
9937 } else if line.contains("9.9.9.9") {
9938 annotation = " <- Quad9";
9939 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9940 annotation = " <- OpenDNS";
9941 }
9942 out.push_str(line);
9943 out.push_str(annotation);
9944 out.push('\n');
9945 }
9946 }
9947
9948 let doh_out = Command::new("powershell")
9949 .args([
9950 "-NoProfile", "-NonInteractive", "-Command",
9951 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
9952 ])
9953 .output()
9954 .ok()
9955 .and_then(|o| String::from_utf8(o.stdout).ok())
9956 .unwrap_or_default();
9957
9958 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9959 if doh_out.trim().is_empty() {
9960 out.push_str(" Not configured (plain DNS).\n");
9961 } else {
9962 out.push_str(doh_out.trim());
9963 out.push('\n');
9964 }
9965
9966 let suffixes = Command::new("powershell")
9967 .args([
9968 "-NoProfile", "-NonInteractive", "-Command",
9969 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
9970 ])
9971 .output()
9972 .ok()
9973 .and_then(|o| String::from_utf8(o.stdout).ok())
9974 .unwrap_or_default();
9975
9976 if !suffixes.trim().is_empty() {
9977 out.push_str("\n=== DNS Search Suffix List ===\n");
9978 out.push_str(suffixes.trim());
9979 out.push('\n');
9980 }
9981 }
9982
9983 #[cfg(not(target_os = "windows"))]
9984 {
9985 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9986 out.push_str("=== /etc/resolv.conf ===\n");
9987 if resolv.is_empty() {
9988 out.push_str(" Not found.\n");
9989 } else {
9990 for line in resolv.lines() {
9991 if !line.trim().is_empty() && !line.starts_with('#') {
9992 out.push_str(&format!(" {}\n", line));
9993 }
9994 }
9995 }
9996 let resolved_out = Command::new("resolvectl")
9997 .args(["status", "--no-pager"])
9998 .output()
9999 .ok()
10000 .and_then(|o| String::from_utf8(o.stdout).ok())
10001 .unwrap_or_default();
10002 if !resolved_out.is_empty() {
10003 out.push_str("\n=== systemd-resolved ===\n");
10004 for line in resolved_out.lines().take(30) {
10005 out.push_str(&format!(" {}\n", line));
10006 }
10007 }
10008 }
10009
10010 Ok(out.trim_end().to_string())
10011}
10012
10013fn inspect_bitlocker() -> Result<String, String> {
10014 let mut out = String::from("Host inspection: bitlocker\n\n");
10015
10016 #[cfg(target_os = "windows")]
10017 {
10018 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10019 let output = Command::new("powershell")
10020 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10021 .output()
10022 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10023
10024 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10025 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10026
10027 if !stdout.trim().is_empty() {
10028 out.push_str("=== BitLocker Volumes ===\n");
10029 for line in stdout.lines() {
10030 out.push_str(&format!(" {}\n", line));
10031 }
10032 } else if !stderr.trim().is_empty() {
10033 if stderr.contains("Access is denied") {
10034 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10035 } else {
10036 out.push_str(&format!(
10037 "Error retrieving BitLocker info: {}\n",
10038 stderr.trim()
10039 ));
10040 }
10041 } else {
10042 out.push_str("No BitLocker volumes detected or access denied.\n");
10043 }
10044 }
10045
10046 #[cfg(not(target_os = "windows"))]
10047 {
10048 out.push_str(
10049 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10050 );
10051 let lsblk = Command::new("lsblk")
10052 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10053 .output()
10054 .ok()
10055 .and_then(|o| String::from_utf8(o.stdout).ok())
10056 .unwrap_or_default();
10057 if lsblk.contains("crypto_LUKS") {
10058 out.push_str("=== LUKS Encrypted Volumes ===\n");
10059 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10060 out.push_str(&format!(" {}\n", line));
10061 }
10062 } else {
10063 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10064 }
10065 }
10066
10067 Ok(out.trim_end().to_string())
10068}
10069
10070fn inspect_rdp() -> Result<String, String> {
10071 let mut out = String::from("Host inspection: rdp\n\n");
10072
10073 #[cfg(target_os = "windows")]
10074 {
10075 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10076 let f_deny = Command::new("powershell")
10077 .args([
10078 "-NoProfile",
10079 "-Command",
10080 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10081 ])
10082 .output()
10083 .ok()
10084 .and_then(|o| String::from_utf8(o.stdout).ok())
10085 .unwrap_or_default()
10086 .trim()
10087 .to_string();
10088
10089 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10090 out.push_str(&format!("=== RDP Status: {} ===\n", status));
10091
10092 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"])
10093 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10094 out.push_str(&format!(
10095 " Port: {}\n",
10096 if port.is_empty() {
10097 "3389 (default)"
10098 } else {
10099 &port
10100 }
10101 ));
10102
10103 let nla = Command::new("powershell")
10104 .args([
10105 "-NoProfile",
10106 "-Command",
10107 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10108 ])
10109 .output()
10110 .ok()
10111 .and_then(|o| String::from_utf8(o.stdout).ok())
10112 .unwrap_or_default()
10113 .trim()
10114 .to_string();
10115 out.push_str(&format!(
10116 " NLA Required: {}\n",
10117 if nla == "1" { "Yes" } else { "No" }
10118 ));
10119
10120 let rdp_tcp_path =
10121 "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10122 let sec_layer = Command::new("powershell")
10123 .args([
10124 "-NoProfile",
10125 "-Command",
10126 &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10127 ])
10128 .output()
10129 .ok()
10130 .and_then(|o| String::from_utf8(o.stdout).ok())
10131 .unwrap_or_default()
10132 .trim()
10133 .to_string();
10134 let sec_label = match sec_layer.as_str() {
10135 "0" => "RDP Security (no SSL)",
10136 "1" => "Negotiate (prefer TLS)",
10137 "2" => "SSL/TLS required",
10138 _ => &sec_layer,
10139 };
10140 out.push_str(&format!(
10141 " Security Layer: {} ({})\n",
10142 sec_layer, sec_label
10143 ));
10144
10145 let enc_level = Command::new("powershell")
10146 .args([
10147 "-NoProfile",
10148 "-Command",
10149 &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10150 ])
10151 .output()
10152 .ok()
10153 .and_then(|o| String::from_utf8(o.stdout).ok())
10154 .unwrap_or_default()
10155 .trim()
10156 .to_string();
10157 let enc_label = match enc_level.as_str() {
10158 "1" => "Low",
10159 "2" => "Client Compatible",
10160 "3" => "High",
10161 "4" => "FIPS Compliant",
10162 _ => "Unknown",
10163 };
10164 out.push_str(&format!(
10165 " Encryption Level: {} ({})\n",
10166 enc_level, enc_label
10167 ));
10168
10169 out.push_str("\n=== Active Sessions ===\n");
10170 let qwinsta = Command::new("qwinsta")
10171 .output()
10172 .ok()
10173 .and_then(|o| String::from_utf8(o.stdout).ok())
10174 .unwrap_or_default();
10175 if qwinsta.trim().is_empty() {
10176 out.push_str(" No active sessions listed.\n");
10177 } else {
10178 for line in qwinsta.lines() {
10179 out.push_str(&format!(" {}\n", line));
10180 }
10181 }
10182
10183 out.push_str("\n=== Firewall Rule Check ===\n");
10184 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))\" }"])
10185 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10186 if fw.trim().is_empty() {
10187 out.push_str(" No enabled RDP firewall rules found.\n");
10188 } else {
10189 out.push_str(fw.trim_end());
10190 out.push('\n');
10191 }
10192 }
10193
10194 #[cfg(not(target_os = "windows"))]
10195 {
10196 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10197 let ss = Command::new("ss")
10198 .args(["-tlnp"])
10199 .output()
10200 .ok()
10201 .and_then(|o| String::from_utf8(o.stdout).ok())
10202 .unwrap_or_default();
10203 let matches: Vec<&str> = ss
10204 .lines()
10205 .filter(|l| l.contains(":3389") || l.contains(":590"))
10206 .collect();
10207 if matches.is_empty() {
10208 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
10209 } else {
10210 for m in matches {
10211 out.push_str(&format!(" {}\n", m));
10212 }
10213 }
10214 }
10215
10216 Ok(out.trim_end().to_string())
10217}
10218
10219fn inspect_shadow_copies() -> Result<String, String> {
10220 let mut out = String::from("Host inspection: shadow_copies\n\n");
10221
10222 #[cfg(target_os = "windows")]
10223 {
10224 let output = Command::new("vssadmin")
10225 .args(["list", "shadows"])
10226 .output()
10227 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10228 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10229
10230 if stdout.contains("No items found") || stdout.trim().is_empty() {
10231 out.push_str("No Volume Shadow Copies found.\n");
10232 } else {
10233 out.push_str("=== Volume Shadow Copies ===\n");
10234 for line in stdout.lines().take(50) {
10235 if line.contains("Creation Time:")
10236 || line.contains("Contents:")
10237 || line.contains("Volume Name:")
10238 {
10239 out.push_str(&format!(" {}\n", line.trim()));
10240 }
10241 }
10242 }
10243
10244 let age_script = r#"
10246try {
10247 $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10248 if ($snaps) {
10249 $newest = $snaps[0]
10250 $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10251 $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10252 $count = @($snaps).Count
10253 "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm')) ($age days ago) — $count total snapshots"
10254 } else { "No snapshots found via WMI." }
10255} catch { "WMI snapshot query unavailable: $_" }
10256"#;
10257 if let Ok(age_out) = Command::new("powershell")
10258 .args(["-NoProfile", "-Command", age_script])
10259 .output()
10260 {
10261 let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10262 if !age_text.is_empty() {
10263 out.push_str("\n=== Snapshot Age ===\n");
10264 out.push_str(&format!(" {}\n", age_text));
10265 }
10266 }
10267
10268 out.push_str("\n=== Shadow Copy Storage ===\n");
10269 let storage_out = Command::new("vssadmin")
10270 .args(["list", "shadowstorage"])
10271 .output()
10272 .ok();
10273 if let Some(o) = storage_out {
10274 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10275 for line in stdout.lines() {
10276 if line.contains("Used Shadow Copy Storage space:")
10277 || line.contains("Max Shadow Copy Storage space:")
10278 {
10279 out.push_str(&format!(" {}\n", line.trim()));
10280 }
10281 }
10282 }
10283 }
10284
10285 #[cfg(not(target_os = "windows"))]
10286 {
10287 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10288 let lvs = Command::new("lvs")
10289 .output()
10290 .ok()
10291 .and_then(|o| String::from_utf8(o.stdout).ok())
10292 .unwrap_or_default();
10293 if !lvs.is_empty() {
10294 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10295 out.push_str(&lvs);
10296 } else {
10297 out.push_str("No LVM volumes detected.\n");
10298 }
10299 }
10300
10301 Ok(out.trim_end().to_string())
10302}
10303
10304fn inspect_pagefile() -> Result<String, String> {
10305 let mut out = String::from("Host inspection: pagefile\n\n");
10306
10307 #[cfg(target_os = "windows")]
10308 {
10309 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)\" }";
10310 let output = Command::new("powershell")
10311 .args(["-NoProfile", "-Command", ps_cmd])
10312 .output()
10313 .ok()
10314 .and_then(|o| String::from_utf8(o.stdout).ok())
10315 .unwrap_or_default();
10316
10317 if output.trim().is_empty() {
10318 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10319 let managed = Command::new("powershell")
10320 .args([
10321 "-NoProfile",
10322 "-Command",
10323 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10324 ])
10325 .output()
10326 .ok()
10327 .and_then(|o| String::from_utf8(o.stdout).ok())
10328 .unwrap_or_default()
10329 .trim()
10330 .to_string();
10331 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
10332 } else {
10333 out.push_str("=== Page File Usage ===\n");
10334 out.push_str(&output);
10335 }
10336 }
10337
10338 #[cfg(not(target_os = "windows"))]
10339 {
10340 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10341 let swap = Command::new("swapon")
10342 .args(["--show"])
10343 .output()
10344 .ok()
10345 .and_then(|o| String::from_utf8(o.stdout).ok())
10346 .unwrap_or_default();
10347 if swap.is_empty() {
10348 let free = Command::new("free")
10349 .args(["-h"])
10350 .output()
10351 .ok()
10352 .and_then(|o| String::from_utf8(o.stdout).ok())
10353 .unwrap_or_default();
10354 out.push_str(&free);
10355 } else {
10356 out.push_str(&swap);
10357 }
10358 }
10359
10360 Ok(out.trim_end().to_string())
10361}
10362
10363fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10364 let mut out = String::from("Host inspection: windows_features\n\n");
10365
10366 #[cfg(target_os = "windows")]
10367 {
10368 out.push_str("=== Quick Check: Notable Features ===\n");
10369 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10370 let output = Command::new("powershell")
10371 .args(["-NoProfile", "-Command", quick_ps])
10372 .output()
10373 .ok();
10374
10375 if let Some(o) = output {
10376 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10377 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10378
10379 if !stdout.trim().is_empty() {
10380 for f in stdout.lines() {
10381 out.push_str(&format!(" [ENABLED] {}\n", f));
10382 }
10383 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10384 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10385 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10386 out.push_str(
10387 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10388 );
10389 }
10390 }
10391
10392 out.push_str(&format!(
10393 "\n=== All Enabled Features (capped at {}) ===\n",
10394 max_entries
10395 ));
10396 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10397 let all_out = Command::new("powershell")
10398 .args(["-NoProfile", "-Command", &all_ps])
10399 .output()
10400 .ok();
10401 if let Some(o) = all_out {
10402 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10403 if !stdout.trim().is_empty() {
10404 out.push_str(&stdout);
10405 }
10406 }
10407 }
10408
10409 #[cfg(not(target_os = "windows"))]
10410 {
10411 let _ = max_entries;
10412 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10413 }
10414
10415 Ok(out.trim_end().to_string())
10416}
10417
10418fn inspect_audio(max_entries: usize) -> Result<String, String> {
10419 let mut out = String::from("Host inspection: audio\n\n");
10420
10421 #[cfg(target_os = "windows")]
10422 {
10423 let n = max_entries.clamp(5, 20);
10424 let services = collect_services().unwrap_or_default();
10425 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10426 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10427
10428 let core_services: Vec<&ServiceEntry> = services
10429 .iter()
10430 .filter(|entry| {
10431 core_service_names
10432 .iter()
10433 .any(|name| entry.name.eq_ignore_ascii_case(name))
10434 })
10435 .collect();
10436 let bluetooth_audio_services: Vec<&ServiceEntry> = services
10437 .iter()
10438 .filter(|entry| {
10439 bluetooth_audio_service_names
10440 .iter()
10441 .any(|name| entry.name.eq_ignore_ascii_case(name))
10442 })
10443 .collect();
10444
10445 let probe_script = r#"
10446$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10447 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10448$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10449 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10450$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10451 Select-Object Name, Status, Manufacturer, PNPDeviceID)
10452[pscustomobject]@{
10453 Media = $media
10454 Endpoints = $endpoints
10455 SoundDevices = $sound
10456} | ConvertTo-Json -Compress -Depth 4
10457"#;
10458 let probe_raw = Command::new("powershell")
10459 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10460 .output()
10461 .ok()
10462 .and_then(|o| String::from_utf8(o.stdout).ok())
10463 .unwrap_or_default();
10464 let probe_loaded = !probe_raw.trim().is_empty();
10465 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10466
10467 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10468 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10469 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10470
10471 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10472 .iter()
10473 .filter(|device| !is_microphone_like_name(&device.name))
10474 .collect();
10475 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10476 .iter()
10477 .filter(|device| is_microphone_like_name(&device.name))
10478 .collect();
10479 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10480 .iter()
10481 .filter(|device| is_bluetooth_like_name(&device.name))
10482 .collect();
10483 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10484 .iter()
10485 .filter(|device| windows_device_has_issue(device))
10486 .collect();
10487 let media_problems: Vec<&WindowsPnpDevice> = media_devices
10488 .iter()
10489 .filter(|device| windows_device_has_issue(device))
10490 .collect();
10491 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10492 .iter()
10493 .filter(|device| windows_sound_device_has_issue(device))
10494 .collect();
10495
10496 let mut findings = Vec::new();
10497
10498 let stopped_core_services: Vec<&ServiceEntry> = core_services
10499 .iter()
10500 .copied()
10501 .filter(|service| !service_is_running(service))
10502 .collect();
10503 if !stopped_core_services.is_empty() {
10504 let names = stopped_core_services
10505 .iter()
10506 .map(|service| service.name.as_str())
10507 .collect::<Vec<_>>()
10508 .join(", ");
10509 findings.push(AuditFinding {
10510 finding: format!("Core audio services are not running: {names}"),
10511 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10512 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10513 });
10514 }
10515
10516 if probe_loaded
10517 && endpoints.is_empty()
10518 && media_devices.is_empty()
10519 && sound_devices.is_empty()
10520 {
10521 findings.push(AuditFinding {
10522 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10523 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(),
10524 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(),
10525 });
10526 }
10527
10528 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10529 {
10530 let mut problem_labels = Vec::new();
10531 problem_labels.extend(
10532 endpoint_problems
10533 .iter()
10534 .take(3)
10535 .map(|device| device.name.clone()),
10536 );
10537 problem_labels.extend(
10538 media_problems
10539 .iter()
10540 .take(3)
10541 .map(|device| device.name.clone()),
10542 );
10543 problem_labels.extend(
10544 sound_problems
10545 .iter()
10546 .take(3)
10547 .map(|device| device.name.clone()),
10548 );
10549 findings.push(AuditFinding {
10550 finding: format!(
10551 "Windows reports audio device issues for: {}",
10552 problem_labels.join(", ")
10553 ),
10554 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10555 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(),
10556 });
10557 }
10558
10559 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10560 .iter()
10561 .copied()
10562 .filter(|service| !service_is_running(service))
10563 .collect();
10564 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10565 let names = stopped_bt_audio_services
10566 .iter()
10567 .map(|service| service.name.as_str())
10568 .collect::<Vec<_>>()
10569 .join(", ");
10570 findings.push(AuditFinding {
10571 finding: format!(
10572 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10573 ),
10574 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10575 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10576 });
10577 }
10578
10579 out.push_str("=== Findings ===\n");
10580 if findings.is_empty() {
10581 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10582 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
10583 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");
10584 } else {
10585 for finding in &findings {
10586 out.push_str(&format!("- Finding: {}\n", finding.finding));
10587 out.push_str(&format!(" Impact: {}\n", finding.impact));
10588 out.push_str(&format!(" Fix: {}\n", finding.fix));
10589 }
10590 }
10591
10592 out.push_str("\n=== Audio services ===\n");
10593 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10594 out.push_str(
10595 "- No Windows audio services were retrieved from the service inventory.\n",
10596 );
10597 } else {
10598 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10599 out.push_str(&format!(
10600 "- {} | Status: {} | Startup: {}\n",
10601 service.name,
10602 service.status,
10603 service.startup.as_deref().unwrap_or("Unknown")
10604 ));
10605 }
10606 }
10607
10608 out.push_str("\n=== Playback and recording endpoints ===\n");
10609 if !probe_loaded {
10610 out.push_str("- Windows endpoint inventory probe returned no data.\n");
10611 } else if endpoints.is_empty() {
10612 out.push_str("- No audio endpoints detected.\n");
10613 } else {
10614 out.push_str(&format!(
10615 "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
10616 playback_endpoints.len(),
10617 recording_endpoints.len()
10618 ));
10619 for device in playback_endpoints.iter().take(n) {
10620 out.push_str(&format!(
10621 "- [PLAYBACK] {} | Status: {}{}\n",
10622 device.name,
10623 device.status,
10624 device
10625 .problem
10626 .filter(|problem| *problem != 0)
10627 .map(|problem| format!(" | ProblemCode: {problem}"))
10628 .unwrap_or_default()
10629 ));
10630 }
10631 for device in recording_endpoints.iter().take(n) {
10632 out.push_str(&format!(
10633 "- [MIC] {} | Status: {}{}\n",
10634 device.name,
10635 device.status,
10636 device
10637 .problem
10638 .filter(|problem| *problem != 0)
10639 .map(|problem| format!(" | ProblemCode: {problem}"))
10640 .unwrap_or_default()
10641 ));
10642 }
10643 }
10644
10645 out.push_str("\n=== Sound hardware devices ===\n");
10646 if sound_devices.is_empty() {
10647 out.push_str("- No Win32_SoundDevice entries were returned.\n");
10648 } else {
10649 for device in sound_devices.iter().take(n) {
10650 out.push_str(&format!(
10651 "- {} | Status: {}{}\n",
10652 device.name,
10653 device.status,
10654 device
10655 .manufacturer
10656 .as_deref()
10657 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10658 .unwrap_or_default()
10659 ));
10660 }
10661 }
10662
10663 out.push_str("\n=== Media-class device inventory ===\n");
10664 if media_devices.is_empty() {
10665 out.push_str("- No media-class PnP devices were returned.\n");
10666 } else {
10667 for device in media_devices.iter().take(n) {
10668 out.push_str(&format!(
10669 "- {} | Status: {}{}\n",
10670 device.name,
10671 device.status,
10672 device
10673 .class_name
10674 .as_deref()
10675 .map(|class_name| format!(" | Class: {class_name}"))
10676 .unwrap_or_default()
10677 ));
10678 }
10679 }
10680 }
10681
10682 #[cfg(not(target_os = "windows"))]
10683 {
10684 let _ = max_entries;
10685 out.push_str(
10686 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10687 );
10688 out.push_str(
10689 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10690 );
10691 }
10692
10693 Ok(out.trim_end().to_string())
10694}
10695
10696fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10697 let mut out = String::from("Host inspection: bluetooth\n\n");
10698
10699 #[cfg(target_os = "windows")]
10700 {
10701 let n = max_entries.clamp(5, 20);
10702 let services = collect_services().unwrap_or_default();
10703 let bluetooth_services: Vec<&ServiceEntry> = services
10704 .iter()
10705 .filter(|entry| {
10706 entry.name.eq_ignore_ascii_case("bthserv")
10707 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10708 || entry.name.eq_ignore_ascii_case("BTAGService")
10709 || entry.name.starts_with("BluetoothUserService")
10710 || entry
10711 .display_name
10712 .as_deref()
10713 .unwrap_or("")
10714 .to_ascii_lowercase()
10715 .contains("bluetooth")
10716 })
10717 .collect();
10718
10719 let probe_script = r#"
10720$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10721 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10722$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10723 Where-Object {
10724 $_.Class -eq 'Bluetooth' -or
10725 $_.FriendlyName -match 'Bluetooth' -or
10726 $_.InstanceId -like 'BTH*'
10727 } |
10728 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10729$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10730 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10731 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10732[pscustomobject]@{
10733 Radios = $radios
10734 Devices = $devices
10735 AudioEndpoints = $audio
10736} | ConvertTo-Json -Compress -Depth 4
10737"#;
10738 let probe_raw = Command::new("powershell")
10739 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10740 .output()
10741 .ok()
10742 .and_then(|o| String::from_utf8(o.stdout).ok())
10743 .unwrap_or_default();
10744 let probe_loaded = !probe_raw.trim().is_empty();
10745 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10746
10747 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10748 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10749 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10750 let radio_problems: Vec<&WindowsPnpDevice> = radios
10751 .iter()
10752 .filter(|device| windows_device_has_issue(device))
10753 .collect();
10754 let device_problems: Vec<&WindowsPnpDevice> = devices
10755 .iter()
10756 .filter(|device| windows_device_has_issue(device))
10757 .collect();
10758
10759 let mut findings = Vec::new();
10760
10761 if probe_loaded && radios.is_empty() {
10762 findings.push(AuditFinding {
10763 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10764 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10765 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10766 });
10767 }
10768
10769 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10770 .iter()
10771 .copied()
10772 .filter(|service| !service_is_running(service))
10773 .collect();
10774 if !stopped_bluetooth_services.is_empty() {
10775 let names = stopped_bluetooth_services
10776 .iter()
10777 .map(|service| service.name.as_str())
10778 .collect::<Vec<_>>()
10779 .join(", ");
10780 findings.push(AuditFinding {
10781 finding: format!("Bluetooth-related services are not fully running: {names}"),
10782 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10783 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10784 });
10785 }
10786
10787 if !radio_problems.is_empty() || !device_problems.is_empty() {
10788 let problem_labels = radio_problems
10789 .iter()
10790 .chain(device_problems.iter())
10791 .take(5)
10792 .map(|device| device.name.as_str())
10793 .collect::<Vec<_>>()
10794 .join(", ");
10795 findings.push(AuditFinding {
10796 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10797 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10798 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(),
10799 });
10800 }
10801
10802 if !audio_endpoints.is_empty()
10803 && bluetooth_services
10804 .iter()
10805 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10806 && bluetooth_services
10807 .iter()
10808 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10809 .any(|service| !service_is_running(service))
10810 {
10811 findings.push(AuditFinding {
10812 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10813 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10814 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10815 });
10816 }
10817
10818 out.push_str("=== Findings ===\n");
10819 if findings.is_empty() {
10820 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10821 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10822 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");
10823 } else {
10824 for finding in &findings {
10825 out.push_str(&format!("- Finding: {}\n", finding.finding));
10826 out.push_str(&format!(" Impact: {}\n", finding.impact));
10827 out.push_str(&format!(" Fix: {}\n", finding.fix));
10828 }
10829 }
10830
10831 out.push_str("\n=== Bluetooth services ===\n");
10832 if bluetooth_services.is_empty() {
10833 out.push_str(
10834 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10835 );
10836 } else {
10837 for service in bluetooth_services.iter().take(n) {
10838 out.push_str(&format!(
10839 "- {} | Status: {} | Startup: {}\n",
10840 service.name,
10841 service.status,
10842 service.startup.as_deref().unwrap_or("Unknown")
10843 ));
10844 }
10845 }
10846
10847 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10848 if !probe_loaded {
10849 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10850 } else if radios.is_empty() {
10851 out.push_str("- No Bluetooth radios detected.\n");
10852 } else {
10853 for device in radios.iter().take(n) {
10854 out.push_str(&format!(
10855 "- {} | Status: {}{}\n",
10856 device.name,
10857 device.status,
10858 device
10859 .problem
10860 .filter(|problem| *problem != 0)
10861 .map(|problem| format!(" | ProblemCode: {problem}"))
10862 .unwrap_or_default()
10863 ));
10864 }
10865 }
10866
10867 out.push_str("\n=== Bluetooth-associated devices ===\n");
10868 if devices.is_empty() {
10869 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10870 } else {
10871 for device in devices.iter().take(n) {
10872 out.push_str(&format!(
10873 "- {} | Status: {}{}\n",
10874 device.name,
10875 device.status,
10876 device
10877 .class_name
10878 .as_deref()
10879 .map(|class_name| format!(" | Class: {class_name}"))
10880 .unwrap_or_default()
10881 ));
10882 }
10883 }
10884
10885 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10886 if audio_endpoints.is_empty() {
10887 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10888 } else {
10889 for device in audio_endpoints.iter().take(n) {
10890 out.push_str(&format!(
10891 "- {} | Status: {}{}\n",
10892 device.name,
10893 device.status,
10894 device
10895 .instance_id
10896 .as_deref()
10897 .map(|instance_id| format!(" | Instance: {instance_id}"))
10898 .unwrap_or_default()
10899 ));
10900 }
10901 }
10902 }
10903
10904 #[cfg(not(target_os = "windows"))]
10905 {
10906 let _ = max_entries;
10907 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10908 out.push_str(
10909 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10910 );
10911 }
10912
10913 Ok(out.trim_end().to_string())
10914}
10915
10916fn inspect_printers(max_entries: usize) -> Result<String, String> {
10917 let mut out = String::from("Host inspection: printers\n\n");
10918
10919 #[cfg(target_os = "windows")]
10920 {
10921 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)])
10922 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10923 if list.trim().is_empty() {
10924 out.push_str("No printers detected.\n");
10925 } else {
10926 out.push_str("=== Installed Printers ===\n");
10927 out.push_str(&list);
10928 }
10929
10930 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10931 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10932 if !jobs.trim().is_empty() {
10933 out.push_str("\n=== Active Print Jobs ===\n");
10934 out.push_str(&jobs);
10935 }
10936 }
10937
10938 #[cfg(not(target_os = "windows"))]
10939 {
10940 let _ = max_entries;
10941 out.push_str("Checking LPSTAT for printers...\n");
10942 let lpstat = Command::new("lpstat")
10943 .args(["-p", "-d"])
10944 .output()
10945 .ok()
10946 .and_then(|o| String::from_utf8(o.stdout).ok())
10947 .unwrap_or_default();
10948 if lpstat.is_empty() {
10949 out.push_str(" No CUPS/LP printers found.\n");
10950 } else {
10951 out.push_str(&lpstat);
10952 }
10953 }
10954
10955 Ok(out.trim_end().to_string())
10956}
10957
10958fn inspect_winrm() -> Result<String, String> {
10959 let mut out = String::from("Host inspection: winrm\n\n");
10960
10961 #[cfg(target_os = "windows")]
10962 {
10963 let svc = Command::new("powershell")
10964 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10965 .output()
10966 .ok()
10967 .and_then(|o| String::from_utf8(o.stdout).ok())
10968 .unwrap_or_default()
10969 .trim()
10970 .to_string();
10971 out.push_str(&format!(
10972 "WinRM Service Status: {}\n\n",
10973 if svc.is_empty() { "NOT_FOUND" } else { &svc }
10974 ));
10975
10976 out.push_str("=== WinRM Listeners ===\n");
10977 let output = Command::new("powershell")
10978 .args([
10979 "-NoProfile",
10980 "-Command",
10981 "winrm enumerate winrm/config/listener 2>$null",
10982 ])
10983 .output()
10984 .ok();
10985 if let Some(o) = output {
10986 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10987 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10988
10989 if !stdout.trim().is_empty() {
10990 for line in stdout.lines() {
10991 if line.contains("Address =")
10992 || line.contains("Transport =")
10993 || line.contains("Port =")
10994 {
10995 out.push_str(&format!(" {}\n", line.trim()));
10996 }
10997 }
10998 } else if stderr.contains("Access is denied") {
10999 out.push_str(" Error: Access denied to WinRM configuration.\n");
11000 } else {
11001 out.push_str(" No listeners configured.\n");
11002 }
11003 }
11004
11005 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11006 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))\" }"])
11007 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11008 if test_out.trim().is_empty() {
11009 out.push_str(" WinRM not responding to local WS-Man requests.\n");
11010 } else {
11011 out.push_str(&test_out);
11012 }
11013 }
11014
11015 #[cfg(not(target_os = "windows"))]
11016 {
11017 out.push_str(
11018 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11019 );
11020 let ss = Command::new("ss")
11021 .args(["-tln"])
11022 .output()
11023 .ok()
11024 .and_then(|o| String::from_utf8(o.stdout).ok())
11025 .unwrap_or_default();
11026 if ss.contains(":5985") || ss.contains(":5986") {
11027 out.push_str(" WinRM ports (5985/5986) are listening.\n");
11028 } else {
11029 out.push_str(" WinRM ports not detected.\n");
11030 }
11031 }
11032
11033 Ok(out.trim_end().to_string())
11034}
11035
11036fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11037 let mut out = String::from("Host inspection: network_stats\n\n");
11038
11039 #[cfg(target_os = "windows")]
11040 {
11041 let ps_cmd = format!(
11042 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11043 Start-Sleep -Milliseconds 250; \
11044 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11045 $s2 | ForEach-Object {{ \
11046 $name = $_.Name; \
11047 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11048 if ($prev) {{ \
11049 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11050 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11051 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11052 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11053 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11054 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11055 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11056 }} \
11057 }}",
11058 max_entries
11059 );
11060 let output = Command::new("powershell")
11061 .args(["-NoProfile", "-Command", &ps_cmd])
11062 .output()
11063 .ok()
11064 .and_then(|o| String::from_utf8(o.stdout).ok())
11065 .unwrap_or_default();
11066 if output.trim().is_empty() {
11067 out.push_str("No network adapter statistics available.\n");
11068 } else {
11069 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11070 out.push_str(&output);
11071 }
11072
11073 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)\" } }"])
11074 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11075 if !discards.trim().is_empty() {
11076 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11077 out.push_str(&discards);
11078 }
11079 }
11080
11081 #[cfg(not(target_os = "windows"))]
11082 {
11083 let _ = max_entries;
11084 out.push_str("=== Network Stats (ip -s link) ===\n");
11085 let ip_s = Command::new("ip")
11086 .args(["-s", "link"])
11087 .output()
11088 .ok()
11089 .and_then(|o| String::from_utf8(o.stdout).ok())
11090 .unwrap_or_default();
11091 if ip_s.is_empty() {
11092 let netstat = Command::new("netstat")
11093 .args(["-i"])
11094 .output()
11095 .ok()
11096 .and_then(|o| String::from_utf8(o.stdout).ok())
11097 .unwrap_or_default();
11098 out.push_str(&netstat);
11099 } else {
11100 out.push_str(&ip_s);
11101 }
11102 }
11103
11104 Ok(out.trim_end().to_string())
11105}
11106
11107fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11108 let mut out = String::from("Host inspection: udp_ports\n\n");
11109
11110 #[cfg(target_os = "windows")]
11111 {
11112 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);
11113 let output = Command::new("powershell")
11114 .args(["-NoProfile", "-Command", &ps_cmd])
11115 .output()
11116 .ok();
11117
11118 if let Some(o) = output {
11119 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11120 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11121
11122 if !stdout.trim().is_empty() {
11123 out.push_str("=== UDP Listeners (Local:Port) ===\n");
11124 for line in stdout.lines() {
11125 let mut note = "";
11126 if line.contains(":53 ") {
11127 note = " [DNS]";
11128 } else if line.contains(":67 ") || line.contains(":68 ") {
11129 note = " [DHCP]";
11130 } else if line.contains(":123 ") {
11131 note = " [NTP]";
11132 } else if line.contains(":161 ") {
11133 note = " [SNMP]";
11134 } else if line.contains(":1900 ") {
11135 note = " [SSDP/UPnP]";
11136 } else if line.contains(":5353 ") {
11137 note = " [mDNS]";
11138 }
11139
11140 out.push_str(&format!("{}{}\n", line, note));
11141 }
11142 } else if stderr.contains("Access is denied") {
11143 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11144 } else {
11145 out.push_str("No UDP listeners detected.\n");
11146 }
11147 }
11148 }
11149
11150 #[cfg(not(target_os = "windows"))]
11151 {
11152 let ss_out = Command::new("ss")
11153 .args(["-ulnp"])
11154 .output()
11155 .ok()
11156 .and_then(|o| String::from_utf8(o.stdout).ok())
11157 .unwrap_or_default();
11158 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11159 if ss_out.is_empty() {
11160 let netstat_out = Command::new("netstat")
11161 .args(["-ulnp"])
11162 .output()
11163 .ok()
11164 .and_then(|o| String::from_utf8(o.stdout).ok())
11165 .unwrap_or_default();
11166 if netstat_out.is_empty() {
11167 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
11168 } else {
11169 for line in netstat_out.lines().take(max_entries) {
11170 out.push_str(&format!(" {}\n", line));
11171 }
11172 }
11173 } else {
11174 for line in ss_out.lines().take(max_entries) {
11175 out.push_str(&format!(" {}\n", line));
11176 }
11177 }
11178 }
11179
11180 Ok(out.trim_end().to_string())
11181}
11182
11183fn inspect_gpo() -> Result<String, String> {
11184 let mut out = String::from("Host inspection: gpo\n\n");
11185
11186 #[cfg(target_os = "windows")]
11187 {
11188 let output = Command::new("gpresult")
11189 .args(["/r", "/scope", "computer"])
11190 .output()
11191 .ok();
11192
11193 if let Some(o) = output {
11194 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11195 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11196
11197 if stdout.contains("Applied Group Policy Objects") {
11198 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11199 let mut capture = false;
11200 for line in stdout.lines() {
11201 if line.contains("Applied Group Policy Objects") {
11202 capture = true;
11203 } else if capture && line.contains("The following GPOs were not applied") {
11204 break;
11205 }
11206 if capture && !line.trim().is_empty() {
11207 out.push_str(&format!(" {}\n", line.trim()));
11208 }
11209 }
11210 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11211 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11212 } else {
11213 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11214 }
11215 }
11216 }
11217
11218 #[cfg(not(target_os = "windows"))]
11219 {
11220 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11221 }
11222
11223 Ok(out.trim_end().to_string())
11224}
11225
11226fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11227 let mut out = String::from("Host inspection: certificates\n\n");
11228
11229 #[cfg(target_os = "windows")]
11230 {
11231 let ps_cmd = format!(
11232 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11233 $days = ($_.NotAfter - (Get-Date)).Days; \
11234 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11235 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11236 }}",
11237 max_entries
11238 );
11239 let output = Command::new("powershell")
11240 .args(["-NoProfile", "-Command", &ps_cmd])
11241 .output()
11242 .ok();
11243
11244 if let Some(o) = output {
11245 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11246 if !stdout.trim().is_empty() {
11247 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11248 out.push_str(&stdout);
11249 } else {
11250 out.push_str("No certificates found in the Local Machine Personal store.\n");
11251 }
11252 }
11253 }
11254
11255 #[cfg(not(target_os = "windows"))]
11256 {
11257 let _ = max_entries;
11258 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11259 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11261 if Path::new(path).exists() {
11262 out.push_str(&format!(" Cert directory found: {}\n", path));
11263 }
11264 }
11265 }
11266
11267 Ok(out.trim_end().to_string())
11268}
11269
11270fn inspect_integrity() -> Result<String, String> {
11271 let mut out = String::from("Host inspection: integrity\n\n");
11272
11273 #[cfg(target_os = "windows")]
11274 {
11275 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11276 let output = Command::new("powershell")
11277 .args(["-NoProfile", "-Command", &ps_cmd])
11278 .output()
11279 .ok();
11280
11281 if let Some(o) = output {
11282 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11283 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11284 out.push_str("=== Windows Component Store Health (CBS) ===\n");
11285 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11286 let repair = val
11287 .get("AutoRepairNeeded")
11288 .and_then(|v| v.as_u64())
11289 .unwrap_or(0);
11290
11291 out.push_str(&format!(
11292 " Corruption Detected: {}\n",
11293 if corrupt != 0 {
11294 "YES (SFC/DISM recommended)"
11295 } else {
11296 "No"
11297 }
11298 ));
11299 out.push_str(&format!(
11300 " Auto-Repair Needed: {}\n",
11301 if repair != 0 { "YES" } else { "No" }
11302 ));
11303
11304 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11305 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
11306 }
11307 } else {
11308 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11309 }
11310 }
11311
11312 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11313 out.push_str(
11314 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11315 );
11316 }
11317 }
11318
11319 #[cfg(not(target_os = "windows"))]
11320 {
11321 out.push_str("System integrity check (Linux)\n\n");
11322 let pkg_check = Command::new("rpm")
11323 .args(["-Va"])
11324 .output()
11325 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11326 .ok();
11327 if let Some(o) = pkg_check {
11328 out.push_str(" Package verification system active.\n");
11329 if o.status.success() {
11330 out.push_str(" No major package integrity issues detected.\n");
11331 }
11332 }
11333 }
11334
11335 Ok(out.trim_end().to_string())
11336}
11337
11338fn inspect_domain() -> Result<String, String> {
11339 let mut out = String::from("Host inspection: domain\n\n");
11340
11341 #[cfg(target_os = "windows")]
11342 {
11343 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11344 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11345 let output = Command::new("powershell")
11346 .args(["-NoProfile", "-Command", &ps_cmd])
11347 .output()
11348 .ok();
11349
11350 if let Some(o) = output {
11351 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11352 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11353 let part_of_domain = val
11354 .get("PartOfDomain")
11355 .and_then(|v| v.as_bool())
11356 .unwrap_or(false);
11357 let domain = val
11358 .get("Domain")
11359 .and_then(|v| v.as_str())
11360 .unwrap_or("Unknown");
11361 let workgroup = val
11362 .get("Workgroup")
11363 .and_then(|v| v.as_str())
11364 .unwrap_or("Unknown");
11365
11366 out.push_str(&format!(
11367 " Join Status: {}\n",
11368 if part_of_domain {
11369 "DOMAIN JOINED"
11370 } else {
11371 "WORKGROUP"
11372 }
11373 ));
11374 if part_of_domain {
11375 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
11376 } else {
11377 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
11378 }
11379
11380 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11381 out.push_str(&format!(" NetBIOS Name: {}\n", name));
11382 }
11383 } else {
11384 out.push_str(" Domain identity data unavailable from WMI.\n");
11385 }
11386 } else {
11387 out.push_str(" Domain identity data unavailable from WMI.\n");
11388 }
11389 }
11390
11391 #[cfg(not(target_os = "windows"))]
11392 {
11393 let domainname = Command::new("domainname")
11394 .output()
11395 .ok()
11396 .and_then(|o| String::from_utf8(o.stdout).ok())
11397 .unwrap_or_default();
11398 out.push_str("=== Linux Domain Identity ===\n");
11399 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11400 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
11401 } else {
11402 out.push_str(" No NIS domain configured.\n");
11403 }
11404 }
11405
11406 Ok(out.trim_end().to_string())
11407}
11408
11409fn inspect_device_health() -> Result<String, String> {
11410 let mut out = String::from("Host inspection: device_health\n\n");
11411
11412 #[cfg(target_os = "windows")]
11413 {
11414 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)\" }";
11415 let output = Command::new("powershell")
11416 .args(["-NoProfile", "-Command", ps_cmd])
11417 .output()
11418 .ok()
11419 .and_then(|o| String::from_utf8(o.stdout).ok())
11420 .unwrap_or_default();
11421
11422 if output.trim().is_empty() {
11423 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11424 } else {
11425 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11426 out.push_str(&output);
11427 out.push_str(
11428 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11429 );
11430 }
11431 }
11432
11433 #[cfg(not(target_os = "windows"))]
11434 {
11435 out.push_str("Checking dmesg for hardware errors...\n");
11436 let dmesg = Command::new("dmesg")
11437 .args(["--level=err,crit,alert"])
11438 .output()
11439 .ok()
11440 .and_then(|o| String::from_utf8(o.stdout).ok())
11441 .unwrap_or_default();
11442 if dmesg.is_empty() {
11443 out.push_str(" No critical hardware errors found in dmesg.\n");
11444 } else {
11445 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
11446 }
11447 }
11448
11449 Ok(out.trim_end().to_string())
11450}
11451
11452fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11453 let mut out = String::from("Host inspection: drivers\n\n");
11454
11455 #[cfg(target_os = "windows")]
11456 {
11457 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11458 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);
11459 let output = Command::new("powershell")
11460 .args(["-NoProfile", "-Command", &ps_cmd])
11461 .output()
11462 .ok()
11463 .and_then(|o| String::from_utf8(o.stdout).ok())
11464 .unwrap_or_default();
11465
11466 if output.trim().is_empty() {
11467 out.push_str(" No drivers retrieved via WMI.\n");
11468 } else {
11469 out.push_str(&output);
11470 }
11471 }
11472
11473 #[cfg(not(target_os = "windows"))]
11474 {
11475 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11476 let lsmod = Command::new("lsmod")
11477 .output()
11478 .ok()
11479 .and_then(|o| String::from_utf8(o.stdout).ok())
11480 .unwrap_or_default();
11481 out.push_str(
11482 &lsmod
11483 .lines()
11484 .take(max_entries)
11485 .collect::<Vec<_>>()
11486 .join("\n"),
11487 );
11488 }
11489
11490 Ok(out.trim_end().to_string())
11491}
11492
11493fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11494 let mut out = String::from("Host inspection: peripherals\n\n");
11495
11496 #[cfg(target_os = "windows")]
11497 {
11498 let _ = max_entries;
11499 out.push_str("=== USB Controllers & Hubs ===\n");
11500 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
11501 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11502 out.push_str(if usb.is_empty() {
11503 " None detected.\n"
11504 } else {
11505 &usb
11506 });
11507
11508 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11509 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
11510 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11511 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
11512 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11513 out.push_str(&kb);
11514 out.push_str(&mouse);
11515
11516 out.push_str("\n=== Connected Monitors (WMI) ===\n");
11517 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11518 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11519 out.push_str(if mon.is_empty() {
11520 " No active monitors identified via WMI.\n"
11521 } else {
11522 &mon
11523 });
11524 }
11525
11526 #[cfg(not(target_os = "windows"))]
11527 {
11528 out.push_str("=== Connected USB Devices (lsusb) ===\n");
11529 let lsusb = Command::new("lsusb")
11530 .output()
11531 .ok()
11532 .and_then(|o| String::from_utf8(o.stdout).ok())
11533 .unwrap_or_default();
11534 out.push_str(
11535 &lsusb
11536 .lines()
11537 .take(max_entries)
11538 .collect::<Vec<_>>()
11539 .join("\n"),
11540 );
11541 }
11542
11543 Ok(out.trim_end().to_string())
11544}
11545
11546fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11547 let mut out = String::from("Host inspection: sessions\n\n");
11548
11549 #[cfg(target_os = "windows")]
11550 {
11551 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11552 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11553 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11554}"#;
11555 if let Ok(o) = Command::new("powershell")
11556 .args(["-NoProfile", "-Command", script])
11557 .output()
11558 {
11559 let text = String::from_utf8_lossy(&o.stdout);
11560 let lines: Vec<&str> = text.lines().collect();
11561 if lines.is_empty() {
11562 out.push_str(" No active logon sessions enumerated via WMI.\n");
11563 } else {
11564 for line in lines
11565 .iter()
11566 .take(max_entries)
11567 .filter(|l| !l.trim().is_empty())
11568 {
11569 let parts: Vec<&str> = line.trim().split('|').collect();
11570 if parts.len() == 4 {
11571 let logon_type = match parts[2] {
11572 "2" => "Interactive",
11573 "3" => "Network",
11574 "4" => "Batch",
11575 "5" => "Service",
11576 "7" => "Unlock",
11577 "8" => "NetworkCleartext",
11578 "9" => "NewCredentials",
11579 "10" => "RemoteInteractive",
11580 "11" => "CachedInteractive",
11581 _ => "Other",
11582 };
11583 out.push_str(&format!(
11584 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
11585 parts[0], logon_type, parts[1], parts[3]
11586 ));
11587 }
11588 }
11589 }
11590 } else {
11591 out.push_str(" Active logon session data unavailable from WMI.\n");
11592 }
11593 }
11594
11595 #[cfg(not(target_os = "windows"))]
11596 {
11597 out.push_str("=== Logged-in Users (who) ===\n");
11598 let who = Command::new("who")
11599 .output()
11600 .ok()
11601 .and_then(|o| String::from_utf8(o.stdout).ok())
11602 .unwrap_or_default();
11603 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
11604 }
11605
11606 Ok(out.trim_end().to_string())
11607}
11608
11609async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11610 let mut out = String::from("Host inspection: disk_benchmark\n\n");
11611 let mut final_path = path;
11612
11613 if !final_path.exists() {
11614 if let Ok(current_exe) = std::env::current_exe() {
11615 out.push_str(&format!(
11616 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
11617 final_path.display()
11618 ));
11619 final_path = current_exe;
11620 } else {
11621 return Err(format!("Target not found: {}", final_path.display()));
11622 }
11623 }
11624
11625 let target = if final_path.is_dir() {
11626 let mut target_file = final_path.join("Cargo.toml");
11628 if !target_file.exists() {
11629 target_file = final_path.join("README.md");
11630 }
11631 if !target_file.exists() {
11632 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11633 }
11634 target_file
11635 } else {
11636 final_path
11637 };
11638
11639 out.push_str(&format!("Target: {}\n", target.display()));
11640 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11641
11642 #[cfg(target_os = "windows")]
11643 {
11644 let script = format!(
11645 r#"
11646$target = "{}"
11647if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11648
11649$diskQueue = @()
11650$readStats = @()
11651$startTime = Get-Date
11652$duration = 5
11653
11654# Background reader job
11655$job = Start-Job -ScriptBlock {{
11656 param($t, $d)
11657 $stop = (Get-Date).AddSeconds($d)
11658 while ((Get-Date) -lt $stop) {{
11659 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11660 }}
11661}} -ArgumentList $target, $duration
11662
11663# Metrics collector loop
11664$stopTime = (Get-Date).AddSeconds($duration)
11665while ((Get-Date) -lt $stopTime) {{
11666 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11667 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11668
11669 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11670 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11671
11672 Start-Sleep -Milliseconds 250
11673}}
11674
11675Stop-Job $job
11676Receive-Job $job | Out-Null
11677Remove-Job $job
11678
11679$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11680$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11681$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11682
11683"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11684"#,
11685 target.display()
11686 );
11687
11688 let output = Command::new("powershell")
11689 .args(["-NoProfile", "-Command", &script])
11690 .output()
11691 .map_err(|e| format!("Benchmark failed: {e}"))?;
11692
11693 let raw = String::from_utf8_lossy(&output.stdout);
11694 let text = raw.trim();
11695
11696 if text.starts_with("ERROR") {
11697 return Err(text.to_string());
11698 }
11699
11700 let mut lines = text.lines();
11701 if let Some(metrics_line) = lines.next() {
11702 let parts: Vec<&str> = metrics_line.split('|').collect();
11703 let mut avg_q = "unknown".to_string();
11704 let mut max_q = "unknown".to_string();
11705 let mut avg_r = "unknown".to_string();
11706
11707 for p in parts {
11708 if let Some((k, v)) = p.split_once(':') {
11709 match k {
11710 "AVG_Q" => avg_q = v.to_string(),
11711 "MAX_Q" => max_q = v.to_string(),
11712 "AVG_R" => avg_r = v.to_string(),
11713 _ => {}
11714 }
11715 }
11716 }
11717
11718 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11719 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11720 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11721 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
11722 out.push_str("\nVerdict: ");
11723 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11724 if q_num > 1.0 {
11725 out.push_str(
11726 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11727 );
11728 } else if q_num > 0.1 {
11729 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11730 } else {
11731 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11732 }
11733 }
11734 }
11735
11736 #[cfg(not(target_os = "windows"))]
11737 {
11738 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11739 out.push_str("Generic disk load simulated.\n");
11740 }
11741
11742 Ok(out)
11743}
11744
11745fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11746 let mut out = String::from("Host inspection: permissions\n\n");
11747 out.push_str(&format!(
11748 "Auditing access control for: {}\n\n",
11749 path.display()
11750 ));
11751
11752 #[cfg(target_os = "windows")]
11753 {
11754 let script = format!(
11755 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11756 path.display()
11757 );
11758 let output = Command::new("powershell")
11759 .args(["-NoProfile", "-Command", &script])
11760 .output()
11761 .map_err(|e| format!("ACL check failed: {e}"))?;
11762
11763 let text = String::from_utf8_lossy(&output.stdout);
11764 if text.trim().is_empty() {
11765 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11766 } else {
11767 out.push_str("=== Windows NTFS Permissions ===\n");
11768 out.push_str(&text);
11769 }
11770 }
11771
11772 #[cfg(not(target_os = "windows"))]
11773 {
11774 let output = Command::new("ls")
11775 .args(["-ld", &path.to_string_lossy()])
11776 .output()
11777 .map_err(|e| format!("ls check failed: {e}"))?;
11778 out.push_str("=== Unix File Permissions ===\n");
11779 out.push_str(&String::from_utf8_lossy(&output.stdout));
11780 }
11781
11782 Ok(out.trim_end().to_string())
11783}
11784
11785fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11786 let mut out = String::from("Host inspection: login_history\n\n");
11787
11788 #[cfg(target_os = "windows")]
11789 {
11790 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11791 out.push_str("Note: This typically requires Administrator elevation.\n\n");
11792
11793 let n = max_entries.clamp(1, 50);
11794 let script = format!(
11795 r#"try {{
11796 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11797 $events | ForEach-Object {{
11798 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11799 # Extract target user name from the XML/Properties if possible
11800 $user = $_.Properties[5].Value
11801 $type = $_.Properties[8].Value
11802 "[$time] User: $user | Type: $type"
11803 }}
11804}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11805 );
11806
11807 let output = Command::new("powershell")
11808 .args(["-NoProfile", "-Command", &script])
11809 .output()
11810 .map_err(|e| format!("Login history query failed: {e}"))?;
11811
11812 let text = String::from_utf8_lossy(&output.stdout);
11813 if text.starts_with("ERROR:") {
11814 out.push_str(&format!("Unable to query Security Log: {}\n", text));
11815 } else if text.trim().is_empty() {
11816 out.push_str("No recent logon events found or access denied.\n");
11817 } else {
11818 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11819 out.push_str(&text);
11820 }
11821 }
11822
11823 #[cfg(not(target_os = "windows"))]
11824 {
11825 let output = Command::new("last")
11826 .args(["-n", &max_entries.to_string()])
11827 .output()
11828 .map_err(|e| format!("last command failed: {e}"))?;
11829 out.push_str("=== Unix Login History (last) ===\n");
11830 out.push_str(&String::from_utf8_lossy(&output.stdout));
11831 }
11832
11833 Ok(out.trim_end().to_string())
11834}
11835
11836fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11837 let mut out = String::from("Host inspection: share_access\n\n");
11838 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11839
11840 #[cfg(target_os = "windows")]
11841 {
11842 let script = format!(
11843 r#"
11844$p = '{}'
11845$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11846if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11847 $res.Reachable = $true
11848 try {{
11849 $null = Get-ChildItem -Path $p -ErrorAction Stop
11850 $res.Readable = $true
11851 }} catch {{
11852 $res.Error = $_.Exception.Message
11853 }}
11854}} else {{
11855 $res.Error = "Server unreachable (Ping failed)"
11856}}
11857"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11858 path.display()
11859 );
11860
11861 let output = Command::new("powershell")
11862 .args(["-NoProfile", "-Command", &script])
11863 .output()
11864 .map_err(|e| format!("Share test failed: {e}"))?;
11865
11866 let text = String::from_utf8_lossy(&output.stdout);
11867 out.push_str("=== Share Triage Results ===\n");
11868 out.push_str(&text);
11869 }
11870
11871 #[cfg(not(target_os = "windows"))]
11872 {
11873 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11874 }
11875
11876 Ok(out.trim_end().to_string())
11877}
11878
11879fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11880 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11881 out.push_str(&format!("Issue: {}\n\n", issue));
11882 out.push_str("Proposed Remediation Steps:\n");
11883 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11884 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11885 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11886 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11887 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11888 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11889 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11890 out.push_str(
11891 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11892 );
11893
11894 Ok(out)
11895}
11896
11897fn inspect_registry_audit() -> Result<String, String> {
11898 let mut out = String::from("Host inspection: registry_audit\n\n");
11899 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11900
11901 #[cfg(target_os = "windows")]
11902 {
11903 let script = r#"
11904$findings = @()
11905
11906# 1. Image File Execution Options (Debugger Hijacking)
11907$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11908if (Test-Path $ifeo) {
11909 Get-ChildItem $ifeo | ForEach-Object {
11910 $p = Get-ItemProperty $_.PSPath
11911 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11912 }
11913}
11914
11915# 2. Winlogon Shell Integrity
11916$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11917$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11918if ($shell -and $shell -ne "explorer.exe") {
11919 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11920}
11921
11922# 3. Session Manager BootExecute
11923$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11924$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11925if ($boot -and $boot -notcontains "autocheck autochk *") {
11926 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11927}
11928
11929if ($findings.Count -eq 0) {
11930 "PASS: No common registry hijacking or shell overrides detected."
11931} else {
11932 $findings -join "`n"
11933}
11934"#;
11935 let output = Command::new("powershell")
11936 .args(["-NoProfile", "-Command", &script])
11937 .output()
11938 .map_err(|e| format!("Registry audit failed: {e}"))?;
11939
11940 let text = String::from_utf8_lossy(&output.stdout);
11941 out.push_str("=== Persistence & Integrity Check ===\n");
11942 out.push_str(&text);
11943 }
11944
11945 #[cfg(not(target_os = "windows"))]
11946 {
11947 out.push_str("Registry auditing is specific to Windows environments.\n");
11948 }
11949
11950 Ok(out.trim_end().to_string())
11951}
11952
11953fn inspect_thermal() -> Result<String, String> {
11954 let mut out = String::from("Host inspection: thermal\n\n");
11955 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11956
11957 #[cfg(target_os = "windows")]
11958 {
11959 let script = r#"
11960$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11961if ($thermal) {
11962 $thermal | ForEach-Object {
11963 $temp = [math]::Round(($_.Temperature - 273.15), 1)
11964 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11965 }
11966} else {
11967 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11968 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11969 "Current CPU Load: $throttling%"
11970}
11971"#;
11972 let output = Command::new("powershell")
11973 .args(["-NoProfile", "-Command", script])
11974 .output()
11975 .map_err(|e| format!("Thermal check failed: {e}"))?;
11976 out.push_str("=== Windows Thermal State ===\n");
11977 out.push_str(&String::from_utf8_lossy(&output.stdout));
11978 }
11979
11980 #[cfg(not(target_os = "windows"))]
11981 {
11982 out.push_str(
11983 "Thermal inspection is currently optimized for Windows performance counters.\n",
11984 );
11985 }
11986
11987 Ok(out.trim_end().to_string())
11988}
11989
11990fn inspect_activation() -> Result<String, String> {
11991 let mut out = String::from("Host inspection: activation\n\n");
11992 out.push_str("Auditing Windows activation and license state...\n\n");
11993
11994 #[cfg(target_os = "windows")]
11995 {
11996 let script = r#"
11997$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11998$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11999"Status: $($xpr.Trim())"
12000"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12001"#;
12002 let output = Command::new("powershell")
12003 .args(["-NoProfile", "-Command", script])
12004 .output()
12005 .map_err(|e| format!("Activation check failed: {e}"))?;
12006 out.push_str("=== Windows License Report ===\n");
12007 out.push_str(&String::from_utf8_lossy(&output.stdout));
12008 }
12009
12010 #[cfg(not(target_os = "windows"))]
12011 {
12012 out.push_str("Windows activation check is specific to the Windows platform.\n");
12013 }
12014
12015 Ok(out.trim_end().to_string())
12016}
12017
12018fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12019 let mut out = String::from("Host inspection: patch_history\n\n");
12020 out.push_str(&format!(
12021 "Listing the last {} installed Windows updates (KBs)...\n\n",
12022 max_entries
12023 ));
12024
12025 #[cfg(target_os = "windows")]
12026 {
12027 let n = max_entries.clamp(1, 50);
12028 let script = format!(
12029 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12030 n
12031 );
12032 let output = Command::new("powershell")
12033 .args(["-NoProfile", "-Command", &script])
12034 .output()
12035 .map_err(|e| format!("Patch history query failed: {e}"))?;
12036 out.push_str("=== Recent HotFixes (KBs) ===\n");
12037 out.push_str(&String::from_utf8_lossy(&output.stdout));
12038 }
12039
12040 #[cfg(not(target_os = "windows"))]
12041 {
12042 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12043 }
12044
12045 Ok(out.trim_end().to_string())
12046}
12047
12048fn inspect_ad_user(identity: &str) -> Result<String, String> {
12051 let mut out = String::from("Host inspection: ad_user\n\n");
12052 let ident = identity.trim();
12053 if ident.is_empty() {
12054 out.push_str("Status: No identity specified. Performing self-discovery...\n");
12055 #[cfg(target_os = "windows")]
12056 {
12057 let script = r#"
12058$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12059"USER: " + $u.Name
12060"SID: " + $u.User.Value
12061"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12062"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12063"#;
12064 let output = Command::new("powershell")
12065 .args(["-NoProfile", "-Command", script])
12066 .output()
12067 .ok();
12068 if let Some(o) = output {
12069 out.push_str(&String::from_utf8_lossy(&o.stdout));
12070 }
12071 }
12072 return Ok(out);
12073 }
12074
12075 #[cfg(target_os = "windows")]
12076 {
12077 let script = format!(
12078 r#"
12079try {{
12080 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12081 "NAME: " + $u.Name
12082 "SID: " + $u.SID
12083 "ENABLED: " + $u.Enabled
12084 "EXPIRED: " + $u.PasswordExpired
12085 "LOGON: " + $u.LastLogonDate
12086 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
12087}} catch {{
12088 # Fallback to net user if AD module is missing or fails
12089 $net = net user "{ident}" /domain 2>&1
12090 if ($LASTEXITCODE -eq 0) {{
12091 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12092 }} else {{
12093 "ERROR: " + $_.Exception.Message
12094 }}
12095}}"#
12096 );
12097
12098 let output = Command::new("powershell")
12099 .args(["-NoProfile", "-Command", &script])
12100 .output()
12101 .ok();
12102
12103 if let Some(o) = output {
12104 let stdout = String::from_utf8_lossy(&o.stdout);
12105 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12106 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12107 }
12108 out.push_str(&stdout);
12109 }
12110 }
12111
12112 #[cfg(not(target_os = "windows"))]
12113 {
12114 let _ = ident;
12115 out.push_str("(AD User lookup only available on Windows nodes)\n");
12116 }
12117
12118 Ok(out.trim_end().to_string())
12119}
12120
12121fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12124 let mut out = String::from("Host inspection: dns_lookup\n\n");
12125 let target = name.trim();
12126 if target.is_empty() {
12127 return Err("Missing required target name for dns_lookup.".to_string());
12128 }
12129
12130 #[cfg(target_os = "windows")]
12131 {
12132 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12133 let output = Command::new("powershell")
12134 .args(["-NoProfile", "-Command", &script])
12135 .output()
12136 .ok();
12137 if let Some(o) = output {
12138 let stdout = String::from_utf8_lossy(&o.stdout);
12139 if stdout.trim().is_empty() {
12140 out.push_str(&format!("No {record_type} records found for {target}.\n"));
12141 } else {
12142 out.push_str(&stdout);
12143 }
12144 }
12145 }
12146
12147 #[cfg(not(target_os = "windows"))]
12148 {
12149 let output = Command::new("dig")
12150 .args([target, record_type, "+short"])
12151 .output()
12152 .ok();
12153 if let Some(o) = output {
12154 out.push_str(&String::from_utf8_lossy(&o.stdout));
12155 }
12156 }
12157
12158 Ok(out.trim_end().to_string())
12159}
12160
12161#[cfg(target_os = "windows")]
12164fn ps_exec(script: &str) -> String {
12165 Command::new("powershell")
12166 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12167 .output()
12168 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12169 .unwrap_or_default()
12170}
12171
12172fn inspect_mdm_enrollment() -> Result<String, String> {
12173 #[cfg(target_os = "windows")]
12174 {
12175 let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12176
12177 out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12179 let ps_dsreg = r#"
12180$raw = dsregcmd /status 2>$null
12181$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12182 'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12183foreach ($line in $raw) {
12184 $t = $line.Trim()
12185 foreach ($f in $fields) {
12186 if ($t -like "$f :*") {
12187 $val = ($t -split ':',2)[1].Trim()
12188 "$f`: $val"
12189 }
12190 }
12191}
12192"#;
12193 match run_powershell(ps_dsreg) {
12194 Ok(o) if !o.trim().is_empty() => {
12195 for line in o.lines() {
12196 let l = line.trim();
12197 if !l.is_empty() {
12198 out.push_str(&format!("- {l}\n"));
12199 }
12200 }
12201 }
12202 Ok(_) => out.push_str(
12203 "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12204 ),
12205 Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
12206 }
12207
12208 out.push_str("\n=== Enrollment accounts (registry) ===\n");
12210 let ps_enroll = r#"
12211$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12212if (Test-Path $base) {
12213 $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12214 if ($accounts) {
12215 foreach ($acct in $accounts) {
12216 $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12217 $upn = if ($p.UPN) { $p.UPN } else { '(none)' }
12218 $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12219 $type = switch ($p.EnrollmentType) {
12220 6 { 'MDM' }
12221 13 { 'MAM' }
12222 default { "Type=$($p.EnrollmentType)" }
12223 }
12224 $state = switch ($p.EnrollmentState) {
12225 1 { 'Enrolled' }
12226 2 { 'InProgress' }
12227 6 { 'Unenrolled' }
12228 default { "State=$($p.EnrollmentState)" }
12229 }
12230 "Account: $upn | $type | $state | $server"
12231 }
12232 } else { "No enrollment accounts found under $base" }
12233} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12234"#;
12235 match run_powershell(ps_enroll) {
12236 Ok(o) => {
12237 for line in o.lines() {
12238 let l = line.trim();
12239 if !l.is_empty() {
12240 out.push_str(&format!("- {l}\n"));
12241 }
12242 }
12243 }
12244 Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
12245 }
12246
12247 out.push_str("\n=== MDM services ===\n");
12249 let ps_svc = r#"
12250$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12251foreach ($n in $names) {
12252 $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12253 if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12254}
12255"#;
12256 match run_powershell(ps_svc) {
12257 Ok(o) if !o.trim().is_empty() => {
12258 for line in o.lines() {
12259 let l = line.trim();
12260 if !l.is_empty() {
12261 out.push_str(&format!("- {l}\n"));
12262 }
12263 }
12264 }
12265 Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12266 Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
12267 }
12268
12269 out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12271 let ps_evt = r#"
12272$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12273 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12274$cutoff = (Get-Date).AddHours(-24)
12275$found = $false
12276foreach ($log in $logs) {
12277 $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12278 Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12279 foreach ($e in $evts) {
12280 $found = $true
12281 $ts = $e.TimeCreated.ToString('HH:mm')
12282 $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12283 "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12284 }
12285}
12286if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12287"#;
12288 match run_powershell(ps_evt) {
12289 Ok(o) => {
12290 for line in o.lines() {
12291 let l = line.trim();
12292 if !l.is_empty() {
12293 out.push_str(&format!("- {l}\n"));
12294 }
12295 }
12296 }
12297 Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
12298 }
12299
12300 out.push_str("\n=== Findings ===\n");
12302 let body = out.clone();
12303 let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12304 let intune_running = body.contains("IntuneManagementExtension: Running");
12305 let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12306
12307 if !enrolled {
12308 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");
12309 } else {
12310 out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12311 if !intune_running {
12312 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");
12313 }
12314 }
12315 if has_errors {
12316 out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12317 }
12318 if !enrolled && !has_errors {
12319 out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12320 }
12321
12322 Ok(out)
12323 }
12324
12325 #[cfg(not(target_os = "windows"))]
12326 {
12327 Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12328 }
12329}
12330
12331fn inspect_hyperv() -> Result<String, String> {
12332 #[cfg(target_os = "windows")]
12333 {
12334 let mut findings: Vec<String> = Vec::new();
12335 let mut out = String::new();
12336
12337 let ps_role = r#"
12339$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12340$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12341$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12342$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12343"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12344 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12345 $(if ($feature) { $feature.State } else { "Unknown" }),
12346 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12347 $(if ($ram) { $ram } else { "0" })
12348"#;
12349 let role_out = ps_exec(ps_role);
12350 out.push_str("=== Hyper-V role state ===\n");
12351
12352 let mut vmms_running = false;
12353 let mut host_ram_bytes: u64 = 0;
12354
12355 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12356 let kv: std::collections::HashMap<&str, &str> = line
12357 .split('|')
12358 .filter_map(|p| {
12359 let mut it = p.splitn(2, ':');
12360 Some((it.next()?, it.next()?))
12361 })
12362 .collect();
12363 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12364 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12365 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12366 host_ram_bytes = kv
12367 .get("HostRAMBytes")
12368 .and_then(|v| v.parse().ok())
12369 .unwrap_or(0);
12370
12371 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12372 vmms_running = vmms_status.starts_with("Running");
12373
12374 out.push_str(&format!("- Host: {host_name}\n"));
12375 out.push_str(&format!(
12376 "- Hyper-V feature: {}\n",
12377 if hyperv_installed {
12378 "Enabled"
12379 } else {
12380 "Not installed"
12381 }
12382 ));
12383 out.push_str(&format!("- VMMS service: {vmms_status}\n"));
12384 if host_ram_bytes > 0 {
12385 out.push_str(&format!(
12386 "- Host physical RAM: {} GB\n",
12387 host_ram_bytes / 1_073_741_824
12388 ));
12389 }
12390
12391 if !hyperv_installed {
12392 findings.push(
12393 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12394 );
12395 } else if !vmms_running {
12396 findings.push(
12397 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12398 );
12399 }
12400 } else {
12401 out.push_str("- Could not determine Hyper-V role state\n");
12402 findings.push("Hyper-V does not appear to be installed on this machine.".into());
12403 }
12404
12405 out.push_str("\n=== Virtual machines ===\n");
12407 if vmms_running {
12408 let ps_vms = r#"
12409Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12410 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12411 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12412 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12413 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12414 $_.Status, $_.Generation
12415}
12416"#;
12417 let vms_out = ps_exec(ps_vms);
12418 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12419
12420 if vm_lines.is_empty() {
12421 out.push_str("- No virtual machines found on this host\n");
12422 } else {
12423 let mut total_ram_bytes: u64 = 0;
12424 let mut saved_vms: Vec<String> = Vec::new();
12425 for line in &vm_lines {
12426 let kv: std::collections::HashMap<&str, &str> = line
12427 .split('|')
12428 .filter_map(|p| {
12429 let mut it = p.splitn(2, ':');
12430 Some((it.next()?, it.next()?))
12431 })
12432 .collect();
12433 let name = kv.get("VM").copied().unwrap_or("Unknown");
12434 let state = kv.get("State").copied().unwrap_or("Unknown");
12435 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12436 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12437 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12438 let status = kv.get("Status").copied().unwrap_or("");
12439 let gen = kv.get("Generation").copied().unwrap_or("?");
12440
12441 if let Ok(r) = ram.parse::<f64>() {
12442 total_ram_bytes += (r * 1_073_741_824.0) as u64;
12443 }
12444 if state.eq_ignore_ascii_case("Saved") {
12445 saved_vms.push(name.to_string());
12446 }
12447
12448 out.push_str(&format!(
12449 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
12450 ));
12451 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12452 out.push_str(&format!(" Status: {status}\n"));
12453 }
12454 }
12455
12456 out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
12457 if total_ram_bytes > 0 && host_ram_bytes > 0 {
12458 let pct = (total_ram_bytes * 100) / host_ram_bytes;
12459 out.push_str(&format!(
12460 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
12461 total_ram_bytes / 1_073_741_824
12462 ));
12463 if pct > 90 {
12464 findings.push(format!(
12465 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12466 ));
12467 }
12468 }
12469 if !saved_vms.is_empty() {
12470 findings.push(format!(
12471 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12472 saved_vms.join(", ")
12473 ));
12474 }
12475 }
12476 } else {
12477 out.push_str("- VMMS not running — cannot enumerate VMs\n");
12478 }
12479
12480 out.push_str("\n=== VM network switches ===\n");
12482 if vmms_running {
12483 let ps_switches = r#"
12484Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12485 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12486 $_.Name, $_.SwitchType,
12487 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12488}
12489"#;
12490 let sw_out = ps_exec(ps_switches);
12491 let switch_lines: Vec<&str> = sw_out
12492 .lines()
12493 .filter(|l| l.starts_with("Switch:"))
12494 .collect();
12495
12496 if switch_lines.is_empty() {
12497 out.push_str("- No VM switches configured\n");
12498 } else {
12499 for line in &switch_lines {
12500 let kv: std::collections::HashMap<&str, &str> = line
12501 .split('|')
12502 .filter_map(|p| {
12503 let mut it = p.splitn(2, ':');
12504 Some((it.next()?, it.next()?))
12505 })
12506 .collect();
12507 let name = kv.get("Switch").copied().unwrap_or("Unknown");
12508 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12509 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12510 out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
12511 }
12512 }
12513 } else {
12514 out.push_str("- VMMS not running — cannot enumerate switches\n");
12515 }
12516
12517 out.push_str("\n=== VM checkpoints ===\n");
12519 if vmms_running {
12520 let ps_checkpoints = r#"
12521$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12522if ($all) {
12523 $all | ForEach-Object {
12524 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12525 $_.Name, $_.VMName,
12526 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12527 $_.SnapshotType
12528 }
12529} else {
12530 "NONE"
12531}
12532"#;
12533 let cp_out = ps_exec(ps_checkpoints);
12534 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12535 out.push_str("- No checkpoints found\n");
12536 } else {
12537 let cp_lines: Vec<&str> = cp_out
12538 .lines()
12539 .filter(|l| l.starts_with("Checkpoint:"))
12540 .collect();
12541 let mut per_vm: std::collections::HashMap<&str, usize> =
12542 std::collections::HashMap::new();
12543 for line in &cp_lines {
12544 let kv: std::collections::HashMap<&str, &str> = line
12545 .split('|')
12546 .filter_map(|p| {
12547 let mut it = p.splitn(2, ':');
12548 Some((it.next()?, it.next()?))
12549 })
12550 .collect();
12551 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12552 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12553 let created = kv.get("Created").copied().unwrap_or("");
12554 let cp_type = kv.get("Type").copied().unwrap_or("");
12555 out.push_str(&format!(
12556 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
12557 ));
12558 *per_vm.entry(vm_name).or_insert(0) += 1;
12559 }
12560 for (vm, count) in &per_vm {
12561 if *count >= 3 {
12562 findings.push(format!(
12563 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12564 ));
12565 }
12566 }
12567 }
12568 } else {
12569 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12570 }
12571
12572 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12573 if findings.is_empty() {
12574 result.push_str("- No Hyper-V health issues detected.\n");
12575 } else {
12576 for f in &findings {
12577 result.push_str(&format!("- Finding: {f}\n"));
12578 }
12579 }
12580 result.push('\n');
12581 result.push_str(&out);
12582 return Ok(result.trim_end().to_string());
12583 }
12584
12585 #[cfg(not(target_os = "windows"))]
12586 Ok(
12587 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12588 .into(),
12589 )
12590}
12591
12592fn inspect_ip_config() -> Result<String, String> {
12595 let mut out = String::from("Host inspection: ip_config\n\n");
12596
12597 #[cfg(target_os = "windows")]
12598 {
12599 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12600 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12601 '\\n Status: ' + $_.NetAdapter.Status + \
12602 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12603 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12604 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12605 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12606 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12607 }";
12608 let output = Command::new("powershell")
12609 .args(["-NoProfile", "-Command", script])
12610 .output()
12611 .ok();
12612 if let Some(o) = output {
12613 out.push_str(&String::from_utf8_lossy(&o.stdout));
12614 }
12615 }
12616
12617 #[cfg(not(target_os = "windows"))]
12618 {
12619 let output = Command::new("ip").args(["addr", "show"]).output().ok();
12620 if let Some(o) = output {
12621 out.push_str(&String::from_utf8_lossy(&o.stdout));
12622 }
12623 }
12624
12625 Ok(out.trim_end().to_string())
12626}
12627
12628fn inspect_event_query(
12631 event_id: Option<u32>,
12632 log_name: Option<&str>,
12633 source: Option<&str>,
12634 hours: u32,
12635 level: Option<&str>,
12636 max_entries: usize,
12637) -> Result<String, String> {
12638 #[cfg(target_os = "windows")]
12639 {
12640 let mut findings: Vec<String> = Vec::new();
12641
12642 let log = log_name.unwrap_or("*");
12644 let cap = max_entries.min(50);
12645
12646 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12648 Some("error") | Some("errors") => Some(2u8),
12649 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12650 Some("information") | Some("info") => Some(4u8),
12651 _ => None,
12652 };
12653
12654 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12656 if log != "*" {
12657 filter_parts.push(format!("LogName = '{log}'"));
12658 }
12659 if let Some(id) = event_id {
12660 filter_parts.push(format!("Id = {id}"));
12661 }
12662 if let Some(src) = source {
12663 filter_parts.push(format!("ProviderName = '{src}'"));
12664 }
12665 if let Some(lvl) = level_filter {
12666 filter_parts.push(format!("Level = {lvl}"));
12667 }
12668
12669 let filter_ht = filter_parts.join("; ");
12670
12671 let ps = format!(
12672 r#"
12673$filter = @{{ {filter_ht} }}
12674try {{
12675 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12676 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12677 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12678 if ($events) {{
12679 $events | ForEach-Object {{
12680 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12681 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12682 $_.Id, $_.LevelDisplayName, $_.ProviderName,
12683 ($_.Msg -replace '\|','/')
12684 }}
12685 }} else {{
12686 "NONE"
12687 }}
12688}} catch {{
12689 "ERROR:$($_.Exception.Message)"
12690}}
12691"#
12692 );
12693
12694 let raw = ps_exec(&ps);
12695 let lines: Vec<&str> = raw.lines().collect();
12696
12697 let mut query_desc = format!("last {hours}h");
12699 if let Some(id) = event_id {
12700 query_desc.push_str(&format!(", Event ID {id}"));
12701 }
12702 if let Some(src) = source {
12703 query_desc.push_str(&format!(", source '{src}'"));
12704 }
12705 if log != "*" {
12706 query_desc.push_str(&format!(", log '{log}'"));
12707 }
12708 if let Some(l) = level {
12709 query_desc.push_str(&format!(", level '{l}'"));
12710 }
12711
12712 let mut out = format!("=== Event query: {query_desc} ===\n");
12713
12714 if lines
12715 .iter()
12716 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12717 {
12718 out.push_str("- No matching events found.\n");
12719 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12720 let msg = err_line.trim_start_matches("ERROR:").trim();
12721 if is_event_query_no_results_message(msg) {
12722 out.push_str("- No matching events found.\n");
12723 } else {
12724 out.push_str(&format!("- Query error: {msg}\n"));
12725 findings.push(format!("Event query failed: {msg}"));
12726 }
12727 } else {
12728 let event_lines: Vec<&str> = lines
12729 .iter()
12730 .filter(|l| l.starts_with("TIME:"))
12731 .copied()
12732 .collect();
12733 if event_lines.is_empty() {
12734 out.push_str("- No matching events found.\n");
12735 } else {
12736 let mut error_count = 0usize;
12738 let mut warning_count = 0usize;
12739
12740 for line in &event_lines {
12741 let kv: std::collections::HashMap<&str, &str> = line
12742 .split('|')
12743 .filter_map(|p| {
12744 let mut it = p.splitn(2, ':');
12745 Some((it.next()?, it.next()?))
12746 })
12747 .collect();
12748 let time = kv.get("TIME").copied().unwrap_or("?");
12749 let id = kv.get("ID").copied().unwrap_or("?");
12750 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12751 let src = kv.get("SOURCE").copied().unwrap_or("?");
12752 let msg = kv.get("MSG").copied().unwrap_or("").trim();
12753
12754 let msg_display = if msg.len() > 120 {
12756 format!("{}…", &msg[..120])
12757 } else {
12758 msg.to_string()
12759 };
12760
12761 out.push_str(&format!(
12762 "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n"
12763 ));
12764
12765 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12766 error_count += 1;
12767 } else if lvl.eq_ignore_ascii_case("warning") {
12768 warning_count += 1;
12769 }
12770 }
12771
12772 out.push_str(&format!(
12773 "\n- Total shown: {} event(s)\n",
12774 event_lines.len()
12775 ));
12776
12777 if error_count > 0 {
12778 findings.push(format!(
12779 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12780 ));
12781 }
12782 if warning_count > 5 {
12783 findings.push(format!(
12784 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12785 ));
12786 }
12787 }
12788 }
12789
12790 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12791 if findings.is_empty() {
12792 result.push_str("- No actionable findings from this event query.\n");
12793 } else {
12794 for f in &findings {
12795 result.push_str(&format!("- Finding: {f}\n"));
12796 }
12797 }
12798 result.push('\n');
12799 result.push_str(&out);
12800 return Ok(result.trim_end().to_string());
12801 }
12802
12803 #[cfg(not(target_os = "windows"))]
12804 {
12805 let _ = (event_id, log_name, source, hours, level, max_entries);
12806 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12807 }
12808}
12809
12810fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12813 let n = max_entries.clamp(5, 50);
12814 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12815 let mut findings: Vec<String> = Vec::new();
12816 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12817 let mut sections = String::new();
12818
12819 #[cfg(target_os = "windows")]
12820 {
12821 let proc_filter_ps = match process_filter {
12822 Some(proc) => format!(
12823 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12824 proc.replace('\'', "''")
12825 ),
12826 None => String::new(),
12827 };
12828
12829 let ps = format!(
12830 r#"
12831$results = @()
12832try {{
12833 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12834 if ($events) {{
12835 foreach ($e in $events) {{
12836 $msg = $e.Message
12837 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12838 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12839 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12840 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12841 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12842 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12843 }}
12844 $results
12845 }} else {{ 'NONE' }}
12846}} catch {{ 'ERROR:' + $_.Exception.Message }}
12847"#
12848 );
12849
12850 let raw = ps_exec(&ps);
12851 let text = raw.trim();
12852
12853 let wer_ps = r#"
12855$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12856$count = 0
12857if (Test-Path $wer) {
12858 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12859}
12860$count
12861"#;
12862 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12863
12864 if text == "NONE" {
12865 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12866 } else if text.starts_with("ERROR:") {
12867 let msg = text.trim_start_matches("ERROR:").trim();
12868 sections.push_str(&format!(
12869 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12870 ));
12871 } else {
12872 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12873 let crash_count = events
12874 .iter()
12875 .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12876 .count();
12877 let hang_count = events
12878 .iter()
12879 .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12880 .count();
12881
12882 let mut app_counts: std::collections::HashMap<String, usize> =
12884 std::collections::HashMap::new();
12885 for line in &events {
12886 let parts: Vec<&str> = line.splitn(6, '|').collect();
12887 if parts.len() >= 3 {
12888 *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12889 }
12890 }
12891
12892 if crash_count > 0 {
12893 findings.push(format!(
12894 "{crash_count} application crash event(s) — review below for faulting app and exception code."
12895 ));
12896 }
12897 if hang_count > 0 {
12898 findings.push(format!(
12899 "{hang_count} application hang event(s) — process stopped responding."
12900 ));
12901 }
12902 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12903 if count > 1 {
12904 findings.push(format!(
12905 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12906 ));
12907 }
12908 }
12909 if wer_count > 10 {
12910 findings.push(format!(
12911 "{wer_count} WER reports archived — elevated crash history on this machine."
12912 ));
12913 }
12914
12915 let filter_note = match process_filter {
12916 Some(p) => format!(" (filtered: {p})"),
12917 None => String::new(),
12918 };
12919 sections.push_str(&format!(
12920 "=== Application crashes and hangs{filter_note} ===\n"
12921 ));
12922
12923 for line in &events {
12924 let parts: Vec<&str> = line.splitn(6, '|').collect();
12925 if parts.len() >= 6 {
12926 let time = parts[0];
12927 let kind = parts[1];
12928 let app = parts[2];
12929 let ver = parts[3];
12930 let module = parts[4];
12931 let exc = parts[5];
12932 let ver_note = if !ver.is_empty() {
12933 format!(" v{ver}")
12934 } else {
12935 String::new()
12936 };
12937 sections.push_str(&format!(" [{time}] {kind}: {app}{ver_note}\n"));
12938 if !module.is_empty() && module != "?" {
12939 let exc_note = if !exc.is_empty() {
12940 format!(" (exc {exc})")
12941 } else {
12942 String::new()
12943 };
12944 sections.push_str(&format!(" faulting module: {module}{exc_note}\n"));
12945 } else if !exc.is_empty() {
12946 sections.push_str(&format!(" exception: {exc}\n"));
12947 }
12948 }
12949 }
12950 sections.push_str(&format!(
12951 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12952 ));
12953
12954 if wer_count > 0 {
12955 sections.push_str(&format!(
12956 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12957 ));
12958 }
12959 }
12960 }
12961
12962 #[cfg(not(target_os = "windows"))]
12963 {
12964 let _ = (process_filter, n);
12965 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12966 }
12967
12968 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12969 if findings.is_empty() {
12970 result.push_str("- No actionable findings.\n");
12971 } else {
12972 for f in &findings {
12973 result.push_str(&format!("- Finding: {f}\n"));
12974 }
12975 }
12976 result.push('\n');
12977 result.push_str(§ions);
12978 Ok(result.trim_end().to_string())
12979}
12980
12981#[cfg(target_os = "windows")]
12982fn gpu_voltage_telemetry_note() -> String {
12983 let output = Command::new("nvidia-smi")
12984 .args(["--help-query-gpu"])
12985 .output();
12986
12987 match output {
12988 Ok(o) => {
12989 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12990 if text.contains("\"voltage\"") || text.contains("voltage.") {
12991 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12992 } else {
12993 "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()
12994 }
12995 }
12996 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12997 }
12998}
12999
13000#[cfg(target_os = "windows")]
13001fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13002 if raw == 0 {
13003 return None;
13004 }
13005 if raw & 0x80 != 0 {
13006 let tenths = raw & 0x7f;
13007 return Some(format!(
13008 "{:.1} V (firmware-reported WMI current voltage)",
13009 tenths as f64 / 10.0
13010 ));
13011 }
13012
13013 let legacy = match raw {
13014 1 => Some("5.0 V"),
13015 2 => Some("3.3 V"),
13016 4 => Some("2.9 V"),
13017 _ => None,
13018 }?;
13019 Some(format!(
13020 "{} (legacy WMI voltage capability flag, not live telemetry)",
13021 legacy
13022 ))
13023}
13024
13025async fn inspect_overclocker() -> Result<String, String> {
13026 let mut out = String::from("Host inspection: overclocker\n\n");
13027
13028 #[cfg(target_os = "windows")]
13029 {
13030 out.push_str(
13031 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13032 );
13033
13034 let nvidia = Command::new("nvidia-smi")
13036 .args([
13037 "--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",
13038 "--format=csv,noheader,nounits",
13039 ])
13040 .output();
13041
13042 if let Ok(o) = nvidia {
13043 let stdout = String::from_utf8_lossy(&o.stdout);
13044 if !stdout.trim().is_empty() {
13045 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13046 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
13047 if parts.len() >= 10 {
13048 out.push_str(&format!("- Model: {}\n", parts[0]));
13049 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
13050 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
13051 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
13052 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
13053 if !parts[6].eq_ignore_ascii_case("[N/A]") {
13054 out.push_str(&format!("- Power Avg: {} W\n", parts[6]));
13055 }
13056 if !parts[7].eq_ignore_ascii_case("[N/A]") {
13057 out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
13058 }
13059 if !parts[8].eq_ignore_ascii_case("[N/A]") {
13060 out.push_str(&format!("- Power Cap: {} W requested\n", parts[8]));
13061 }
13062 if !parts[9].eq_ignore_ascii_case("[N/A]") {
13063 out.push_str(&format!("- Power Enf: {} W enforced\n", parts[9]));
13064 }
13065 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
13066
13067 if parts.len() > 10 {
13068 let throttle_hex = parts[10];
13069 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13070 if !reasons.is_empty() {
13071 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
13072 } else {
13073 out.push_str("- Throttling: None (Performance State: Max)\n");
13074 }
13075 }
13076 }
13077 out.push_str("\n");
13078 }
13079 }
13080
13081 out.push_str("=== VOLTAGE TELEMETRY ===\n");
13082 out.push_str(&format!(
13083 "- GPU Voltage: {}\n\n",
13084 gpu_voltage_telemetry_note()
13085 ));
13086
13087 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13089 let history = gpu_state.history.lock().unwrap();
13090 if history.len() >= 2 {
13091 out.push_str("=== SILICON TRENDS (Session) ===\n");
13092 let first = history.front().unwrap();
13093 let last = history.back().unwrap();
13094
13095 let temp_diff = last.temperature as i32 - first.temperature as i32;
13096 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13097
13098 let temp_trend = if temp_diff > 1 {
13099 "Rising"
13100 } else if temp_diff < -1 {
13101 "Falling"
13102 } else {
13103 "Stable"
13104 };
13105 let clock_trend = if clock_diff > 10 {
13106 "Increasing"
13107 } else if clock_diff < -10 {
13108 "Decreasing"
13109 } else {
13110 "Stable"
13111 };
13112
13113 out.push_str(&format!(
13114 "- Temperature: {} ({}°C anomaly)\n",
13115 temp_trend, temp_diff
13116 ));
13117 out.push_str(&format!(
13118 "- Core Clock: {} ({} MHz delta)\n",
13119 clock_trend, clock_diff
13120 ));
13121 out.push_str("\n");
13122 }
13123
13124 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))\" }";
13126 let cpu_stats = Command::new("powershell")
13127 .args(["-NoProfile", "-Command", ps_cmd])
13128 .output();
13129
13130 if let Ok(o) = cpu_stats {
13131 let stdout = String::from_utf8_lossy(&o.stdout);
13132 if !stdout.trim().is_empty() {
13133 out.push_str("=== SILICON CORE (CPU) ===\n");
13134 for line in stdout.lines() {
13135 if let Some((path, val)) = line.split_once(':') {
13136 if path.to_lowercase().contains("processor frequency") {
13137 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
13138 } else if path.to_lowercase().contains("% of maximum frequency") {
13139 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
13140 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13141 if throttle_num < 95.0 {
13142 out.push_str(
13143 " [WARNING] Active downclocking or power-saving detected.\n",
13144 );
13145 }
13146 }
13147 }
13148 }
13149 }
13150 }
13151
13152 let thermal = Command::new("powershell")
13154 .args([
13155 "-NoProfile",
13156 "-Command",
13157 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13158 ])
13159 .output();
13160 if let Ok(o) = thermal {
13161 let stdout = String::from_utf8_lossy(&o.stdout);
13162 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13163 let temp = if v.is_array() {
13164 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13165 } else {
13166 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13167 };
13168 if temp > 1.0 {
13169 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
13170 }
13171 }
13172 }
13173
13174 let wmi = Command::new("powershell")
13176 .args([
13177 "-NoProfile",
13178 "-Command",
13179 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13180 ])
13181 .output();
13182
13183 if let Ok(o) = wmi {
13184 let stdout = String::from_utf8_lossy(&o.stdout);
13185 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13186 out.push_str("\n=== HARDWARE DNA ===\n");
13187 out.push_str(&format!(
13188 "- Rated Max: {} MHz\n",
13189 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13190 ));
13191 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13192 Some(raw) => {
13193 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13194 out.push_str(&format!("- CPU Voltage: {}\n", decoded));
13195 } else {
13196 out.push_str(
13197 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
13198 );
13199 }
13200 }
13201 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
13202 }
13203 }
13204 }
13205 }
13206
13207 #[cfg(not(target_os = "windows"))]
13208 {
13209 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13210 }
13211
13212 Ok(out.trim_end().to_string())
13213}
13214
13215#[cfg(target_os = "windows")]
13217fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13218 let hex = hex.trim().trim_start_matches("0x");
13219 let val = match u64::from_str_radix(hex, 16) {
13220 Ok(v) => v,
13221 Err(_) => return String::new(),
13222 };
13223
13224 if val == 0 {
13225 return String::new();
13226 }
13227
13228 let mut reasons = Vec::new();
13229 if val & 0x01 != 0 {
13230 reasons.push("GPU Idle");
13231 }
13232 if val & 0x02 != 0 {
13233 reasons.push("Applications Clocks Setting");
13234 }
13235 if val & 0x04 != 0 {
13236 reasons.push("SW Power Cap (PL1/PL2)");
13237 }
13238 if val & 0x08 != 0 {
13239 reasons.push("HW Slowdown (Thermal/Power)");
13240 }
13241 if val & 0x10 != 0 {
13242 reasons.push("Sync Boost");
13243 }
13244 if val & 0x20 != 0 {
13245 reasons.push("SW Thermal Slowdown");
13246 }
13247 if val & 0x40 != 0 {
13248 reasons.push("HW Thermal Slowdown");
13249 }
13250 if val & 0x80 != 0 {
13251 reasons.push("HW Power Brake Slowdown");
13252 }
13253 if val & 0x100 != 0 {
13254 reasons.push("Display Clock Setting");
13255 }
13256
13257 reasons.join(", ")
13258}
13259
13260#[cfg(windows)]
13263fn run_powershell(script: &str) -> Result<String, String> {
13264 use std::process::Command;
13265 let out = Command::new("powershell")
13266 .args(["-NoProfile", "-NonInteractive", "-Command", script])
13267 .output()
13268 .map_err(|e| format!("powershell launch failed: {e}"))?;
13269 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13270}
13271
13272#[cfg(windows)]
13275fn inspect_camera(max_entries: usize) -> Result<String, String> {
13276 let mut out = String::from("=== Camera devices ===\n");
13277
13278 let ps_devices = r#"
13280Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13281ForEach-Object {
13282 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13283 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13284}
13285"#;
13286 match run_powershell(ps_devices) {
13287 Ok(o) if !o.trim().is_empty() => {
13288 for line in o.lines().take(max_entries) {
13289 let l = line.trim();
13290 if !l.is_empty() {
13291 out.push_str(&format!("- {l}\n"));
13292 }
13293 }
13294 }
13295 _ => out.push_str("- No camera devices found via PnP\n"),
13296 }
13297
13298 out.push_str("\n=== Windows camera privacy ===\n");
13300 let ps_privacy = r#"
13301$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13302$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13303"Global: $global"
13304$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13305 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13306 ForEach-Object {
13307 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13308 if ($v) { " $($_.PSChildName): $v" }
13309 }
13310$apps
13311"#;
13312 match run_powershell(ps_privacy) {
13313 Ok(o) if !o.trim().is_empty() => {
13314 for line in o.lines().take(max_entries) {
13315 let l = line.trim_end();
13316 if !l.is_empty() {
13317 out.push_str(&format!("{l}\n"));
13318 }
13319 }
13320 }
13321 _ => out.push_str("- Could not read camera privacy registry\n"),
13322 }
13323
13324 out.push_str("\n=== Biometric / Hello camera ===\n");
13326 let ps_bio = r#"
13327Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13328ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13329"#;
13330 match run_powershell(ps_bio) {
13331 Ok(o) if !o.trim().is_empty() => {
13332 for line in o.lines().take(max_entries) {
13333 let l = line.trim();
13334 if !l.is_empty() {
13335 out.push_str(&format!("- {l}\n"));
13336 }
13337 }
13338 }
13339 _ => out.push_str("- No biometric devices found\n"),
13340 }
13341
13342 let mut findings: Vec<String> = Vec::new();
13344 if out.contains("Status: Error") || out.contains("Status: Unknown") {
13345 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13346 }
13347 if out.contains("Global: Deny") {
13348 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());
13349 }
13350
13351 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13352 if findings.is_empty() {
13353 result.push_str("- No obvious camera or privacy gate issue detected.\n");
13354 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13355 } else {
13356 for f in &findings {
13357 result.push_str(&format!("- Finding: {f}\n"));
13358 }
13359 }
13360 result.push('\n');
13361 result.push_str(&out);
13362 Ok(result)
13363}
13364
13365#[cfg(not(windows))]
13366fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13367 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13368}
13369
13370#[cfg(windows)]
13373fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13374 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13375
13376 let ps_hello = r#"
13378$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13379$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13380$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13381"PIN-style logon path: $helloKey"
13382"WbioSrvc start type: $faceConfigured"
13383"FingerPrint key present: $pinConfigured"
13384"#;
13385 match run_powershell(ps_hello) {
13386 Ok(o) => {
13387 for line in o.lines().take(max_entries) {
13388 let l = line.trim();
13389 if !l.is_empty() {
13390 out.push_str(&format!("- {l}\n"));
13391 }
13392 }
13393 }
13394 Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
13395 }
13396
13397 out.push_str("\n=== Biometric service ===\n");
13399 let ps_bio_svc = r#"
13400$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13401if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13402else { "WbioSrvc not found" }
13403"#;
13404 match run_powershell(ps_bio_svc) {
13405 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
13406 Err(_) => out.push_str("- Could not query biometric service\n"),
13407 }
13408
13409 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13411 let ps_events = r#"
13412$cutoff = (Get-Date).AddHours(-24)
13413Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13414ForEach-Object {
13415 $xml = [xml]$_.ToXml()
13416 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13417 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13418 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13419} | Select-Object -First 10
13420"#;
13421 match run_powershell(ps_events) {
13422 Ok(o) if !o.trim().is_empty() => {
13423 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13424 out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
13425 for line in o.lines().take(max_entries) {
13426 let l = line.trim();
13427 if !l.is_empty() {
13428 out.push_str(&format!(" {l}\n"));
13429 }
13430 }
13431 }
13432 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13433 }
13434
13435 out.push_str("\n=== Active credential providers ===\n");
13437 let ps_cp = r#"
13438Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13439ForEach-Object {
13440 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13441 if ($name) { $name }
13442} | Select-Object -First 15
13443"#;
13444 match run_powershell(ps_cp) {
13445 Ok(o) if !o.trim().is_empty() => {
13446 for line in o.lines().take(max_entries) {
13447 let l = line.trim();
13448 if !l.is_empty() {
13449 out.push_str(&format!("- {l}\n"));
13450 }
13451 }
13452 }
13453 _ => out.push_str("- Could not enumerate credential providers\n"),
13454 }
13455
13456 let mut findings: Vec<String> = Vec::new();
13457 if out.contains("WbioSrvc | Status: Stopped") {
13458 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13459 }
13460 if out.contains("recent logon failure") && !out.contains("0 recent") {
13461 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13462 }
13463
13464 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13465 if findings.is_empty() {
13466 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13467 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13468 } else {
13469 for f in &findings {
13470 result.push_str(&format!("- Finding: {f}\n"));
13471 }
13472 }
13473 result.push('\n');
13474 result.push_str(&out);
13475 Ok(result)
13476}
13477
13478#[cfg(not(windows))]
13479fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13480 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13481}
13482
13483#[cfg(windows)]
13486fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13487 let mut out = String::from("=== Installer engines ===\n");
13488
13489 let ps_engines = r#"
13490$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13491foreach ($name in $services) {
13492 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13493 if ($svc) {
13494 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13495 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13496 "$name | Status: $($svc.Status) | StartType: $startType"
13497 } else {
13498 "$name | Not present"
13499 }
13500}
13501if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13502 "msiexec.exe | Present: Yes"
13503} else {
13504 "msiexec.exe | Present: No"
13505}
13506"#;
13507 match run_powershell(ps_engines) {
13508 Ok(o) if !o.trim().is_empty() => {
13509 for line in o.lines().take(max_entries + 6) {
13510 let l = line.trim();
13511 if !l.is_empty() {
13512 out.push_str(&format!("- {l}\n"));
13513 }
13514 }
13515 }
13516 _ => out.push_str("- Could not inspect installer engine services\n"),
13517 }
13518
13519 out.push_str("\n=== winget and App Installer ===\n");
13520 let ps_winget = r#"
13521$cmd = Get-Command winget -ErrorAction SilentlyContinue
13522if ($cmd) {
13523 try {
13524 $v = & winget --version 2>$null
13525 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13526 } catch { "winget | Present but invocation failed" }
13527} else {
13528 "winget | Missing"
13529}
13530$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13531if ($appInstaller) {
13532 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13533} else {
13534 "DesktopAppInstaller | Status: Missing"
13535}
13536"#;
13537 match run_powershell(ps_winget) {
13538 Ok(o) if !o.trim().is_empty() => {
13539 for line in o.lines().take(max_entries) {
13540 let l = line.trim();
13541 if !l.is_empty() {
13542 out.push_str(&format!("- {l}\n"));
13543 }
13544 }
13545 }
13546 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13547 }
13548
13549 out.push_str("\n=== Microsoft Store packages ===\n");
13550 let ps_store = r#"
13551$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13552if ($store) {
13553 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13554} else {
13555 "Microsoft.WindowsStore | Status: Missing"
13556}
13557"#;
13558 match run_powershell(ps_store) {
13559 Ok(o) if !o.trim().is_empty() => {
13560 for line in o.lines().take(max_entries) {
13561 let l = line.trim();
13562 if !l.is_empty() {
13563 out.push_str(&format!("- {l}\n"));
13564 }
13565 }
13566 }
13567 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13568 }
13569
13570 out.push_str("\n=== Reboot and transaction blockers ===\n");
13571 let ps_blockers = r#"
13572$pending = $false
13573if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13574 "RebootPending: CBS"
13575 $pending = $true
13576}
13577if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13578 "RebootPending: WindowsUpdate"
13579 $pending = $true
13580}
13581$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13582if ($rename) {
13583 "PendingFileRenameOperations: Yes"
13584 $pending = $true
13585}
13586if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13587 "InstallerInProgress: Yes"
13588 $pending = $true
13589}
13590if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13591"#;
13592 match run_powershell(ps_blockers) {
13593 Ok(o) if !o.trim().is_empty() => {
13594 for line in o.lines().take(max_entries) {
13595 let l = line.trim();
13596 if !l.is_empty() {
13597 out.push_str(&format!("- {l}\n"));
13598 }
13599 }
13600 }
13601 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13602 }
13603
13604 out.push_str("\n=== Recent installer failures (7d) ===\n");
13605 let ps_failures = r#"
13606$cutoff = (Get-Date).AddDays(-7)
13607$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13608 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13609$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13610 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13611 Select-Object -First 6 |
13612 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13613$all = @($msi) + @($appx)
13614if ($all.Count -eq 0) {
13615 "No recent MSI/AppX installer errors detected"
13616} else {
13617 $all | Select-Object -First 8
13618}
13619"#;
13620 match run_powershell(ps_failures) {
13621 Ok(o) if !o.trim().is_empty() => {
13622 for line in o.lines().take(max_entries + 2) {
13623 let l = line.trim();
13624 if !l.is_empty() {
13625 out.push_str(&format!("- {l}\n"));
13626 }
13627 }
13628 }
13629 _ => out.push_str("- Could not inspect recent installer failure events\n"),
13630 }
13631
13632 let mut findings: Vec<String> = Vec::new();
13633 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13634 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13635 }
13636 if out.contains("msiexec.exe | Present: No") {
13637 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13638 }
13639 if out.contains("winget | Missing") {
13640 findings.push(
13641 "winget is missing - App Installer may not be installed or registered for this user."
13642 .into(),
13643 );
13644 }
13645 if out.contains("DesktopAppInstaller | Status: Missing") {
13646 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13647 }
13648 if out.contains("Microsoft.WindowsStore | Status: Missing") {
13649 findings.push(
13650 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13651 .into(),
13652 );
13653 }
13654 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13655 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13656 }
13657 if out.contains("InstallerInProgress: Yes") {
13658 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13659 }
13660 if out.contains("MSI | ") || out.contains("AppX | ") {
13661 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());
13662 }
13663
13664 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13665 if findings.is_empty() {
13666 result.push_str("- No obvious installer-platform blocker detected.\n");
13667 } else {
13668 for finding in &findings {
13669 result.push_str(&format!("- Finding: {finding}\n"));
13670 }
13671 }
13672 result.push('\n');
13673 result.push_str(&out);
13674 Ok(result)
13675}
13676
13677#[cfg(not(windows))]
13678fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13679 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())
13680}
13681
13682#[cfg(windows)]
13685fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13686 let mut out = String::from("=== OneDrive client ===\n");
13687
13688 let ps_client = r#"
13689$candidatePaths = @(
13690 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13691 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13692 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13693) | Where-Object { $_ -and (Test-Path $_) }
13694$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13695$exe = $candidatePaths | Select-Object -First 1
13696if (-not $exe -and $proc) {
13697 try { $exe = $proc.Path } catch {}
13698}
13699if ($exe) {
13700 "Installed: Yes"
13701 "Executable: $exe"
13702 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13703} else {
13704 "Installed: Unknown"
13705}
13706if ($proc) {
13707 "Process: Running | PID: $($proc.Id)"
13708} else {
13709 "Process: Not running"
13710}
13711"#;
13712 match run_powershell(ps_client) {
13713 Ok(o) if !o.trim().is_empty() => {
13714 for line in o.lines().take(max_entries) {
13715 let l = line.trim();
13716 if !l.is_empty() {
13717 out.push_str(&format!("- {l}\n"));
13718 }
13719 }
13720 }
13721 _ => out.push_str("- Could not inspect OneDrive client state\n"),
13722 }
13723
13724 out.push_str("\n=== OneDrive accounts ===\n");
13725 let ps_accounts = r#"
13726function MaskEmail([string]$Email) {
13727 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13728 $parts = $Email.Split('@', 2)
13729 $local = $parts[0]
13730 $domain = $parts[1]
13731 if ($local.Length -le 1) { return "*@$domain" }
13732 return ($local.Substring(0,1) + "***@" + $domain)
13733}
13734$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13735if (Test-Path $base) {
13736 Get-ChildItem $base -ErrorAction SilentlyContinue |
13737 Sort-Object PSChildName |
13738 Select-Object -First 12 |
13739 ForEach-Object {
13740 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13741 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13742 $mail = MaskEmail ([string]$p.UserEmail)
13743 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13744 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13745 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13746 }
13747} else {
13748 "No OneDrive accounts configured"
13749}
13750"#;
13751 match run_powershell(ps_accounts) {
13752 Ok(o) if !o.trim().is_empty() => {
13753 for line in o.lines().take(max_entries) {
13754 let l = line.trim();
13755 if !l.is_empty() {
13756 out.push_str(&format!("- {l}\n"));
13757 }
13758 }
13759 }
13760 _ => out.push_str("- Could not read OneDrive account registry state\n"),
13761 }
13762
13763 out.push_str("\n=== OneDrive policy overrides ===\n");
13764 let ps_policy = r#"
13765$paths = @(
13766 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13767 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13768)
13769$names = @(
13770 'DisableFileSyncNGSC',
13771 'DisableLibrariesDefaultSaveToOneDrive',
13772 'KFMSilentOptIn',
13773 'KFMBlockOptIn',
13774 'SilentAccountConfig'
13775)
13776$found = $false
13777foreach ($path in $paths) {
13778 if (Test-Path $path) {
13779 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13780 foreach ($name in $names) {
13781 $value = $p.$name
13782 if ($null -ne $value -and [string]$value -ne '') {
13783 "$path | $name=$value"
13784 $found = $true
13785 }
13786 }
13787 }
13788}
13789if (-not $found) { "No OneDrive policy overrides detected" }
13790"#;
13791 match run_powershell(ps_policy) {
13792 Ok(o) if !o.trim().is_empty() => {
13793 for line in o.lines().take(max_entries) {
13794 let l = line.trim();
13795 if !l.is_empty() {
13796 out.push_str(&format!("- {l}\n"));
13797 }
13798 }
13799 }
13800 _ => out.push_str("- Could not read OneDrive policy state\n"),
13801 }
13802
13803 out.push_str("\n=== Known Folder Backup ===\n");
13804 let ps_kfm = r#"
13805$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13806$roots = @()
13807if (Test-Path $base) {
13808 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13809 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13810 if ($p.UserFolder) {
13811 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13812 }
13813 }
13814}
13815$roots = $roots | Select-Object -Unique
13816$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13817if (Test-Path $shell) {
13818 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13819 $folders = @(
13820 @{ Name='Desktop'; Value=$props.Desktop },
13821 @{ Name='Documents'; Value=$props.Personal },
13822 @{ Name='Pictures'; Value=$props.'My Pictures' }
13823 )
13824 foreach ($folder in $folders) {
13825 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13826 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13827 $protected = $false
13828 foreach ($root in $roots) {
13829 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13830 $protected = $true
13831 break
13832 }
13833 }
13834 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13835 }
13836} else {
13837 "Explorer shell folders unavailable"
13838}
13839"#;
13840 match run_powershell(ps_kfm) {
13841 Ok(o) if !o.trim().is_empty() => {
13842 for line in o.lines().take(max_entries) {
13843 let l = line.trim();
13844 if !l.is_empty() {
13845 out.push_str(&format!("- {l}\n"));
13846 }
13847 }
13848 }
13849 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13850 }
13851
13852 let mut findings: Vec<String> = Vec::new();
13853 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13854 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13855 }
13856 if out.contains("No OneDrive accounts configured") {
13857 findings.push(
13858 "No OneDrive accounts are configured - sync cannot start until the user signs in."
13859 .into(),
13860 );
13861 }
13862 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13863 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13864 }
13865 if out.contains("Exists: No") {
13866 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13867 }
13868 if out.contains("DisableFileSyncNGSC=1") {
13869 findings
13870 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13871 }
13872 if out.contains("KFMBlockOptIn=1") {
13873 findings
13874 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13875 }
13876 if out.contains("SyncRoot: C:\\") {
13877 let mut missing_kfm: Vec<&str> = Vec::new();
13878 for folder in ["Desktop", "Documents", "Pictures"] {
13879 if out.lines().any(|line| {
13880 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13881 }) {
13882 missing_kfm.push(folder);
13883 }
13884 }
13885 if !missing_kfm.is_empty() {
13886 findings.push(format!(
13887 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13888 missing_kfm.join(", ")
13889 ));
13890 }
13891 }
13892
13893 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13894 if findings.is_empty() {
13895 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13896 } else {
13897 for finding in &findings {
13898 result.push_str(&format!("- Finding: {finding}\n"));
13899 }
13900 }
13901 result.push('\n');
13902 result.push_str(&out);
13903 Ok(result)
13904}
13905
13906#[cfg(not(windows))]
13907fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13908 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13909}
13910
13911#[cfg(windows)]
13912fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13913 let mut out = String::from("=== Browser inventory ===\n");
13914
13915 let ps_inventory = r#"
13916$browsers = @(
13917 @{ Name='Edge'; Paths=@(
13918 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13919 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13920 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13921 @{ Name='Chrome'; Paths=@(
13922 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13923 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13924 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13925 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13926 @{ Name='Firefox'; Paths=@(
13927 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13928 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13929 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13930)
13931foreach ($browser in $browsers) {
13932 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13933 if ($exe) {
13934 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13935 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13936 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13937 } else {
13938 "$($browser.Name) | Installed: No"
13939 }
13940}
13941$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13942$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13943$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13944"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13945"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13946"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13947"#;
13948 match run_powershell(ps_inventory) {
13949 Ok(o) if !o.trim().is_empty() => {
13950 for line in o.lines().take(max_entries + 6) {
13951 let l = line.trim();
13952 if !l.is_empty() {
13953 out.push_str(&format!("- {l}\n"));
13954 }
13955 }
13956 }
13957 _ => out.push_str("- Could not inspect installed browser inventory\n"),
13958 }
13959
13960 out.push_str("\n=== Runtime state ===\n");
13961 let ps_runtime = r#"
13962$targets = 'msedge','chrome','firefox','msedgewebview2'
13963foreach ($name in $targets) {
13964 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13965 if ($procs) {
13966 $count = @($procs).Count
13967 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13968 "$name | Processes: $count | WorkingSetMB: $wsMb"
13969 } else {
13970 "$name | Processes: 0 | WorkingSetMB: 0"
13971 }
13972}
13973"#;
13974 match run_powershell(ps_runtime) {
13975 Ok(o) if !o.trim().is_empty() => {
13976 for line in o.lines().take(max_entries + 4) {
13977 let l = line.trim();
13978 if !l.is_empty() {
13979 out.push_str(&format!("- {l}\n"));
13980 }
13981 }
13982 }
13983 _ => out.push_str("- Could not inspect browser runtime state\n"),
13984 }
13985
13986 out.push_str("\n=== WebView2 runtime ===\n");
13987 let ps_webview = r#"
13988$paths = @(
13989 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13990 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13991) | Where-Object { $_ -and (Test-Path $_) }
13992$runtimeDir = $paths | ForEach-Object {
13993 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13994 Where-Object { $_.Name -match '^\d+\.' } |
13995 Sort-Object Name -Descending |
13996 Select-Object -First 1
13997} | Select-Object -First 1
13998if ($runtimeDir) {
13999 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14000 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14001 "Installed: Yes"
14002 "Version: $version"
14003 "Executable: $exe"
14004} else {
14005 "Installed: No"
14006}
14007$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14008"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14009"#;
14010 match run_powershell(ps_webview) {
14011 Ok(o) if !o.trim().is_empty() => {
14012 for line in o.lines().take(max_entries) {
14013 let l = line.trim();
14014 if !l.is_empty() {
14015 out.push_str(&format!("- {l}\n"));
14016 }
14017 }
14018 }
14019 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14020 }
14021
14022 out.push_str("\n=== Policy and proxy surface ===\n");
14023 let ps_policy = r#"
14024$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14025$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14026$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14027$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14028$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14029"UserProxyEnabled: $proxyEnabled"
14030"UserProxyServer: $proxyServer"
14031"UserAutoConfigURL: $autoConfig"
14032"UserAutoDetect: $autoDetect"
14033$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14034if ($winhttp) {
14035 $normalized = ($winhttp -replace '\s+', ' ').Trim()
14036 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14037 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14038 "WinHTTP: $normalized"
14039}
14040$policyTargets = @(
14041 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14042 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14043)
14044foreach ($policy in $policyTargets) {
14045 if (Test-Path $policy.Path) {
14046 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14047 foreach ($key in $policy.Keys) {
14048 $value = $item.$key
14049 if ($null -ne $value -and [string]$value -ne '') {
14050 if ($value -is [array]) {
14051 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14052 } else {
14053 "$($policy.Name)Policy | $key=$value"
14054 }
14055 }
14056 }
14057 }
14058}
14059"#;
14060 match run_powershell(ps_policy) {
14061 Ok(o) if !o.trim().is_empty() => {
14062 for line in o.lines().take(max_entries + 8) {
14063 let l = line.trim();
14064 if !l.is_empty() {
14065 out.push_str(&format!("- {l}\n"));
14066 }
14067 }
14068 }
14069 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14070 }
14071
14072 out.push_str("\n=== Profile and cache pressure ===\n");
14073 let ps_profiles = r#"
14074$profiles = @(
14075 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14076 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14077 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14078)
14079foreach ($profile in $profiles) {
14080 if (Test-Path $profile.Root) {
14081 if ($profile.Name -eq 'Firefox') {
14082 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14083 } else {
14084 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14085 Where-Object {
14086 $_.Name -eq 'Default' -or
14087 $_.Name -eq 'Guest Profile' -or
14088 $_.Name -eq 'System Profile' -or
14089 $_.Name -like 'Profile *'
14090 }
14091 }
14092 $profileCount = @($dirs).Count
14093 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14094 if (-not $sizeBytes) { $sizeBytes = 0 }
14095 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14096 $extCount = 'Unknown'
14097 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14098 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14099 }
14100 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14101 } else {
14102 "$($profile.Name) | ProfileRoot: Missing"
14103 }
14104}
14105"#;
14106 match run_powershell(ps_profiles) {
14107 Ok(o) if !o.trim().is_empty() => {
14108 for line in o.lines().take(max_entries + 4) {
14109 let l = line.trim();
14110 if !l.is_empty() {
14111 out.push_str(&format!("- {l}\n"));
14112 }
14113 }
14114 }
14115 _ => out.push_str("- Could not inspect browser profile pressure\n"),
14116 }
14117
14118 out.push_str("\n=== Recent browser failures (7d) ===\n");
14119 let ps_failures = r#"
14120$cutoff = (Get-Date).AddDays(-7)
14121$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14122$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14123 Where-Object {
14124 $msg = [string]$_.Message
14125 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14126 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14127 } |
14128 Select-Object -First 6
14129if ($events) {
14130 foreach ($event in $events) {
14131 $msg = ($event.Message -replace '\s+', ' ')
14132 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14133 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14134 }
14135} else {
14136 "No recent browser crash or WER events detected"
14137}
14138"#;
14139 match run_powershell(ps_failures) {
14140 Ok(o) if !o.trim().is_empty() => {
14141 for line in o.lines().take(max_entries + 2) {
14142 let l = line.trim();
14143 if !l.is_empty() {
14144 out.push_str(&format!("- {l}\n"));
14145 }
14146 }
14147 }
14148 _ => out.push_str("- Could not inspect recent browser failure events\n"),
14149 }
14150
14151 let mut findings: Vec<String> = Vec::new();
14152 if out.contains("Edge | Installed: No")
14153 && out.contains("Chrome | Installed: No")
14154 && out.contains("Firefox | Installed: No")
14155 {
14156 findings.push(
14157 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14158 .into(),
14159 );
14160 }
14161 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14162 findings.push(
14163 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14164 .into(),
14165 );
14166 }
14167 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14168 findings.push(
14169 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14170 .into(),
14171 );
14172 }
14173 if out.contains("EdgePolicy | Proxy")
14174 || out.contains("ChromePolicy | Proxy")
14175 || out.contains("ExtensionInstallForcelist=")
14176 {
14177 findings.push(
14178 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14179 .into(),
14180 );
14181 }
14182 for browser in ["msedge", "chrome", "firefox"] {
14183 let process_marker = format!("{browser} | Processes: ");
14184 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14185 let count = line
14186 .split("| Processes: ")
14187 .nth(1)
14188 .and_then(|rest| rest.split(" |").next())
14189 .and_then(|value| value.trim().parse::<usize>().ok())
14190 .unwrap_or(0);
14191 let ws_mb = line
14192 .split("| WorkingSetMB: ")
14193 .nth(1)
14194 .and_then(|value| value.trim().parse::<f64>().ok())
14195 .unwrap_or(0.0);
14196 if count >= 25 {
14197 findings.push(format!(
14198 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14199 ));
14200 } else if ws_mb >= 2500.0 {
14201 findings.push(format!(
14202 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14203 ));
14204 }
14205 }
14206 }
14207 if out.contains("=== WebView2 runtime ===\n- Installed: No")
14208 || (out.contains("=== WebView2 runtime ===")
14209 && out.contains("- Installed: No")
14210 && out.contains("- ProcessCount: 0"))
14211 {
14212 findings.push(
14213 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14214 .into(),
14215 );
14216 }
14217 for browser in ["Edge", "Chrome", "Firefox"] {
14218 let prefix = format!("{browser} | ProfileRoot:");
14219 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14220 let size_gb = line
14221 .split("| SizeGB: ")
14222 .nth(1)
14223 .and_then(|rest| rest.split(" |").next())
14224 .and_then(|value| value.trim().parse::<f64>().ok())
14225 .unwrap_or(0.0);
14226 let ext_count = line
14227 .split("| Extensions: ")
14228 .nth(1)
14229 .and_then(|value| value.trim().parse::<usize>().ok())
14230 .unwrap_or(0);
14231 if size_gb >= 2.5 {
14232 findings.push(format!(
14233 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14234 ));
14235 }
14236 if ext_count >= 20 {
14237 findings.push(format!(
14238 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14239 ));
14240 }
14241 }
14242 }
14243 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14244 findings.push(
14245 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14246 .into(),
14247 );
14248 }
14249
14250 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14251 if findings.is_empty() {
14252 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14253 } else {
14254 for finding in &findings {
14255 result.push_str(&format!("- Finding: {finding}\n"));
14256 }
14257 }
14258 result.push('\n');
14259 result.push_str(&out);
14260 Ok(result)
14261}
14262
14263#[cfg(not(windows))]
14264fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14265 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())
14266}
14267
14268#[cfg(windows)]
14269fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14270 let mut out = String::from("=== Outlook install inventory ===\n");
14271
14272 let ps_install = r#"
14273$installPaths = @(
14274 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14275 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14276 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14277 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14278 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14279 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14280)
14281$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14282if ($exe) {
14283 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14284 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14285 "Installed: Yes"
14286 "Executable: $exe"
14287 "Version: $version"
14288 "Product: $productName"
14289} else {
14290 "Installed: No"
14291}
14292$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14293if ($newOutlook) {
14294 "NewOutlook: Installed | Version: $($newOutlook.Version)"
14295} else {
14296 "NewOutlook: Not installed"
14297}
14298"#;
14299 match run_powershell(ps_install) {
14300 Ok(o) if !o.trim().is_empty() => {
14301 for line in o.lines().take(max_entries + 4) {
14302 let l = line.trim();
14303 if !l.is_empty() {
14304 out.push_str(&format!("- {l}\n"));
14305 }
14306 }
14307 }
14308 _ => out.push_str("- Could not inspect Outlook install paths\n"),
14309 }
14310
14311 out.push_str("\n=== Runtime state ===\n");
14312 let ps_runtime = r#"
14313$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14314if ($proc) {
14315 $count = @($proc).Count
14316 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14317 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14318 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14319} else {
14320 "Running: No"
14321}
14322"#;
14323 match run_powershell(ps_runtime) {
14324 Ok(o) if !o.trim().is_empty() => {
14325 for line in o.lines().take(4) {
14326 let l = line.trim();
14327 if !l.is_empty() {
14328 out.push_str(&format!("- {l}\n"));
14329 }
14330 }
14331 }
14332 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14333 }
14334
14335 out.push_str("\n=== Mail profiles ===\n");
14336 let ps_profiles = r#"
14337$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14338if (-not (Test-Path $profileKey)) {
14339 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14340}
14341if (Test-Path $profileKey) {
14342 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14343 $count = @($profiles).Count
14344 "ProfileCount: $count"
14345 foreach ($p in $profiles | Select-Object -First 10) {
14346 "Profile: $($p.PSChildName)"
14347 }
14348} else {
14349 "ProfileCount: 0"
14350 "No Outlook profiles found in registry"
14351}
14352"#;
14353 match run_powershell(ps_profiles) {
14354 Ok(o) if !o.trim().is_empty() => {
14355 for line in o.lines().take(max_entries + 2) {
14356 let l = line.trim();
14357 if !l.is_empty() {
14358 out.push_str(&format!("- {l}\n"));
14359 }
14360 }
14361 }
14362 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14363 }
14364
14365 out.push_str("\n=== OST and PST data files ===\n");
14366 let ps_datafiles = r#"
14367$searchRoots = @(
14368 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14369 (Join-Path $env:USERPROFILE 'Documents'),
14370 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14371) | Where-Object { $_ -and (Test-Path $_) }
14372$files = foreach ($root in $searchRoots) {
14373 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14374 Select-Object FullName,
14375 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14376 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14377 LastWriteTime
14378}
14379if ($files) {
14380 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14381 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14382 }
14383} else {
14384 "No OST or PST files found in standard locations"
14385}
14386"#;
14387 match run_powershell(ps_datafiles) {
14388 Ok(o) if !o.trim().is_empty() => {
14389 for line in o.lines().take(max_entries + 4) {
14390 let l = line.trim();
14391 if !l.is_empty() {
14392 out.push_str(&format!("- {l}\n"));
14393 }
14394 }
14395 }
14396 _ => out.push_str("- Could not inspect OST/PST data files\n"),
14397 }
14398
14399 out.push_str("\n=== Add-in pressure ===\n");
14400 let ps_addins = r#"
14401$addinPaths = @(
14402 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14403 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14404 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14405)
14406$addins = foreach ($path in $addinPaths) {
14407 if (Test-Path $path) {
14408 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14409 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14410 $loadBehavior = $item.LoadBehavior
14411 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14412 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14413 }
14414 }
14415}
14416$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14417$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14418"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14419foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14420 $state = switch ($a.LoadBehavior) {
14421 0 { 'Disabled' }
14422 2 { 'LoadOnStart(inactive)' }
14423 3 { 'ActiveOnStart' }
14424 8 { 'DemandLoad' }
14425 9 { 'ActiveDemand' }
14426 16 { 'ConnectedFirst' }
14427 default { "LoadBehavior=$($a.LoadBehavior)" }
14428 }
14429 "$($a.Name) | $state"
14430}
14431$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14432$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14433if (Test-Path $disabledByResiliency) {
14434 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14435 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14436 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14437}
14438"#;
14439 match run_powershell(ps_addins) {
14440 Ok(o) if !o.trim().is_empty() => {
14441 for line in o.lines().take(max_entries + 8) {
14442 let l = line.trim();
14443 if !l.is_empty() {
14444 out.push_str(&format!("- {l}\n"));
14445 }
14446 }
14447 }
14448 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14449 }
14450
14451 out.push_str("\n=== Authentication and cache friction ===\n");
14452 let ps_auth = r#"
14453$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14454$tokenCount = if (Test-Path $tokenCache) {
14455 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14456} else { 0 }
14457"TokenBrokerCacheFiles: $tokenCount"
14458$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14459$credsCount = @($credentialManager).Count
14460"OfficeCredentialsInVault: $credsCount"
14461$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14462if (Test-Path $samlKey) {
14463 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14464 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14465 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14466 "WAMOverride: $connected"
14467 "SignedInUserId: $signedIn"
14468}
14469$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14470if (Test-Path $outlookReg) {
14471 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14472 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14473}
14474"#;
14475 match run_powershell(ps_auth) {
14476 Ok(o) if !o.trim().is_empty() => {
14477 for line in o.lines().take(max_entries + 4) {
14478 let l = line.trim();
14479 if !l.is_empty() {
14480 out.push_str(&format!("- {l}\n"));
14481 }
14482 }
14483 }
14484 _ => out.push_str("- Could not inspect Outlook auth state\n"),
14485 }
14486
14487 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14488 let ps_events = r#"
14489$cutoff = (Get-Date).AddDays(-7)
14490$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14491 Where-Object {
14492 $msg = [string]$_.Message
14493 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14494 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14495 } |
14496 Select-Object -First 8
14497if ($events) {
14498 foreach ($event in $events) {
14499 $msg = ($event.Message -replace '\s+', ' ')
14500 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14501 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14502 }
14503} else {
14504 "No recent Outlook crash or error events detected in Application log"
14505}
14506"#;
14507 match run_powershell(ps_events) {
14508 Ok(o) if !o.trim().is_empty() => {
14509 for line in o.lines().take(max_entries + 4) {
14510 let l = line.trim();
14511 if !l.is_empty() {
14512 out.push_str(&format!("- {l}\n"));
14513 }
14514 }
14515 }
14516 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14517 }
14518
14519 let mut findings: Vec<String> = Vec::new();
14520
14521 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14522 findings.push(
14523 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14524 .into(),
14525 );
14526 }
14527
14528 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14529 let ws_mb = line
14530 .split("WorkingSetMB: ")
14531 .nth(1)
14532 .and_then(|r| r.split(" |").next())
14533 .and_then(|v| v.trim().parse::<f64>().ok())
14534 .unwrap_or(0.0);
14535 if ws_mb >= 1500.0 {
14536 findings.push(format!(
14537 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14538 ));
14539 }
14540 }
14541
14542 let large_ost: Vec<String> = out
14543 .lines()
14544 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14545 .filter_map(|l| {
14546 let mb = l
14547 .split("SizeMB: ")
14548 .nth(1)
14549 .and_then(|r| r.split(" |").next())
14550 .and_then(|v| v.trim().parse::<f64>().ok())
14551 .unwrap_or(0.0);
14552 if mb >= 10_000.0 {
14553 Some(format!("{mb:.0} MB OST file detected"))
14554 } else {
14555 None
14556 }
14557 })
14558 .collect();
14559 for msg in large_ost {
14560 findings.push(format!(
14561 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14562 ));
14563 }
14564
14565 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14566 let active_count = line
14567 .split("Active: ")
14568 .nth(1)
14569 .and_then(|r| r.split(" |").next())
14570 .and_then(|v| v.trim().parse::<usize>().ok())
14571 .unwrap_or(0);
14572 if active_count >= 8 {
14573 findings.push(format!(
14574 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14575 ));
14576 }
14577 }
14578
14579 if out.contains("ResiliencyDisabledItems:") {
14580 findings.push(
14581 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14582 .into(),
14583 );
14584 }
14585
14586 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14587 findings.push(
14588 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14589 .into(),
14590 );
14591 }
14592
14593 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14594 findings.push(
14595 "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)."
14596 .into(),
14597 );
14598 }
14599
14600 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14601 if findings.is_empty() {
14602 result.push_str("- No obvious Outlook health blocker detected.\n");
14603 } else {
14604 for finding in &findings {
14605 result.push_str(&format!("- Finding: {finding}\n"));
14606 }
14607 }
14608 result.push('\n');
14609 result.push_str(&out);
14610 Ok(result)
14611}
14612
14613#[cfg(not(windows))]
14614fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14615 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14616}
14617
14618#[cfg(windows)]
14619fn inspect_teams(max_entries: usize) -> Result<String, String> {
14620 let mut out = String::from("=== Teams install inventory ===\n");
14621
14622 let ps_install = r#"
14623# Classic Teams (Teams 1.0)
14624$classicExe = @(
14625 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14626 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14627) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14628
14629if ($classicExe) {
14630 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14631 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14632} else {
14633 "ClassicTeams: Not installed"
14634}
14635
14636# New Teams (Teams 2.0 / ms-teams.exe)
14637$newTeamsExe = @(
14638 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14639 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14640) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14641
14642$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14643if ($newTeamsPkg) {
14644 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14645} elseif ($newTeamsExe) {
14646 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14647 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14648} else {
14649 "NewTeams: Not installed"
14650}
14651
14652# Teams Machine-Wide Installer (MSI/per-machine)
14653$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14654 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14655 Select-Object -First 1
14656if ($mwi) {
14657 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14658} else {
14659 "MachineWideInstaller: Not found"
14660}
14661"#;
14662 match run_powershell(ps_install) {
14663 Ok(o) if !o.trim().is_empty() => {
14664 for line in o.lines().take(max_entries + 4) {
14665 let l = line.trim();
14666 if !l.is_empty() {
14667 out.push_str(&format!("- {l}\n"));
14668 }
14669 }
14670 }
14671 _ => out.push_str("- Could not inspect Teams install paths\n"),
14672 }
14673
14674 out.push_str("\n=== Runtime state ===\n");
14675 let ps_runtime = r#"
14676$targets = @('Teams','ms-teams')
14677foreach ($name in $targets) {
14678 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14679 if ($procs) {
14680 $count = @($procs).Count
14681 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14682 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14683 } else {
14684 "$name | Running: No"
14685 }
14686}
14687"#;
14688 match run_powershell(ps_runtime) {
14689 Ok(o) if !o.trim().is_empty() => {
14690 for line in o.lines().take(6) {
14691 let l = line.trim();
14692 if !l.is_empty() {
14693 out.push_str(&format!("- {l}\n"));
14694 }
14695 }
14696 }
14697 _ => out.push_str("- Could not inspect Teams runtime state\n"),
14698 }
14699
14700 out.push_str("\n=== Cache directory sizing ===\n");
14701 let ps_cache = r#"
14702$cachePaths = @(
14703 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14704 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14705 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14706 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14707)
14708foreach ($entry in $cachePaths) {
14709 if (Test-Path $entry.Path) {
14710 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14711 if (-not $sizeBytes) { $sizeBytes = 0 }
14712 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14713 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14714 } else {
14715 "$($entry.Name) | Path: $($entry.Path) | Not found"
14716 }
14717}
14718"#;
14719 match run_powershell(ps_cache) {
14720 Ok(o) if !o.trim().is_empty() => {
14721 for line in o.lines().take(max_entries + 4) {
14722 let l = line.trim();
14723 if !l.is_empty() {
14724 out.push_str(&format!("- {l}\n"));
14725 }
14726 }
14727 }
14728 _ => out.push_str("- Could not inspect Teams cache directories\n"),
14729 }
14730
14731 out.push_str("\n=== WebView2 runtime ===\n");
14732 let ps_webview = r#"
14733$paths = @(
14734 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14735 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14736) | Where-Object { $_ -and (Test-Path $_) }
14737$runtimeDir = $paths | ForEach-Object {
14738 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14739 Where-Object { $_.Name -match '^\d+\.' } |
14740 Sort-Object Name -Descending |
14741 Select-Object -First 1
14742} | Select-Object -First 1
14743if ($runtimeDir) {
14744 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14745 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14746 "Installed: Yes | Version: $version"
14747} else {
14748 "Installed: No -- New Teams and some Office features require WebView2"
14749}
14750"#;
14751 match run_powershell(ps_webview) {
14752 Ok(o) if !o.trim().is_empty() => {
14753 for line in o.lines().take(4) {
14754 let l = line.trim();
14755 if !l.is_empty() {
14756 out.push_str(&format!("- {l}\n"));
14757 }
14758 }
14759 }
14760 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14761 }
14762
14763 out.push_str("\n=== Account and sign-in state ===\n");
14764 let ps_auth = r#"
14765# Classic Teams account registry
14766$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14767if (Test-Path $classicAcct) {
14768 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14769 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14770 "ClassicTeamsAccount: $email"
14771} else {
14772 "ClassicTeamsAccount: Not configured"
14773}
14774# WAM / token broker state for Teams
14775$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14776$tokenCount = if (Test-Path $tokenCache) {
14777 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14778} else { 0 }
14779"TokenBrokerCacheFiles: $tokenCount"
14780# Office identity
14781$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14782if (Test-Path $officeId) {
14783 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14784 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14785 "OfficeSignedInUserId: $signedIn"
14786}
14787# Check if Teams is in startup
14788$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14789$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14790"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14791"#;
14792 match run_powershell(ps_auth) {
14793 Ok(o) if !o.trim().is_empty() => {
14794 for line in o.lines().take(max_entries + 4) {
14795 let l = line.trim();
14796 if !l.is_empty() {
14797 out.push_str(&format!("- {l}\n"));
14798 }
14799 }
14800 }
14801 _ => out.push_str("- Could not inspect Teams account state\n"),
14802 }
14803
14804 out.push_str("\n=== Audio and video device binding ===\n");
14805 let ps_devices = r#"
14806# Teams stores device prefs in the settings file
14807$settingsPaths = @(
14808 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14809 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14810)
14811$found = $false
14812foreach ($sp in $settingsPaths) {
14813 if (Test-Path $sp) {
14814 $found = $true
14815 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14816 if ($raw) {
14817 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14818 if ($json) {
14819 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14820 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14821 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14822 "ConfigFile: $sp"
14823 "Microphone: $mic"
14824 "Speaker: $spk"
14825 "Camera: $cam"
14826 } else {
14827 "ConfigFile: $sp (not parseable as JSON)"
14828 }
14829 } else {
14830 "ConfigFile: $sp (empty)"
14831 }
14832 break
14833 }
14834}
14835if (-not $found) {
14836 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14837}
14838"#;
14839 match run_powershell(ps_devices) {
14840 Ok(o) if !o.trim().is_empty() => {
14841 for line in o.lines().take(max_entries + 4) {
14842 let l = line.trim();
14843 if !l.is_empty() {
14844 out.push_str(&format!("- {l}\n"));
14845 }
14846 }
14847 }
14848 _ => out.push_str("- Could not inspect Teams device binding\n"),
14849 }
14850
14851 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14852 let ps_events = r#"
14853$cutoff = (Get-Date).AddDays(-7)
14854$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14855 Where-Object {
14856 $msg = [string]$_.Message
14857 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14858 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14859 } |
14860 Select-Object -First 8
14861if ($events) {
14862 foreach ($event in $events) {
14863 $msg = ($event.Message -replace '\s+', ' ')
14864 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14865 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14866 }
14867} else {
14868 "No recent Teams crash or error events detected in Application log"
14869}
14870"#;
14871 match run_powershell(ps_events) {
14872 Ok(o) if !o.trim().is_empty() => {
14873 for line in o.lines().take(max_entries + 4) {
14874 let l = line.trim();
14875 if !l.is_empty() {
14876 out.push_str(&format!("- {l}\n"));
14877 }
14878 }
14879 }
14880 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14881 }
14882
14883 let mut findings: Vec<String> = Vec::new();
14884
14885 let classic_installed = out.contains("- ClassicTeams: Installed");
14886 let new_installed = out.contains("- NewTeams: Installed");
14887 if !classic_installed && !new_installed {
14888 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14889 }
14890
14891 for name in ["Teams", "ms-teams"] {
14892 let marker = format!("{name} | Running: Yes | Processes:");
14893 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14894 let ws_mb = line
14895 .split("WorkingSetMB: ")
14896 .nth(1)
14897 .and_then(|v| v.trim().parse::<f64>().ok())
14898 .unwrap_or(0.0);
14899 if ws_mb >= 1000.0 {
14900 findings.push(format!(
14901 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14902 ));
14903 }
14904 }
14905 }
14906
14907 for (label, threshold_mb) in [
14908 ("ClassicTeamsCache", 500.0_f64),
14909 ("ClassicTeamsSquirrel", 2000.0),
14910 ("NewTeamsCache", 500.0),
14911 ("NewTeamsAppData", 3000.0),
14912 ] {
14913 let marker = format!("{label} |");
14914 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14915 let mb = line
14916 .split("SizeMB: ")
14917 .nth(1)
14918 .and_then(|v| v.trim().parse::<f64>().ok())
14919 .unwrap_or(0.0);
14920 if mb >= threshold_mb {
14921 findings.push(format!(
14922 "{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."
14923 ));
14924 }
14925 }
14926 }
14927
14928 if out.contains("- Installed: No -- New Teams") {
14929 findings.push(
14930 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14931 .into(),
14932 );
14933 }
14934
14935 if out.contains("- ClassicTeamsAccount: Not configured")
14936 && out.contains("- OfficeSignedInUserId: None")
14937 {
14938 findings.push(
14939 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14940 .into(),
14941 );
14942 }
14943
14944 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14945 findings.push(
14946 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14947 .into(),
14948 );
14949 }
14950
14951 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14952 if findings.is_empty() {
14953 result.push_str("- No obvious Teams health blocker detected.\n");
14954 } else {
14955 for finding in &findings {
14956 result.push_str(&format!("- Finding: {finding}\n"));
14957 }
14958 }
14959 result.push('\n');
14960 result.push_str(&out);
14961 Ok(result)
14962}
14963
14964#[cfg(not(windows))]
14965fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14966 Ok(
14967 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14968 .into(),
14969 )
14970}
14971
14972#[cfg(windows)]
14973fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14974 let mut out = String::from("=== Identity broker services ===\n");
14975
14976 let ps_services = r#"
14977$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14978foreach ($name in $serviceNames) {
14979 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14980 if ($svc) {
14981 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14982 } else {
14983 "$name | Not found"
14984 }
14985}
14986"#;
14987 match run_powershell(ps_services) {
14988 Ok(o) if !o.trim().is_empty() => {
14989 for line in o.lines().take(max_entries) {
14990 let l = line.trim();
14991 if !l.is_empty() {
14992 out.push_str(&format!("- {l}\n"));
14993 }
14994 }
14995 }
14996 _ => out.push_str("- Could not inspect identity broker services\n"),
14997 }
14998
14999 out.push_str("\n=== Device registration ===\n");
15000 let ps_device = r#"
15001$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15002if ($dsreg) {
15003 try {
15004 $raw = & $dsreg.Source /status 2>$null
15005 $text = ($raw -join "`n")
15006 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15007 $seen = $false
15008 foreach ($key in $keys) {
15009 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15010 if ($match.Success) {
15011 "${key}: $($match.Groups[1].Value.Trim())"
15012 $seen = $true
15013 }
15014 }
15015 if (-not $seen) {
15016 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15017 }
15018 } catch {
15019 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15020 }
15021} else {
15022 "DeviceRegistration: dsregcmd unavailable"
15023}
15024"#;
15025 match run_powershell(ps_device) {
15026 Ok(o) if !o.trim().is_empty() => {
15027 for line in o.lines().take(max_entries + 4) {
15028 let l = line.trim();
15029 if !l.is_empty() {
15030 out.push_str(&format!("- {l}\n"));
15031 }
15032 }
15033 }
15034 _ => out.push_str(
15035 "- DeviceRegistration: Could not inspect device registration state in this session\n",
15036 ),
15037 }
15038
15039 out.push_str("\n=== Broker packages and caches ===\n");
15040 let ps_broker = r#"
15041$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15042if ($pkg) {
15043 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15044} else {
15045 "AADBrokerPlugin: Not installed"
15046}
15047$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15048$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15049"TokenBrokerCacheFiles: $tokenCount"
15050$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15051$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15052"IdentityCacheFiles: $identityCount"
15053$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15054$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15055"OneAuthFiles: $oneAuthCount"
15056"#;
15057 match run_powershell(ps_broker) {
15058 Ok(o) if !o.trim().is_empty() => {
15059 for line in o.lines().take(max_entries + 4) {
15060 let l = line.trim();
15061 if !l.is_empty() {
15062 out.push_str(&format!("- {l}\n"));
15063 }
15064 }
15065 }
15066 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15067 }
15068
15069 out.push_str("\n=== Microsoft app account signals ===\n");
15070 let ps_accounts = r#"
15071function MaskEmail([string]$Email) {
15072 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15073 $parts = $Email.Split('@', 2)
15074 $local = $parts[0]
15075 $domain = $parts[1]
15076 if ($local.Length -le 1) { return "*@$domain" }
15077 return ($local.Substring(0,1) + "***@" + $domain)
15078}
15079$allAccounts = @()
15080$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15081if (Test-Path $officeId) {
15082 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15083 if ($id.SignedInUserId) {
15084 $allAccounts += [string]$id.SignedInUserId
15085 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15086 } else {
15087 "OfficeSignedInUserId: None"
15088 }
15089} else {
15090 "OfficeSignedInUserId: Not configured"
15091}
15092$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15093if (Test-Path $teamsAcct) {
15094 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15095 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15096 if (-not [string]::IsNullOrWhiteSpace($email)) {
15097 $allAccounts += $email
15098 "TeamsAccount: $(MaskEmail $email)"
15099 } else {
15100 "TeamsAccount: Unknown"
15101 }
15102} else {
15103 "TeamsAccount: Not configured"
15104}
15105$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15106$oneDriveEmails = @()
15107if (Test-Path $oneDriveBase) {
15108 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15109 ForEach-Object {
15110 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15111 if ($p.UserEmail) { [string]$p.UserEmail }
15112 } |
15113 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15114 Sort-Object -Unique
15115}
15116$allAccounts += $oneDriveEmails
15117"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15118if (@($oneDriveEmails).Count -gt 0) {
15119 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15120}
15121$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15122"DistinctIdentityCount: $($distinct.Count)"
15123if ($distinct.Count -gt 0) {
15124 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15125}
15126"#;
15127 match run_powershell(ps_accounts) {
15128 Ok(o) if !o.trim().is_empty() => {
15129 for line in o.lines().take(max_entries + 6) {
15130 let l = line.trim();
15131 if !l.is_empty() {
15132 out.push_str(&format!("- {l}\n"));
15133 }
15134 }
15135 }
15136 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15137 }
15138
15139 out.push_str("\n=== WebView2 auth dependency ===\n");
15140 let ps_webview = r#"
15141$paths = @(
15142 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15143 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15144) | Where-Object { $_ -and (Test-Path $_) }
15145$runtimeDir = $paths | ForEach-Object {
15146 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15147 Where-Object { $_.Name -match '^\d+\.' } |
15148 Sort-Object Name -Descending |
15149 Select-Object -First 1
15150} | Select-Object -First 1
15151if ($runtimeDir) {
15152 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15153 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15154 "WebView2: Installed | Version: $version"
15155} else {
15156 "WebView2: Not installed"
15157}
15158"#;
15159 match run_powershell(ps_webview) {
15160 Ok(o) if !o.trim().is_empty() => {
15161 for line in o.lines().take(4) {
15162 let l = line.trim();
15163 if !l.is_empty() {
15164 out.push_str(&format!("- {l}\n"));
15165 }
15166 }
15167 }
15168 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15169 }
15170
15171 out.push_str("\n=== Recent auth-related events (24h) ===\n");
15172 let ps_events = r#"
15173try {
15174 $cutoff = (Get-Date).AddHours(-24)
15175 $events = @()
15176 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15177 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15178 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15179 Select-Object -First 4
15180 }
15181 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15182 Where-Object {
15183 ($_.LevelDisplayName -in @('Error','Warning')) -and (
15184 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15185 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15186 )
15187 } |
15188 Select-Object -First 6
15189 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15190 "AuthEventCount: $(@($events).Count)"
15191 if ($events) {
15192 foreach ($e in $events) {
15193 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15194 'No message'
15195 } else {
15196 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15197 }
15198 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15199 }
15200 } else {
15201 "No auth-related warning/error events detected"
15202 }
15203} catch {
15204 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15205}
15206"#;
15207 match run_powershell(ps_events) {
15208 Ok(o) if !o.trim().is_empty() => {
15209 for line in o.lines().take(max_entries + 8) {
15210 let l = line.trim();
15211 if !l.is_empty() {
15212 out.push_str(&format!("- {l}\n"));
15213 }
15214 }
15215 }
15216 _ => out
15217 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15218 }
15219
15220 let parse_count = |prefix: &str| -> Option<u64> {
15221 out.lines().find_map(|line| {
15222 line.trim()
15223 .strip_prefix(prefix)
15224 .and_then(|value| value.trim().parse::<u64>().ok())
15225 })
15226 };
15227
15228 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15229 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15230
15231 let mut findings: Vec<String> = Vec::new();
15232 if out.contains("TokenBroker | Status: Stopped")
15233 || out.contains("wlidsvc | Status: Stopped")
15234 || out.contains("OneAuth | Status: Stopped")
15235 {
15236 findings.push(
15237 "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."
15238 .into(),
15239 );
15240 }
15241 if out.contains("AADBrokerPlugin: Not installed") {
15242 findings.push(
15243 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15244 .into(),
15245 );
15246 }
15247 if out.contains("WebView2: Not installed") {
15248 findings.push(
15249 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15250 .into(),
15251 );
15252 }
15253 if distinct_identity_count > 1 {
15254 findings.push(format!(
15255 "{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."
15256 ));
15257 }
15258 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15259 && distinct_identity_count > 0
15260 {
15261 findings.push(
15262 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15263 .into(),
15264 );
15265 }
15266 if out.contains("DeviceRegistration: dsregcmd")
15267 || out.contains("DeviceRegistration: Could not inspect device registration state")
15268 {
15269 findings.push(
15270 "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."
15271 .into(),
15272 );
15273 }
15274 if auth_event_count > 0 {
15275 findings.push(format!(
15276 "{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."
15277 ));
15278 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15279 findings.push(
15280 "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."
15281 .into(),
15282 );
15283 }
15284
15285 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15286 if findings.is_empty() {
15287 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15288 } else {
15289 for finding in &findings {
15290 result.push_str(&format!("- Finding: {finding}\n"));
15291 }
15292 }
15293 result.push('\n');
15294 result.push_str(&out);
15295 Ok(result)
15296}
15297
15298#[cfg(not(windows))]
15299fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15300 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())
15301}
15302
15303#[cfg(windows)]
15304fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15305 let mut out = String::from("=== File History ===\n");
15306
15307 let ps_fh = r#"
15308$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15309if ($svc) {
15310 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15311} else {
15312 "FileHistoryService: Not found"
15313}
15314# File History config in registry
15315$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15316$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15317if (Test-Path $fhUser) {
15318 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15319 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15320 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15321 $lastBackup = if ($fh.ProtectedUpToTime) {
15322 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15323 } else { 'Never' }
15324 "Enabled: $enabled"
15325 "BackupDrive: $target"
15326 "LastBackup: $lastBackup"
15327} else {
15328 "Enabled: Not configured"
15329 "BackupDrive: Not configured"
15330 "LastBackup: Never"
15331}
15332"#;
15333 match run_powershell(ps_fh) {
15334 Ok(o) if !o.trim().is_empty() => {
15335 for line in o.lines().take(6) {
15336 let l = line.trim();
15337 if !l.is_empty() {
15338 out.push_str(&format!("- {l}\n"));
15339 }
15340 }
15341 }
15342 _ => out.push_str("- Could not inspect File History state\n"),
15343 }
15344
15345 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15346 let ps_wbadmin = r#"
15347$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15348"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15349# Last backup from wbadmin
15350$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15351if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15352 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15353 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15354 if ($lastDate) { $lastDate.Trim() }
15355 if ($lastTarget) { $lastTarget.Trim() }
15356} else {
15357 "LastWbadminBackup: No backup versions found"
15358}
15359# Task-based backup
15360$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15361foreach ($t in $task) {
15362 "BackupTask: $($t.TaskName) | State: $($t.State)"
15363}
15364"#;
15365 match run_powershell(ps_wbadmin) {
15366 Ok(o) if !o.trim().is_empty() => {
15367 for line in o.lines().take(8) {
15368 let l = line.trim();
15369 if !l.is_empty() {
15370 out.push_str(&format!("- {l}\n"));
15371 }
15372 }
15373 }
15374 _ => out.push_str("- Could not inspect Windows Backup state\n"),
15375 }
15376
15377 out.push_str("\n=== System Restore ===\n");
15378 let ps_sr = r#"
15379$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15380 Select-Object -ExpandProperty DeviceID
15381foreach ($drive in $drives) {
15382 $protection = try {
15383 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15384 } catch { $null }
15385 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15386 $rpConf = try {
15387 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15388 } catch { $null }
15389 # Check if SR is disabled for this drive
15390 $disabled = $false
15391 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15392 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15393}
15394# Most recent restore point
15395$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15396if ($points) {
15397 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15398 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15399 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15400} else {
15401 "MostRecentRestorePoint: None found"
15402}
15403$srEnabled = try {
15404 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15405 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15406} catch { 'Unknown' }
15407"SystemRestoreState: $srEnabled"
15408"#;
15409 match run_powershell(ps_sr) {
15410 Ok(o) if !o.trim().is_empty() => {
15411 for line in o.lines().take(8) {
15412 let l = line.trim();
15413 if !l.is_empty() {
15414 out.push_str(&format!("- {l}\n"));
15415 }
15416 }
15417 }
15418 _ => out.push_str("- Could not inspect System Restore state\n"),
15419 }
15420
15421 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15422 let ps_kfm = r#"
15423$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15424if (Test-Path $kfmKey) {
15425 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15426 foreach ($acct in $accounts | Select-Object -First 3) {
15427 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15428 $email = $props.UserEmail
15429 $kfmDesktop = $props.'KFMSilentOptInDesktop'
15430 $kfmDocs = $props.'KFMSilentOptInDocuments'
15431 $kfmPics = $props.'KFMSilentOptInPictures'
15432 "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' })"
15433 }
15434} else {
15435 "OneDriveKFM: No OneDrive accounts found"
15436}
15437"#;
15438 match run_powershell(ps_kfm) {
15439 Ok(o) if !o.trim().is_empty() => {
15440 for line in o.lines().take(6) {
15441 let l = line.trim();
15442 if !l.is_empty() {
15443 out.push_str(&format!("- {l}\n"));
15444 }
15445 }
15446 }
15447 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15448 }
15449
15450 out.push_str("\n=== Recent backup failure events (7d) ===\n");
15451 let ps_events = r#"
15452$cutoff = (Get-Date).AddDays(-7)
15453$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15454 Where-Object {
15455 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15456 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15457 } |
15458 Where-Object { $_.Level -le 3 } |
15459 Select-Object -First 6
15460if ($events) {
15461 foreach ($event in $events) {
15462 $msg = ($event.Message -replace '\s+', ' ')
15463 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15464 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15465 }
15466} else {
15467 "No recent backup failure events detected"
15468}
15469"#;
15470 match run_powershell(ps_events) {
15471 Ok(o) if !o.trim().is_empty() => {
15472 for line in o.lines().take(8) {
15473 let l = line.trim();
15474 if !l.is_empty() {
15475 out.push_str(&format!("- {l}\n"));
15476 }
15477 }
15478 }
15479 _ => out.push_str("- Could not inspect backup failure events\n"),
15480 }
15481
15482 let mut findings: Vec<String> = Vec::new();
15483
15484 let fh_enabled = out.contains("- Enabled: Enabled");
15485 let fh_never =
15486 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15487 let no_wbadmin = out.contains("No backup versions found");
15488 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15489
15490 if !fh_enabled && no_wbadmin {
15491 findings.push(
15492 "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(),
15493 );
15494 } else if fh_enabled && fh_never {
15495 findings.push(
15496 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15497 );
15498 }
15499
15500 if no_restore_point {
15501 findings.push(
15502 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15503 );
15504 }
15505
15506 if out.contains("- FileHistoryService: Stopped")
15507 || out.contains("- FileHistoryService: Not found")
15508 {
15509 findings.push(
15510 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15511 );
15512 }
15513
15514 if out.contains("Application Error |")
15515 || out.contains("Microsoft-Windows-Backup |")
15516 || out.contains("wbengine |")
15517 {
15518 findings.push(
15519 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15520 );
15521 }
15522
15523 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15524 if findings.is_empty() {
15525 result.push_str("- No obvious backup health blocker detected.\n");
15526 } else {
15527 for finding in &findings {
15528 result.push_str(&format!("- Finding: {finding}\n"));
15529 }
15530 }
15531 result.push('\n');
15532 result.push_str(&out);
15533 Ok(result)
15534}
15535
15536#[cfg(not(windows))]
15537fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15538 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15539}
15540
15541#[cfg(windows)]
15542fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15543 let mut out = String::from("=== Windows Search service ===\n");
15544
15545 let ps_svc = r#"
15547$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15548if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15549else { "WSearch service not found" }
15550"#;
15551 match run_powershell(ps_svc) {
15552 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15553 Err(_) => out.push_str("- Could not query WSearch service\n"),
15554 }
15555
15556 out.push_str("\n=== Indexer state ===\n");
15558 let ps_idx = r#"
15559$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15560$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15561if ($props) {
15562 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15563 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15564 "DataDirectory: $($props.DataDirectory)"
15565} else { "Registry key not found" }
15566"#;
15567 match run_powershell(ps_idx) {
15568 Ok(o) => {
15569 for line in o.lines() {
15570 let l = line.trim();
15571 if !l.is_empty() {
15572 out.push_str(&format!("- {l}\n"));
15573 }
15574 }
15575 }
15576 Err(_) => out.push_str("- Could not read indexer registry\n"),
15577 }
15578
15579 out.push_str("\n=== Indexed locations ===\n");
15581 let ps_locs = r#"
15582$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15583if ($comObj) {
15584 $catalog = $comObj.GetCatalog('SystemIndex')
15585 $manager = $catalog.GetCrawlScopeManager()
15586 $rules = $manager.EnumerateRoots()
15587 while ($true) {
15588 try {
15589 $root = $rules.Next(1)
15590 if ($root.Count -eq 0) { break }
15591 $r = $root[0]
15592 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15593 } catch { break }
15594 }
15595} else { " COM admin interface not available (normal on non-admin sessions)" }
15596"#;
15597 match run_powershell(ps_locs) {
15598 Ok(o) if !o.trim().is_empty() => {
15599 for line in o.lines() {
15600 let l = line.trim_end();
15601 if !l.is_empty() {
15602 out.push_str(&format!("{l}\n"));
15603 }
15604 }
15605 }
15606 _ => {
15607 let ps_reg = r#"
15609Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15610ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
15611"#;
15612 match run_powershell(ps_reg) {
15613 Ok(o) if !o.trim().is_empty() => {
15614 for line in o.lines() {
15615 let l = line.trim_end();
15616 if !l.is_empty() {
15617 out.push_str(&format!("{l}\n"));
15618 }
15619 }
15620 }
15621 _ => out.push_str(" - Could not enumerate indexed locations\n"),
15622 }
15623 }
15624 }
15625
15626 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15628 let ps_evts = r#"
15629Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15630Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15631ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15632"#;
15633 match run_powershell(ps_evts) {
15634 Ok(o) if !o.trim().is_empty() => {
15635 for line in o.lines() {
15636 let l = line.trim();
15637 if !l.is_empty() {
15638 out.push_str(&format!("- {l}\n"));
15639 }
15640 }
15641 }
15642 _ => out.push_str("- No recent indexer errors found\n"),
15643 }
15644
15645 let mut findings: Vec<String> = Vec::new();
15646 if out.contains("Status: Stopped") {
15647 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15648 }
15649 if out.contains("IsContentIndexingEnabled: 0")
15650 || out.contains("IsContentIndexingEnabled: False")
15651 {
15652 findings.push(
15653 "Content indexing is disabled — file content won't be searchable, only filenames."
15654 .into(),
15655 );
15656 }
15657 if out.contains("SetupCompletedSuccessfully: 0")
15658 || out.contains("SetupCompletedSuccessfully: False")
15659 {
15660 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15661 }
15662
15663 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15664 if findings.is_empty() {
15665 result.push_str("- Windows Search service and indexer appear healthy.\n");
15666 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15667 } else {
15668 for f in &findings {
15669 result.push_str(&format!("- Finding: {f}\n"));
15670 }
15671 }
15672 result.push('\n');
15673 result.push_str(&out);
15674 Ok(result)
15675}
15676
15677#[cfg(not(windows))]
15678fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15679 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15680}
15681
15682#[cfg(windows)]
15685fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15686 let mut out = String::new();
15687
15688 out.push_str("=== Active displays ===\n");
15690 let ps_displays = r#"
15691Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15692Select-Object -First 20 |
15693ForEach-Object {
15694 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15695}
15696"#;
15697 match run_powershell(ps_displays) {
15698 Ok(o) if !o.trim().is_empty() => {
15699 for line in o.lines().take(max_entries) {
15700 let l = line.trim();
15701 if !l.is_empty() {
15702 out.push_str(&format!("- {l}\n"));
15703 }
15704 }
15705 }
15706 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15707 }
15708
15709 out.push_str("\n=== Video adapters ===\n");
15711 let ps_gpu = r#"
15712Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15713ForEach-Object {
15714 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15715 $hz = "$($_.CurrentRefreshRate) Hz"
15716 $bits = "$($_.CurrentBitsPerPixel) bpp"
15717 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15718}
15719"#;
15720 match run_powershell(ps_gpu) {
15721 Ok(o) if !o.trim().is_empty() => {
15722 for line in o.lines().take(max_entries) {
15723 let l = line.trim();
15724 if !l.is_empty() {
15725 out.push_str(&format!("- {l}\n"));
15726 }
15727 }
15728 }
15729 _ => out.push_str("- Could not query video adapter info\n"),
15730 }
15731
15732 out.push_str("\n=== Connected monitors ===\n");
15734 let ps_monitors = r#"
15735Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15736ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15737"#;
15738 match run_powershell(ps_monitors) {
15739 Ok(o) if !o.trim().is_empty() => {
15740 for line in o.lines().take(max_entries) {
15741 let l = line.trim();
15742 if !l.is_empty() {
15743 out.push_str(&format!("- {l}\n"));
15744 }
15745 }
15746 }
15747 _ => out.push_str("- No monitor info available via WMI\n"),
15748 }
15749
15750 out.push_str("\n=== DPI / scaling ===\n");
15752 let ps_dpi = r#"
15753Add-Type -TypeDefinition @'
15754using System; using System.Runtime.InteropServices;
15755public class DPI {
15756 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15757 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15758 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15759}
15760'@ -ErrorAction SilentlyContinue
15761try {
15762 $hdc = [DPI]::GetDC([IntPtr]::Zero)
15763 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15764 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15765 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15766 $scale = [Math]::Round($dpiX / 96.0 * 100)
15767 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15768} catch { "DPI query unavailable" }
15769"#;
15770 match run_powershell(ps_dpi) {
15771 Ok(o) if !o.trim().is_empty() => {
15772 out.push_str(&format!("- {}\n", o.trim()));
15773 }
15774 _ => out.push_str("- DPI info unavailable\n"),
15775 }
15776
15777 let mut findings: Vec<String> = Vec::new();
15778 if out.contains("0x0") || out.contains("@ 0 Hz") {
15779 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15780 }
15781
15782 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15783 if findings.is_empty() {
15784 result.push_str("- Display configuration appears normal.\n");
15785 } else {
15786 for f in &findings {
15787 result.push_str(&format!("- Finding: {f}\n"));
15788 }
15789 }
15790 result.push('\n');
15791 result.push_str(&out);
15792 Ok(result)
15793}
15794
15795#[cfg(not(windows))]
15796fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15797 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15798}
15799
15800#[cfg(windows)]
15803fn inspect_ntp() -> Result<String, String> {
15804 let mut out = String::new();
15805
15806 out.push_str("=== Windows Time service ===\n");
15808 let ps_svc = r#"
15809$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15810if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15811else { "W32Time service not found" }
15812"#;
15813 match run_powershell(ps_svc) {
15814 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15815 Err(_) => out.push_str("- Could not query W32Time service\n"),
15816 }
15817
15818 out.push_str("\n=== NTP source and sync status ===\n");
15820 let ps_sync = r#"
15821$q = w32tm /query /status 2>$null
15822if ($q) { $q } else { "w32tm query unavailable" }
15823"#;
15824 match run_powershell(ps_sync) {
15825 Ok(o) if !o.trim().is_empty() => {
15826 for line in o.lines() {
15827 let l = line.trim();
15828 if !l.is_empty() {
15829 out.push_str(&format!(" {l}\n"));
15830 }
15831 }
15832 }
15833 _ => out.push_str(" - Could not query w32tm status\n"),
15834 }
15835
15836 out.push_str("\n=== Configured NTP servers ===\n");
15838 let ps_peers = r#"
15839w32tm /query /peers 2>$null | Select-Object -First 10
15840"#;
15841 match run_powershell(ps_peers) {
15842 Ok(o) if !o.trim().is_empty() => {
15843 for line in o.lines() {
15844 let l = line.trim();
15845 if !l.is_empty() {
15846 out.push_str(&format!(" {l}\n"));
15847 }
15848 }
15849 }
15850 _ => {
15851 let ps_reg = r#"
15853(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15854"#;
15855 match run_powershell(ps_reg) {
15856 Ok(o) if !o.trim().is_empty() => {
15857 out.push_str(&format!(" NtpServer (registry): {}\n", o.trim()));
15858 }
15859 _ => out.push_str(" - Could not enumerate NTP peers\n"),
15860 }
15861 }
15862 }
15863
15864 let mut findings: Vec<String> = Vec::new();
15865 if out.contains("W32Time | Status: Stopped") {
15866 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15867 }
15868 if out.contains("The computer did not resync") || out.contains("Error") {
15869 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15870 }
15871
15872 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15873 if findings.is_empty() {
15874 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15875 } else {
15876 for f in &findings {
15877 result.push_str(&format!("- Finding: {f}\n"));
15878 }
15879 }
15880 result.push('\n');
15881 result.push_str(&out);
15882 Ok(result)
15883}
15884
15885#[cfg(not(windows))]
15886fn inspect_ntp() -> Result<String, String> {
15887 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15889
15890 let timedatectl = std::process::Command::new("timedatectl")
15891 .arg("status")
15892 .output();
15893
15894 if let Ok(o) = timedatectl {
15895 let text = String::from_utf8_lossy(&o.stdout);
15896 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15897 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15898 } else {
15899 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15900 }
15901 for line in text.lines() {
15902 let l = line.trim();
15903 if !l.is_empty() {
15904 out.push_str(&format!(" {l}\n"));
15905 }
15906 }
15907 return Ok(out);
15908 }
15909
15910 let sntp = std::process::Command::new("sntp")
15912 .args(["-d", "time.apple.com"])
15913 .output();
15914 if let Ok(o) = sntp {
15915 out.push_str("- NTP check via sntp:\n");
15916 out.push_str(&String::from_utf8_lossy(&o.stdout));
15917 return Ok(out);
15918 }
15919
15920 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15921 Ok(out)
15922}
15923
15924#[cfg(windows)]
15927fn inspect_cpu_power() -> Result<String, String> {
15928 let mut out = String::new();
15929
15930 out.push_str("=== Active power plan ===\n");
15932 let ps_plan = r#"
15933$plan = powercfg /getactivescheme 2>$null
15934if ($plan) { $plan } else { "Could not query power scheme" }
15935"#;
15936 match run_powershell(ps_plan) {
15937 Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15938 _ => out.push_str("- Could not read active power plan\n"),
15939 }
15940
15941 out.push_str("\n=== Processor performance policy ===\n");
15943 let ps_proc = r#"
15944$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15945$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15946$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15947$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15948if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15949if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15950if ($boost) {
15951 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15952 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15953 "Turbo boost mode: $bname"
15954}
15955"#;
15956 match run_powershell(ps_proc) {
15957 Ok(o) if !o.trim().is_empty() => {
15958 for line in o.lines() {
15959 let l = line.trim();
15960 if !l.is_empty() {
15961 out.push_str(&format!("- {l}\n"));
15962 }
15963 }
15964 }
15965 _ => out.push_str("- Could not query processor performance settings\n"),
15966 }
15967
15968 out.push_str("\n=== CPU frequency ===\n");
15970 let ps_freq = r#"
15971Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15972ForEach-Object {
15973 $cur = $_.CurrentClockSpeed
15974 $max = $_.MaxClockSpeed
15975 $load = $_.LoadPercentage
15976 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15977}
15978"#;
15979 match run_powershell(ps_freq) {
15980 Ok(o) if !o.trim().is_empty() => {
15981 for line in o.lines() {
15982 let l = line.trim();
15983 if !l.is_empty() {
15984 out.push_str(&format!("- {l}\n"));
15985 }
15986 }
15987 }
15988 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15989 }
15990
15991 out.push_str("\n=== Throttling indicators ===\n");
15993 let ps_throttle = r#"
15994$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15995if ($pwr) {
15996 $pwr | Select-Object -First 4 | ForEach-Object {
15997 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15998 "Thermal zone $($_.InstanceName): ${c}°C"
15999 }
16000} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16001"#;
16002 match run_powershell(ps_throttle) {
16003 Ok(o) if !o.trim().is_empty() => {
16004 for line in o.lines() {
16005 let l = line.trim();
16006 if !l.is_empty() {
16007 out.push_str(&format!("- {l}\n"));
16008 }
16009 }
16010 }
16011 _ => out.push_str("- Thermal zone info unavailable\n"),
16012 }
16013
16014 let mut findings: Vec<String> = Vec::new();
16015 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
16016 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16017 }
16018 if out.contains("Turbo boost mode: Disabled") {
16019 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16020 }
16021 if out.contains("Min processor state: 100%") {
16022 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16023 }
16024
16025 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16026 if findings.is_empty() {
16027 result.push_str("- CPU power and frequency settings appear normal.\n");
16028 } else {
16029 for f in &findings {
16030 result.push_str(&format!("- Finding: {f}\n"));
16031 }
16032 }
16033 result.push('\n');
16034 result.push_str(&out);
16035 Ok(result)
16036}
16037
16038#[cfg(windows)]
16039fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16040 let mut out = String::new();
16041
16042 out.push_str("=== Credential vault summary ===\n");
16043 let ps_summary = r#"
16044$raw = cmdkey /list 2>&1
16045$lines = $raw -split "`n"
16046$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16047"Total stored credentials: $total"
16048$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16049$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16050$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16051" Windows credentials: $windows"
16052" Generic credentials: $generic"
16053" Certificate-based: $cert"
16054"#;
16055 match run_powershell(ps_summary) {
16056 Ok(o) => {
16057 for line in o.lines() {
16058 let l = line.trim();
16059 if !l.is_empty() {
16060 out.push_str(&format!("- {l}\n"));
16061 }
16062 }
16063 }
16064 Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
16065 }
16066
16067 out.push_str("\n=== Credential targets (up to 20) ===\n");
16068 let ps_list = r#"
16069$raw = cmdkey /list 2>&1
16070$entries = @(); $cur = @{}
16071foreach ($line in ($raw -split "`n")) {
16072 $l = $line.Trim()
16073 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
16074 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
16075 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16076}
16077$entries | Select-Object -Last 20 | ForEach-Object {
16078 "[$($_.Type)] $($_.Target) (user: $($_.User))"
16079}
16080"#;
16081 match run_powershell(ps_list) {
16082 Ok(o) => {
16083 let lines: Vec<&str> = o
16084 .lines()
16085 .map(|l| l.trim())
16086 .filter(|l| !l.is_empty())
16087 .collect();
16088 if lines.is_empty() {
16089 out.push_str("- No credential entries found\n");
16090 } else {
16091 for l in &lines {
16092 out.push_str(&format!("- {l}\n"));
16093 }
16094 }
16095 }
16096 Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
16097 }
16098
16099 let total_creds: usize = {
16100 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16101 run_powershell(ps_count)
16102 .ok()
16103 .and_then(|s| s.trim().parse().ok())
16104 .unwrap_or(0)
16105 };
16106
16107 let mut findings: Vec<String> = Vec::new();
16108 if total_creds > 30 {
16109 findings.push(format!(
16110 "{total_creds} stored credentials found — consider auditing for stale entries."
16111 ));
16112 }
16113
16114 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16115 if findings.is_empty() {
16116 result.push_str("- Credential store looks normal.\n");
16117 } else {
16118 for f in &findings {
16119 result.push_str(&format!("- Finding: {f}\n"));
16120 }
16121 }
16122 result.push('\n');
16123 result.push_str(&out);
16124 Ok(result)
16125}
16126
16127#[cfg(not(windows))]
16128fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16129 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16130}
16131
16132#[cfg(windows)]
16133fn inspect_tpm() -> Result<String, String> {
16134 let mut out = String::new();
16135
16136 out.push_str("=== TPM state ===\n");
16137 let ps_tpm = r#"
16138function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16139 $text = if ($null -eq $Value) { "" } else { [string]$Value }
16140 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16141 "$Name$text"
16142}
16143$t = Get-Tpm -ErrorAction SilentlyContinue
16144if ($t) {
16145 Emit-Field "TpmPresent: " $t.TpmPresent
16146 Emit-Field "TpmReady: " $t.TpmReady
16147 Emit-Field "TpmEnabled: " $t.TpmEnabled
16148 Emit-Field "TpmOwned: " $t.TpmOwned
16149 Emit-Field "RestartPending: " $t.RestartPending
16150 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
16151 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16152} else { "TPM module unavailable" }
16153"#;
16154 match run_powershell(ps_tpm) {
16155 Ok(o) => {
16156 for line in o.lines() {
16157 let l = line.trim();
16158 if !l.is_empty() {
16159 out.push_str(&format!("- {l}\n"));
16160 }
16161 }
16162 }
16163 Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
16164 }
16165
16166 out.push_str("\n=== TPM spec version (WMI) ===\n");
16167 let ps_spec = r#"
16168$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16169if ($wmi) {
16170 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16171 "SpecVersion: $spec"
16172 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16173 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16174 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16175} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16176"#;
16177 match run_powershell(ps_spec) {
16178 Ok(o) => {
16179 for line in o.lines() {
16180 let l = line.trim();
16181 if !l.is_empty() {
16182 out.push_str(&format!("- {l}\n"));
16183 }
16184 }
16185 }
16186 Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
16187 }
16188
16189 out.push_str("\n=== Secure Boot state ===\n");
16190 let ps_sb = r#"
16191try {
16192 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16193 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16194} catch {
16195 $msg = $_.Exception.Message
16196 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16197 "Secure Boot: Unknown (administrator privileges required)"
16198 } elseif ($msg -match "Cmdlet not supported on this platform") {
16199 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16200 } else {
16201 "Secure Boot: N/A ($msg)"
16202 }
16203}
16204"#;
16205 match run_powershell(ps_sb) {
16206 Ok(o) => {
16207 for line in o.lines() {
16208 let l = line.trim();
16209 if !l.is_empty() {
16210 out.push_str(&format!("- {l}\n"));
16211 }
16212 }
16213 }
16214 Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
16215 }
16216
16217 out.push_str("\n=== Firmware type ===\n");
16218 let ps_fw = r#"
16219$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16220switch ($fw) {
16221 1 { "Firmware type: BIOS (Legacy)" }
16222 2 { "Firmware type: UEFI" }
16223 default {
16224 $bcd = bcdedit /enum firmware 2>$null
16225 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16226 else { "Firmware type: Unknown or not set" }
16227 }
16228}
16229"#;
16230 match run_powershell(ps_fw) {
16231 Ok(o) => {
16232 for line in o.lines() {
16233 let l = line.trim();
16234 if !l.is_empty() {
16235 out.push_str(&format!("- {l}\n"));
16236 }
16237 }
16238 }
16239 Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
16240 }
16241
16242 let mut findings: Vec<String> = Vec::new();
16243 let mut indeterminate = false;
16244 if out.contains("TpmPresent: False") {
16245 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16246 }
16247 if out.contains("TpmReady: False") {
16248 findings.push(
16249 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16250 );
16251 }
16252 if out.contains("SpecVersion: 1.2") {
16253 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16254 }
16255 if out.contains("Secure Boot: DISABLED") {
16256 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16257 }
16258 if out.contains("Firmware type: BIOS (Legacy)") {
16259 findings.push(
16260 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16261 );
16262 }
16263
16264 if out.contains("TPM module unavailable")
16265 || out.contains("Win32_Tpm WMI class unavailable")
16266 || out.contains("Secure Boot: N/A")
16267 || out.contains("Secure Boot: Unknown")
16268 || out.contains("Firmware type: Unknown or not set")
16269 || out.contains("TpmPresent: Unknown")
16270 || out.contains("TpmReady: Unknown")
16271 || out.contains("TpmEnabled: Unknown")
16272 {
16273 indeterminate = true;
16274 }
16275 if indeterminate {
16276 findings.push(
16277 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16278 .into(),
16279 );
16280 }
16281
16282 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16283 if findings.is_empty() {
16284 result.push_str("- TPM and Secure Boot appear healthy.\n");
16285 } else {
16286 for f in &findings {
16287 result.push_str(&format!("- Finding: {f}\n"));
16288 }
16289 }
16290 result.push('\n');
16291 result.push_str(&out);
16292 Ok(result)
16293}
16294
16295#[cfg(not(windows))]
16296fn inspect_tpm() -> Result<String, String> {
16297 Ok(
16298 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16299 .into(),
16300 )
16301}
16302
16303#[cfg(windows)]
16304fn inspect_latency() -> Result<String, String> {
16305 let mut out = String::new();
16306
16307 let ps_gw = r#"
16309$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16310 Sort-Object RouteMetric | Select-Object -First 1).NextHop
16311if ($gw) { $gw } else { "" }
16312"#;
16313 let gateway = run_powershell(ps_gw)
16314 .ok()
16315 .map(|s| s.trim().to_string())
16316 .filter(|s| !s.is_empty());
16317
16318 let targets: Vec<(&str, String)> = {
16319 let mut t = Vec::new();
16320 if let Some(ref gw) = gateway {
16321 t.push(("Default gateway", gw.clone()));
16322 }
16323 t.push(("Cloudflare DNS", "1.1.1.1".into()));
16324 t.push(("Google DNS", "8.8.8.8".into()));
16325 t
16326 };
16327
16328 let mut findings: Vec<String> = Vec::new();
16329
16330 for (label, host) in &targets {
16331 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16332 let ps_ping = format!(
16334 r#"
16335$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16336if ($r) {{
16337 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16338 $min = ($rtts | Measure-Object -Minimum).Minimum
16339 $max = ($rtts | Measure-Object -Maximum).Maximum
16340 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16341 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16342 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16343 "Packet loss: ${{loss}}%"
16344 "Sent: 4 Received: $($r.Count)"
16345}} else {{
16346 "UNREACHABLE — 100% packet loss"
16347}}
16348"#
16349 );
16350 match run_powershell(&ps_ping) {
16351 Ok(o) => {
16352 let body = o.trim().to_string();
16353 for line in body.lines() {
16354 let l = line.trim();
16355 if !l.is_empty() {
16356 out.push_str(&format!("- {l}\n"));
16357 }
16358 }
16359 if body.contains("UNREACHABLE") {
16360 findings.push(format!(
16361 "{label} ({host}) is unreachable — possible routing or firewall issue."
16362 ));
16363 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16364 let pct: u32 = loss_line
16365 .chars()
16366 .filter(|c| c.is_ascii_digit())
16367 .collect::<String>()
16368 .parse()
16369 .unwrap_or(0);
16370 if pct >= 25 {
16371 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16372 }
16373 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16375 let parts: Vec<&str> = rtt_line.split('/').collect();
16377 if parts.len() >= 2 {
16378 let avg_str: String =
16379 parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
16380 let avg: u32 = avg_str.parse().unwrap_or(0);
16381 if avg > 150 {
16382 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16383 }
16384 }
16385 }
16386 }
16387 }
16388 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16389 }
16390 }
16391
16392 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16393 if findings.is_empty() {
16394 result.push_str("- Latency and reachability look normal.\n");
16395 } else {
16396 for f in &findings {
16397 result.push_str(&format!("- Finding: {f}\n"));
16398 }
16399 }
16400 result.push('\n');
16401 result.push_str(&out);
16402 Ok(result)
16403}
16404
16405#[cfg(not(windows))]
16406fn inspect_latency() -> Result<String, String> {
16407 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16408 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16409 let mut findings: Vec<String> = Vec::new();
16410
16411 for (label, host) in &targets {
16412 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16413 let ping = std::process::Command::new("ping")
16414 .args(["-c", "4", "-W", "2", host])
16415 .output();
16416 match ping {
16417 Ok(o) => {
16418 let body = String::from_utf8_lossy(&o.stdout).into_owned();
16419 for line in body.lines() {
16420 let l = line.trim();
16421 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16422 out.push_str(&format!("- {l}\n"));
16423 }
16424 }
16425 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16426 findings.push(format!("{label} ({host}) is unreachable."));
16427 }
16428 }
16429 Err(e) => out.push_str(&format!("- ping error: {e}\n")),
16430 }
16431 }
16432
16433 if findings.is_empty() {
16434 out.insert_str(
16435 "Host inspection: latency\n\n=== Findings ===\n".len(),
16436 "- Latency and reachability look normal.\n",
16437 );
16438 } else {
16439 let mut prefix = String::new();
16440 for f in &findings {
16441 prefix.push_str(&format!("- Finding: {f}\n"));
16442 }
16443 out.insert_str(
16444 "Host inspection: latency\n\n=== Findings ===\n".len(),
16445 &prefix,
16446 );
16447 }
16448 Ok(out)
16449}
16450
16451#[cfg(windows)]
16452fn inspect_network_adapter() -> Result<String, String> {
16453 let mut out = String::new();
16454
16455 out.push_str("=== Network adapters ===\n");
16456 let ps_adapters = r#"
16457Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16458 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16459 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16460}
16461"#;
16462 match run_powershell(ps_adapters) {
16463 Ok(o) => {
16464 for line in o.lines() {
16465 let l = line.trim();
16466 if !l.is_empty() {
16467 out.push_str(&format!("- {l}\n"));
16468 }
16469 }
16470 }
16471 Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
16472 }
16473
16474 out.push_str("\n=== Duplex and negotiated speed ===\n");
16475 let ps_duplex = r#"
16476Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16477 $name = $_.Name
16478 $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16479 Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16480 Select-Object DisplayName, DisplayValue
16481 if ($duplex) {
16482 "--- $name ---"
16483 $duplex | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16484 } else {
16485 "--- $name --- (no duplex/speed property exposed by driver)"
16486 }
16487}
16488"#;
16489 match run_powershell(ps_duplex) {
16490 Ok(o) => {
16491 let lines: Vec<&str> = o
16492 .lines()
16493 .map(|l| l.trim())
16494 .filter(|l| !l.is_empty())
16495 .collect();
16496 for l in &lines {
16497 out.push_str(&format!("- {l}\n"));
16498 }
16499 }
16500 Err(e) => out.push_str(&format!("- Duplex query error: {e}\n")),
16501 }
16502
16503 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16504 let ps_offload = r#"
16505Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16506 $name = $_.Name
16507 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16508 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16509 Select-Object DisplayName, DisplayValue
16510 if ($props) {
16511 "--- $name ---"
16512 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16513 }
16514}
16515"#;
16516 match run_powershell(ps_offload) {
16517 Ok(o) => {
16518 let lines: Vec<&str> = o
16519 .lines()
16520 .map(|l| l.trim())
16521 .filter(|l| !l.is_empty())
16522 .collect();
16523 if lines.is_empty() {
16524 out.push_str(
16525 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16526 );
16527 } else {
16528 for l in &lines {
16529 out.push_str(&format!("- {l}\n"));
16530 }
16531 }
16532 }
16533 Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
16534 }
16535
16536 out.push_str("\n=== Adapter error counters ===\n");
16537 let ps_errors = r#"
16538Get-NetAdapterStatistics | ForEach-Object {
16539 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16540 if ($errs -gt 0) {
16541 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16542 }
16543}
16544"#;
16545 match run_powershell(ps_errors) {
16546 Ok(o) => {
16547 let lines: Vec<&str> = o
16548 .lines()
16549 .map(|l| l.trim())
16550 .filter(|l| !l.is_empty())
16551 .collect();
16552 if lines.is_empty() {
16553 out.push_str("- No adapter errors or discards detected.\n");
16554 } else {
16555 for l in &lines {
16556 out.push_str(&format!("- {l}\n"));
16557 }
16558 }
16559 }
16560 Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
16561 }
16562
16563 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16564 let ps_wol = r#"
16565Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16566 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16567 if ($wol) {
16568 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16569 }
16570}
16571"#;
16572 match run_powershell(ps_wol) {
16573 Ok(o) => {
16574 let lines: Vec<&str> = o
16575 .lines()
16576 .map(|l| l.trim())
16577 .filter(|l| !l.is_empty())
16578 .collect();
16579 if lines.is_empty() {
16580 out.push_str("- Power management data unavailable for active adapters.\n");
16581 } else {
16582 for l in &lines {
16583 out.push_str(&format!("- {l}\n"));
16584 }
16585 }
16586 }
16587 Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
16588 }
16589
16590 let mut findings: Vec<String> = Vec::new();
16591 if out.contains("RX errors:") || out.contains("TX errors:") {
16593 findings
16594 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16595 }
16596 if out.contains("Half") {
16598 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16599 }
16600
16601 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16602 if findings.is_empty() {
16603 result.push_str("- Network adapter configuration looks normal.\n");
16604 } else {
16605 for f in &findings {
16606 result.push_str(&format!("- Finding: {f}\n"));
16607 }
16608 }
16609 result.push('\n');
16610 result.push_str(&out);
16611 Ok(result)
16612}
16613
16614#[cfg(not(windows))]
16615fn inspect_network_adapter() -> Result<String, String> {
16616 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16617
16618 out.push_str("=== Network adapters (ip link) ===\n");
16619 let ip_link = std::process::Command::new("ip")
16620 .args(["link", "show"])
16621 .output();
16622 if let Ok(o) = ip_link {
16623 for line in String::from_utf8_lossy(&o.stdout).lines() {
16624 let l = line.trim();
16625 if !l.is_empty() {
16626 out.push_str(&format!("- {l}\n"));
16627 }
16628 }
16629 }
16630
16631 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16632 let ip_stats = std::process::Command::new("ip")
16633 .args(["-s", "link", "show"])
16634 .output();
16635 if let Ok(o) = ip_stats {
16636 for line in String::from_utf8_lossy(&o.stdout).lines() {
16637 let l = line.trim();
16638 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16639 {
16640 out.push_str(&format!("- {l}\n"));
16641 }
16642 }
16643 }
16644 Ok(out)
16645}
16646
16647#[cfg(windows)]
16648fn inspect_dhcp() -> Result<String, String> {
16649 let mut out = String::new();
16650
16651 out.push_str("=== DHCP lease details (per adapter) ===\n");
16652 let ps_dhcp = r#"
16653$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16654 Where-Object { $_.IPEnabled -eq $true }
16655foreach ($a in $adapters) {
16656 "--- $($a.Description) ---"
16657 " DHCP Enabled: $($a.DHCPEnabled)"
16658 if ($a.DHCPEnabled) {
16659 " DHCP Server: $($a.DHCPServer)"
16660 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16661 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
16662 " Lease Obtained: $obtained"
16663 " Lease Expires: $expires"
16664 }
16665 " IP Address: $($a.IPAddress -join ', ')"
16666 " Subnet Mask: $($a.IPSubnet -join ', ')"
16667 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
16668 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
16669 " MAC Address: $($a.MACAddress)"
16670 ""
16671}
16672"#;
16673 match run_powershell(ps_dhcp) {
16674 Ok(o) => {
16675 for line in o.lines() {
16676 let l = line.trim_end();
16677 if !l.is_empty() {
16678 out.push_str(&format!("{l}\n"));
16679 }
16680 }
16681 }
16682 Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16683 }
16684
16685 let mut findings: Vec<String> = Vec::new();
16687 let ps_expiry = r#"
16688$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16689foreach ($a in $adapters) {
16690 try {
16691 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16692 $now = Get-Date
16693 $hrs = ($exp - $now).TotalHours
16694 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16695 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16696 } catch {}
16697}
16698"#;
16699 if let Ok(o) = run_powershell(ps_expiry) {
16700 for line in o.lines() {
16701 let l = line.trim();
16702 if !l.is_empty() {
16703 if l.contains("EXPIRED") {
16704 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16705 } else if l.contains("expires in") {
16706 findings.push(format!("DHCP lease expiring soon — {l}"));
16707 }
16708 }
16709 }
16710 }
16711
16712 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16713 if findings.is_empty() {
16714 result.push_str("- DHCP leases look healthy.\n");
16715 } else {
16716 for f in &findings {
16717 result.push_str(&format!("- Finding: {f}\n"));
16718 }
16719 }
16720 result.push('\n');
16721 result.push_str(&out);
16722 Ok(result)
16723}
16724
16725#[cfg(not(windows))]
16726fn inspect_dhcp() -> Result<String, String> {
16727 let mut out = String::from(
16728 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16729 );
16730 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16731 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16732 if std::path::Path::new(path).exists() {
16733 let cat = std::process::Command::new("cat").arg(path).output();
16734 if let Ok(o) = cat {
16735 let text = String::from_utf8_lossy(&o.stdout);
16736 for line in text.lines().take(40) {
16737 let l = line.trim();
16738 if l.contains("lease")
16739 || l.contains("expire")
16740 || l.contains("server")
16741 || l.contains("address")
16742 {
16743 out.push_str(&format!("- {l}\n"));
16744 }
16745 }
16746 }
16747 }
16748 }
16749 let ip = std::process::Command::new("ip")
16751 .args(["addr", "show"])
16752 .output();
16753 if let Ok(o) = ip {
16754 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16755 for line in String::from_utf8_lossy(&o.stdout).lines() {
16756 let l = line.trim();
16757 if l.starts_with("inet") || l.contains("dynamic") {
16758 out.push_str(&format!("- {l}\n"));
16759 }
16760 }
16761 }
16762 Ok(out)
16763}
16764
16765#[cfg(windows)]
16766fn inspect_mtu() -> Result<String, String> {
16767 let mut out = String::new();
16768
16769 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16770 let ps_mtu = r#"
16771Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16772 Sort-Object ConnectionState, InterfaceAlias |
16773 ForEach-Object {
16774 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16775 }
16776"#;
16777 match run_powershell(ps_mtu) {
16778 Ok(o) => {
16779 for line in o.lines() {
16780 let l = line.trim();
16781 if !l.is_empty() {
16782 out.push_str(&format!("- {l}\n"));
16783 }
16784 }
16785 }
16786 Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16787 }
16788
16789 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16790 let ps_mtu6 = r#"
16791Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16792 Sort-Object ConnectionState, InterfaceAlias |
16793 ForEach-Object {
16794 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16795 }
16796"#;
16797 match run_powershell(ps_mtu6) {
16798 Ok(o) => {
16799 for line in o.lines() {
16800 let l = line.trim();
16801 if !l.is_empty() {
16802 out.push_str(&format!("- {l}\n"));
16803 }
16804 }
16805 }
16806 Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16807 }
16808
16809 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16810 let ps_pmtu = r#"
16812$sizes = @(1472, 1400, 1280, 576)
16813$result = $null
16814foreach ($s in $sizes) {
16815 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16816 if ($r) { $result = $s; break }
16817}
16818if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16819else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16820"#;
16821 match run_powershell(ps_pmtu) {
16822 Ok(o) => {
16823 for line in o.lines() {
16824 let l = line.trim();
16825 if !l.is_empty() {
16826 out.push_str(&format!("- {l}\n"));
16827 }
16828 }
16829 }
16830 Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16831 }
16832
16833 let mut findings: Vec<String> = Vec::new();
16834 if out.contains("MTU: 576 bytes") {
16835 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16836 }
16837 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16838 findings.push(
16839 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16840 .into(),
16841 );
16842 }
16843 if out.contains("All test sizes failed") {
16844 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16845 }
16846
16847 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16848 if findings.is_empty() {
16849 result.push_str("- MTU configuration looks normal.\n");
16850 } else {
16851 for f in &findings {
16852 result.push_str(&format!("- Finding: {f}\n"));
16853 }
16854 }
16855 result.push('\n');
16856 result.push_str(&out);
16857 Ok(result)
16858}
16859
16860#[cfg(not(windows))]
16861fn inspect_mtu() -> Result<String, String> {
16862 let mut out = String::from(
16863 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16864 );
16865
16866 out.push_str("=== Per-interface MTU (ip link) ===\n");
16867 let ip = std::process::Command::new("ip")
16868 .args(["link", "show"])
16869 .output();
16870 if let Ok(o) = ip {
16871 for line in String::from_utf8_lossy(&o.stdout).lines() {
16872 let l = line.trim();
16873 if l.contains("mtu") || l.starts_with("\\d") {
16874 out.push_str(&format!("- {l}\n"));
16875 }
16876 }
16877 }
16878
16879 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16880 let ping = std::process::Command::new("ping")
16881 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16882 .output();
16883 match ping {
16884 Ok(o) => {
16885 let body = String::from_utf8_lossy(&o.stdout);
16886 for line in body.lines() {
16887 let l = line.trim();
16888 if !l.is_empty() {
16889 out.push_str(&format!("- {l}\n"));
16890 }
16891 }
16892 }
16893 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16894 }
16895 Ok(out)
16896}
16897
16898#[cfg(not(windows))]
16899fn inspect_cpu_power() -> Result<String, String> {
16900 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16901
16902 out.push_str("=== CPU frequency (Linux) ===\n");
16904 let cat_scaling = std::process::Command::new("cat")
16905 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16906 .output();
16907 if let Ok(o) = cat_scaling {
16908 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16909 .trim()
16910 .parse()
16911 .unwrap_or(0);
16912 if khz > 0 {
16913 out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16914 }
16915 }
16916 let cat_max = std::process::Command::new("cat")
16917 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16918 .output();
16919 if let Ok(o) = cat_max {
16920 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16921 .trim()
16922 .parse()
16923 .unwrap_or(0);
16924 if khz > 0 {
16925 out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16926 }
16927 }
16928 let governor = std::process::Command::new("cat")
16929 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16930 .output();
16931 if let Ok(o) = governor {
16932 let g = String::from_utf8_lossy(&o.stdout);
16933 let g = g.trim();
16934 if !g.is_empty() {
16935 out.push_str(&format!("- Governor: {g}\n"));
16936 }
16937 }
16938 Ok(out)
16939}
16940
16941#[cfg(windows)]
16944fn inspect_ipv6() -> Result<String, String> {
16945 let script = r#"
16946$result = [System.Text.StringBuilder]::new()
16947
16948# Per-adapter IPv6 addresses
16949$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16950$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16951 Where-Object { $_.IPAddress -notmatch '^::1$' } |
16952 Sort-Object InterfaceAlias
16953foreach ($a in $adapters) {
16954 $prefix = $a.PrefixOrigin
16955 $suffix = $a.SuffixOrigin
16956 $scope = $a.AddressState
16957 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
16958}
16959if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
16960
16961# Default gateway IPv6
16962$result.AppendLine("") | Out-Null
16963$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16964$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16965if ($gw6) {
16966 foreach ($g in $gw6) {
16967 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
16968 }
16969} else {
16970 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
16971}
16972
16973# DHCPv6 lease info
16974$result.AppendLine("") | Out-Null
16975$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16976$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16977 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16978if ($dhcpv6) {
16979 foreach ($d in $dhcpv6) {
16980 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16981 }
16982} else {
16983 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16984}
16985
16986# Privacy extensions
16987$result.AppendLine("") | Out-Null
16988$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16989try {
16990 $priv = netsh interface ipv6 show privacy
16991 $result.AppendLine(($priv -join "`n")) | Out-Null
16992} catch {
16993 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
16994}
16995
16996# Tunnel adapters
16997$result.AppendLine("") | Out-Null
16998$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16999$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17000if ($tunnels) {
17001 foreach ($t in $tunnels) {
17002 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
17003 }
17004} else {
17005 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17006}
17007
17008# Findings
17009$findings = [System.Collections.Generic.List[string]]::new()
17010$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17011 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17012if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17013$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17014if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17015
17016$result.AppendLine("") | Out-Null
17017$result.AppendLine("=== Findings ===") | Out-Null
17018if ($findings.Count -eq 0) {
17019 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17020} else {
17021 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17022}
17023
17024Write-Output $result.ToString()
17025"#;
17026 let out = run_powershell(script)?;
17027 Ok(format!("Host inspection: ipv6\n\n{out}"))
17028}
17029
17030#[cfg(not(windows))]
17031fn inspect_ipv6() -> Result<String, String> {
17032 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17033 if let Ok(o) = std::process::Command::new("ip")
17034 .args(["-6", "addr", "show"])
17035 .output()
17036 {
17037 out.push_str(&String::from_utf8_lossy(&o.stdout));
17038 }
17039 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17040 if let Ok(o) = std::process::Command::new("ip")
17041 .args(["-6", "route"])
17042 .output()
17043 {
17044 out.push_str(&String::from_utf8_lossy(&o.stdout));
17045 }
17046 Ok(out)
17047}
17048
17049#[cfg(windows)]
17052fn inspect_tcp_params() -> Result<String, String> {
17053 let script = r#"
17054$result = [System.Text.StringBuilder]::new()
17055
17056# Autotuning and global TCP settings
17057$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17058try {
17059 $global = netsh interface tcp show global
17060 foreach ($line in $global) {
17061 $l = $line.Trim()
17062 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17063 $result.AppendLine(" $l") | Out-Null
17064 }
17065 }
17066} catch {
17067 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
17068}
17069
17070# Supplemental params via Get-NetTCPSetting
17071$result.AppendLine("") | Out-Null
17072$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17073try {
17074 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17075 foreach ($s in $tcpSettings) {
17076 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
17077 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
17078 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17079 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
17080 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
17081 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
17082 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17083 $result.AppendLine("") | Out-Null
17084 }
17085} catch {
17086 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
17087}
17088
17089# Chimney offload state
17090$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17091try {
17092 $chimney = netsh interface tcp show chimney
17093 $result.AppendLine(($chimney -join "`n ")) | Out-Null
17094} catch {
17095 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
17096}
17097
17098# ECN state
17099$result.AppendLine("") | Out-Null
17100$result.AppendLine("=== ECN capability ===") | Out-Null
17101try {
17102 $ecn = netsh interface tcp show ecncapability
17103 $result.AppendLine(($ecn -join "`n ")) | Out-Null
17104} catch {
17105 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
17106}
17107
17108# Findings
17109$findings = [System.Collections.Generic.List[string]]::new()
17110try {
17111 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17112 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17113 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17114 }
17115 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17116 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17117 }
17118} catch {}
17119
17120$result.AppendLine("") | Out-Null
17121$result.AppendLine("=== Findings ===") | Out-Null
17122if ($findings.Count -eq 0) {
17123 $result.AppendLine("- TCP parameters look normal.") | Out-Null
17124} else {
17125 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17126}
17127
17128Write-Output $result.ToString()
17129"#;
17130 let out = run_powershell(script)?;
17131 Ok(format!("Host inspection: tcp_params\n\n{out}"))
17132}
17133
17134#[cfg(not(windows))]
17135fn inspect_tcp_params() -> Result<String, String> {
17136 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17137 for key in &[
17138 "net.ipv4.tcp_congestion_control",
17139 "net.ipv4.tcp_rmem",
17140 "net.ipv4.tcp_wmem",
17141 "net.ipv4.tcp_window_scaling",
17142 "net.ipv4.tcp_ecn",
17143 "net.ipv4.tcp_timestamps",
17144 ] {
17145 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17146 out.push_str(&format!(
17147 " {}\n",
17148 String::from_utf8_lossy(&o.stdout).trim()
17149 ));
17150 }
17151 }
17152 Ok(out)
17153}
17154
17155#[cfg(windows)]
17158fn inspect_wlan_profiles() -> Result<String, String> {
17159 let script = r#"
17160$result = [System.Text.StringBuilder]::new()
17161
17162# List all saved profiles
17163$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17164try {
17165 $profilesRaw = netsh wlan show profiles
17166 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17167 $_.Matches[0].Groups[1].Value.Trim()
17168 }
17169
17170 if (-not $profiles) {
17171 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
17172 } else {
17173 foreach ($p in $profiles) {
17174 $result.AppendLine("") | Out-Null
17175 $result.AppendLine(" Profile: $p") | Out-Null
17176 # Get detail for each profile
17177 $detail = netsh wlan show profile name="$p" key=clear 2>$null
17178 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17179 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17180 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17181 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17182 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17183 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17184 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17185 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17186 }
17187 }
17188} catch {
17189 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17190}
17191
17192# Currently connected SSID
17193$result.AppendLine("") | Out-Null
17194$result.AppendLine("=== Currently connected ===") | Out-Null
17195try {
17196 $conn = netsh wlan show interfaces
17197 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17198 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17199 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17200 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17201 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17202 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17203 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17204 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17205 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
17206} catch {
17207 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
17208}
17209
17210# Findings
17211$findings = [System.Collections.Generic.List[string]]::new()
17212try {
17213 $allDetail = netsh wlan show profiles 2>$null
17214 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17215 $_.Matches[0].Groups[1].Value.Trim()
17216 }
17217 foreach ($pn in $profileNames) {
17218 $det = netsh wlan show profile name="$pn" key=clear 2>$null
17219 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17220 if ($authLine) {
17221 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17222 if ($authVal -match 'Open|WEP|None') {
17223 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17224 }
17225 }
17226 }
17227} catch {}
17228
17229$result.AppendLine("") | Out-Null
17230$result.AppendLine("=== Findings ===") | Out-Null
17231if ($findings.Count -eq 0) {
17232 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17233} else {
17234 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17235}
17236
17237Write-Output $result.ToString()
17238"#;
17239 let out = run_powershell(script)?;
17240 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17241}
17242
17243#[cfg(not(windows))]
17244fn inspect_wlan_profiles() -> Result<String, String> {
17245 let mut out =
17246 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17247 if let Ok(o) = std::process::Command::new("nmcli")
17249 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17250 .output()
17251 {
17252 for line in String::from_utf8_lossy(&o.stdout).lines() {
17253 if line.contains("wireless") || line.contains("wifi") {
17254 out.push_str(&format!(" {line}\n"));
17255 }
17256 }
17257 } else {
17258 out.push_str(" nmcli not available.\n");
17259 }
17260 Ok(out)
17261}
17262
17263#[cfg(windows)]
17266fn inspect_ipsec() -> Result<String, String> {
17267 let script = r#"
17268$result = [System.Text.StringBuilder]::new()
17269
17270# IPSec rules (firewall-integrated)
17271$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17272try {
17273 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17274 if ($rules) {
17275 foreach ($r in $rules) {
17276 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
17277 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
17278 $result.AppendLine(" Action: $($r.Action)") | Out-Null
17279 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
17280 }
17281 } else {
17282 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
17283 }
17284} catch {
17285 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
17286}
17287
17288# Active main-mode SAs
17289$result.AppendLine("") | Out-Null
17290$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17291try {
17292 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17293 if ($mmSAs) {
17294 foreach ($sa in $mmSAs) {
17295 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17296 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
17297 }
17298 } else {
17299 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
17300 }
17301} catch {
17302 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
17303}
17304
17305# Active quick-mode SAs
17306$result.AppendLine("") | Out-Null
17307$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17308try {
17309 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17310 if ($qmSAs) {
17311 foreach ($sa in $qmSAs) {
17312 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17313 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17314 }
17315 } else {
17316 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
17317 }
17318} catch {
17319 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17320}
17321
17322# IKE service state
17323$result.AppendLine("") | Out-Null
17324$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17325$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17326if ($ikeAgentSvc) {
17327 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17328} else {
17329 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
17330}
17331
17332# Findings
17333$findings = [System.Collections.Generic.List[string]]::new()
17334$mmSACount = 0
17335try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17336if ($mmSACount -gt 0) {
17337 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17338}
17339
17340$result.AppendLine("") | Out-Null
17341$result.AppendLine("=== Findings ===") | Out-Null
17342if ($findings.Count -eq 0) {
17343 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17344} else {
17345 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17346}
17347
17348Write-Output $result.ToString()
17349"#;
17350 let out = run_powershell(script)?;
17351 Ok(format!("Host inspection: ipsec\n\n{out}"))
17352}
17353
17354#[cfg(not(windows))]
17355fn inspect_ipsec() -> Result<String, String> {
17356 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17357 if let Ok(o) = std::process::Command::new("ip")
17358 .args(["xfrm", "state"])
17359 .output()
17360 {
17361 let body = String::from_utf8_lossy(&o.stdout);
17362 if body.trim().is_empty() {
17363 out.push_str(" No active IPSec SAs.\n");
17364 } else {
17365 out.push_str(&body);
17366 }
17367 }
17368 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17369 if let Ok(o) = std::process::Command::new("ip")
17370 .args(["xfrm", "policy"])
17371 .output()
17372 {
17373 let body = String::from_utf8_lossy(&o.stdout);
17374 if body.trim().is_empty() {
17375 out.push_str(" No IPSec policies.\n");
17376 } else {
17377 out.push_str(&body);
17378 }
17379 }
17380 Ok(out)
17381}
17382
17383#[cfg(windows)]
17386fn inspect_netbios() -> Result<String, String> {
17387 let script = r#"
17388$result = [System.Text.StringBuilder]::new()
17389
17390# NetBIOS node type and WINS per adapter
17391$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17392try {
17393 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17394 Where-Object { $_.IPEnabled -eq $true }
17395 foreach ($a in $adapters) {
17396 $nodeType = switch ($a.TcpipNetbiosOptions) {
17397 0 { "EnableNetBIOSViaDHCP" }
17398 1 { "Enabled" }
17399 2 { "Disabled" }
17400 default { "Unknown ($($a.TcpipNetbiosOptions))" }
17401 }
17402 $result.AppendLine(" [$($a.Description)]") | Out-Null
17403 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
17404 if ($a.WINSPrimaryServer) {
17405 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
17406 }
17407 if ($a.WINSSecondaryServer) {
17408 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
17409 }
17410 }
17411} catch {
17412 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
17413}
17414
17415# nbtstat -n — registered local NetBIOS names
17416$result.AppendLine("") | Out-Null
17417$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17418try {
17419 $nbt = nbtstat -n 2>$null
17420 foreach ($line in $nbt) {
17421 $l = $line.Trim()
17422 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17423 $result.AppendLine(" $l") | Out-Null
17424 }
17425 }
17426} catch {
17427 $result.AppendLine(" nbtstat not available.") | Out-Null
17428}
17429
17430# NetBIOS session table
17431$result.AppendLine("") | Out-Null
17432$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17433try {
17434 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17435 if ($sessions) {
17436 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
17437 } else {
17438 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
17439 }
17440} catch {
17441 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
17442}
17443
17444# Findings
17445$findings = [System.Collections.Generic.List[string]]::new()
17446try {
17447 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17448 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17449 if ($enabled) {
17450 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17451 }
17452 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17453 Where-Object { $_.WINSPrimaryServer }
17454 if ($wins) {
17455 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17456 }
17457} catch {}
17458
17459$result.AppendLine("") | Out-Null
17460$result.AppendLine("=== Findings ===") | Out-Null
17461if ($findings.Count -eq 0) {
17462 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17463} else {
17464 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17465}
17466
17467Write-Output $result.ToString()
17468"#;
17469 let out = run_powershell(script)?;
17470 Ok(format!("Host inspection: netbios\n\n{out}"))
17471}
17472
17473#[cfg(not(windows))]
17474fn inspect_netbios() -> Result<String, String> {
17475 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17476 if let Ok(o) = std::process::Command::new("nmblookup")
17477 .arg("-A")
17478 .arg("localhost")
17479 .output()
17480 {
17481 out.push_str(&String::from_utf8_lossy(&o.stdout));
17482 } else {
17483 out.push_str(" nmblookup not available (Samba not installed).\n");
17484 }
17485 Ok(out)
17486}
17487
17488#[cfg(windows)]
17491fn inspect_nic_teaming() -> Result<String, String> {
17492 let script = r#"
17493$result = [System.Text.StringBuilder]::new()
17494
17495# Team inventory
17496$result.AppendLine("=== NIC teams ===") | Out-Null
17497try {
17498 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17499 if ($teams) {
17500 foreach ($t in $teams) {
17501 $result.AppendLine(" Team: $($t.Name)") | Out-Null
17502 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
17503 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
17504 $result.AppendLine(" Status: $($t.Status)") | Out-Null
17505 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
17506 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17507 }
17508 } else {
17509 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
17510 }
17511} catch {
17512 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17513}
17514
17515# Team members detail
17516$result.AppendLine("") | Out-Null
17517$result.AppendLine("=== Team member detail ===") | Out-Null
17518try {
17519 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17520 if ($members) {
17521 foreach ($m in $members) {
17522 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
17523 }
17524 } else {
17525 $result.AppendLine(" No team members found.") | Out-Null
17526 }
17527} catch {
17528 $result.AppendLine(" Could not query team members.") | Out-Null
17529}
17530
17531# Findings
17532$findings = [System.Collections.Generic.List[string]]::new()
17533try {
17534 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17535 if ($degraded) {
17536 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17537 }
17538 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17539 if ($downMembers) {
17540 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17541 }
17542} catch {}
17543
17544$result.AppendLine("") | Out-Null
17545$result.AppendLine("=== Findings ===") | Out-Null
17546if ($findings.Count -eq 0) {
17547 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17548} else {
17549 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17550}
17551
17552Write-Output $result.ToString()
17553"#;
17554 let out = run_powershell(script)?;
17555 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17556}
17557
17558#[cfg(not(windows))]
17559fn inspect_nic_teaming() -> Result<String, String> {
17560 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17561 if let Ok(o) = std::process::Command::new("cat")
17562 .arg("/proc/net/bonding/bond0")
17563 .output()
17564 {
17565 if o.status.success() {
17566 out.push_str(&String::from_utf8_lossy(&o.stdout));
17567 } else {
17568 out.push_str(" No bond0 interface found.\n");
17569 }
17570 }
17571 if let Ok(o) = std::process::Command::new("ip")
17572 .args(["link", "show", "type", "bond"])
17573 .output()
17574 {
17575 let body = String::from_utf8_lossy(&o.stdout);
17576 if !body.trim().is_empty() {
17577 out.push_str("\n=== Bond links (ip link) ===\n");
17578 out.push_str(&body);
17579 }
17580 }
17581 Ok(out)
17582}
17583
17584#[cfg(windows)]
17587fn inspect_snmp() -> Result<String, String> {
17588 let script = r#"
17589$result = [System.Text.StringBuilder]::new()
17590
17591# SNMP service state
17592$result.AppendLine("=== SNMP service state ===") | Out-Null
17593$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17594if ($svc) {
17595 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17596} else {
17597 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
17598}
17599
17600$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17601if ($svcTrap) {
17602 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17603}
17604
17605# Community strings (presence only — values redacted)
17606$result.AppendLine("") | Out-Null
17607$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17608try {
17609 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17610 if ($communities) {
17611 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17612 if ($names) {
17613 foreach ($n in $names) {
17614 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
17615 }
17616 } else {
17617 $result.AppendLine(" No community strings configured.") | Out-Null
17618 }
17619 } else {
17620 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
17621 }
17622} catch {
17623 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
17624}
17625
17626# Permitted managers
17627$result.AppendLine("") | Out-Null
17628$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17629try {
17630 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17631 if ($managers) {
17632 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17633 if ($mgrs) {
17634 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
17635 } else {
17636 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
17637 }
17638 } else {
17639 $result.AppendLine(" No manager restrictions configured.") | Out-Null
17640 }
17641} catch {
17642 $result.AppendLine(" Could not read permitted managers.") | Out-Null
17643}
17644
17645# Findings
17646$findings = [System.Collections.Generic.List[string]]::new()
17647$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17648if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17649 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17650 try {
17651 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17652 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17653 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17654 } catch {}
17655}
17656
17657$result.AppendLine("") | Out-Null
17658$result.AppendLine("=== Findings ===") | Out-Null
17659if ($findings.Count -eq 0) {
17660 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17661} else {
17662 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17663}
17664
17665Write-Output $result.ToString()
17666"#;
17667 let out = run_powershell(script)?;
17668 Ok(format!("Host inspection: snmp\n\n{out}"))
17669}
17670
17671#[cfg(not(windows))]
17672fn inspect_snmp() -> Result<String, String> {
17673 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17674 for svc in &["snmpd", "snmp"] {
17675 if let Ok(o) = std::process::Command::new("systemctl")
17676 .args(["is-active", svc])
17677 .output()
17678 {
17679 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17680 out.push_str(&format!(" {svc}: {status}\n"));
17681 }
17682 }
17683 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17684 if let Ok(o) = std::process::Command::new("grep")
17685 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17686 .output()
17687 {
17688 if o.status.success() {
17689 for line in String::from_utf8_lossy(&o.stdout).lines() {
17690 out.push_str(&format!(" {line}\n"));
17691 }
17692 } else {
17693 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
17694 }
17695 }
17696 Ok(out)
17697}
17698
17699#[cfg(windows)]
17702fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17703 let target_host = host.unwrap_or("8.8.8.8");
17704 let target_port = port.unwrap_or(443);
17705
17706 let script = format!(
17707 r#"
17708$result = [System.Text.StringBuilder]::new()
17709$result.AppendLine("=== Port reachability test ===") | Out-Null
17710$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
17711$result.AppendLine("") | Out-Null
17712
17713try {{
17714 $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17715 if ($test) {{
17716 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17717 $result.AppendLine(" Result: $status") | Out-Null
17718 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
17719 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
17720 if ($test.PingSucceeded) {{
17721 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17722 }} else {{
17723 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
17724 }}
17725 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
17726 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
17727
17728 $result.AppendLine("") | Out-Null
17729 $result.AppendLine("=== Findings ===") | Out-Null
17730 if ($test.TcpTestSucceeded) {{
17731 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17732 }} else {{
17733 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17734 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17735 }}
17736 }}
17737}} catch {{
17738 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17739}}
17740
17741Write-Output $result.ToString()
17742"#
17743 );
17744 let out = run_powershell(&script)?;
17745 Ok(format!("Host inspection: port_test\n\n{out}"))
17746}
17747
17748#[cfg(not(windows))]
17749fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17750 let target_host = host.unwrap_or("8.8.8.8");
17751 let target_port = port.unwrap_or(443);
17752 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
17753 let nc = std::process::Command::new("nc")
17755 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17756 .output();
17757 match nc {
17758 Ok(o) => {
17759 let stderr = String::from_utf8_lossy(&o.stderr);
17760 let stdout = String::from_utf8_lossy(&o.stdout);
17761 let body = if !stdout.trim().is_empty() {
17762 stdout.as_ref()
17763 } else {
17764 stderr.as_ref()
17765 };
17766 out.push_str(&format!(" {}\n", body.trim()));
17767 out.push_str("\n=== Findings ===\n");
17768 if o.status.success() {
17769 out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17770 } else {
17771 out.push_str(&format!(
17772 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17773 ));
17774 }
17775 }
17776 Err(e) => out.push_str(&format!(" nc not available: {e}\n")),
17777 }
17778 Ok(out)
17779}
17780
17781#[cfg(windows)]
17784fn inspect_network_profile() -> Result<String, String> {
17785 let script = r#"
17786$result = [System.Text.StringBuilder]::new()
17787
17788$result.AppendLine("=== Network location profiles ===") | Out-Null
17789try {
17790 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17791 if ($profiles) {
17792 foreach ($p in $profiles) {
17793 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
17794 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
17795 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
17796 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
17797 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
17798 $result.AppendLine("") | Out-Null
17799 }
17800 } else {
17801 $result.AppendLine(" No network connection profiles found.") | Out-Null
17802 }
17803} catch {
17804 $result.AppendLine(" Could not query network profiles.") | Out-Null
17805}
17806
17807# Findings
17808$findings = [System.Collections.Generic.List[string]]::new()
17809try {
17810 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17811 if ($pub) {
17812 foreach ($p in $pub) {
17813 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17814 }
17815 }
17816 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17817 if ($domain) {
17818 foreach ($d in $domain) {
17819 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17820 }
17821 }
17822} catch {}
17823
17824$result.AppendLine("=== Findings ===") | Out-Null
17825if ($findings.Count -eq 0) {
17826 $result.AppendLine("- Network profiles look normal.") | Out-Null
17827} else {
17828 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17829}
17830
17831Write-Output $result.ToString()
17832"#;
17833 let out = run_powershell(script)?;
17834 Ok(format!("Host inspection: network_profile\n\n{out}"))
17835}
17836
17837#[cfg(not(windows))]
17838fn inspect_network_profile() -> Result<String, String> {
17839 let mut out = String::from(
17840 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17841 );
17842 if let Ok(o) = std::process::Command::new("nmcli")
17843 .args([
17844 "-t",
17845 "-f",
17846 "NAME,TYPE,STATE,DEVICE",
17847 "connection",
17848 "show",
17849 "--active",
17850 ])
17851 .output()
17852 {
17853 out.push_str(&String::from_utf8_lossy(&o.stdout));
17854 } else {
17855 out.push_str(" nmcli not available.\n");
17856 }
17857 Ok(out)
17858}
17859
17860#[cfg(windows)]
17863fn inspect_storage_spaces() -> Result<String, String> {
17864 let script = r#"
17865$result = [System.Text.StringBuilder]::new()
17866
17867# Storage Pools
17868try {
17869 $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
17870 if ($pools) {
17871 $result.AppendLine("=== Storage Pools ===") | Out-Null
17872 foreach ($pool in $pools) {
17873 $health = $pool.HealthStatus
17874 $oper = $pool.OperationalStatus
17875 $sizGB = [math]::Round($pool.Size / 1GB, 1)
17876 $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
17877 $result.AppendLine(" Pool: $($pool.FriendlyName) Size: ${sizGB}GB Allocated: ${allocGB}GB Health: $health Status: $oper") | Out-Null
17878 }
17879 $result.AppendLine("") | Out-Null
17880 } else {
17881 $result.AppendLine("=== Storage Pools ===") | Out-Null
17882 $result.AppendLine(" No Storage Spaces pools configured.") | Out-Null
17883 $result.AppendLine("") | Out-Null
17884 }
17885} catch {
17886 $result.AppendLine("=== Storage Pools ===") | Out-Null
17887 $result.AppendLine(" Unable to query storage pools (may require elevation).") | Out-Null
17888 $result.AppendLine("") | Out-Null
17889}
17890
17891# Virtual Disks
17892try {
17893 $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
17894 if ($vdisks) {
17895 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17896 foreach ($vd in $vdisks) {
17897 $health = $vd.HealthStatus
17898 $oper = $vd.OperationalStatus
17899 $layout = $vd.ResiliencySettingName
17900 $sizGB = [math]::Round($vd.Size / 1GB, 1)
17901 $result.AppendLine(" VDisk: $($vd.FriendlyName) Layout: $layout Size: ${sizGB}GB Health: $health Status: $oper") | Out-Null
17902 }
17903 $result.AppendLine("") | Out-Null
17904 } else {
17905 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17906 $result.AppendLine(" No Storage Spaces virtual disks configured.") | Out-Null
17907 $result.AppendLine("") | Out-Null
17908 }
17909} catch {
17910 $result.AppendLine("=== Virtual Disks ===") | Out-Null
17911 $result.AppendLine(" Unable to query virtual disks.") | Out-Null
17912 $result.AppendLine("") | Out-Null
17913}
17914
17915# Physical Disks in pools
17916try {
17917 $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
17918 if ($pdisks) {
17919 $result.AppendLine("=== Physical Disks ===") | Out-Null
17920 foreach ($pd in $pdisks) {
17921 $sizGB = [math]::Round($pd.Size / 1GB, 1)
17922 $health = $pd.HealthStatus
17923 $usage = $pd.Usage
17924 $media = $pd.MediaType
17925 $result.AppendLine(" $($pd.FriendlyName) ${sizGB}GB $media Usage: $usage Health: $health") | Out-Null
17926 }
17927 $result.AppendLine("") | Out-Null
17928 }
17929} catch {}
17930
17931# Findings
17932$findings = @()
17933try {
17934 $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17935 foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
17936 $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17937 foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
17938 $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
17939 foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
17940} catch {}
17941
17942if ($findings.Count -gt 0) {
17943 $result.AppendLine("=== Findings ===") | Out-Null
17944 foreach ($f in $findings) { $result.AppendLine(" $f") | Out-Null }
17945} else {
17946 $result.AppendLine("=== Findings ===") | Out-Null
17947 $result.AppendLine(" All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
17948}
17949
17950Write-Output $result.ToString().TrimEnd()
17951"#;
17952 let out = run_powershell(script)?;
17953 Ok(format!("Host inspection: storage_spaces\n\n{out}"))
17954}
17955
17956#[cfg(not(windows))]
17957fn inspect_storage_spaces() -> Result<String, String> {
17958 let mut out = String::from("Host inspection: storage_spaces\n\n");
17959 let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
17961 if !mdstat.is_empty() {
17962 out.push_str("=== Software RAID (/proc/mdstat) ===\n");
17963 out.push_str(&mdstat);
17964 } else {
17965 out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
17966 }
17967 if let Ok(o) = Command::new("lvs")
17969 .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
17970 .output()
17971 {
17972 let lvs = String::from_utf8_lossy(&o.stdout).to_string();
17973 if !lvs.trim().is_empty() {
17974 out.push_str("\n=== LVM Logical Volumes ===\n");
17975 out.push_str(&lvs);
17976 }
17977 }
17978 Ok(out)
17979}
17980
17981#[cfg(windows)]
17984fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
17985 let limit = max_entries.min(50);
17986 let script = format!(
17987 r#"
17988$result = [System.Text.StringBuilder]::new()
17989
17990# Current threat detections (active + quarantined)
17991try {{
17992 $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
17993 if ($threats) {{
17994 $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
17995 foreach ($t in $threats) {{
17996 $name = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
17997 if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
17998 $time = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
17999 $action = $t.ActionSuccess
18000 $status = $t.CurrentThreatExecutionStatusID
18001 $result.AppendLine(" [$time] $name ActionSuccess:$action Status:$status") | Out-Null
18002 }}
18003 $result.AppendLine("") | Out-Null
18004 }} else {{
18005 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18006 $result.AppendLine(" No threat detections on record — Defender history is clean.") | Out-Null
18007 $result.AppendLine("") | Out-Null
18008 }}
18009}} catch {{
18010 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18011 $result.AppendLine(" Unable to query threat detections: $_") | Out-Null
18012 $result.AppendLine("") | Out-Null
18013}}
18014
18015# Quarantine items
18016try {{
18017 $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18018 if ($quarantine) {{
18019 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18020 foreach ($q in $quarantine) {{
18021 $result.AppendLine(" $($q.ThreatName) Severity:$($q.SeverityID) Category:$($q.CategoryID) Active:$($q.IsActive)") | Out-Null
18022 }}
18023 $result.AppendLine("") | Out-Null
18024 }} else {{
18025 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18026 $result.AppendLine(" No quarantined threats found.") | Out-Null
18027 $result.AppendLine("") | Out-Null
18028 }}
18029}} catch {{
18030 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18031 $result.AppendLine(" Unable to query quarantine list: $_") | Out-Null
18032 $result.AppendLine("") | Out-Null
18033}}
18034
18035# Defender scan stats
18036try {{
18037 $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18038 if ($status) {{
18039 $lastScan = $status.QuickScanStartTime
18040 $lastFull = $status.FullScanStartTime
18041 $sigDate = $status.AntivirusSignatureLastUpdated
18042 $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18043 $result.AppendLine(" Last quick scan : $lastScan") | Out-Null
18044 $result.AppendLine(" Last full scan : $lastFull") | Out-Null
18045 $result.AppendLine(" Signature date : $sigDate") | Out-Null
18046 }}
18047}} catch {{}}
18048
18049Write-Output $result.ToString().TrimEnd()
18050"#,
18051 limit = limit
18052 );
18053 let out = run_powershell(&script)?;
18054 Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18055}
18056
18057#[cfg(windows)]
18060fn inspect_domain_health() -> Result<String, String> {
18061 let script = r#"
18062$result = [System.Text.StringBuilder]::new()
18063
18064# Domain membership
18065try {
18066 $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18067 $joined = $cs.PartOfDomain
18068 $domain = $cs.Domain
18069 $result.AppendLine("=== Domain Membership ===") | Out-Null
18070 $result.AppendLine(" Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18071 if ($joined) { $result.AppendLine(" Domain : $domain") | Out-Null }
18072 $result.AppendLine(" Computer : $($cs.Name)") | Out-Null
18073} catch {
18074 $result.AppendLine(" Domain membership check failed: $_") | Out-Null
18075}
18076
18077# dsregcmd device registration state
18078try {
18079 $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18080 if ($dsreg) {
18081 $result.AppendLine("") | Out-Null
18082 $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18083 foreach ($line in $dsreg) { $result.AppendLine(" $($line.Trim())") | Out-Null }
18084 }
18085} catch {}
18086
18087# DC discovery via nltest
18088$result.AppendLine("") | Out-Null
18089$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18090try {
18091 $nl = nltest /dsgetdc:. 2>&1
18092 $dc_name = $null
18093 foreach ($line in $nl) {
18094 if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18095 $result.AppendLine(" $($line.Trim())") | Out-Null
18096 }
18097 if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18098 }
18099 if ($dc_name) {
18100 $result.AppendLine("") | Out-Null
18101 $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18102 foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18103 try {
18104 $tcp = New-Object System.Net.Sockets.TcpClient
18105 $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18106 $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18107 $tcp.Close()
18108 $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18109 } catch { $status = 'FAILED' }
18110 $result.AppendLine(" Port $($entry.p) ($($entry.n)): $status") | Out-Null
18111 }
18112 }
18113} catch {
18114 $result.AppendLine(" nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18115}
18116
18117# Last GPO machine refresh time
18118try {
18119 $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18120 if (Test-Path $gpoKey) {
18121 $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18122 $result.AppendLine("") | Out-Null
18123 $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18124 $result.AppendLine(" Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18125 }
18126} catch {}
18127
18128Write-Output $result.ToString().TrimEnd()
18129"#;
18130 let out = run_powershell(script)?;
18131 Ok(format!("Host inspection: domain_health\n\n{out}"))
18132}
18133
18134#[cfg(not(windows))]
18135fn inspect_domain_health() -> Result<String, String> {
18136 let mut out = String::from("Host inspection: domain_health\n\n");
18137 for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18138 if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18139 let s = String::from_utf8_lossy(&o.stdout);
18140 if !s.trim().is_empty() {
18141 out.push_str(&format!("$ {}\n{}\n", cmd_args.join(" "), s.trim_end()));
18142 }
18143 }
18144 }
18145 if out.trim_end().ends_with("domain_health") {
18146 out.push_str("Not domain-joined or realm/sssd not installed.\n");
18147 }
18148 Ok(out)
18149}
18150
18151#[cfg(windows)]
18154fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18155 let limit = max_entries.min(60);
18156 let script = format!(
18157 r#"
18158$result = [System.Text.StringBuilder]::new()
18159$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18160$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18161$result.AppendLine("") | Out-Null
18162$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18163foreach ($s in $svc) {{
18164 $req = if ($s.RequiredServices.Count -gt 0) {{ "requires: $($s.RequiredServices.Name -join ', ')" }} else {{ "" }}
18165 $dep = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18166 $parts = @($req, $dep) | Where-Object {{ $_ }}
18167 if ($parts) {{
18168 $result.AppendLine(" [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18169 }}
18170}}
18171Write-Output $result.ToString().TrimEnd()
18172"#,
18173 limit = limit
18174 );
18175 let out = run_powershell(&script)?;
18176 Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18177}
18178
18179#[cfg(not(windows))]
18180fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18181 let out = Command::new("systemctl")
18182 .args(["list-dependencies", "--no-pager", "--plain"])
18183 .output()
18184 .ok()
18185 .and_then(|o| String::from_utf8(o.stdout).ok())
18186 .unwrap_or_else(|| "systemctl not available.\n".to_string());
18187 Ok(format!(
18188 "Host inspection: service_dependencies\n\n{}",
18189 out.trim_end()
18190 ))
18191}
18192
18193#[cfg(windows)]
18196fn inspect_wmi_health() -> Result<String, String> {
18197 let script = r#"
18198$result = [System.Text.StringBuilder]::new()
18199$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18200
18201# Basic WMI query test
18202try {
18203 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18204 $result.AppendLine(" Query (Win32_OperatingSystem): OK") | Out-Null
18205 $result.AppendLine(" OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18206} catch {
18207 $result.AppendLine(" Query FAILED: $_") | Out-Null
18208 $result.AppendLine(" FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18209}
18210
18211# Repository integrity
18212try {
18213 $verify = & winmgmt /verifyrepository 2>&1
18214 $result.AppendLine(" winmgmt /verifyrepository: $verify") | Out-Null
18215} catch {
18216 $result.AppendLine(" winmgmt check unavailable: $_") | Out-Null
18217}
18218
18219# WMI service state
18220$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18221if ($svc) {
18222 $result.AppendLine(" Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18223}
18224
18225# Repository folder size
18226$repPath = "$env:SystemRoot\System32\wbem\Repository"
18227if (Test-Path $repPath) {
18228 $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18229 $mb = [math]::Round($bytes / 1MB, 1)
18230 $result.AppendLine(" Repository size: $mb MB ($repPath)") | Out-Null
18231 if ($mb -gt 200) {
18232 $result.AppendLine(" FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18233 }
18234}
18235
18236$result.AppendLine("") | Out-Null
18237$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18238$result.AppendLine(" 1. net stop winmgmt") | Out-Null
18239$result.AppendLine(" 2. winmgmt /salvagerepository (try first)") | Out-Null
18240$result.AppendLine(" 3. winmgmt /resetrepository (last resort — loses custom namespaces)") | Out-Null
18241$result.AppendLine(" 4. net start winmgmt") | Out-Null
18242
18243Write-Output $result.ToString().TrimEnd()
18244"#;
18245 let out = run_powershell(script)?;
18246 Ok(format!("Host inspection: wmi_health\n\n{out}"))
18247}
18248
18249#[cfg(not(windows))]
18250fn inspect_wmi_health() -> Result<String, String> {
18251 Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18252}
18253
18254#[cfg(windows)]
18257fn inspect_local_security_policy() -> Result<String, String> {
18258 let script = r#"
18259$result = [System.Text.StringBuilder]::new()
18260$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18261$na = net accounts 2>&1
18262foreach ($line in $na) {
18263 if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18264 $result.AppendLine(" $($line.Trim())") | Out-Null
18265 }
18266}
18267
18268$result.AppendLine("") | Out-Null
18269$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18270try {
18271 $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18272 if ($null -eq $lmLevel) { $lmLevel = 3 }
18273 $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'}
18274 $result.AppendLine(" LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18275 if ($lmLevel -lt 3) {
18276 $result.AppendLine(" FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18277 }
18278} catch {}
18279
18280$result.AppendLine("") | Out-Null
18281$result.AppendLine("=== UAC Settings ===") | Out-Null
18282try {
18283 $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18284 if ($uac) {
18285 $result.AppendLine(" UAC Enabled : $($uac.EnableLUA) (1=on, 0=disabled)") | Out-Null
18286 $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18287 $bval = $uac.ConsentPromptBehaviorAdmin
18288 $result.AppendLine(" Admin Prompt Behavior : $bval — $($behavMap[$bval])") | Out-Null
18289 if ($uac.EnableLUA -eq 0) {
18290 $result.AppendLine(" FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18291 }
18292 }
18293} catch {}
18294
18295Write-Output $result.ToString().TrimEnd()
18296"#;
18297 let out = run_powershell(script)?;
18298 Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18299}
18300
18301#[cfg(not(windows))]
18302fn inspect_local_security_policy() -> Result<String, String> {
18303 let mut out = String::from("Host inspection: local_security_policy\n\n");
18304 if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18305 out.push_str("=== /etc/login.defs ===\n");
18306 for line in content.lines() {
18307 let t = line.trim();
18308 if !t.is_empty() && !t.starts_with('#') {
18309 out.push_str(&format!(" {t}\n"));
18310 }
18311 }
18312 }
18313 Ok(out)
18314}
18315
18316#[cfg(windows)]
18319fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18320 let limit = max_entries.min(50);
18321 let script = format!(
18322 r#"
18323$result = [System.Text.StringBuilder]::new()
18324$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18325$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18326if (Test-Path $usbPath) {{
18327 $count = 0
18328 $seen = @{{}}
18329 $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18330 foreach ($class in $classes) {{
18331 $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18332 foreach ($inst in $instances) {{
18333 if ($count -ge {limit}) {{ break }}
18334 try {{
18335 $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18336 $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18337 if (-not $seen[$fn]) {{
18338 $seen[$fn] = $true
18339 $result.AppendLine(" $fn") | Out-Null
18340 $count++
18341 }}
18342 }} catch {{}}
18343 }}
18344 }}
18345 if ($count -eq 0) {{
18346 $result.AppendLine(" No USB storage devices found in registry.") | Out-Null
18347 }} else {{
18348 $result.AppendLine("") | Out-Null
18349 $result.AppendLine(" ($count unique devices; requires elevation for full history)") | Out-Null
18350 }}
18351}} else {{
18352 $result.AppendLine(" USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18353}}
18354Write-Output $result.ToString().TrimEnd()
18355"#,
18356 limit = limit
18357 );
18358 let out = run_powershell(&script)?;
18359 Ok(format!("Host inspection: usb_history\n\n{out}"))
18360}
18361
18362#[cfg(not(windows))]
18363fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18364 let mut out = String::from("Host inspection: usb_history\n\n");
18365 if let Ok(o) = Command::new("journalctl")
18366 .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18367 .output()
18368 {
18369 let s = String::from_utf8_lossy(&o.stdout);
18370 let usb_lines: Vec<&str> = s
18371 .lines()
18372 .filter(|l| l.to_ascii_lowercase().contains("usb"))
18373 .take(30)
18374 .collect();
18375 if !usb_lines.is_empty() {
18376 out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18377 for line in usb_lines {
18378 out.push_str(&format!(" {line}\n"));
18379 }
18380 }
18381 } else {
18382 out.push_str("USB history via journalctl not available.\n");
18383 }
18384 Ok(out)
18385}
18386
18387#[cfg(windows)]
18390fn inspect_print_spooler() -> Result<String, String> {
18391 let script = r#"
18392$result = [System.Text.StringBuilder]::new()
18393
18394$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18395$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18396if ($svc) {
18397 $result.AppendLine(" Status : $($svc.Status)") | Out-Null
18398 $result.AppendLine(" Start Type : $($svc.StartType)") | Out-Null
18399} else {
18400 $result.AppendLine(" Spooler service not found.") | Out-Null
18401}
18402
18403# PrintNightmare mitigations (CVE-2021-34527)
18404$result.AppendLine("") | Out-Null
18405$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18406try {
18407 $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18408 if ($val -eq 1) {
18409 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18410 } else {
18411 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18412 $result.AppendLine(" FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18413 }
18414} catch { $result.AppendLine(" Mitigation key not readable: $_") | Out-Null }
18415
18416try {
18417 $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18418 if (Test-Path $pnpPath) {
18419 $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18420 $result.AppendLine(" RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18421 $result.AppendLine(" NoWarningNoElevationOnInstall : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18422 if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18423 $result.AppendLine(" FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18424 }
18425 } else {
18426 $result.AppendLine(" No Point and Print policy (using Windows defaults).") | Out-Null
18427 }
18428} catch {}
18429
18430# Pending print jobs
18431$result.AppendLine("") | Out-Null
18432$result.AppendLine("=== Print Queue ===") | Out-Null
18433try {
18434 $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18435 if ($jobs) {
18436 foreach ($j in $jobs | Select-Object -First 5) {
18437 $result.AppendLine(" $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18438 }
18439 } else {
18440 $result.AppendLine(" No pending print jobs.") | Out-Null
18441 }
18442} catch {
18443 $result.AppendLine(" Print queue check requires elevation.") | Out-Null
18444}
18445
18446Write-Output $result.ToString().TrimEnd()
18447"#;
18448 let out = run_powershell(script)?;
18449 Ok(format!("Host inspection: print_spooler\n\n{out}"))
18450}
18451
18452#[cfg(not(windows))]
18453fn inspect_print_spooler() -> Result<String, String> {
18454 let mut out = String::from("Host inspection: print_spooler\n\n");
18455 if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18456 let s = String::from_utf8_lossy(&o.stdout);
18457 if !s.trim().is_empty() {
18458 out.push_str("=== CUPS Status (lpstat -s) ===\n");
18459 out.push_str(s.trim_end());
18460 out.push('\n');
18461 }
18462 } else {
18463 out.push_str("CUPS not detected (lpstat not found).\n");
18464 }
18465 Ok(out)
18466}
18467
18468#[cfg(not(windows))]
18469fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18470 let mut out = String::from("Host inspection: defender_quarantine\n\n");
18471 out.push_str("Windows Defender is Windows-only.\n");
18472 if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18474 if o.status.success() {
18475 out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18476 if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18477 out.push_str("\n=== ClamAV Recent Log ===\n");
18478 for line in log.lines().rev().take(20) {
18479 out.push_str(&format!(" {line}\n"));
18480 }
18481 }
18482 }
18483 } else {
18484 out.push_str("No AV tool detected (ClamAV not found).\n");
18485 }
18486 Ok(out)
18487}