1use crate::agent::truncation::safe_head;
2use serde_json::Value;
3use std::collections::HashSet;
4use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9const DEFAULT_MAX_ENTRIES: usize = 10;
10const MAX_ENTRIES_CAP: usize = 25;
11const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
12
13pub async fn inspect_host(args: &Value) -> Result<String, String> {
14 let mut topic = args
15 .get("topic")
16 .and_then(|v| v.as_str())
17 .unwrap_or("summary")
18 .to_string();
19 let max_entries = parse_max_entries(args);
20 let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
21
22 if (topic == "processes" || topic == "network" || topic == "summary")
24 && (filter.contains("ad")
25 || filter.contains("sid")
26 || filter.contains("administrator")
27 || filter.contains("domain"))
28 {
29 topic = "ad_user".to_string();
30 }
31
32 let result = match topic.as_str() {
33 "summary" => inspect_summary(max_entries),
34 "toolchains" => inspect_toolchains(),
35 "path" => inspect_path(max_entries),
36 "env_doctor" => inspect_env_doctor(max_entries),
37 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
38 "network" => inspect_network(max_entries),
39 "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
40 inspect_lan_discovery(max_entries)
41 }
42 "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
43 inspect_audio(max_entries)
44 }
45 "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
46 inspect_bluetooth(max_entries)
47 }
48 "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
49 "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
50 inspect_sign_in(max_entries)
51 }
52 "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
53 inspect_installer_health(max_entries)
54 }
55 "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
56 inspect_onedrive(max_entries)
57 }
58 "browser_health" | "browser" | "webview2" | "default_browser" => {
59 inspect_browser_health(max_entries)
60 }
61 "identity_auth"
62 | "office_auth"
63 | "m365_auth"
64 | "microsoft_365_auth"
65 | "auth_broker" => inspect_identity_auth(max_entries),
66 "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
67 "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
68 "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
69 inspect_windows_backup(max_entries)
70 }
71 "search_index" | "windows_search" | "indexing" | "search" => {
72 inspect_search_index(max_entries)
73 }
74 "services" => inspect_services(parse_name_filter(args), max_entries),
75 "processes" => inspect_processes(parse_name_filter(args), max_entries),
76 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
77 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
78 "disk" => {
79 let path = resolve_optional_path(args)?;
80 inspect_disk(path, max_entries).await
81 }
82 "ports" => inspect_ports(parse_port_filter(args), max_entries),
83 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
84 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
85 "health_report" | "system_health" => inspect_health_report(),
86 "storage" => inspect_storage(max_entries),
87 "hardware" => inspect_hardware(),
88 "updates" | "windows_update" => inspect_updates(),
89 "security" | "antivirus" | "defender" => inspect_security(),
90 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
91 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
92 "battery" => inspect_battery(),
93 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
94 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
95 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
96 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
97 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
98 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
99 "vpn" => inspect_vpn(),
100 "public_ip" | "external_ip" | "myip" => inspect_public_ip().await,
101 "ssl_cert" | "tls_cert" | "cert_audit" | "website_cert" => {
102 let host = args.get("host").and_then(|v| v.as_str()).unwrap_or("google.com");
103 inspect_ssl_cert(host)
104 }
105 "proxy" | "proxy_settings" => inspect_proxy(),
106 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
107 "traceroute" | "tracert" | "trace_route" | "trace" => {
108 let host = args
109 .get("host")
110 .and_then(|v| v.as_str())
111 .unwrap_or("8.8.8.8")
112 .to_string();
113 inspect_traceroute(&host, max_entries)
114 }
115 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
116 "arp" | "arp_table" => inspect_arp(),
117 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
118 "os_config" | "system_config" => inspect_os_config(),
119 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
120 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
121 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
122 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
123 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
124 inspect_docker_filesystems(max_entries)
125 }
126 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
127 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
128 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
129 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
130 "git_config" | "git_global" => inspect_git_config(),
131 "databases" | "database" | "db_services" | "db" => inspect_databases(),
132 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
133 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
134 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
135 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
136 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
137 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
138 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
139 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
140 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
141 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
142 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
143 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
144 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
145 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
146 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
147 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
148 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
149 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
150 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
151 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
152 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
153 "data_audit" | "csv_audit" | "file_audit" => {
154 let path = resolve_optional_path(args)?;
155 inspect_data_audit(path, max_entries).await
156 }
157 "repo_doctor" => {
158 let path = resolve_optional_path(args)?;
159 inspect_repo_doctor(path, max_entries)
160 }
161 "directory" => {
162 let raw_path = args
163 .get("path")
164 .and_then(|v| v.as_str())
165 .ok_or_else(|| {
166 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
167 .to_string()
168 })?;
169 let resolved = resolve_path(raw_path)?;
170 inspect_directory("Directory", resolved, max_entries).await
171 }
172 "disk_benchmark" | "stress_test" | "io_intensity" => {
173 let path = resolve_optional_path(args)?;
174 inspect_disk_benchmark(path).await
175 }
176 "permissions" | "acl" | "access_control" => {
177 let path = resolve_optional_path(args)?;
178 inspect_permissions(path, max_entries)
179 }
180 "login_history" | "logon_history" | "user_logins" => {
181 inspect_login_history(max_entries)
182 }
183 "share_access" | "unc_access" | "remote_share" => {
184 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
185 inspect_share_access(path)
186 }
187 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
188 "thermal" | "throttling" | "overheating" => inspect_thermal(),
189 "activation" | "license_status" | "slmgr" => inspect_activation(),
190 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
191 "ad_user" | "ad" | "domain_user" => {
192 let identity = parse_name_filter(args).unwrap_or_default();
193 inspect_ad_user(&identity)
194 }
195 "dns_lookup" | "dig" | "nslookup" => {
196 let name = parse_name_filter(args).unwrap_or_default();
197 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
198 inspect_dns_lookup(&name, record_type)
199 }
200 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
201 "ip_config" | "ip_detail" => inspect_ip_config(),
202 "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
203 "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
204 "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
205 "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
206 "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
207 "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
208 "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
209 "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
210 "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
211 "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
212 let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
213 let pt_port = args.get("port").and_then(|v| v.as_u64()).and_then(|p| u16::try_from(p).ok());
214 inspect_port_test(pt_host.as_deref(), pt_port)
215 }
216 "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
217 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
218 "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
219 inspect_display_config(max_entries)
220 }
221 "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
222 inspect_ntp()
223 }
224 "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
225 inspect_cpu_power()
226 }
227 "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
228 inspect_credentials(max_entries)
229 }
230 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
231 inspect_tpm()
232 }
233 "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
234 inspect_latency()
235 }
236 "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
237 inspect_network_adapter()
238 }
239 "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
240 let event_id = args.get("event_id").and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok());
241 let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
242 let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
243 let hours = args.get("hours").and_then(|v| v.as_u64()).and_then(|h| u32::try_from(h).ok()).unwrap_or(24u32);
244 let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
245 inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
246 }
247 "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
248 let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
249 inspect_app_crashes(process_filter.as_deref(), max_entries)
250 }
251 "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
252 inspect_mdm_enrollment()
253 }
254 "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
255 inspect_storage_spaces()
256 }
257 "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
258 inspect_defender_quarantine(max_entries)
259 }
260 "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
261 inspect_domain_health()
262 }
263 "service_dependencies" | "svc_deps" | "service_deps" => {
264 inspect_service_dependencies(max_entries)
265 }
266 "wmi_health" | "wmi_repository" | "wmi_status" => {
267 inspect_wmi_health()
268 }
269 "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
270 inspect_local_security_policy()
271 }
272 "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
273 inspect_usb_history(max_entries)
274 }
275 "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
276 inspect_print_spooler()
277 }
278 other => Err(format!(
279 "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.",
280 other
281 )),
282
283 };
284
285 result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
286}
287
288fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
289 let Some(scope) = admin_sensitive_topic_scope(topic) else {
290 return body;
291 };
292 let lower = body.to_lowercase();
293 let privilege_limited = lower.contains("access denied")
294 || lower.contains("administrator privilege is required")
295 || lower.contains("administrator privileges required")
296 || lower.contains("requires administrator")
297 || lower.contains("requires elevation")
298 || lower.contains("non-admin session")
299 || lower.contains("could not be fully determined from this session");
300 if !privilege_limited || lower.contains("=== elevation note ===") {
301 return body;
302 }
303
304 let mut annotated = body;
305 annotated.push_str("\n=== Elevation note ===\n");
306 annotated.push_str("- Hematite should stay non-admin by default.\n");
307 annotated.push_str(
308 "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
309 );
310 let _ = writeln!(
311 annotated,
312 "- Rerun Hematite as Administrator only if you need a definitive {scope} answer."
313 );
314 annotated
315}
316
317fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
318 match topic {
319 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
320 Some("TPM / Secure Boot / firmware")
321 }
322 "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
323 "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
324 "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
325 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
326 "windows_features" | "optional_features" | "installed_features" | "features" => {
327 Some("Windows Features")
328 }
329 "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
330 _ => None,
331 }
332}
333
334#[cfg(test)]
335mod privilege_hint_tests {
336 use super::annotate_privilege_limited_output;
337
338 #[test]
339 fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
340 let body = "Host inspection: network\nError: Access denied.\n".to_string();
341 let annotated = annotate_privilege_limited_output("network", body.clone());
342 assert_eq!(annotated, body);
343 }
344
345 #[test]
346 fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
347 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();
348 let annotated = annotate_privilege_limited_output("tpm", body);
349 assert!(annotated.contains("=== Elevation note ==="));
350 assert!(annotated.contains("stay non-admin by default"));
351 assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
352 }
353}
354
355#[cfg(test)]
356mod event_query_tests {
357 use super::is_event_query_no_results_message;
358
359 #[cfg(target_os = "windows")]
360 #[test]
361 fn treats_windows_no_results_message_as_empty_query() {
362 assert!(is_event_query_no_results_message(
363 "No events were found that match the specified selection criteria."
364 ));
365 }
366
367 #[cfg(target_os = "windows")]
368 #[test]
369 fn does_not_treat_real_errors_as_empty_query() {
370 assert!(!is_event_query_no_results_message("Access is denied."));
371 }
372}
373
374fn parse_max_entries(args: &Value) -> usize {
375 args.get("max_entries")
376 .and_then(|v| v.as_u64())
377 .map(|n| n as usize)
378 .unwrap_or(DEFAULT_MAX_ENTRIES)
379 .clamp(1, MAX_ENTRIES_CAP)
380}
381
382fn parse_port_filter(args: &Value) -> Option<u16> {
383 args.get("port")
384 .and_then(|v| v.as_u64())
385 .and_then(|n| u16::try_from(n).ok())
386}
387
388fn parse_name_filter(args: &Value) -> Option<String> {
389 args.get("name")
390 .and_then(|v| v.as_str())
391 .map(str::trim)
392 .filter(|value| !value.is_empty())
393 .map(|value| value.to_string())
394}
395
396fn parse_lookback_hours(args: &Value) -> Option<u32> {
397 args.get("lookback_hours")
398 .and_then(|v| v.as_u64())
399 .map(|n| n as u32)
400}
401
402fn parse_issue_text(args: &Value) -> Option<String> {
403 args.get("issue")
404 .and_then(|v| v.as_str())
405 .map(str::trim)
406 .filter(|value| !value.is_empty())
407 .map(|value| value.to_string())
408}
409
410#[cfg(target_os = "windows")]
411fn is_event_query_no_results_message(message: &str) -> bool {
412 let lower = message.to_ascii_lowercase();
413 lower.contains("no events were found")
414 || lower.contains("no events match the specified selection criteria")
415}
416
417fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
418 match args.get("path").and_then(|v| v.as_str()) {
419 Some(raw_path) => resolve_path(raw_path),
420 None => {
421 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
422 }
423 }
424}
425
426fn inspect_summary(max_entries: usize) -> Result<String, String> {
427 let current_dir =
428 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
429 let workspace_root = crate::tools::file_ops::workspace_root();
430 let workspace_mode = workspace_mode_label(&workspace_root);
431 let path_stats = analyze_path_env();
432 let toolchains = collect_toolchains();
433
434 let mut out = String::from("Host inspection: summary\n\n");
435 let _ = writeln!(out, "- OS: {}", std::env::consts::OS);
436 let _ = writeln!(out, "- Current directory: {}", current_dir.display());
437 let _ = writeln!(out, "- Workspace root: {}", workspace_root.display());
438 let _ = writeln!(out, "- Workspace mode: {}", workspace_mode);
439 let _ = writeln!(out, "- Preferred shell: {}", preferred_shell_label());
440 let _ = writeln!(
441 out,
442 "- PATH entries: {} total, {} unique, {} duplicates, {} missing",
443 path_stats.total_entries,
444 path_stats.unique_entries,
445 path_stats.duplicate_entries.len(),
446 path_stats.missing_entries.len()
447 );
448
449 if toolchains.found.is_empty() {
450 out.push_str(
451 "- Toolchains found: none of the common developer tools were detected on PATH\n",
452 );
453 } else {
454 out.push_str("- Toolchains found:\n");
455 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
456 let _ = writeln!(out, " - {}: {}", label, version);
457 }
458 if toolchains.found.len() > max_entries.min(8) {
459 let _ = writeln!(
460 out,
461 " - ... {} more found tools omitted",
462 toolchains.found.len() - max_entries.min(8)
463 );
464 }
465 }
466
467 if !toolchains.missing.is_empty() {
468 let _ = writeln!(
469 out,
470 "- Common tools not detected on PATH: {}",
471 toolchains.missing.join(", ")
472 );
473 }
474
475 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
476 match path {
477 Some(path) if path.exists() => match count_top_level_items(&path) {
478 Ok(count) => {
479 let _ = writeln!(
480 out,
481 "- {}: {} top-level items at {}",
482 label,
483 count,
484 path.display()
485 );
486 }
487 Err(e) => {
488 let _ = writeln!(
489 out,
490 "- {}: exists at {} but could not inspect ({})",
491 label,
492 path.display(),
493 e
494 );
495 }
496 },
497 Some(path) => {
498 let _ = writeln!(
499 out,
500 "- {}: expected at {} but not found",
501 label,
502 path.display()
503 );
504 }
505 None => {
506 let _ = writeln!(out, "- {}: location unavailable on this host", label);
507 }
508 }
509 }
510
511 Ok(out.trim_end().to_string())
512}
513
514fn inspect_toolchains() -> Result<String, String> {
515 let report = collect_toolchains();
516 let mut out = String::from("Host inspection: toolchains\n\n");
517
518 if report.found.is_empty() {
519 out.push_str("- No common developer tools were detected on PATH.");
520 } else {
521 out.push_str("Detected developer tools:\n");
522 for (label, version) in report.found {
523 let _ = writeln!(out, "- {}: {}", label, version);
524 }
525 }
526
527 if !report.missing.is_empty() {
528 out.push_str("\nNot detected on PATH:\n");
529 for label in report.missing {
530 let _ = writeln!(out, "- {}", label);
531 }
532 }
533
534 Ok(out.trim_end().to_string())
535}
536
537fn inspect_path(max_entries: usize) -> Result<String, String> {
538 let path_stats = analyze_path_env();
539 let mut out = String::from("Host inspection: PATH\n\n");
540 let _ = writeln!(out, "- Total entries: {}", path_stats.total_entries);
541 let _ = writeln!(out, "- Unique entries: {}", path_stats.unique_entries);
542 let _ = writeln!(
543 out,
544 "- Duplicate entries: {}",
545 path_stats.duplicate_entries.len()
546 );
547 let _ = writeln!(out, "- Missing paths: {}", path_stats.missing_entries.len());
548
549 out.push_str("\nPATH entries:\n");
550 for entry in path_stats.entries.iter().take(max_entries) {
551 let _ = writeln!(out, "- {}", entry);
552 }
553 if path_stats.entries.len() > max_entries {
554 let _ = writeln!(
555 out,
556 "- ... {} more entries omitted",
557 path_stats.entries.len() - max_entries
558 );
559 }
560
561 if !path_stats.duplicate_entries.is_empty() {
562 out.push_str("\nDuplicate entries:\n");
563 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
564 let _ = writeln!(out, "- {}", entry);
565 }
566 if path_stats.duplicate_entries.len() > max_entries {
567 let _ = writeln!(
568 out,
569 "- ... {} more duplicates omitted",
570 path_stats.duplicate_entries.len() - max_entries
571 );
572 }
573 }
574
575 if !path_stats.missing_entries.is_empty() {
576 out.push_str("\nMissing directories:\n");
577 for entry in path_stats.missing_entries.iter().take(max_entries) {
578 let _ = writeln!(out, "- {}", entry);
579 }
580 if path_stats.missing_entries.len() > max_entries {
581 let _ = writeln!(
582 out,
583 "- ... {} more missing entries omitted",
584 path_stats.missing_entries.len() - max_entries
585 );
586 }
587 }
588
589 Ok(out.trim_end().to_string())
590}
591
592fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
593 let path_stats = analyze_path_env();
594 let toolchains = collect_toolchains();
595 let package_managers = collect_package_managers();
596 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
597
598 let mut out = String::from("Host inspection: env_doctor\n\n");
599 let _ = writeln!(
600 out,
601 "- PATH health: {} duplicates, {} missing entries",
602 path_stats.duplicate_entries.len(),
603 path_stats.missing_entries.len()
604 );
605 let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
606 let _ = writeln!(
607 out,
608 "- Package managers found: {}",
609 package_managers.found.len()
610 );
611
612 if !package_managers.found.is_empty() {
613 out.push_str("\nPackage managers:\n");
614 for (label, version) in package_managers.found.iter().take(max_entries) {
615 let _ = writeln!(out, "- {}: {}", label, version);
616 }
617 if package_managers.found.len() > max_entries {
618 let _ = writeln!(
619 out,
620 "- ... {} more package managers omitted",
621 package_managers.found.len() - max_entries
622 );
623 }
624 }
625
626 if !path_stats.duplicate_entries.is_empty() {
627 out.push_str("\nDuplicate PATH entries:\n");
628 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
629 let _ = writeln!(out, "- {}", entry);
630 }
631 if path_stats.duplicate_entries.len() > max_entries.min(5) {
632 let _ = writeln!(
633 out,
634 "- ... {} more duplicate entries omitted",
635 path_stats.duplicate_entries.len() - max_entries.min(5)
636 );
637 }
638 }
639
640 if !path_stats.missing_entries.is_empty() {
641 out.push_str("\nMissing PATH entries:\n");
642 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
643 let _ = writeln!(out, "- {}", entry);
644 }
645 if path_stats.missing_entries.len() > max_entries.min(5) {
646 let _ = writeln!(
647 out,
648 "- ... {} more missing entries omitted",
649 path_stats.missing_entries.len() - max_entries.min(5)
650 );
651 }
652 }
653
654 if !findings.is_empty() {
655 out.push_str("\nFindings:\n");
656 for finding in findings.iter().take(max_entries.max(5)) {
657 let _ = writeln!(out, "- {}", finding);
658 }
659 if findings.len() > max_entries.max(5) {
660 let _ = writeln!(
661 out,
662 "- ... {} more findings omitted",
663 findings.len() - max_entries.max(5)
664 );
665 }
666 } else {
667 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
668 }
669
670 out.push_str(
671 "\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.",
672 );
673
674 Ok(out.trim_end().to_string())
675}
676
677#[derive(Clone, Copy, Debug, Eq, PartialEq)]
678enum FixPlanKind {
679 EnvPath,
680 PortConflict,
681 LmStudio,
682 DriverInstall,
683 GroupPolicy,
684 FirewallRule,
685 SshKey,
686 WslSetup,
687 ServiceConfig,
688 WindowsActivation,
689 RegistryEdit,
690 ScheduledTaskCreate,
691 DiskCleanup,
692 DnsResolution,
693 Generic,
694}
695
696async fn inspect_fix_plan(
697 issue: Option<String>,
698 port_filter: Option<u16>,
699 max_entries: usize,
700) -> Result<String, String> {
701 let issue = issue.unwrap_or_else(|| {
702 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
703 .to_string()
704 });
705 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
706 match plan_kind {
707 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
708 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
709 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
710 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
711 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
712 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
713 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
714 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
715 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
716 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
717 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
718 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
719 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
720 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
721 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
722 }
723}
724
725fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
726 let lower = issue.to_ascii_lowercase();
727 if lower.contains("firewall rule")
730 || lower.contains("inbound rule")
731 || lower.contains("outbound rule")
732 || (lower.contains("firewall")
733 && (lower.contains("allow")
734 || lower.contains("block")
735 || lower.contains("create")
736 || lower.contains("open")))
737 {
738 FixPlanKind::FirewallRule
739 } else if port_filter.is_some()
740 || lower.contains("port ")
741 || lower.contains("address already in use")
742 || lower.contains("already in use")
743 || lower.contains("what owns port")
744 || lower.contains("listening on port")
745 {
746 FixPlanKind::PortConflict
747 } else if lower.contains("lm studio")
748 || lower.contains("localhost:1234")
749 || lower.contains("/v1/models")
750 || lower.contains("no coding model loaded")
751 || lower.contains("embedding model")
752 || lower.contains("server on port 1234")
753 || lower.contains("runtime refresh")
754 {
755 FixPlanKind::LmStudio
756 } else if lower.contains("driver")
757 || lower.contains("gpu driver")
758 || lower.contains("nvidia driver")
759 || lower.contains("amd driver")
760 || lower.contains("install driver")
761 || lower.contains("update driver")
762 {
763 FixPlanKind::DriverInstall
764 } else if lower.contains("group policy")
765 || lower.contains("gpedit")
766 || lower.contains("local policy")
767 || lower.contains("secpol")
768 || lower.contains("administrative template")
769 {
770 FixPlanKind::GroupPolicy
771 } else if lower.contains("ssh key")
772 || lower.contains("ssh-keygen")
773 || lower.contains("generate ssh")
774 || lower.contains("authorized_keys")
775 || lower.contains("id_rsa")
776 || lower.contains("id_ed25519")
777 {
778 FixPlanKind::SshKey
779 } else if lower.contains("wsl")
780 || lower.contains("windows subsystem for linux")
781 || lower.contains("install ubuntu")
782 || lower.contains("install linux on windows")
783 || lower.contains("wsl2")
784 {
785 FixPlanKind::WslSetup
786 } else if lower.contains("service")
787 && (lower.contains("start ")
788 || lower.contains("stop ")
789 || lower.contains("restart ")
790 || lower.contains("enable ")
791 || lower.contains("disable ")
792 || lower.contains("configure service"))
793 {
794 FixPlanKind::ServiceConfig
795 } else if lower.contains("activate windows")
796 || lower.contains("windows activation")
797 || lower.contains("product key")
798 || lower.contains("kms")
799 || lower.contains("not activated")
800 {
801 FixPlanKind::WindowsActivation
802 } else if lower.contains("registry")
803 || lower.contains("regedit")
804 || lower.contains("hklm")
805 || lower.contains("hkcu")
806 || lower.contains("reg add")
807 || lower.contains("reg delete")
808 || lower.contains("registry key")
809 {
810 FixPlanKind::RegistryEdit
811 } else if lower.contains("scheduled task")
812 || lower.contains("task scheduler")
813 || lower.contains("schtasks")
814 || lower.contains("create task")
815 || lower.contains("run on startup")
816 || lower.contains("run on schedule")
817 || lower.contains("cron")
818 {
819 FixPlanKind::ScheduledTaskCreate
820 } else if lower.contains("disk cleanup")
821 || lower.contains("free up disk")
822 || lower.contains("free up space")
823 || lower.contains("clear cache")
824 || lower.contains("disk full")
825 || lower.contains("low disk space")
826 || lower.contains("reclaim space")
827 {
828 FixPlanKind::DiskCleanup
829 } else if lower.contains("cargo")
830 || lower.contains("rustc")
831 || lower.contains("path")
832 || lower.contains("package manager")
833 || lower.contains("package managers")
834 || lower.contains("toolchain")
835 || lower.contains("winget")
836 || lower.contains("choco")
837 || lower.contains("scoop")
838 || lower.contains("python")
839 || lower.contains("node")
840 {
841 FixPlanKind::EnvPath
842 } else if lower.contains("dns ")
843 || lower.contains("nameserver")
844 || lower.contains("cannot resolve")
845 || lower.contains("nslookup")
846 || lower.contains("flushdns")
847 {
848 FixPlanKind::DnsResolution
849 } else {
850 FixPlanKind::Generic
851 }
852}
853
854fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
855 let path_stats = analyze_path_env();
856 let toolchains = collect_toolchains();
857 let package_managers = collect_package_managers();
858 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
859 let found_tools = toolchains
860 .found
861 .iter()
862 .map(|(label, _)| label.as_str())
863 .collect::<HashSet<_>>();
864 let found_managers = package_managers
865 .found
866 .iter()
867 .map(|(label, _)| label.as_str())
868 .collect::<HashSet<_>>();
869
870 let mut out = String::from("Host inspection: fix_plan\n\n");
871 let _ = writeln!(out, "- Requested issue: {}", issue);
872 out.push_str("- Fix-plan type: environment/path\n");
873 let _ = writeln!(
874 out,
875 "- PATH health: {} duplicates, {} missing entries",
876 path_stats.duplicate_entries.len(),
877 path_stats.missing_entries.len()
878 );
879 let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
880 let _ = writeln!(
881 out,
882 "- Package managers found: {}",
883 package_managers.found.len()
884 );
885
886 out.push_str("\nLikely causes:\n");
887 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
888 out.push_str(
889 "- 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",
890 );
891 }
892 if path_stats.duplicate_entries.is_empty()
893 && path_stats.missing_entries.is_empty()
894 && !findings.is_empty()
895 {
896 for finding in findings.iter().take(max_entries.max(4)) {
897 let _ = writeln!(out, "- {}", finding);
898 }
899 } else {
900 if !path_stats.duplicate_entries.is_empty() {
901 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
902 }
903 if !path_stats.missing_entries.is_empty() {
904 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
905 }
906 }
907 if found_tools.contains("node")
908 && !found_managers.contains("npm")
909 && !found_managers.contains("pnpm")
910 {
911 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
912 }
913 if found_tools.contains("python")
914 && !found_managers.contains("pip")
915 && !found_managers.contains("uv")
916 && !found_managers.contains("pipx")
917 {
918 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
919 }
920
921 out.push_str("\nFix plan:\n");
922 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");
923 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
924 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");
925 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
926 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");
927 }
928 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
929 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
930 }
931 if found_tools.contains("node")
932 && !found_managers.contains("npm")
933 && !found_managers.contains("pnpm")
934 {
935 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");
936 }
937 if found_tools.contains("python")
938 && !found_managers.contains("pip")
939 && !found_managers.contains("uv")
940 && !found_managers.contains("pipx")
941 {
942 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");
943 }
944
945 if !path_stats.duplicate_entries.is_empty() {
946 out.push_str("\nExample duplicate PATH rows:\n");
947 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
948 let _ = writeln!(out, "- {}", entry);
949 }
950 }
951 if !path_stats.missing_entries.is_empty() {
952 out.push_str("\nExample missing PATH rows:\n");
953 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
954 let _ = writeln!(out, "- {}", entry);
955 }
956 }
957
958 out.push_str(
959 "\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.",
960 );
961 Ok(out.trim_end().to_string())
962}
963
964fn inspect_port_fix_plan(
965 issue: &str,
966 port_filter: Option<u16>,
967 max_entries: usize,
968) -> Result<String, String> {
969 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
970 let listeners = collect_listening_ports().unwrap_or_default();
971 let mut matching = listeners;
972 if let Some(port) = requested_port {
973 matching.retain(|entry| entry.port == port);
974 }
975 let processes = collect_processes().unwrap_or_default();
976
977 let mut out = String::from("Host inspection: fix_plan\n\n");
978 let _ = writeln!(out, "- Requested issue: {}", issue);
979 out.push_str("- Fix-plan type: port_conflict\n");
980 if let Some(port) = requested_port {
981 let _ = writeln!(out, "- Requested port: {}", port);
982 } else {
983 out.push_str("- Requested port: not parsed from the issue text\n");
984 }
985 let _ = writeln!(out, "- Matching listeners found: {}", matching.len());
986
987 if !matching.is_empty() {
988 out.push_str("\nCurrent listeners:\n");
989 for entry in matching.iter().take(max_entries.min(5)) {
990 let process_name = entry
991 .pid
992 .as_deref()
993 .and_then(|pid| pid.parse::<u32>().ok())
994 .and_then(|pid| {
995 processes
996 .iter()
997 .find(|process| process.pid == pid)
998 .map(|process| process.name.as_str())
999 })
1000 .unwrap_or("unknown");
1001 let pid = entry.pid.as_deref().unwrap_or("unknown");
1002 let _ = writeln!(
1003 out,
1004 "- {} {} ({}) pid {} process {}",
1005 entry.protocol, entry.local, entry.state, pid, process_name
1006 );
1007 }
1008 }
1009
1010 out.push_str("\nFix plan:\n");
1011 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");
1012 if !matching.is_empty() {
1013 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");
1014 } else {
1015 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");
1016 }
1017 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
1018 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");
1019 out.push_str(
1020 "\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.",
1021 );
1022 Ok(out.trim_end().to_string())
1023}
1024
1025async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
1026 let config = crate::agent::config::load_config();
1027 let configured_api = config
1028 .api_url
1029 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
1030 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
1031 let reachability = probe_http_endpoint(&models_url).await;
1032 let embed_model = detect_loaded_embed_model(&configured_api).await;
1033
1034 let mut out = String::from("Host inspection: fix_plan\n\n");
1035 let _ = writeln!(out, "- Requested issue: {}", issue);
1036 out.push_str("- Fix-plan type: lm_studio\n");
1037 let _ = writeln!(out, "- Configured API URL: {}", configured_api);
1038 let _ = writeln!(out, "- Probe URL: {}", models_url);
1039 match &reachability {
1040 EndpointProbe::Reachable(status) => {
1041 let _ = writeln!(out, "- Endpoint reachable: yes (HTTP {})", status);
1042 }
1043 EndpointProbe::Unreachable(detail) => {
1044 let _ = writeln!(out, "- Endpoint reachable: no ({})", detail);
1045 }
1046 }
1047 let _ = writeln!(
1048 out,
1049 "- Embedding model loaded: {}",
1050 embed_model.as_deref().unwrap_or("none detected")
1051 );
1052
1053 out.push_str("\nFix plan:\n");
1054 match reachability {
1055 EndpointProbe::Reachable(_) => {
1056 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");
1057 }
1058 EndpointProbe::Unreachable(_) => {
1059 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");
1060 }
1061 }
1062 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");
1063 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");
1064 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");
1065 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");
1066 if let Some(model) = embed_model {
1067 let _ = writeln!(out,
1068 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.",
1069 model
1070 );
1071 }
1072 if max_entries > 0 {
1073 out.push_str(
1074 "\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.",
1075 );
1076 }
1077 Ok(out.trim_end().to_string())
1078}
1079
1080fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1081 #[cfg(target_os = "windows")]
1083 let gpu_info = {
1084 let out = Command::new("powershell")
1085 .args([
1086 "-NoProfile",
1087 "-NonInteractive",
1088 "-Command",
1089 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1090 ])
1091 .output()
1092 .ok()
1093 .and_then(|o| String::from_utf8(o.stdout).ok())
1094 .unwrap_or_default();
1095 out.trim().to_string()
1096 };
1097 #[cfg(not(target_os = "windows"))]
1098 let gpu_info = String::from("(GPU detection not available on this platform)");
1099
1100 let mut out = String::from("Host inspection: fix_plan\n\n");
1101 let _ = writeln!(out, "- Requested issue: {}", issue);
1102 out.push_str("- Fix-plan type: driver_install\n");
1103 if !gpu_info.is_empty() {
1104 let _ = write!(out, "\nDetected GPU(s):\n{}\n", gpu_info);
1105 }
1106 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1107 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1108 out.push_str(
1109 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1110 );
1111 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1112 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1113 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1114 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
1115 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1116 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");
1117 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1118 out.push_str("\nVerification:\n");
1119 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1120 out.push_str("- The DriverVersion should match what you installed.\n");
1121 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.");
1122 Ok(out.trim_end().to_string())
1123}
1124
1125fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1126 #[cfg(target_os = "windows")]
1128 let edition = {
1129 Command::new("powershell")
1130 .args([
1131 "-NoProfile",
1132 "-NonInteractive",
1133 "-Command",
1134 "(Get-CimInstance Win32_OperatingSystem).Caption",
1135 ])
1136 .output()
1137 .ok()
1138 .and_then(|o| String::from_utf8(o.stdout).ok())
1139 .unwrap_or_default()
1140 .trim()
1141 .to_string()
1142 };
1143 #[cfg(not(target_os = "windows"))]
1144 let edition = String::from("(Windows edition detection not available)");
1145
1146 let is_home = edition.to_lowercase().contains("home");
1147
1148 let mut out = String::from("Host inspection: fix_plan\n\n");
1149 let _ = writeln!(out, "- Requested issue: {}", issue);
1150 out.push_str("- Fix-plan type: group_policy\n");
1151 let _ = writeln!(
1152 out,
1153 "- Windows edition detected: {}",
1154 if edition.is_empty() {
1155 "unknown".to_string()
1156 } else {
1157 edition.clone()
1158 }
1159 );
1160
1161 if is_home {
1162 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1163 out.push_str("Options on Home edition:\n");
1164 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");
1165 out.push_str(
1166 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1167 );
1168 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1169 } else {
1170 out.push_str("\nFix plan — Editing Local Group Policy:\n");
1171 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1172 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1173 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1174 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1175 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1176 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
1177 }
1178 out.push_str("\nVerification:\n");
1179 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1180 out.push_str(
1181 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1182 );
1183 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.");
1184 Ok(out.trim_end().to_string())
1185}
1186
1187fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1188 #[cfg(target_os = "windows")]
1189 let profile_state = {
1190 Command::new("powershell")
1191 .args([
1192 "-NoProfile",
1193 "-NonInteractive",
1194 "-Command",
1195 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1196 ])
1197 .output()
1198 .ok()
1199 .and_then(|o| String::from_utf8(o.stdout).ok())
1200 .unwrap_or_default()
1201 .trim()
1202 .to_string()
1203 };
1204 #[cfg(not(target_os = "windows"))]
1205 let profile_state = String::new();
1206
1207 let mut out = String::from("Host inspection: fix_plan\n\n");
1208 let _ = writeln!(out, "- Requested issue: {}", issue);
1209 out.push_str("- Fix-plan type: firewall_rule\n");
1210 if !profile_state.is_empty() {
1211 let _ = write!(out, "\nFirewall profile state:\n{}\n", profile_state);
1212 }
1213 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1214 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1215 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1216 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1217 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1218 out.push_str("\nTo ALLOW an application through the firewall:\n");
1219 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1220 out.push_str("\nTo REMOVE a rule you created:\n");
1221 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1222 out.push_str("\nTo see existing custom rules:\n");
1223 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1224 out.push_str("\nVerification:\n");
1225 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1226 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.");
1227 Ok(out.trim_end().to_string())
1228}
1229
1230fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1231 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1232 let ssh_dir = home.join(".ssh");
1233 let has_ssh_dir = ssh_dir.exists();
1234 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1235 let has_rsa = ssh_dir.join("id_rsa").exists();
1236 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1237
1238 let mut out = String::from("Host inspection: fix_plan\n\n");
1239 let _ = writeln!(out, "- Requested issue: {}", issue);
1240 out.push_str("- Fix-plan type: ssh_key\n");
1241 let _ = writeln!(out, "- ~/.ssh directory exists: {}", has_ssh_dir);
1242 let _ = writeln!(out, "- id_ed25519 key found: {}", has_ed25519);
1243 let _ = writeln!(out, "- id_rsa key found: {}", has_rsa);
1244 let _ = writeln!(out, "- authorized_keys found: {}", has_authorized_keys);
1245
1246 if has_ed25519 {
1247 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1248 }
1249
1250 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1251 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1252 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1253 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1254 out.push_str(
1255 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1256 );
1257 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1258 out.push_str("3. Start the SSH agent and add your key:\n");
1259 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1260 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1261 out.push_str(" Start-Service ssh-agent\n");
1262 out.push_str(" # Then add the key (normal PowerShell):\n");
1263 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1264 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1265 out.push_str(" # Print your public key:\n");
1266 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1267 out.push_str(" # On the target server, append it:\n");
1268 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1269 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1270 out.push_str("5. Test the connection:\n");
1271 out.push_str(" ssh user@server-address\n");
1272 out.push_str("\nFor GitHub/GitLab:\n");
1273 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1274 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1275 out.push_str("- Test: ssh -T git@github.com\n");
1276 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.");
1277 Ok(out.trim_end().to_string())
1278}
1279
1280fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1281 #[cfg(target_os = "windows")]
1282 let wsl_status = {
1283 let out = Command::new("wsl")
1284 .args(["--status"])
1285 .output()
1286 .ok()
1287 .map(|o| {
1288 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1289 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1290 format!("{}{}", stdout, stderr)
1291 })
1292 .unwrap_or_default();
1293 out.trim().to_string()
1294 };
1295 #[cfg(not(target_os = "windows"))]
1296 let wsl_status = String::new();
1297
1298 let wsl_installed =
1299 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1300
1301 let mut out = String::from("Host inspection: fix_plan\n\n");
1302 let _ = writeln!(out, "- Requested issue: {}", issue);
1303 out.push_str("- Fix-plan type: wsl_setup\n");
1304 let _ = writeln!(out, "- WSL already installed: {}", wsl_installed);
1305 if !wsl_status.is_empty() {
1306 let _ = write!(out, "- WSL status:\n{}\n", wsl_status);
1307 }
1308
1309 if wsl_installed {
1310 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1311 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1312 out.push_str(" Available distros: wsl --list --online\n");
1313 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1314 out.push_str("3. Create your Linux username and password when prompted.\n");
1315 } else {
1316 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1317 out.push_str("1. Open PowerShell as Administrator.\n");
1318 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1319 out.push_str(" wsl --install\n");
1320 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1321 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1322 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1323 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1324 out.push_str(" wsl --set-default-version 2\n");
1325 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1326 out.push_str(" wsl --install -d Debian\n");
1327 out.push_str(" wsl --list --online # to see all available distros\n");
1328 }
1329 out.push_str("\nVerification:\n");
1330 out.push_str("- Run: wsl --list --verbose\n");
1331 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1332 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.");
1333 Ok(out.trim_end().to_string())
1334}
1335
1336fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1337 let lower = issue.to_ascii_lowercase();
1338 let service_hint = if lower.contains("ssh") {
1340 Some("sshd")
1341 } else if lower.contains("mysql") {
1342 Some("MySQL80")
1343 } else if lower.contains("postgres") || lower.contains("postgresql") {
1344 Some("postgresql")
1345 } else if lower.contains("redis") {
1346 Some("Redis")
1347 } else if lower.contains("nginx") {
1348 Some("nginx")
1349 } else if lower.contains("apache") {
1350 Some("Apache2.4")
1351 } else {
1352 None
1353 };
1354
1355 #[cfg(target_os = "windows")]
1356 let service_state = if let Some(svc) = service_hint {
1357 Command::new("powershell")
1358 .args([
1359 "-NoProfile",
1360 "-NonInteractive",
1361 "-Command",
1362 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1363 ])
1364 .output()
1365 .ok()
1366 .and_then(|o| String::from_utf8(o.stdout).ok())
1367 .unwrap_or_default()
1368 .trim()
1369 .to_string()
1370 } else {
1371 String::new()
1372 };
1373 #[cfg(not(target_os = "windows"))]
1374 let service_state = String::new();
1375
1376 let mut out = String::from("Host inspection: fix_plan\n\n");
1377 let _ = writeln!(out, "- Requested issue: {}", issue);
1378 out.push_str("- Fix-plan type: service_config\n");
1379 if let Some(svc) = service_hint {
1380 let _ = writeln!(out, "- Service detected in request: {}", svc);
1381 }
1382 if !service_state.is_empty() {
1383 let _ = writeln!(out, "- Current state: {}", service_state);
1384 }
1385
1386 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1387 out.push_str("\nStart a service:\n");
1388 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1389 out.push_str("\nStop a service:\n");
1390 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1391 out.push_str("\nRestart a service:\n");
1392 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1393 out.push_str("\nEnable a service to start automatically:\n");
1394 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1395 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1396 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1397 out.push_str("\nFind the exact service name:\n");
1398 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1399 out.push_str("\nVerification:\n");
1400 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1401 if let Some(svc) = service_hint {
1402 let _ = write!(
1403 out,
1404 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1405 svc, svc
1406 );
1407 }
1408 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.");
1409 Ok(out.trim_end().to_string())
1410}
1411
1412fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1413 #[cfg(target_os = "windows")]
1414 let activation_status = {
1415 Command::new("powershell")
1416 .args([
1417 "-NoProfile",
1418 "-NonInteractive",
1419 "-Command",
1420 "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 + ')' })\" }",
1421 ])
1422 .output()
1423 .ok()
1424 .and_then(|o| String::from_utf8(o.stdout).ok())
1425 .unwrap_or_default()
1426 .trim()
1427 .to_string()
1428 };
1429 #[cfg(not(target_os = "windows"))]
1430 let activation_status = String::new();
1431
1432 let activation_lower = activation_status.to_lowercase();
1433 let is_licensed =
1434 activation_lower.contains("licensed") && !activation_lower.contains("not licensed");
1435
1436 let mut out = String::from("Host inspection: fix_plan\n\n");
1437 let _ = writeln!(out, "- Requested issue: {}", issue);
1438 out.push_str("- Fix-plan type: windows_activation\n");
1439 if !activation_status.is_empty() {
1440 let _ = write!(out, "- Current activation state:\n{}\n", activation_status);
1441 }
1442
1443 if is_licensed {
1444 out.push_str(
1445 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1446 );
1447 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1448 out.push_str(" (Forces an online activation attempt)\n");
1449 out.push_str("2. Check activation details: slmgr /dli\n");
1450 } else {
1451 out.push_str("\nFix plan — Activating Windows:\n");
1452 out.push_str("1. Check your current status first:\n");
1453 out.push_str(" slmgr /dli (basic info)\n");
1454 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1455 out.push_str("\n2. If you have a retail product key:\n");
1456 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1457 out.push_str(" slmgr /ato (activate online)\n");
1458 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1459 out.push_str(" - Go to Settings → System → Activation\n");
1460 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1461 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1462 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1463 out.push_str(" - Contact your IT department for the KMS server address\n");
1464 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1465 out.push_str(" - Activate: slmgr /ato\n");
1466 }
1467 out.push_str("\nVerification:\n");
1468 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1469 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1470 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.");
1471 Ok(out.trim_end().to_string())
1472}
1473
1474fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1475 let mut out = String::from("Host inspection: fix_plan\n\n");
1476 let _ = writeln!(out, "- Requested issue: {}", issue);
1477 out.push_str("- Fix-plan type: registry_edit\n");
1478 out.push_str(
1479 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1480 );
1481 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1482 out.push_str("\n1. Back up before you touch anything:\n");
1483 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1484 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1485 out.push_str(" # Or export the whole registry (takes a while):\n");
1486 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1487 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1488 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1489 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1490 out.push_str(
1491 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1492 );
1493 out.push_str("\n4. Create a new key:\n");
1494 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1495 out.push_str("\n5. Delete a value:\n");
1496 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1497 out.push_str("\n6. Restore from backup if something breaks:\n");
1498 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1499 out.push_str("\nCommon registry hives:\n");
1500 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1501 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1502 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1503 out.push_str("\nVerification:\n");
1504 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1505 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.");
1506 Ok(out.trim_end().to_string())
1507}
1508
1509fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1510 let mut out = String::from("Host inspection: fix_plan\n\n");
1511 let _ = writeln!(out, "- Requested issue: {}", issue);
1512 out.push_str("- Fix-plan type: scheduled_task_create\n");
1513 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1514 out.push_str("\nExample: Run a script at 9 AM every day\n");
1515 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1516 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1517 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1518 out.push_str("\nExample: Run at Windows startup\n");
1519 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1520 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1521 out.push_str("\nExample: Run at user logon\n");
1522 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1523 out.push_str(
1524 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1525 );
1526 out.push_str("\nExample: Run every 30 minutes\n");
1527 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1528 out.push_str("\nView all tasks:\n");
1529 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1530 out.push_str("\nDelete a task:\n");
1531 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1532 out.push_str("\nRun a task immediately:\n");
1533 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1534 out.push_str("\nVerification:\n");
1535 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1536 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.");
1537 Ok(out.trim_end().to_string())
1538}
1539
1540fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1541 #[cfg(target_os = "windows")]
1542 let disk_info = {
1543 Command::new("powershell")
1544 .args([
1545 "-NoProfile",
1546 "-NonInteractive",
1547 "-Command",
1548 "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\" }",
1549 ])
1550 .output()
1551 .ok()
1552 .and_then(|o| String::from_utf8(o.stdout).ok())
1553 .unwrap_or_default()
1554 .trim()
1555 .to_string()
1556 };
1557 #[cfg(not(target_os = "windows"))]
1558 let disk_info = String::new();
1559
1560 let mut out = String::from("Host inspection: fix_plan\n\n");
1561 let _ = writeln!(out, "- Requested issue: {}", issue);
1562 out.push_str("- Fix-plan type: disk_cleanup\n");
1563 if !disk_info.is_empty() {
1564 let _ = write!(out, "\nCurrent drive usage:\n{}\n", disk_info);
1565 }
1566 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1567 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1568 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1569 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1570 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1571 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1572 out.push_str(" Stop-Service wuauserv\n");
1573 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1574 out.push_str(" Start-Service wuauserv\n");
1575 out.push_str("\n3. Clear Windows Temp folder:\n");
1576 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1577 out.push_str(
1578 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1579 );
1580 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1581 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1582 out.push_str(" - npm cache: npm cache clean --force\n");
1583 out.push_str(" - pip cache: pip cache purge\n");
1584 out.push_str(
1585 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1586 );
1587 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1588 out.push_str("\n5. Check for large files:\n");
1589 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");
1590 out.push_str("\nVerification:\n");
1591 out.push_str(
1592 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1593 );
1594 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.");
1595 Ok(out.trim_end().to_string())
1596}
1597
1598fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1599 let mut out = String::from("Host inspection: fix_plan\n\n");
1600 let _ = writeln!(out, "- Requested issue: {}", issue);
1601 out.push_str("- Fix-plan type: generic\n");
1602 out.push_str(
1603 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1604 Structured lanes available:\n\
1605 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1606 - Port conflict (address already in use, what owns port)\n\
1607 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1608 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1609 - Group Policy (gpedit, local policy, administrative template)\n\
1610 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1611 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1612 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1613 - Service config (start/stop/restart/enable/disable a service)\n\
1614 - Windows activation (product key, not activated, kms)\n\
1615 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1616 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1617 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1618 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1619 );
1620 Ok(out.trim_end().to_string())
1621}
1622
1623fn inspect_resource_load() -> Result<String, String> {
1624 #[cfg(target_os = "windows")]
1625 {
1626 let output = Command::new("powershell")
1627 .args([
1628 "-NoProfile",
1629 "-Command",
1630 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1631 ])
1632 .output()
1633 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1634
1635 let text = String::from_utf8_lossy(&output.stdout);
1636 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1637
1638 let cpu_load = lines
1639 .next()
1640 .and_then(|l| l.parse::<u32>().ok())
1641 .unwrap_or(0);
1642 let mem_json: String = lines.collect();
1643 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1644
1645 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1646 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1647 let used_kb = total_kb.saturating_sub(free_kb);
1648 let mem_percent = (used_kb * 100).checked_div(total_kb).unwrap_or(0);
1649
1650 let mut out = String::from("Host inspection: resource_load\n\n");
1651 out.push_str("**System Performance Summary:**\n");
1652 let _ = writeln!(out, "- CPU Load: {}%", cpu_load);
1653 let _ = writeln!(
1654 out,
1655 "- Memory Usage: {} / {} ({}%)",
1656 human_bytes(used_kb * 1024),
1657 human_bytes(total_kb * 1024),
1658 mem_percent
1659 );
1660
1661 if cpu_load > 85 {
1662 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1663 }
1664 if mem_percent > 90 {
1665 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1666 }
1667
1668 Ok(out)
1669 }
1670 #[cfg(not(target_os = "windows"))]
1671 {
1672 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1673 }
1674}
1675
1676#[derive(Debug)]
1677enum EndpointProbe {
1678 Reachable(u16),
1679 Unreachable(String),
1680}
1681
1682async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1683 let client = match reqwest::Client::builder()
1684 .timeout(std::time::Duration::from_secs(3))
1685 .build()
1686 {
1687 Ok(client) => client,
1688 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1689 };
1690
1691 match client.get(url).send().await {
1692 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1693 Err(err) => EndpointProbe::Unreachable(err.to_string()),
1694 }
1695}
1696
1697async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1698 if configured_api.contains("11434") {
1699 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1700 let url = format!("{}/api/ps", base);
1701 let client = reqwest::Client::builder()
1702 .timeout(std::time::Duration::from_secs(3))
1703 .build()
1704 .ok()?;
1705 let response = client.get(url).send().await.ok()?;
1706 let body = response.json::<serde_json::Value>().await.ok()?;
1707 let entries = body["models"].as_array()?;
1708 for entry in entries {
1709 let name = entry["name"]
1710 .as_str()
1711 .or_else(|| entry["model"].as_str())
1712 .unwrap_or_default();
1713 let lower = name.to_ascii_lowercase();
1714 if lower.contains("embed")
1715 || lower.contains("embedding")
1716 || lower.contains("minilm")
1717 || lower.contains("bge")
1718 || lower.contains("e5")
1719 {
1720 return Some(name.to_string());
1721 }
1722 }
1723 return None;
1724 }
1725
1726 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1727 let url = format!("{}/api/v0/models", base);
1728 let client = reqwest::Client::builder()
1729 .timeout(std::time::Duration::from_secs(3))
1730 .build()
1731 .ok()?;
1732
1733 #[derive(serde::Deserialize)]
1734 struct ModelList {
1735 data: Vec<ModelEntry>,
1736 }
1737 #[derive(serde::Deserialize)]
1738 struct ModelEntry {
1739 id: String,
1740 #[serde(rename = "type", default)]
1741 model_type: String,
1742 #[serde(default)]
1743 state: String,
1744 }
1745
1746 let response = client.get(url).send().await.ok()?;
1747 let models = response.json::<ModelList>().await.ok()?;
1748 models
1749 .data
1750 .into_iter()
1751 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1752 .map(|model| model.id)
1753}
1754
1755fn first_port_in_text(text: &str) -> Option<u16> {
1756 text.split(|c: char| !c.is_ascii_digit())
1757 .find(|fragment| !fragment.is_empty())
1758 .and_then(|fragment| fragment.parse::<u16>().ok())
1759}
1760
1761fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1762 let mut processes = collect_processes()?;
1763 if let Some(filter) = name_filter.as_deref() {
1764 let lowered = filter.to_ascii_lowercase();
1765 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1766 }
1767 processes.sort_by(|a, b| {
1768 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1769 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1770 b_cpu
1771 .partial_cmp(&a_cpu)
1772 .unwrap_or(std::cmp::Ordering::Equal)
1773 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1774 .then_with(|| a.name.cmp(&b.name))
1775 .then_with(|| a.pid.cmp(&b.pid))
1776 });
1777
1778 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1779
1780 let mut out = String::from("Host inspection: processes\n\n");
1781 if let Some(filter) = name_filter.as_deref() {
1782 let _ = writeln!(out, "- Filter name: {}", filter);
1783 }
1784 let _ = writeln!(out, "- Processes found: {}", processes.len());
1785 let _ = writeln!(
1786 out,
1787 "- Total reported working set: {}",
1788 human_bytes(total_memory)
1789 );
1790
1791 if processes.is_empty() {
1792 out.push_str("\nNo running processes matched.");
1793 return Ok(out);
1794 }
1795
1796 out.push_str("\nTop processes by resource usage:\n");
1797 for entry in processes.iter().take(max_entries) {
1798 let cpu_str = entry
1799 .cpu_percent
1800 .map(|p| format!(" [CPU: {:.1}%]", p))
1801 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1802 .unwrap_or_default();
1803 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1804 format!(" [I/O R:{}/W:{}]", r, w)
1805 } else {
1806 " [I/O unknown]".to_string()
1807 };
1808 let _ = writeln!(
1809 out,
1810 "- {} (pid {}) - {}{}{}{}",
1811 entry.name,
1812 entry.pid,
1813 human_bytes(entry.memory_bytes),
1814 cpu_str,
1815 io_str,
1816 entry
1817 .detail
1818 .as_deref()
1819 .map(|detail| format!(" [{}]", detail))
1820 .unwrap_or_default()
1821 );
1822 }
1823 if processes.len() > max_entries {
1824 let _ = writeln!(
1825 out,
1826 "- ... {} more processes omitted",
1827 processes.len() - max_entries
1828 );
1829 }
1830
1831 Ok(out.trim_end().to_string())
1832}
1833
1834fn inspect_network(max_entries: usize) -> Result<String, String> {
1835 let adapters = collect_network_adapters()?;
1836 let active_count = adapters
1837 .iter()
1838 .filter(|adapter| adapter.is_active())
1839 .count();
1840 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1841
1842 let mut out = String::from("Host inspection: network\n\n");
1843 let _ = writeln!(out, "- Adapters found: {}", adapters.len());
1844 let _ = writeln!(out, "- Active adapters: {}", active_count);
1845 let _ = writeln!(
1846 out,
1847 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind",
1848 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1849 );
1850
1851 if adapters.is_empty() {
1852 out.push_str("\nNo adapter details were detected.");
1853 return Ok(out);
1854 }
1855
1856 out.push_str("\nAdapter summary:\n");
1857 for adapter in adapters.iter().take(max_entries) {
1858 let status = if adapter.is_active() {
1859 "active"
1860 } else if adapter.disconnected {
1861 "disconnected"
1862 } else {
1863 "idle"
1864 };
1865 let mut details = vec![status.to_string()];
1866 if !adapter.ipv4.is_empty() {
1867 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1868 }
1869 if !adapter.ipv6.is_empty() {
1870 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1871 }
1872 if !adapter.gateways.is_empty() {
1873 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1874 }
1875 if !adapter.dns_servers.is_empty() {
1876 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1877 }
1878 let _ = writeln!(out, "- {} - {}", adapter.name, details.join(" | "));
1879 }
1880 if adapters.len() > max_entries {
1881 let _ = writeln!(
1882 out,
1883 "- ... {} more adapters omitted",
1884 adapters.len() - max_entries
1885 );
1886 }
1887
1888 Ok(out.trim_end().to_string())
1889}
1890
1891fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1892 let mut out = String::from("Host inspection: lan_discovery\n\n");
1893
1894 #[cfg(target_os = "windows")]
1895 {
1896 let n = max_entries.clamp(5, 20);
1897 let adapters = collect_network_adapters()?;
1898 let services = collect_services().unwrap_or_default();
1899 let active_adapters: Vec<&NetworkAdapter> = adapters
1900 .iter()
1901 .filter(|adapter| adapter.is_active())
1902 .collect();
1903 let gateways: Vec<String> = active_adapters
1904 .iter()
1905 .flat_map(|adapter| adapter.gateways.clone())
1906 .collect::<HashSet<_>>()
1907 .into_iter()
1908 .collect();
1909
1910 let neighbor_script = r#"
1911$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1912 Where-Object {
1913 $_.IPAddress -notlike '127.*' -and
1914 $_.IPAddress -notlike '169.254*' -and
1915 $_.State -notin @('Unreachable','Invalid')
1916 } |
1917 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1918$neighbors | ConvertTo-Json -Compress
1919"#;
1920 let neighbor_text = Command::new("powershell")
1921 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1922 .output()
1923 .ok()
1924 .and_then(|o| String::from_utf8(o.stdout).ok())
1925 .unwrap_or_default();
1926 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1927 .into_iter()
1928 .take(n)
1929 .collect();
1930
1931 let listener_script = r#"
1932Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1933 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1934 Select-Object LocalAddress, LocalPort, OwningProcess |
1935 ForEach-Object {
1936 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1937 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1938 }
1939"#;
1940 let listener_text = Command::new("powershell")
1941 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1942 .output()
1943 .ok()
1944 .and_then(|o| String::from_utf8(o.stdout).ok())
1945 .unwrap_or_default();
1946 let listeners: Vec<(String, u16, String, String)> = listener_text
1947 .lines()
1948 .filter_map(|line| {
1949 let mut it = line.trim().splitn(4, '|');
1950 let a = it.next()?.to_string();
1951 let b = it.next()?.parse::<u16>().ok()?;
1952 let c = it.next()?.to_string();
1953 let d = it.next()?.to_string();
1954 Some((a, b, c, d))
1955 })
1956 .take(n)
1957 .collect();
1958
1959 let smb_mapping_script = r#"
1960Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1961 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1962"#;
1963 let smb_mappings: Vec<String> = Command::new("powershell")
1964 .args([
1965 "-NoProfile",
1966 "-NonInteractive",
1967 "-Command",
1968 smb_mapping_script,
1969 ])
1970 .output()
1971 .ok()
1972 .and_then(|o| String::from_utf8(o.stdout).ok())
1973 .unwrap_or_default()
1974 .lines()
1975 .take(n)
1976 .map(|line| line.trim().to_string())
1977 .filter(|line| !line.is_empty())
1978 .collect();
1979
1980 let smb_connections_script = r#"
1981Get-SmbConnection -ErrorAction SilentlyContinue |
1982 Select-Object ServerName, ShareName, NumOpens |
1983 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1984"#;
1985 let smb_connections: Vec<String> = Command::new("powershell")
1986 .args([
1987 "-NoProfile",
1988 "-NonInteractive",
1989 "-Command",
1990 smb_connections_script,
1991 ])
1992 .output()
1993 .ok()
1994 .and_then(|o| String::from_utf8(o.stdout).ok())
1995 .unwrap_or_default()
1996 .lines()
1997 .take(n)
1998 .map(|line| line.trim().to_string())
1999 .filter(|line| !line.is_empty())
2000 .collect();
2001
2002 let discovery_service_names = [
2003 "FDResPub",
2004 "fdPHost",
2005 "SSDPSRV",
2006 "upnphost",
2007 "LanmanServer",
2008 "LanmanWorkstation",
2009 "lmhosts",
2010 ];
2011 let discovery_services: Vec<&ServiceEntry> = services
2012 .iter()
2013 .filter(|entry| {
2014 discovery_service_names
2015 .iter()
2016 .any(|name| entry.name.eq_ignore_ascii_case(name))
2017 })
2018 .collect();
2019
2020 let mut findings = Vec::with_capacity(4);
2021 if active_adapters.is_empty() {
2022 findings.push(AuditFinding {
2023 finding: "No active LAN adapters were detected.".to_string(),
2024 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2025 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(),
2026 });
2027 }
2028
2029 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2030 .iter()
2031 .copied()
2032 .filter(|entry| {
2033 !entry.status.eq_ignore_ascii_case("running")
2034 && !entry.status.eq_ignore_ascii_case("active")
2035 })
2036 .collect();
2037 if !stopped_discovery_services.is_empty() {
2038 let names = {
2039 let mut s = String::new();
2040 for (i, entry) in stopped_discovery_services.iter().enumerate() {
2041 if i > 0 {
2042 s.push_str(", ");
2043 }
2044 s.push_str(&entry.name);
2045 }
2046 s
2047 };
2048 findings.push(AuditFinding {
2049 finding: format!("Discovery-related services are not running: {names}"),
2050 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2051 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(),
2052 });
2053 }
2054
2055 if listeners.is_empty() {
2056 findings.push(AuditFinding {
2057 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2058 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2059 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(),
2060 });
2061 }
2062
2063 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2064 findings.push(AuditFinding {
2065 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2066 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2067 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(),
2068 });
2069 }
2070
2071 out.push_str("=== Findings ===\n");
2072 if findings.is_empty() {
2073 out.push_str(
2074 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2075 );
2076 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2077 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");
2078 } else {
2079 for finding in &findings {
2080 let _ = writeln!(out, "- Finding: {}", finding.finding);
2081 let _ = writeln!(out, " Impact: {}", finding.impact);
2082 let _ = writeln!(out, " Fix: {}", finding.fix);
2083 }
2084 }
2085
2086 out.push_str("\n=== Active adapter and gateway summary ===\n");
2087 if active_adapters.is_empty() {
2088 out.push_str("- No active adapters detected.\n");
2089 } else {
2090 for adapter in active_adapters.iter().take(n) {
2091 let ipv4 = if adapter.ipv4.is_empty() {
2092 "no IPv4".to_string()
2093 } else {
2094 adapter.ipv4.join(", ")
2095 };
2096 let gateway = if adapter.gateways.is_empty() {
2097 "no gateway".to_string()
2098 } else {
2099 adapter.gateways.join(", ")
2100 };
2101 let _ = writeln!(
2102 out,
2103 "- {} | IPv4: {} | Gateway: {}",
2104 adapter.name, ipv4, gateway
2105 );
2106 }
2107 }
2108
2109 out.push_str("\n=== Neighborhood evidence ===\n");
2110 let _ = writeln!(out, "- Gateway count: {}", gateways.len());
2111 let _ = writeln!(out, "- Neighbor entries observed: {}", neighbors.len());
2112 if neighbors.is_empty() {
2113 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2114 } else {
2115 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2116 let _ = writeln!(
2117 out,
2118 "- {} on {} | MAC: {} | State: {}",
2119 ip, iface, mac, state
2120 );
2121 }
2122 }
2123
2124 out.push_str("\n=== Discovery services ===\n");
2125 if discovery_services.is_empty() {
2126 out.push_str("- Discovery service status unavailable.\n");
2127 } else {
2128 for entry in discovery_services.iter().take(n) {
2129 let startup = entry.startup.as_deref().unwrap_or("unknown");
2130 let _ = writeln!(
2131 out,
2132 "- {} | Status: {} | Startup: {}",
2133 entry.name, entry.status, startup
2134 );
2135 }
2136 }
2137
2138 out.push_str("\n=== Discovery listener surface ===\n");
2139 if listeners.is_empty() {
2140 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2141 } else {
2142 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2143 let label = match *port {
2144 137 => "NetBIOS Name Service",
2145 138 => "NetBIOS Datagram",
2146 1900 => "SSDP/UPnP",
2147 5353 => "mDNS",
2148 5355 => "LLMNR",
2149 _ => "Discovery",
2150 };
2151 let proc_label = if proc_name.is_empty() {
2152 "unknown".to_string()
2153 } else {
2154 proc_name.clone()
2155 };
2156 let _ = writeln!(
2157 out,
2158 "- {}:{} | {} | PID {} ({})",
2159 addr, port, label, pid, proc_label
2160 );
2161 }
2162 }
2163
2164 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2165 if smb_mappings.is_empty() && smb_connections.is_empty() {
2166 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2167 } else {
2168 if !smb_mappings.is_empty() {
2169 out.push_str("- Mapped drives:\n");
2170 for mapping in smb_mappings.iter().take(n) {
2171 let mut it = mapping.splitn(3, '|');
2172 if let (Some(a), Some(b)) = (it.next(), it.next()) {
2173 let _ = writeln!(out, " - {} -> {}", a, b);
2174 }
2175 }
2176 }
2177 if !smb_connections.is_empty() {
2178 out.push_str("- Active SMB connections:\n");
2179 for connection in smb_connections.iter().take(n) {
2180 let mut it = connection.splitn(4, '|');
2181 if let (Some(a), Some(b), Some(c)) = (it.next(), it.next(), it.next()) {
2182 let _ = writeln!(out, " - {}\\{} | Opens: {}", a, b, c);
2183 }
2184 }
2185 }
2186 }
2187 }
2188
2189 #[cfg(not(target_os = "windows"))]
2190 {
2191 let n = max_entries.clamp(5, 20);
2192 let adapters = collect_network_adapters()?;
2193 let arp_output = Command::new("ip")
2194 .args(["neigh"])
2195 .output()
2196 .ok()
2197 .and_then(|o| String::from_utf8(o.stdout).ok())
2198 .unwrap_or_default();
2199 let neighbors: Vec<&str> = arp_output
2200 .lines()
2201 .filter(|line| !line.trim().is_empty())
2202 .take(n)
2203 .collect();
2204
2205 out.push_str("=== Findings ===\n");
2206 if adapters.iter().any(|adapter| adapter.is_active()) {
2207 out.push_str(
2208 "- Finding: LAN discovery support is partially available on this platform.\n",
2209 );
2210 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2211 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2212 } else {
2213 out.push_str("- Finding: No active LAN adapters were detected.\n");
2214 out.push_str(
2215 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2216 );
2217 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2218 }
2219
2220 out.push_str("\n=== Active adapter and gateway summary ===\n");
2221 if adapters.is_empty() {
2222 out.push_str("- No adapters detected.\n");
2223 } else {
2224 for adapter in adapters.iter().take(n) {
2225 let ipv4 = if adapter.ipv4.is_empty() {
2226 "no IPv4".to_string()
2227 } else {
2228 adapter.ipv4.join(", ")
2229 };
2230 let gateway = if adapter.gateways.is_empty() {
2231 "no gateway".to_string()
2232 } else {
2233 adapter.gateways.join(", ")
2234 };
2235 let _ = write!(
2236 out,
2237 "- {} | IPv4: {} | Gateway: {}\n",
2238 adapter.name, ipv4, gateway
2239 );
2240 }
2241 }
2242
2243 out.push_str("\n=== Neighborhood evidence ===\n");
2244 if neighbors.is_empty() {
2245 out.push_str("- No neighbor entries detected.\n");
2246 } else {
2247 for line in neighbors {
2248 let _ = write!(out, "- {}\n", line.trim());
2249 }
2250 }
2251 }
2252
2253 Ok(out.trim_end().to_string())
2254}
2255
2256fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2257 let mut services = collect_services()?;
2258 if let Some(filter) = name_filter.as_deref() {
2259 let lowered = filter.to_ascii_lowercase();
2260 services.retain(|entry| {
2261 entry.name.to_ascii_lowercase().contains(&lowered)
2262 || entry
2263 .display_name
2264 .as_deref()
2265 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2266 .unwrap_or(false)
2267 });
2268 }
2269
2270 services.sort_by(|a, b| {
2271 let a_running =
2272 a.status.eq_ignore_ascii_case("running") || a.status.eq_ignore_ascii_case("active");
2273 let b_running =
2274 b.status.eq_ignore_ascii_case("running") || b.status.eq_ignore_ascii_case("active");
2275 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2276 });
2277
2278 let running = services
2279 .iter()
2280 .filter(|entry| {
2281 entry.status.eq_ignore_ascii_case("running")
2282 || entry.status.eq_ignore_ascii_case("active")
2283 })
2284 .count();
2285 let failed = services
2286 .iter()
2287 .filter(|entry| {
2288 entry.status.eq_ignore_ascii_case("failed")
2289 || entry.status.eq_ignore_ascii_case("error")
2290 || entry.status.eq_ignore_ascii_case("stopped")
2291 })
2292 .count();
2293
2294 let mut out = String::from("Host inspection: services\n\n");
2295 if let Some(filter) = name_filter.as_deref() {
2296 let _ = writeln!(out, "- Filter name: {}", filter);
2297 }
2298 let _ = writeln!(out, "- Services found: {}", services.len());
2299 let _ = writeln!(out, "- Running/active: {}", running);
2300 let _ = writeln!(out, "- Failed/stopped: {}", failed);
2301
2302 if services.is_empty() {
2303 out.push_str("\nNo services matched.");
2304 return Ok(out);
2305 }
2306
2307 let per_section = (max_entries / 2).max(5);
2309
2310 let running_services: Vec<_> = services
2311 .iter()
2312 .filter(|e| {
2313 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2314 })
2315 .collect();
2316 let stopped_services: Vec<_> = services
2317 .iter()
2318 .filter(|e| {
2319 e.status.eq_ignore_ascii_case("stopped")
2320 || e.status.eq_ignore_ascii_case("failed")
2321 || e.status.eq_ignore_ascii_case("error")
2322 })
2323 .collect();
2324
2325 let fmt_entry = |entry: &&ServiceEntry| {
2326 let startup = entry
2327 .startup
2328 .as_deref()
2329 .map(|v| format!(" | startup {}", v))
2330 .unwrap_or_default();
2331 let logon = entry
2332 .start_name
2333 .as_deref()
2334 .map(|v| format!(" | LogOn: {}", v))
2335 .unwrap_or_default();
2336 let display = entry
2337 .display_name
2338 .as_deref()
2339 .filter(|v| *v != entry.name)
2340 .map(|v| format!(" [{}]", v))
2341 .unwrap_or_default();
2342 format!(
2343 "- {}{} - {}{}{}\n",
2344 entry.name, display, entry.status, startup, logon
2345 )
2346 };
2347
2348 let _ = write!(
2349 out,
2350 "\nRunning services ({} total, showing up to {}):\n",
2351 running_services.len(),
2352 per_section
2353 );
2354 for entry in running_services.iter().take(per_section) {
2355 out.push_str(&fmt_entry(entry));
2356 }
2357 if running_services.len() > per_section {
2358 let _ = writeln!(
2359 out,
2360 "- ... {} more running services omitted",
2361 running_services.len() - per_section
2362 );
2363 }
2364
2365 let _ = write!(
2366 out,
2367 "\nStopped/failed services ({} total, showing up to {}):\n",
2368 stopped_services.len(),
2369 per_section
2370 );
2371 for entry in stopped_services.iter().take(per_section) {
2372 out.push_str(&fmt_entry(entry));
2373 }
2374 if stopped_services.len() > per_section {
2375 let _ = writeln!(
2376 out,
2377 "- ... {} more stopped services omitted",
2378 stopped_services.len() - per_section
2379 );
2380 }
2381
2382 Ok(out.trim_end().to_string())
2383}
2384
2385async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2386 inspect_directory("Disk", path, max_entries).await
2387}
2388
2389fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2390 let mut listeners = collect_listening_ports()?;
2391 if let Some(port) = port_filter {
2392 listeners.retain(|entry| entry.port == port);
2393 }
2394 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2395
2396 let mut out = String::from("Host inspection: ports\n\n");
2397 if let Some(port) = port_filter {
2398 let _ = writeln!(out, "- Filter port: {}", port);
2399 }
2400 let _ = writeln!(out, "- Listening endpoints found: {}", listeners.len());
2401
2402 if listeners.is_empty() {
2403 out.push_str("\nNo listening endpoints matched.");
2404 return Ok(out);
2405 }
2406
2407 out.push_str("\nListening endpoints:\n");
2408 for entry in listeners.iter().take(max_entries) {
2409 let pid_str = entry
2410 .pid
2411 .as_deref()
2412 .map(|p| format!(" pid {}", p))
2413 .unwrap_or_default();
2414 let name_str = entry
2415 .process_name
2416 .as_deref()
2417 .map(|n| format!(" [{}]", n))
2418 .unwrap_or_default();
2419 let _ = writeln!(
2420 out,
2421 "- {} {} ({}){}{}",
2422 entry.protocol, entry.local, entry.state, pid_str, name_str
2423 );
2424 }
2425 if listeners.len() > max_entries {
2426 let _ = writeln!(
2427 out,
2428 "- ... {} more listening endpoints omitted",
2429 listeners.len() - max_entries
2430 );
2431 }
2432
2433 Ok(out.trim_end().to_string())
2434}
2435
2436fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2437 if !path.exists() {
2438 return Err(format!("Path does not exist: {}", path.display()));
2439 }
2440 if !path.is_dir() {
2441 return Err(format!("Path is not a directory: {}", path.display()));
2442 }
2443
2444 let markers = collect_project_markers(&path);
2445 let hematite_state = collect_hematite_state(&path);
2446 let git_state = inspect_git_state(&path);
2447 let release_state = inspect_release_artifacts(&path);
2448
2449 let mut out = String::from("Host inspection: repo_doctor\n\n");
2450 let _ = writeln!(out, "- Path: {}", path.display());
2451 let _ = writeln!(out, "- Workspace mode: {}", workspace_mode_for_path(&path));
2452
2453 if markers.is_empty() {
2454 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");
2455 } else {
2456 out.push_str("- Project markers:\n");
2457 for marker in markers.iter().take(max_entries) {
2458 let _ = writeln!(out, " - {}", marker);
2459 }
2460 }
2461
2462 match git_state {
2463 Some(git) => {
2464 let _ = writeln!(out, "- Git root: {}", git.root.display());
2465 let _ = writeln!(out, "- Git branch: {}", git.branch);
2466 let _ = writeln!(out, "- Git status: {}", git.status_label());
2467 }
2468 None => out.push_str("- Git: not inside a detected work tree\n"),
2469 }
2470
2471 let _ = writeln!(
2472 out,
2473 "- Hematite docs/imports/reports: {}/{}/{}",
2474 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2475 );
2476 if hematite_state.workspace_profile {
2477 out.push_str("- Workspace profile: present\n");
2478 } else {
2479 out.push_str("- Workspace profile: absent\n");
2480 }
2481
2482 if let Some(release) = release_state {
2483 let _ = writeln!(out, "- Cargo version: {}", release.version);
2484 let _ = writeln!(
2485 out,
2486 "- Windows artifacts for current version: {}/{}/{}",
2487 bool_label(release.portable_dir),
2488 bool_label(release.portable_zip),
2489 bool_label(release.setup_exe)
2490 );
2491 }
2492
2493 Ok(out.trim_end().to_string())
2494}
2495
2496async fn inspect_known_directory(
2497 label: &str,
2498 path: Option<PathBuf>,
2499 max_entries: usize,
2500) -> Result<String, String> {
2501 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2502 inspect_directory(label, path, max_entries).await
2503}
2504
2505async fn inspect_directory(
2506 label: &str,
2507 path: PathBuf,
2508 max_entries: usize,
2509) -> Result<String, String> {
2510 let label = label.to_string();
2511 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2512 .await
2513 .map_err(|e| format!("inspect_host task failed: {e}"))?
2514}
2515
2516fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2517 if !path.exists() {
2518 return Err(format!("Path does not exist: {}", path.display()));
2519 }
2520 if !path.is_dir() {
2521 return Err(format!("Path is not a directory: {}", path.display()));
2522 }
2523
2524 let mut top_level_entries = Vec::new();
2525 for entry in fs::read_dir(path)
2526 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2527 {
2528 match entry {
2529 Ok(entry) => top_level_entries.push(entry),
2530 Err(_) => continue,
2531 }
2532 }
2533 top_level_entries.sort_by_key(|entry| entry.file_name());
2534
2535 let top_level_count = top_level_entries.len();
2536 let mut sample_names = Vec::with_capacity(max_entries.min(top_level_count));
2537 let mut largest_entries = Vec::with_capacity(top_level_count);
2538 let mut aggregate = PathAggregate::default();
2539 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2540
2541 for entry in top_level_entries {
2542 let name = entry.file_name().to_string_lossy().to_string();
2543 if sample_names.len() < max_entries {
2544 sample_names.push(name.clone());
2545 }
2546 let kind = match entry.file_type() {
2547 Ok(ft) if ft.is_dir() => "dir",
2548 Ok(ft) if ft.is_symlink() => "symlink",
2549 _ => "file",
2550 };
2551 let stats = measure_path(&entry.path(), &mut budget);
2552 aggregate.merge(&stats);
2553 largest_entries.push(LargestEntry {
2554 name,
2555 kind,
2556 bytes: stats.total_bytes,
2557 });
2558 }
2559
2560 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2561
2562 let mut out = format!("Directory inspection: {}\n\n", label);
2563 let _ = writeln!(out, "- Path: {}", path.display());
2564 let _ = writeln!(out, "- Top-level items: {}", top_level_count);
2565 let _ = writeln!(out, "- Recursive files: {}", aggregate.file_count);
2566 let _ = writeln!(out, "- Recursive directories: {}", aggregate.dir_count);
2567 let _ = writeln!(
2568 out,
2569 "- Total size: {}{}",
2570 human_bytes(aggregate.total_bytes),
2571 if aggregate.partial {
2572 " (partial scan)"
2573 } else {
2574 ""
2575 }
2576 );
2577 if aggregate.skipped_entries > 0 {
2578 let _ = writeln!(
2579 out,
2580 "- Skipped entries: {} (permissions, symlinks, or scan budget)",
2581 aggregate.skipped_entries
2582 );
2583 }
2584
2585 if !largest_entries.is_empty() {
2586 out.push_str("\nLargest top-level entries:\n");
2587 for entry in largest_entries.iter().take(max_entries) {
2588 let _ = writeln!(
2589 out,
2590 "- {} [{}] - {}",
2591 entry.name,
2592 entry.kind,
2593 human_bytes(entry.bytes)
2594 );
2595 }
2596 }
2597
2598 if !sample_names.is_empty() {
2599 out.push_str("\nSample names:\n");
2600 for name in sample_names {
2601 let _ = writeln!(out, "- {}", name);
2602 }
2603 }
2604
2605 Ok(out.trim_end().to_string())
2606}
2607
2608fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2609 let trimmed = raw.trim();
2610 if trimmed.is_empty() {
2611 return Err("Path must not be empty.".to_string());
2612 }
2613
2614 if let Some(rest) = trimmed
2615 .strip_prefix("~/")
2616 .or_else(|| trimmed.strip_prefix("~\\"))
2617 {
2618 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2619 return Ok(home.join(rest));
2620 }
2621
2622 let path = PathBuf::from(trimmed);
2623 if path.is_absolute() {
2624 Ok(path)
2625 } else {
2626 let cwd =
2627 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2628 let full_path = cwd.join(&path);
2629
2630 if !full_path.exists()
2633 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2634 {
2635 if let Some(home) = home::home_dir() {
2636 let home_path = home.join(trimmed);
2637 if home_path.exists() {
2638 return Ok(home_path);
2639 }
2640 }
2641 }
2642
2643 Ok(full_path)
2644 }
2645}
2646
2647fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2648 workspace_mode_for_path(workspace_root)
2649}
2650
2651fn workspace_mode_for_path(path: &Path) -> &'static str {
2652 if is_project_marker_path(path) {
2653 "project"
2654 } else if path.join(".hematite").join("docs").exists()
2655 || path.join(".hematite").join("imports").exists()
2656 || path.join(".hematite").join("reports").exists()
2657 {
2658 "docs-only"
2659 } else {
2660 "general directory"
2661 }
2662}
2663
2664fn is_project_marker_path(path: &Path) -> bool {
2665 [
2666 "Cargo.toml",
2667 "package.json",
2668 "pyproject.toml",
2669 "go.mod",
2670 "composer.json",
2671 "requirements.txt",
2672 "Makefile",
2673 "justfile",
2674 ]
2675 .iter()
2676 .any(|name| path.join(name).exists())
2677 || path.join(".git").exists()
2678}
2679
2680fn preferred_shell_label() -> &'static str {
2681 #[cfg(target_os = "windows")]
2682 {
2683 "PowerShell"
2684 }
2685 #[cfg(not(target_os = "windows"))]
2686 {
2687 "sh"
2688 }
2689}
2690
2691fn desktop_dir() -> Option<PathBuf> {
2692 home::home_dir().map(|home| home.join("Desktop"))
2693}
2694
2695fn downloads_dir() -> Option<PathBuf> {
2696 home::home_dir().map(|home| home.join("Downloads"))
2697}
2698
2699fn count_top_level_items(path: &Path) -> Result<usize, String> {
2700 let mut count = 0usize;
2701 for entry in
2702 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2703 {
2704 if entry.is_ok() {
2705 count += 1;
2706 }
2707 }
2708 Ok(count)
2709}
2710
2711#[derive(Default)]
2712struct PathAggregate {
2713 total_bytes: u64,
2714 file_count: u64,
2715 dir_count: u64,
2716 skipped_entries: u64,
2717 partial: bool,
2718}
2719
2720impl PathAggregate {
2721 fn merge(&mut self, other: &PathAggregate) {
2722 self.total_bytes += other.total_bytes;
2723 self.file_count += other.file_count;
2724 self.dir_count += other.dir_count;
2725 self.skipped_entries += other.skipped_entries;
2726 self.partial |= other.partial;
2727 }
2728}
2729
2730struct LargestEntry {
2731 name: String,
2732 kind: &'static str,
2733 bytes: u64,
2734}
2735
2736fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2737 if *budget == 0 {
2738 return PathAggregate {
2739 partial: true,
2740 skipped_entries: 1,
2741 ..PathAggregate::default()
2742 };
2743 }
2744 *budget -= 1;
2745
2746 let metadata = match fs::symlink_metadata(path) {
2747 Ok(metadata) => metadata,
2748 Err(_) => {
2749 return PathAggregate {
2750 skipped_entries: 1,
2751 ..PathAggregate::default()
2752 }
2753 }
2754 };
2755
2756 let file_type = metadata.file_type();
2757 if file_type.is_symlink() {
2758 return PathAggregate {
2759 skipped_entries: 1,
2760 ..PathAggregate::default()
2761 };
2762 }
2763
2764 if metadata.is_file() {
2765 return PathAggregate {
2766 total_bytes: metadata.len(),
2767 file_count: 1,
2768 ..PathAggregate::default()
2769 };
2770 }
2771
2772 if !metadata.is_dir() {
2773 return PathAggregate::default();
2774 }
2775
2776 let mut aggregate = PathAggregate {
2777 dir_count: 1,
2778 ..PathAggregate::default()
2779 };
2780
2781 let read_dir = match fs::read_dir(path) {
2782 Ok(read_dir) => read_dir,
2783 Err(_) => {
2784 aggregate.skipped_entries += 1;
2785 return aggregate;
2786 }
2787 };
2788
2789 for child in read_dir {
2790 match child {
2791 Ok(child) => {
2792 let child_stats = measure_path(&child.path(), budget);
2793 aggregate.merge(&child_stats);
2794 }
2795 Err(_) => aggregate.skipped_entries += 1,
2796 }
2797 }
2798
2799 aggregate
2800}
2801
2802struct PathAnalysis {
2803 total_entries: usize,
2804 unique_entries: usize,
2805 entries: Vec<String>,
2806 duplicate_entries: Vec<String>,
2807 missing_entries: Vec<String>,
2808}
2809
2810fn analyze_path_env() -> PathAnalysis {
2811 let mut entries = Vec::new();
2812 let mut duplicate_entries = Vec::new();
2813 let mut missing_entries = Vec::new();
2814 let mut seen = HashSet::new();
2815
2816 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2817 for path in std::env::split_paths(&raw_path) {
2818 let display = path.display().to_string();
2819 if display.trim().is_empty() {
2820 continue;
2821 }
2822
2823 let normalized = normalize_path_entry(&display);
2824 if !seen.insert(normalized) {
2825 duplicate_entries.push(display.clone());
2826 }
2827 if !path.exists() {
2828 missing_entries.push(display.clone());
2829 }
2830 entries.push(display);
2831 }
2832
2833 let total_entries = entries.len();
2834 let unique_entries = seen.len();
2835
2836 PathAnalysis {
2837 total_entries,
2838 unique_entries,
2839 entries,
2840 duplicate_entries,
2841 missing_entries,
2842 }
2843}
2844
2845fn normalize_path_entry(value: &str) -> String {
2846 #[cfg(target_os = "windows")]
2847 {
2848 value
2849 .replace('/', "\\")
2850 .trim_end_matches(['\\', '/'])
2851 .to_ascii_lowercase()
2852 }
2853 #[cfg(not(target_os = "windows"))]
2854 {
2855 value.trim_end_matches('/').to_string()
2856 }
2857}
2858
2859struct ToolchainReport {
2860 found: Vec<(String, String)>,
2861 missing: Vec<String>,
2862}
2863
2864struct PackageManagerReport {
2865 found: Vec<(String, String)>,
2866}
2867
2868#[derive(Debug, Clone)]
2869struct ProcessEntry {
2870 name: String,
2871 pid: u32,
2872 memory_bytes: u64,
2873 cpu_seconds: Option<f64>,
2874 cpu_percent: Option<f64>,
2875 read_ops: Option<u64>,
2876 write_ops: Option<u64>,
2877 detail: Option<String>,
2878}
2879
2880#[derive(Debug, Clone)]
2881struct ServiceEntry {
2882 name: String,
2883 status: String,
2884 startup: Option<String>,
2885 display_name: Option<String>,
2886 start_name: Option<String>,
2887}
2888
2889#[derive(Debug, Clone, Default)]
2890struct NetworkAdapter {
2891 name: String,
2892 ipv4: Vec<String>,
2893 ipv6: Vec<String>,
2894 gateways: Vec<String>,
2895 dns_servers: Vec<String>,
2896 disconnected: bool,
2897}
2898
2899impl NetworkAdapter {
2900 fn is_active(&self) -> bool {
2901 !self.disconnected
2902 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2903 }
2904}
2905
2906#[derive(Debug, Clone, Copy, Default)]
2907struct ListenerExposureSummary {
2908 loopback_only: usize,
2909 wildcard_public: usize,
2910 specific_bind: usize,
2911}
2912
2913#[derive(Debug, Clone)]
2914struct ListeningPort {
2915 protocol: String,
2916 local: String,
2917 port: u16,
2918 state: String,
2919 pid: Option<String>,
2920 process_name: Option<String>,
2921}
2922
2923fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2924 #[cfg(target_os = "windows")]
2925 {
2926 collect_windows_listening_ports()
2927 }
2928 #[cfg(not(target_os = "windows"))]
2929 {
2930 collect_unix_listening_ports()
2931 }
2932}
2933
2934fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2935 #[cfg(target_os = "windows")]
2936 {
2937 collect_windows_network_adapters()
2938 }
2939 #[cfg(not(target_os = "windows"))]
2940 {
2941 collect_unix_network_adapters()
2942 }
2943}
2944
2945fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2946 #[cfg(target_os = "windows")]
2947 {
2948 collect_windows_services()
2949 }
2950 #[cfg(not(target_os = "windows"))]
2951 {
2952 collect_unix_services()
2953 }
2954}
2955
2956#[cfg(target_os = "windows")]
2957fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2958 let output = Command::new("netstat")
2959 .args(["-ano", "-p", "tcp"])
2960 .output()
2961 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2962 if !output.status.success() {
2963 return Err("netstat returned a non-success status.".to_string());
2964 }
2965
2966 let text = String::from_utf8_lossy(&output.stdout);
2967 let mut listeners = Vec::new();
2968 for line in text.lines() {
2969 let trimmed = line.trim();
2970 if !trimmed.starts_with("TCP") {
2971 continue;
2972 }
2973 let mut it = trimmed.split_whitespace();
2974 if let (Some(proto), Some(local), Some(_), Some(state), Some(pid)) =
2975 (it.next(), it.next(), it.next(), it.next(), it.next())
2976 {
2977 if state != "LISTENING" {
2978 continue;
2979 }
2980 let Some(port) = extract_port_from_socket(local) else {
2981 continue;
2982 };
2983 listeners.push(ListeningPort {
2984 protocol: proto.to_string(),
2985 local: local.to_string(),
2986 port,
2987 state: state.to_string(),
2988 pid: Some(pid.to_string()),
2989 process_name: None,
2990 });
2991 }
2992 }
2993
2994 let unique_pids: Vec<String> = listeners
2997 .iter()
2998 .filter_map(|l| l.pid.clone())
2999 .collect::<HashSet<_>>()
3000 .into_iter()
3001 .collect();
3002
3003 if !unique_pids.is_empty() {
3004 let pid_list = unique_pids.join(",");
3005 let ps_cmd = format!(
3006 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
3007 pid_list
3008 );
3009 if let Ok(ps_out) = Command::new("powershell")
3010 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
3011 .output()
3012 {
3013 let mut pid_map = std::collections::HashMap::<String, String>::new();
3014 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
3015 for line in ps_text.lines() {
3016 let mut it = line.split_whitespace();
3017 if let (Some(a), Some(b)) = (it.next(), it.next()) {
3018 pid_map.insert(a.to_string(), b.to_string());
3019 }
3020 }
3021 for listener in &mut listeners {
3022 if let Some(pid) = &listener.pid {
3023 listener.process_name = pid_map.get(pid).cloned();
3024 }
3025 }
3026 }
3027 }
3028
3029 Ok(listeners)
3030}
3031
3032#[cfg(not(target_os = "windows"))]
3033fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3034 let output = Command::new("ss")
3035 .args(["-ltn"])
3036 .output()
3037 .map_err(|e| format!("Failed to run ss: {e}"))?;
3038 if !output.status.success() {
3039 return Err("ss returned a non-success status.".to_string());
3040 }
3041
3042 let text = String::from_utf8_lossy(&output.stdout);
3043 let mut listeners = Vec::new();
3044 for line in text.lines().skip(1) {
3045 let mut it = line.split_whitespace();
3046 if let (Some(state), Some(_), Some(_), Some(local)) =
3047 (it.next(), it.next(), it.next(), it.next())
3048 {
3049 let Some(port) = extract_port_from_socket(local) else {
3050 continue;
3051 };
3052 listeners.push(ListeningPort {
3053 protocol: "tcp".to_string(),
3054 local: local.to_string(),
3055 port,
3056 state: state.to_string(),
3057 pid: None,
3058 process_name: None,
3059 });
3060 }
3061 }
3062
3063 Ok(listeners)
3064}
3065
3066fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3067 #[cfg(target_os = "windows")]
3068 {
3069 collect_windows_processes()
3070 }
3071 #[cfg(not(target_os = "windows"))]
3072 {
3073 collect_unix_processes()
3074 }
3075}
3076
3077#[cfg(target_os = "windows")]
3078fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3079 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3080 let output = Command::new("powershell")
3081 .args(["-NoProfile", "-Command", command])
3082 .output()
3083 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3084 if !output.status.success() {
3085 return Err("PowerShell service inspection returned a non-success status.".to_string());
3086 }
3087
3088 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3089}
3090
3091#[cfg(not(target_os = "windows"))]
3092fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3093 let status_output = Command::new("systemctl")
3094 .args([
3095 "list-units",
3096 "--type=service",
3097 "--all",
3098 "--no-pager",
3099 "--no-legend",
3100 "--plain",
3101 ])
3102 .output()
3103 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3104 if !status_output.status.success() {
3105 return Err("systemctl list-units returned a non-success status.".to_string());
3106 }
3107
3108 let startup_output = Command::new("systemctl")
3109 .args([
3110 "list-unit-files",
3111 "--type=service",
3112 "--no-legend",
3113 "--no-pager",
3114 "--plain",
3115 ])
3116 .output()
3117 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3118 if !startup_output.status.success() {
3119 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3120 }
3121
3122 Ok(parse_unix_services(
3123 &String::from_utf8_lossy(&status_output.stdout),
3124 &String::from_utf8_lossy(&startup_output.stdout),
3125 ))
3126}
3127
3128#[cfg(target_os = "windows")]
3129fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3130 let output = Command::new("ipconfig")
3131 .args(["/all"])
3132 .output()
3133 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3134 if !output.status.success() {
3135 return Err("ipconfig returned a non-success status.".to_string());
3136 }
3137
3138 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3139 &output.stdout,
3140 )))
3141}
3142
3143#[cfg(not(target_os = "windows"))]
3144fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3145 let addr_output = Command::new("ip")
3146 .args(["-o", "addr", "show", "up"])
3147 .output()
3148 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3149 if !addr_output.status.success() {
3150 return Err("ip addr returned a non-success status.".to_string());
3151 }
3152
3153 let route_output = Command::new("ip")
3154 .args(["route", "show", "default"])
3155 .output()
3156 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3157 if !route_output.status.success() {
3158 return Err("ip route returned a non-success status.".to_string());
3159 }
3160
3161 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3162 apply_unix_default_routes(
3163 &mut adapters,
3164 &String::from_utf8_lossy(&route_output.stdout),
3165 );
3166 apply_unix_dns_servers(&mut adapters);
3167 Ok(adapters)
3168}
3169
3170#[cfg(target_os = "windows")]
3171fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3172 let script = r#"
3174 $s1 = Get-Process | Select-Object Id, CPU
3175 Start-Sleep -Milliseconds 250
3176 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3177 $s2 | ForEach-Object {
3178 $p2 = $_
3179 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3180 $pct = 0.0
3181 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3182 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3183 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3184 # Standard Task Manager style is (delta / interval) * 100.
3185 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3186 }
3187 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3188 }
3189 "#;
3190
3191 let output = Command::new("powershell")
3192 .args(["-NoProfile", "-Command", script])
3193 .output()
3194 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3195
3196 let text = String::from_utf8_lossy(&output.stdout);
3197 let mut out = Vec::new();
3198 let mut parts: Vec<&str> = Vec::with_capacity(8);
3199 for line in text.lines() {
3200 parts.clear();
3201 parts.extend(line.trim().split('|'));
3202 if parts.len() < 5 {
3203 continue;
3204 }
3205 let mut entry = ProcessEntry {
3206 name: "unknown".to_string(),
3207 pid: 0,
3208 memory_bytes: 0,
3209 cpu_seconds: None,
3210 cpu_percent: None,
3211 read_ops: None,
3212 write_ops: None,
3213 detail: None,
3214 };
3215 for p in &parts {
3216 if let Some((k, v)) = p.split_once(':') {
3217 match k {
3218 "PID" => entry.pid = v.parse().unwrap_or(0),
3219 "NAME" => entry.name = v.to_string(),
3220 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3221 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3222 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3223 "READ" => entry.read_ops = v.parse().ok(),
3224 "WRITE" => entry.write_ops = v.parse().ok(),
3225 _ => {}
3226 }
3227 }
3228 }
3229 out.push(entry);
3230 }
3231 Ok(out)
3232}
3233
3234#[cfg(not(target_os = "windows"))]
3235fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3236 let output = Command::new("ps")
3237 .args(["-eo", "pid=,rss=,comm="])
3238 .output()
3239 .map_err(|e| format!("Failed to run ps: {e}"))?;
3240 if !output.status.success() {
3241 return Err("ps returned a non-success status.".to_string());
3242 }
3243
3244 let text = String::from_utf8_lossy(&output.stdout);
3245 let mut processes = Vec::new();
3246 for line in text.lines() {
3247 let mut it = line.split_whitespace();
3248 let Some(pid_str) = it.next() else {
3249 continue;
3250 };
3251 let Some(rss_str) = it.next() else {
3252 continue;
3253 };
3254 let Some(first_word) = it.next() else {
3255 continue;
3256 };
3257 let Ok(pid) = pid_str.parse::<u32>() else {
3258 continue;
3259 };
3260 let Ok(rss_kib) = rss_str.parse::<u64>() else {
3261 continue;
3262 };
3263 let mut name = first_word.to_string();
3264 for w in it {
3265 name.push(' ');
3266 name.push_str(w);
3267 }
3268 processes.push(ProcessEntry {
3269 name,
3270 pid,
3271 memory_bytes: rss_kib * 1024,
3272 cpu_seconds: None,
3273 cpu_percent: None,
3274 read_ops: None,
3275 write_ops: None,
3276 detail: None,
3277 });
3278 }
3279
3280 Ok(processes)
3281}
3282
3283fn extract_port_from_socket(value: &str) -> Option<u16> {
3284 let cleaned = value.trim().trim_matches(['[', ']']);
3285 let port_str = cleaned.rsplit(':').next()?;
3286 port_str.parse::<u16>().ok()
3287}
3288
3289fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3290 let mut summary = ListenerExposureSummary::default();
3291 for entry in listeners {
3292 let local = entry.local.to_ascii_lowercase();
3293 if is_loopback_listener(&local) {
3294 summary.loopback_only += 1;
3295 } else if is_wildcard_listener(&local) {
3296 summary.wildcard_public += 1;
3297 } else {
3298 summary.specific_bind += 1;
3299 }
3300 }
3301 summary
3302}
3303
3304fn is_loopback_listener(local: &str) -> bool {
3305 local.starts_with("127.")
3306 || local.starts_with("[::1]")
3307 || local.starts_with("::1")
3308 || local.starts_with("localhost:")
3309}
3310
3311fn is_wildcard_listener(local: &str) -> bool {
3312 local.starts_with("0.0.0.0:")
3313 || local.starts_with("[::]:")
3314 || local.starts_with(":::")
3315 || local == "*:*"
3316}
3317
3318struct GitState {
3319 root: PathBuf,
3320 branch: String,
3321 dirty_entries: usize,
3322}
3323
3324impl GitState {
3325 fn status_label(&self) -> String {
3326 if self.dirty_entries == 0 {
3327 "clean".to_string()
3328 } else {
3329 format!("dirty ({} changed path(s))", self.dirty_entries)
3330 }
3331 }
3332}
3333
3334fn inspect_git_state(path: &Path) -> Option<GitState> {
3335 let root = capture_first_line(
3336 "git",
3337 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3338 )?;
3339 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3340 .unwrap_or_else(|| "detached".to_string());
3341 let output = Command::new("git")
3342 .args(["-C", path.to_str()?, "status", "--short"])
3343 .output()
3344 .ok()?;
3345 if !output.status.success() {
3346 return None;
3347 }
3348 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3349 Some(GitState {
3350 root: PathBuf::from(root),
3351 branch,
3352 dirty_entries,
3353 })
3354}
3355
3356struct HematiteState {
3357 docs_count: usize,
3358 import_count: usize,
3359 report_count: usize,
3360 workspace_profile: bool,
3361}
3362
3363fn collect_hematite_state(path: &Path) -> HematiteState {
3364 let root = path.join(".hematite");
3365 HematiteState {
3366 docs_count: count_entries_if_exists(&root.join("docs")),
3367 import_count: count_entries_if_exists(&root.join("imports")),
3368 report_count: count_entries_if_exists(&root.join("reports")),
3369 workspace_profile: root.join("workspace_profile.json").exists(),
3370 }
3371}
3372
3373fn count_entries_if_exists(path: &Path) -> usize {
3374 if !path.exists() || !path.is_dir() {
3375 return 0;
3376 }
3377 fs::read_dir(path)
3378 .ok()
3379 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3380 .unwrap_or(0)
3381}
3382
3383fn collect_project_markers(path: &Path) -> Vec<String> {
3384 [
3385 "Cargo.toml",
3386 "package.json",
3387 "pyproject.toml",
3388 "go.mod",
3389 "justfile",
3390 "Makefile",
3391 ".git",
3392 ]
3393 .iter()
3394 .filter(|&name| path.join(name).exists())
3395 .map(|name| (*name).to_string())
3396 .collect()
3397}
3398
3399struct ReleaseArtifactState {
3400 version: String,
3401 portable_dir: bool,
3402 portable_zip: bool,
3403 setup_exe: bool,
3404}
3405
3406fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3407 let cargo_toml = path.join("Cargo.toml");
3408 if !cargo_toml.exists() {
3409 return None;
3410 }
3411 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3412 let version = [regex_line_capture(
3413 &cargo_text,
3414 r#"(?m)^version\s*=\s*"([^"]+)""#,
3415 )?]
3416 .concat();
3417 let dist_windows = path.join("dist").join("windows");
3418 let prefix = format!("Hematite-{}", version);
3419 Some(ReleaseArtifactState {
3420 version,
3421 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3422 portable_zip: dist_windows
3423 .join(format!("{}-portable.zip", prefix))
3424 .exists(),
3425 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3426 })
3427}
3428
3429fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3430 let regex = regex::Regex::new(pattern).ok()?;
3431 let captures = regex.captures(text)?;
3432 captures.get(1).map(|m| m.as_str().to_string())
3433}
3434
3435fn bool_label(value: bool) -> &'static str {
3436 if value {
3437 "yes"
3438 } else {
3439 "no"
3440 }
3441}
3442
3443fn collect_toolchains() -> ToolchainReport {
3444 let config = crate::agent::config::load_config();
3445 let mut python_probes = Vec::with_capacity(5);
3446 if let Some(ref path) = config.python_path {
3447 python_probes.push(CommandProbe::new(path, &["--version"]));
3448 };
3449
3450 python_probes.extend([
3451 CommandProbe::new("python3", &["--version"]),
3452 CommandProbe::new("python", &["--version"]),
3453 CommandProbe::new("py", &["-3", "--version"]),
3454 CommandProbe::new("py", &["--version"]),
3455 ]);
3456
3457 let checks = [
3458 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3459 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3460 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3461 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3462 ToolCheck::new(
3463 "npm",
3464 &[
3465 CommandProbe::new("npm", &["--version"]),
3466 CommandProbe::new("npm.cmd", &["--version"]),
3467 ],
3468 ),
3469 ToolCheck::new(
3470 "pnpm",
3471 &[
3472 CommandProbe::new("pnpm", &["--version"]),
3473 CommandProbe::new("pnpm.cmd", &["--version"]),
3474 ],
3475 ),
3476 ToolCheck::new("python", &python_probes),
3477 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3478 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3479 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3480 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3481 ];
3482
3483 let mut found = Vec::with_capacity(checks.len());
3484 let mut missing = Vec::with_capacity(checks.len());
3485
3486 for check in checks {
3487 match check.detect() {
3488 Some(version) => found.push((check.label.to_string(), version)),
3489 None => missing.push(check.label.to_string()),
3490 }
3491 }
3492
3493 ToolchainReport { found, missing }
3494}
3495
3496fn collect_package_managers() -> PackageManagerReport {
3497 let config = crate::agent::config::load_config();
3498 let mut pip_probes = Vec::with_capacity(6);
3499 if let Some(ref path) = config.python_path {
3500 pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3501 }
3502 pip_probes.extend([
3503 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3504 CommandProbe::new("python", &["-m", "pip", "--version"]),
3505 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3506 CommandProbe::new("py", &["-m", "pip", "--version"]),
3507 CommandProbe::new("pip", &["--version"]),
3508 ]);
3509
3510 let checks = [
3511 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3512 ToolCheck::new(
3513 "npm",
3514 &[
3515 CommandProbe::new("npm", &["--version"]),
3516 CommandProbe::new("npm.cmd", &["--version"]),
3517 ],
3518 ),
3519 ToolCheck::new(
3520 "pnpm",
3521 &[
3522 CommandProbe::new("pnpm", &["--version"]),
3523 CommandProbe::new("pnpm.cmd", &["--version"]),
3524 ],
3525 ),
3526 ToolCheck::new("pip", &pip_probes),
3527 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3528 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3529 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3530 ToolCheck::new(
3531 "choco",
3532 &[
3533 CommandProbe::new("choco", &["--version"]),
3534 CommandProbe::new("choco.exe", &["--version"]),
3535 ],
3536 ),
3537 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3538 ];
3539
3540 let mut found = Vec::with_capacity(checks.len());
3541 for check in checks {
3542 if let Some(version) = check.detect() {
3543 found.push((check.label.to_string(), version))
3544 }
3545 }
3546
3547 PackageManagerReport { found }
3548}
3549
3550#[derive(Clone)]
3551struct ToolCheck {
3552 label: &'static str,
3553 probes: Vec<CommandProbe>,
3554}
3555
3556impl ToolCheck {
3557 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3558 Self {
3559 label,
3560 probes: probes.to_vec(),
3561 }
3562 }
3563
3564 fn detect(&self) -> Option<String> {
3565 for probe in &self.probes {
3566 if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3567 return Some(output);
3568 }
3569 }
3570 None
3571 }
3572}
3573
3574#[derive(Clone)]
3575struct CommandProbe {
3576 program: String,
3577 args: Vec<String>,
3578}
3579
3580impl CommandProbe {
3581 fn new(program: &str, args: &[&str]) -> Self {
3582 Self {
3583 program: program.to_string(),
3584 args: args.iter().map(|s| s.to_string()).collect(),
3585 }
3586 }
3587}
3588
3589fn build_env_doctor_findings(
3590 toolchains: &ToolchainReport,
3591 package_managers: &PackageManagerReport,
3592 path_stats: &PathAnalysis,
3593) -> Vec<String> {
3594 let found_tools = toolchains
3595 .found
3596 .iter()
3597 .map(|(label, _)| label.as_str())
3598 .collect::<HashSet<_>>();
3599 let found_managers = package_managers
3600 .found
3601 .iter()
3602 .map(|(label, _)| label.as_str())
3603 .collect::<HashSet<_>>();
3604
3605 let mut findings = Vec::with_capacity(4);
3606
3607 if !path_stats.duplicate_entries.is_empty() {
3608 findings.push(format!(
3609 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3610 path_stats.duplicate_entries.len()
3611 ));
3612 }
3613 if !path_stats.missing_entries.is_empty() {
3614 findings.push(format!(
3615 "PATH contains {} entries that do not exist on disk.",
3616 path_stats.missing_entries.len()
3617 ));
3618 }
3619 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3620 findings.push(
3621 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3622 .to_string(),
3623 );
3624 }
3625 if found_tools.contains("node")
3626 && !found_managers.contains("npm")
3627 && !found_managers.contains("pnpm")
3628 {
3629 findings.push(
3630 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3631 .to_string(),
3632 );
3633 }
3634 if found_tools.contains("python")
3635 && !found_managers.contains("pip")
3636 && !found_managers.contains("uv")
3637 && !found_managers.contains("pipx")
3638 {
3639 findings.push(
3640 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3641 .to_string(),
3642 );
3643 }
3644 let windows_manager_count = ["winget", "choco", "scoop"]
3645 .iter()
3646 .filter(|label| found_managers.contains(**label))
3647 .count();
3648 if windows_manager_count > 1 {
3649 findings.push(
3650 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3651 .to_string(),
3652 );
3653 }
3654 if findings.is_empty() && !found_managers.is_empty() {
3655 findings.push(
3656 "Core package-manager coverage looks healthy for a normal developer workstation."
3657 .to_string(),
3658 );
3659 }
3660
3661 findings
3662}
3663
3664fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3665 let output = std::process::Command::new(program)
3666 .args(args.iter().map(|s| s.as_ref()))
3667 .output()
3668 .ok()?;
3669 if !output.status.success() {
3670 return None;
3671 }
3672
3673 let stdout = if output.stdout.is_empty() {
3674 String::from_utf8_lossy(&output.stderr).into_owned()
3675 } else {
3676 String::from_utf8_lossy(&output.stdout).into_owned()
3677 };
3678
3679 stdout
3680 .lines()
3681 .map(str::trim)
3682 .find(|line| !line.is_empty())
3683 .map(|line| line.to_string())
3684}
3685
3686fn human_bytes(bytes: u64) -> String {
3687 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3688 let mut value = bytes as f64;
3689 let mut unit_index = 0usize;
3690
3691 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3692 value /= 1024.0;
3693 unit_index += 1;
3694 }
3695
3696 if unit_index == 0 {
3697 format!("{} {}", bytes, UNITS[unit_index])
3698 } else {
3699 format!("{value:.1} {}", UNITS[unit_index])
3700 }
3701}
3702
3703#[cfg(target_os = "windows")]
3704fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3705 let mut adapters = Vec::new();
3706 let mut current: Option<NetworkAdapter> = None;
3707 let mut pending_dns = false;
3708
3709 for raw_line in text.lines() {
3710 let line = raw_line.trim_end();
3711 let trimmed = line.trim();
3712 if trimmed.is_empty() {
3713 pending_dns = false;
3714 continue;
3715 }
3716
3717 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3718 if let Some(adapter) = current.take() {
3719 adapters.push(adapter);
3720 }
3721 current = Some(NetworkAdapter {
3722 name: trimmed.trim_end_matches(':').to_string(),
3723 ..NetworkAdapter::default()
3724 });
3725 pending_dns = false;
3726 continue;
3727 }
3728
3729 let Some(adapter) = current.as_mut() else {
3730 continue;
3731 };
3732
3733 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3734 adapter.disconnected = true;
3735 }
3736
3737 if let Some(value) = value_after_colon(trimmed) {
3738 let normalized = normalize_ipconfig_value(value);
3739 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3740 adapter.ipv4.push(normalized);
3741 pending_dns = false;
3742 } else if trimmed.starts_with("IPv6 Address")
3743 || trimmed.starts_with("Temporary IPv6 Address")
3744 || trimmed.starts_with("Link-local IPv6 Address")
3745 {
3746 if !normalized.is_empty() {
3747 adapter.ipv6.push(normalized);
3748 }
3749 pending_dns = false;
3750 } else if trimmed.starts_with("Default Gateway") {
3751 if !normalized.is_empty() {
3752 adapter.gateways.push(normalized);
3753 }
3754 pending_dns = false;
3755 } else if trimmed.starts_with("DNS Servers") {
3756 if !normalized.is_empty() {
3757 adapter.dns_servers.push(normalized);
3758 }
3759 pending_dns = true;
3760 } else {
3761 pending_dns = false;
3762 }
3763 } else if pending_dns {
3764 let normalized = normalize_ipconfig_value(trimmed);
3765 if !normalized.is_empty() {
3766 adapter.dns_servers.push(normalized);
3767 }
3768 }
3769 }
3770
3771 if let Some(adapter) = current.take() {
3772 adapters.push(adapter);
3773 }
3774
3775 for adapter in &mut adapters {
3776 dedup_vec(&mut adapter.ipv4);
3777 dedup_vec(&mut adapter.ipv6);
3778 dedup_vec(&mut adapter.gateways);
3779 dedup_vec(&mut adapter.dns_servers);
3780 }
3781
3782 adapters
3783}
3784
3785#[cfg(not(target_os = "windows"))]
3786fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3787 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3788
3789 for line in text.lines() {
3790 let mut it = line.split_whitespace();
3791 let (Some(_), Some(iface), Some(family), Some(addr_full)) =
3792 (it.next(), it.next(), it.next(), it.next())
3793 else {
3794 continue;
3795 };
3796 let name = iface.trim_end_matches(':').to_string();
3797 let addr = addr_full.split('/').next().unwrap_or("").to_string();
3798 let entry = adapters
3799 .entry(name.clone())
3800 .or_insert_with(|| NetworkAdapter {
3801 name,
3802 ..NetworkAdapter::default()
3803 });
3804 match family {
3805 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3806 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3807 _ => {}
3808 }
3809 }
3810
3811 adapters.into_values().collect()
3812}
3813
3814#[cfg(not(target_os = "windows"))]
3815fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3816 for line in text.lines() {
3817 let cols: Vec<&str> = line.split_whitespace().collect();
3818 if cols.len() < 5 {
3819 continue;
3820 }
3821 let gateway = cols
3822 .windows(2)
3823 .find(|pair| pair[0] == "via")
3824 .map(|pair| pair[1].to_string());
3825 let dev = cols
3826 .windows(2)
3827 .find(|pair| pair[0] == "dev")
3828 .map(|pair| pair[1]);
3829 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3830 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3831 adapter.gateways.push(gateway);
3832 }
3833 }
3834 }
3835
3836 for adapter in adapters {
3837 dedup_vec(&mut adapter.gateways);
3838 }
3839}
3840
3841#[cfg(not(target_os = "windows"))]
3842fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3843 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3844 return;
3845 };
3846 let mut dns_servers = text
3847 .lines()
3848 .filter_map(|line| line.strip_prefix("nameserver "))
3849 .map(str::trim)
3850 .filter(|value| !value.is_empty())
3851 .map(|value| value.to_string())
3852 .collect::<Vec<_>>();
3853 dedup_vec(&mut dns_servers);
3854 if dns_servers.is_empty() {
3855 return;
3856 }
3857 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3858 adapter.dns_servers = dns_servers.clone();
3859 }
3860}
3861
3862#[cfg(target_os = "windows")]
3863fn value_after_colon(line: &str) -> Option<&str> {
3864 line.split_once(':').map(|(_, value)| value.trim())
3865}
3866
3867#[cfg(target_os = "windows")]
3868fn normalize_ipconfig_value(value: &str) -> String {
3869 value
3870 .trim()
3871 .trim_end_matches("(Preferred)")
3872 .trim_end_matches("(Deprecated)")
3873 .trim()
3874 .trim_matches(['(', ')'])
3875 .trim()
3876 .to_string()
3877}
3878
3879#[cfg(target_os = "windows")]
3880fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3881 let mac_upper = mac.to_ascii_uppercase();
3882 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3883 return true;
3884 }
3885
3886 ip == "255.255.255.255"
3887 || ip.starts_with("224.")
3888 || ip.starts_with("225.")
3889 || ip.starts_with("226.")
3890 || ip.starts_with("227.")
3891 || ip.starts_with("228.")
3892 || ip.starts_with("229.")
3893 || ip.starts_with("230.")
3894 || ip.starts_with("231.")
3895 || ip.starts_with("232.")
3896 || ip.starts_with("233.")
3897 || ip.starts_with("234.")
3898 || ip.starts_with("235.")
3899 || ip.starts_with("236.")
3900 || ip.starts_with("237.")
3901 || ip.starts_with("238.")
3902 || ip.starts_with("239.")
3903}
3904
3905fn dedup_vec(values: &mut Vec<String>) {
3906 let mut seen = HashSet::new();
3907 values.retain(|value| seen.insert(value.clone()));
3908}
3909
3910#[cfg(target_os = "windows")]
3911fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3912 let trimmed = text.trim();
3913 if trimmed.is_empty() {
3914 return Vec::new();
3915 }
3916
3917 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3918 return Vec::new();
3919 };
3920 let entries = match value {
3921 Value::Array(items) => items,
3922 other => vec![other],
3923 };
3924
3925 let mut neighbors = Vec::with_capacity(entries.len());
3926 for entry in entries {
3927 let ip = entry
3928 .get("IPAddress")
3929 .and_then(|v| v.as_str())
3930 .unwrap_or("")
3931 .to_string();
3932 if ip.is_empty() {
3933 continue;
3934 }
3935 let mac = entry
3936 .get("LinkLayerAddress")
3937 .and_then(|v| v.as_str())
3938 .unwrap_or("unknown")
3939 .to_string();
3940 let state = entry
3941 .get("State")
3942 .and_then(|v| v.as_str())
3943 .unwrap_or("unknown")
3944 .to_string();
3945 let iface = entry
3946 .get("InterfaceAlias")
3947 .and_then(|v| v.as_str())
3948 .unwrap_or("unknown")
3949 .to_string();
3950 if is_noise_lan_neighbor(&ip, &mac) {
3951 continue;
3952 }
3953 neighbors.push((ip, mac, state, iface));
3954 }
3955
3956 neighbors
3957}
3958
3959#[cfg(target_os = "windows")]
3960fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3961 let trimmed = text.trim();
3962 if trimmed.is_empty() {
3963 return Ok(Vec::new());
3964 }
3965
3966 let value: Value = serde_json::from_str(trimmed)
3967 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3968 let entries = match value {
3969 Value::Array(items) => items,
3970 other => vec![other],
3971 };
3972
3973 let mut services = Vec::with_capacity(entries.len());
3974 for entry in entries {
3975 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3976 continue;
3977 };
3978 services.push(ServiceEntry {
3979 name: name.to_string(),
3980 status: entry
3981 .get("State")
3982 .and_then(|v| v.as_str())
3983 .unwrap_or("unknown")
3984 .to_string(),
3985 startup: entry
3986 .get("StartMode")
3987 .and_then(|v| v.as_str())
3988 .map(|v| v.to_string()),
3989 display_name: entry
3990 .get("DisplayName")
3991 .and_then(|v| v.as_str())
3992 .map(|v| v.to_string()),
3993 start_name: entry
3994 .get("StartName")
3995 .and_then(|v| v.as_str())
3996 .map(|v| v.to_string()),
3997 });
3998 }
3999
4000 Ok(services)
4001}
4002
4003#[cfg(target_os = "windows")]
4004fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
4005 match node.cloned() {
4006 Some(Value::Array(items)) => items,
4007 Some(other) => vec![other],
4008 None => Vec::new(),
4009 }
4010}
4011
4012#[cfg(target_os = "windows")]
4013fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
4014 windows_json_entries(node)
4015 .into_iter()
4016 .filter_map(|entry| {
4017 let name = entry
4018 .get("FriendlyName")
4019 .and_then(|v| v.as_str())
4020 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
4021 .unwrap_or("")
4022 .trim()
4023 .to_string();
4024 if name.is_empty() {
4025 return None;
4026 }
4027 Some(WindowsPnpDevice {
4028 name,
4029 status: entry
4030 .get("Status")
4031 .and_then(|v| v.as_str())
4032 .unwrap_or("Unknown")
4033 .trim()
4034 .to_string(),
4035 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
4036 entry
4037 .get("Problem")
4038 .and_then(|v| v.as_i64())
4039 .map(|v| v as u64)
4040 }),
4041 class_name: entry
4042 .get("Class")
4043 .and_then(|v| v.as_str())
4044 .map(|v| v.trim().to_string()),
4045 instance_id: entry
4046 .get("InstanceId")
4047 .and_then(|v| v.as_str())
4048 .map(|v| v.trim().to_string()),
4049 })
4050 })
4051 .collect()
4052}
4053
4054#[cfg(target_os = "windows")]
4055fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4056 windows_json_entries(node)
4057 .into_iter()
4058 .filter_map(|entry| {
4059 let name = entry
4060 .get("Name")
4061 .and_then(|v| v.as_str())
4062 .unwrap_or("")
4063 .trim()
4064 .to_string();
4065 if name.is_empty() {
4066 return None;
4067 }
4068 Some(WindowsSoundDevice {
4069 name,
4070 status: entry
4071 .get("Status")
4072 .and_then(|v| v.as_str())
4073 .unwrap_or("Unknown")
4074 .trim()
4075 .to_string(),
4076 manufacturer: entry
4077 .get("Manufacturer")
4078 .and_then(|v| v.as_str())
4079 .map(|v| v.trim().to_string()),
4080 })
4081 })
4082 .collect()
4083}
4084
4085#[cfg(target_os = "windows")]
4086fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4087 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4088 || device.problem.unwrap_or(0) != 0
4089}
4090
4091#[cfg(target_os = "windows")]
4092fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4093 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4094}
4095
4096#[cfg(target_os = "windows")]
4097fn is_microphone_like_name(name: &str) -> bool {
4098 let lower = name.to_ascii_lowercase();
4099 lower.contains("microphone")
4100 || lower.contains("mic")
4101 || lower.contains("input")
4102 || lower.contains("array")
4103 || lower.contains("capture")
4104 || lower.contains("record")
4105}
4106
4107#[cfg(target_os = "windows")]
4108fn is_bluetooth_like_name(name: &str) -> bool {
4109 let lower = name.to_ascii_lowercase();
4110 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4111}
4112
4113#[cfg(target_os = "windows")]
4114fn service_is_running(service: &ServiceEntry) -> bool {
4115 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4116}
4117
4118#[cfg(not(target_os = "windows"))]
4119fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4120 let mut startup_modes = std::collections::HashMap::<String, String>::new();
4121 for line in startup_text.lines() {
4122 let mut it = line.split_whitespace();
4123 if let (Some(name), Some(mode)) = (it.next(), it.next()) {
4124 startup_modes.insert(name.to_string(), mode.to_string());
4125 }
4126 }
4127
4128 let mut services = Vec::new();
4129 for line in status_text.lines() {
4130 let mut it = line.split_whitespace();
4131 let Some(unit) = it.next() else {
4132 continue;
4133 };
4134 let Some(load) = it.next() else {
4135 continue;
4136 };
4137 let Some(active) = it.next() else {
4138 continue;
4139 };
4140 let Some(sub) = it.next() else {
4141 continue;
4142 };
4143 let description = {
4144 let mut desc = String::new();
4145 for (i, w) in it.enumerate() {
4146 if i > 0 {
4147 desc.push(' ');
4148 }
4149 desc.push_str(w);
4150 }
4151 if desc.is_empty() {
4152 None
4153 } else {
4154 Some(desc)
4155 }
4156 };
4157 services.push(ServiceEntry {
4158 name: unit.to_string(),
4159 status: format!("{}/{}", active, sub),
4160 startup: startup_modes
4161 .get(unit)
4162 .cloned()
4163 .or_else(|| Some(load.to_string())),
4164 display_name: description,
4165 start_name: None,
4166 });
4167 }
4168
4169 services
4170}
4171
4172fn inspect_health_report() -> Result<String, String> {
4178 let mut needs_fix: Vec<String> = Vec::with_capacity(8);
4179 let mut watch: Vec<String> = Vec::with_capacity(8);
4180 let mut good: Vec<String> = Vec::with_capacity(8);
4181 let mut tips: Vec<String> = Vec::with_capacity(8);
4182
4183 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4184 health_check_memory(&mut watch, &mut good);
4185 health_check_network(&mut needs_fix, &mut watch, &mut good);
4186 health_check_pending_reboot(&mut watch, &mut good);
4187 health_check_services(&mut needs_fix, &mut watch, &mut good);
4188 health_check_thermal(&mut watch, &mut good);
4189 health_check_tools(&mut watch, &mut good, &mut tips);
4190 health_check_recent_errors(&mut watch, &mut tips);
4191
4192 let overall = if !needs_fix.is_empty() {
4193 "ACTION REQUIRED"
4194 } else if !watch.is_empty() {
4195 "WORTH A LOOK"
4196 } else {
4197 "ALL GOOD"
4198 };
4199
4200 let mut out = format!("System Health Report — {overall}\n\n");
4201
4202 if !needs_fix.is_empty() {
4203 out.push_str("Needs fixing:\n");
4204 for item in &needs_fix {
4205 let _ = writeln!(out, " [!] {item}");
4206 }
4207 out.push('\n');
4208 }
4209 if !watch.is_empty() {
4210 out.push_str("Worth watching:\n");
4211 for item in &watch {
4212 let _ = writeln!(out, " [-] {item}");
4213 }
4214 out.push('\n');
4215 }
4216 if !good.is_empty() {
4217 out.push_str("Looking good:\n");
4218 for item in &good {
4219 let _ = writeln!(out, " [+] {item}");
4220 }
4221 out.push('\n');
4222 }
4223 if !tips.is_empty() {
4224 out.push_str("To dig deeper:\n");
4225 for tip in &tips {
4226 let _ = writeln!(out, " {tip}");
4227 }
4228 }
4229
4230 Ok(out.trim_end().to_string())
4231}
4232
4233fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4234 #[cfg(target_os = "windows")]
4235 {
4236 let script = r#"try {
4237 $d = Get-PSDrive C -ErrorAction Stop
4238 "$($d.Free)|$($d.Used)"
4239} catch { "ERR" }"#;
4240 if let Ok(out) = Command::new("powershell")
4241 .args(["-NoProfile", "-Command", script])
4242 .output()
4243 {
4244 let text = String::from_utf8_lossy(&out.stdout);
4245 let text = text.trim();
4246 if !text.starts_with("ERR") {
4247 let mut it = text.splitn(3, '|');
4248 if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4249 let free_bytes: u64 = p0.trim().parse().unwrap_or(0);
4250 let used_bytes: u64 = p1.trim().parse().unwrap_or(0);
4251 let total = free_bytes + used_bytes;
4252 let free_gb = free_bytes / 1_073_741_824;
4253 let pct_free = if total > 0 {
4254 (free_bytes as f64 / total as f64 * 100.0) as u64
4255 } else {
4256 0
4257 };
4258 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4259 if free_gb < 5 {
4260 needs_fix.push(format!(
4261 "{msg} — very low. Free up space or your system may slow down or stop working."
4262 ));
4263 } else if free_gb < 15 {
4264 watch.push(format!("{msg} — getting low, consider cleaning up."));
4265 } else {
4266 good.push(msg);
4267 }
4268 return;
4269 }
4270 }
4271 }
4272 watch.push("Disk: could not read free space from C: drive.".to_string());
4273 }
4274
4275 #[cfg(not(target_os = "windows"))]
4276 {
4277 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4278 let text = String::from_utf8_lossy(&out.stdout);
4279 for line in text.lines().skip(1) {
4280 let mut it = line.split_whitespace();
4281 if let (Some(_), Some(_), Some(_), Some(avail_raw), Some(use_pct_raw)) =
4282 (it.next(), it.next(), it.next(), it.next(), it.next())
4283 {
4284 let avail_str = avail_raw.trim_end_matches('G');
4285 let use_pct = use_pct_raw.trim_end_matches('%');
4286 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4287 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4288 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4289 if avail_gb < 5 {
4290 needs_fix.push(format!(
4291 "{msg} — very low. Free up space to prevent system issues."
4292 ));
4293 } else if avail_gb < 15 {
4294 watch.push(format!("{msg} — getting low."));
4295 } else {
4296 good.push(msg);
4297 }
4298 return;
4299 }
4300 }
4301 }
4302 watch.push("Disk: could not determine free space.".to_string());
4303 }
4304}
4305
4306fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4307 #[cfg(target_os = "windows")]
4308 {
4309 let script = r#"try {
4310 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4311 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4312} catch { "ERR" }"#;
4313 if let Ok(out) = Command::new("powershell")
4314 .args(["-NoProfile", "-Command", script])
4315 .output()
4316 {
4317 let text = String::from_utf8_lossy(&out.stdout);
4318 let text = text.trim();
4319 if !text.starts_with("ERR") {
4320 let mut it = text.splitn(3, '|');
4321 if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4322 let free_kb: u64 = p0.trim().parse().unwrap_or(0);
4323 let total_kb: u64 = p1.trim().parse().unwrap_or(0);
4324 if total_kb > 0 {
4325 let free_gb = free_kb / 1_048_576;
4326 let total_gb = total_kb / 1_048_576;
4327 let free_pct = free_kb * 100 / total_kb;
4328 let msg = format!(
4329 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4330 );
4331 if free_pct < 10 {
4332 watch.push(format!(
4333 "{msg} — very low. Close unused apps to free up memory."
4334 ));
4335 } else if free_pct < 25 {
4336 watch.push(format!("{msg} — running a bit low."));
4337 } else {
4338 good.push(msg);
4339 }
4340 }
4341 }
4342 }
4343 }
4344 }
4345
4346 #[cfg(not(target_os = "windows"))]
4347 {
4348 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4349 let mut total_kb = 0u64;
4350 let mut avail_kb = 0u64;
4351 for line in content.lines() {
4352 if line.starts_with("MemTotal:") {
4353 total_kb = line
4354 .split_whitespace()
4355 .nth(1)
4356 .and_then(|v| v.parse().ok())
4357 .unwrap_or(0);
4358 } else if line.starts_with("MemAvailable:") {
4359 avail_kb = line
4360 .split_whitespace()
4361 .nth(1)
4362 .and_then(|v| v.parse().ok())
4363 .unwrap_or(0);
4364 }
4365 }
4366 if total_kb > 0 {
4367 let free_gb = avail_kb / 1_048_576;
4368 let total_gb = total_kb / 1_048_576;
4369 let free_pct = avail_kb * 100 / total_kb;
4370 let msg =
4371 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4372 if free_pct < 10 {
4373 watch.push(format!("{msg} — very low. Close unused apps."));
4374 } else if free_pct < 25 {
4375 watch.push(format!("{msg} — running a bit low."));
4376 } else {
4377 good.push(msg);
4378 }
4379 }
4380 }
4381 }
4382}
4383
4384fn probe_tool(cmd: &str, arg: &str) -> bool {
4388 if Command::new(cmd)
4389 .arg(arg)
4390 .stdout(std::process::Stdio::null())
4391 .stderr(std::process::Stdio::null())
4392 .status()
4393 .map(|s| s.success())
4394 .unwrap_or(false)
4395 {
4396 return true;
4397 }
4398 #[cfg(windows)]
4400 {
4401 let home = std::env::var("USERPROFILE").unwrap_or_default();
4402 let fallback: Option<String> = match cmd {
4403 "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4404 "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4405 "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4406 _ => None,
4407 };
4408 if let Some(path) = fallback {
4409 return Command::new(&path)
4410 .arg(arg)
4411 .stdout(std::process::Stdio::null())
4412 .stderr(std::process::Stdio::null())
4413 .status()
4414 .map(|s| s.success())
4415 .unwrap_or(false);
4416 }
4417 }
4418 false
4419}
4420
4421fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4422 let tool_checks: &[(&str, &str, &str)] = &[
4423 ("git", "--version", "Git"),
4424 ("cargo", "--version", "Rust / Cargo"),
4425 ("node", "--version", "Node.js"),
4426 ("python", "--version", "Python"),
4427 ("python3", "--version", "Python 3"),
4428 ("npm", "--version", "npm"),
4429 ];
4430
4431 let mut found: Vec<String> = Vec::with_capacity(tool_checks.len());
4432 let mut missing: Vec<String> = Vec::with_capacity(tool_checks.len());
4433 let mut python_found = false;
4434
4435 for (cmd, arg, label) in tool_checks {
4436 if cmd.starts_with("python") && python_found {
4437 continue;
4438 }
4439 let ok = probe_tool(cmd, arg);
4440 if ok {
4441 found.push((*label).to_string());
4442 if cmd.starts_with("python") {
4443 python_found = true;
4444 }
4445 } else if !cmd.starts_with("python") || !python_found {
4446 missing.push((*label).to_string());
4447 }
4448 }
4449
4450 if !found.is_empty() {
4451 good.push(format!("Dev tools found: {}", found.join(", ")));
4452 }
4453 if !missing.is_empty() {
4454 watch.push(format!(
4455 "Not installed (or not on PATH): {} — only matters if you need them",
4456 missing.join(", ")
4457 ));
4458 tips.push(
4459 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4460 .to_string(),
4461 );
4462 }
4463}
4464
4465fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4466 #[cfg(target_os = "windows")]
4467 {
4468 let script = r#"try {
4469 $cutoff = (Get-Date).AddHours(-24)
4470 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4471 $count
4472} catch { "0" }"#;
4473 if let Ok(out) = Command::new("powershell")
4474 .args(["-NoProfile", "-Command", script])
4475 .output()
4476 {
4477 let text = String::from_utf8_lossy(&out.stdout);
4478 let count: u64 = text.trim().parse().unwrap_or(0);
4479 if count > 0 {
4480 watch.push(format!(
4481 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4482 if count == 1 { "" } else { "s" }
4483 ));
4484 tips.push(
4485 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4486 .to_string(),
4487 );
4488 }
4489 }
4490 }
4491
4492 #[cfg(not(target_os = "windows"))]
4493 {
4494 if let Ok(out) = Command::new("journalctl")
4495 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4496 .output()
4497 {
4498 let text = String::from_utf8_lossy(&out.stdout);
4499 if !text.trim().is_empty() {
4500 watch.push("Critical/error entries found in the system journal.".to_string());
4501 tips.push(
4502 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4503 );
4504 }
4505 }
4506 }
4507}
4508
4509fn health_check_network(
4510 needs_fix: &mut Vec<String>,
4511 watch: &mut Vec<String>,
4512 good: &mut Vec<String>,
4513) {
4514 #[cfg(target_os = "windows")]
4515 {
4516 let script = r#"try {
4518 $ping = New-Object System.Net.NetworkInformation.Ping
4519 $r = $ping.Send("1.1.1.1", 2000)
4520 if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4521} catch { "FAIL" }"#;
4522 if let Ok(out) = Command::new("powershell")
4523 .args(["-NoProfile", "-Command", script])
4524 .output()
4525 {
4526 let text = String::from_utf8_lossy(&out.stdout);
4527 let text = text.trim();
4528 if text.starts_with("OK") {
4529 let latency = text.split('|').nth(1).unwrap_or("?");
4530 let latency_ms: u64 = latency.parse().unwrap_or(0);
4531 let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4532 if latency_ms > 300 {
4533 watch.push(format!("{msg} — high latency, may indicate network issue."));
4534 } else {
4535 good.push(msg);
4536 }
4537 } else {
4538 needs_fix.push(
4539 "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4540 Check adapter, gateway, or DNS."
4541 .to_string(),
4542 );
4543 }
4544 return;
4545 }
4546 watch.push("Network: could not run connectivity check.".to_string());
4547 }
4548
4549 #[cfg(not(target_os = "windows"))]
4550 {
4551 let _ = watch;
4552 let ok = Command::new("ping")
4553 .args(["-c", "1", "-W", "2", "1.1.1.1"])
4554 .stdout(std::process::Stdio::null())
4555 .stderr(std::process::Stdio::null())
4556 .status()
4557 .map(|s| s.success())
4558 .unwrap_or(false);
4559 if ok {
4560 good.push("Internet connectivity: reachable.".to_string());
4561 } else {
4562 needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4563 }
4564 }
4565}
4566
4567fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4568 #[cfg(target_os = "windows")]
4569 {
4570 let script = r#"try {
4571 $pending = $false
4572 $reasons = @()
4573 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4574 $pending = $true; $reasons += 'CBS/component update'
4575 }
4576 if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4577 $pending = $true; $reasons += 'Windows Update'
4578 }
4579 $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4580 if ($pfr -and $pfr.PendingFileRenameOperations) {
4581 $pending = $true; $reasons += 'file rename ops'
4582 }
4583 if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4584} catch { "OK" }"#;
4585 if let Ok(out) = Command::new("powershell")
4586 .args(["-NoProfile", "-Command", script])
4587 .output()
4588 {
4589 let text = String::from_utf8_lossy(&out.stdout);
4590 let text = text.trim();
4591 if text.starts_with("PENDING") {
4592 let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4593 watch.push(format!(
4594 "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4595 ));
4596 } else {
4597 good.push("No pending reboot.".to_string());
4598 }
4599 }
4600 }
4601
4602 #[cfg(not(target_os = "windows"))]
4603 {
4604 if std::path::Path::new("/var/run/reboot-required").exists() {
4606 watch.push(
4607 "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4608 );
4609 } else {
4610 good.push("No pending reboot.".to_string());
4611 }
4612 }
4613}
4614
4615fn health_check_services(
4616 needs_fix: &mut Vec<String>,
4617 watch: &mut Vec<String>,
4618 good: &mut Vec<String>,
4619) {
4620 #[cfg(not(target_os = "windows"))]
4621 let _ = (&needs_fix, &good);
4622 #[cfg(target_os = "windows")]
4623 let _ = &watch;
4624
4625 #[cfg(target_os = "windows")]
4626 {
4627 let script = r#"try {
4629 $names = @('EventLog','WinDefend','Dnscache')
4630 $stopped = @()
4631 foreach ($n in $names) {
4632 $s = Get-Service $n -ErrorAction SilentlyContinue
4633 if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4634 }
4635 if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4636} catch { "OK" }"#;
4637 if let Ok(out) = Command::new("powershell")
4638 .args(["-NoProfile", "-Command", script])
4639 .output()
4640 {
4641 let text = String::from_utf8_lossy(&out.stdout);
4642 let text = text.trim();
4643 if text.starts_with("STOPPED") {
4644 let names = text.split('|').nth(1).unwrap_or("unknown");
4645 needs_fix.push(format!(
4646 "Critical service(s) not running: {names} — these should always be active."
4647 ));
4648 } else {
4649 good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4650 }
4651 }
4652 }
4653
4654 #[cfg(not(target_os = "windows"))]
4655 {
4656 if let Ok(out) = Command::new("systemctl")
4658 .args(["--failed", "--no-legend", "--plain"])
4659 .output()
4660 {
4661 let text = String::from_utf8_lossy(&out.stdout);
4662 let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4663 if !failed.is_empty() {
4664 watch.push(format!(
4665 "{} failed systemd unit(s): {}",
4666 failed.len(),
4667 failed.join(", ")
4668 ));
4669 } else {
4670 good.push("No failed systemd units.".to_string());
4671 }
4672 }
4673 }
4674}
4675
4676fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4677 #[cfg(target_os = "windows")]
4678 {
4679 let script = r#"try {
4681 $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4682 $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4683 $max = ($temps | Measure-Object -Maximum).Maximum
4684 "$max"
4685} catch { "NA" }"#;
4686 if let Ok(out) = Command::new("powershell")
4687 .args(["-NoProfile", "-Command", script])
4688 .output()
4689 {
4690 let text = String::from_utf8_lossy(&out.stdout);
4691 let text = text.trim();
4692 if text != "NA" && !text.is_empty() {
4693 if let Ok(temp) = text.parse::<f64>() {
4694 let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4695 if temp >= 90.0 {
4696 watch.push(format!("{msg} — very high, check cooling and airflow."));
4697 } else if temp >= 75.0 {
4698 watch.push(format!(
4699 "{msg} — elevated under load, monitor for throttling."
4700 ));
4701 } else {
4702 good.push(format!("{msg} — normal."));
4703 }
4704 }
4705 }
4706 }
4708 }
4709
4710 #[cfg(not(target_os = "windows"))]
4711 {
4712 let paths = [
4714 "/sys/class/thermal/thermal_zone0/temp",
4715 "/sys/class/hwmon/hwmon0/temp1_input",
4716 ];
4717 for path in &paths {
4718 if let Ok(content) = std::fs::read_to_string(path) {
4719 if let Ok(raw) = content.trim().parse::<u64>() {
4720 let temp_c = raw / 1000;
4721 let msg = format!("CPU thermal: {temp_c}°C");
4722 if temp_c >= 90 {
4723 watch.push(format!("{msg} — very high, check cooling."));
4724 } else if temp_c >= 75 {
4725 watch.push(format!("{msg} — elevated under load."));
4726 } else {
4727 good.push(format!("{msg} — normal."));
4728 }
4729 return;
4730 }
4731 }
4732 }
4733 }
4734}
4735
4736fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4739 let mut out = String::from("Host inspection: log_check\n\n");
4740
4741 #[cfg(target_os = "windows")]
4742 {
4743 let hours = lookback_hours.unwrap_or(24);
4745 let _ = write!(
4746 out,
4747 "Checking System/Application logs from the last {} hours...\n\n",
4748 hours
4749 );
4750
4751 let n = max_entries.clamp(1, 50);
4752 let script = format!(
4753 r#"try {{
4754 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4755 if (-not $events) {{ "NO_EVENTS"; exit }}
4756 $events | Select-Object -First {n} | ForEach-Object {{
4757 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4758 $line
4759 }}
4760}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4761 hours = hours,
4762 n = n
4763 );
4764 let output = Command::new("powershell")
4765 .args(["-NoProfile", "-Command", &script])
4766 .output()
4767 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4768
4769 let raw = String::from_utf8_lossy(&output.stdout);
4770 let text = raw.trim();
4771
4772 if text.is_empty() || text == "NO_EVENTS" {
4773 out.push_str("No critical or error events found in Application/System logs.\n");
4774 return Ok(out.trim_end().to_string());
4775 }
4776 if text.starts_with("ERROR:") {
4777 let _ = writeln!(out, "Warning: event log query returned: {text}");
4778 return Ok(out.trim_end().to_string());
4779 }
4780
4781 let mut count = 0usize;
4782 for line in text.lines() {
4783 let mut it = line.splitn(4, '|');
4784 if let (Some(time), Some(level), Some(source), Some(msg)) =
4785 (it.next(), it.next(), it.next(), it.next())
4786 {
4787 let _ = writeln!(out, "[{time}] [{level}] {source}: {msg}");
4788 count += 1;
4789 }
4790 }
4791 let _ = write!(
4792 out,
4793 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4794 );
4795 }
4796
4797 #[cfg(not(target_os = "windows"))]
4798 {
4799 let _ = lookback_hours;
4800 let n = max_entries.clamp(1, 50).to_string();
4802 let output = Command::new("journalctl")
4803 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4804 .output();
4805
4806 match output {
4807 Ok(o) if o.status.success() => {
4808 let text = String::from_utf8_lossy(&o.stdout);
4809 let trimmed = text.trim();
4810 if trimmed.is_empty() || trimmed.contains("No entries") {
4811 out.push_str("No critical or error entries found in the system journal.\n");
4812 } else {
4813 out.push_str(trimmed);
4814 out.push('\n');
4815 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4816 }
4817 }
4818 _ => {
4819 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4821 let mut found = false;
4822 for log_path in &log_paths {
4823 if let Ok(content) = std::fs::read_to_string(log_path) {
4824 let lines: Vec<&str> = content.lines().collect();
4825 let mut tail: Vec<&str> = lines
4826 .iter()
4827 .rev()
4828 .filter(|l| {
4829 let l_lower = l.to_ascii_lowercase();
4830 l_lower.contains("error") || l_lower.contains("crit")
4831 })
4832 .take(max_entries)
4833 .copied()
4834 .collect::<Vec<_>>();
4835 tail.reverse();
4836 if !tail.is_empty() {
4837 let _ = write!(out, "Source: {log_path}\n");
4838 for l in &tail {
4839 out.push_str(l);
4840 out.push('\n');
4841 }
4842 found = true;
4843 break;
4844 }
4845 }
4846 }
4847 if !found {
4848 out.push_str(
4849 "journalctl not found and no readable syslog detected on this system.\n",
4850 );
4851 }
4852 }
4853 }
4854 }
4855
4856 Ok(out.trim_end().to_string())
4857}
4858
4859fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4862 let mut out = String::from("Host inspection: startup_items\n\n");
4863
4864 #[cfg(target_os = "windows")]
4865 {
4866 let script = r#"
4868$hives = @(
4869 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4870 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4871 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4872)
4873foreach ($h in $hives) {
4874 try {
4875 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4876 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4877 "$($h.Hive)|$($_.Name)|$($_.Value)"
4878 }
4879 } catch {}
4880}
4881"#;
4882 let output = Command::new("powershell")
4883 .args(["-NoProfile", "-Command", script])
4884 .output()
4885 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4886
4887 let raw = String::from_utf8_lossy(&output.stdout);
4888 let text = raw.trim();
4889
4890 let entries: Vec<(String, String, String)> = text
4891 .lines()
4892 .filter_map(|l| {
4893 let mut it = l.splitn(3, '|');
4894 match (it.next(), it.next(), it.next()) {
4895 (Some(a), Some(b), Some(c)) => {
4896 Some((a.to_string(), b.to_string(), c.to_string()))
4897 }
4898 _ => None,
4899 }
4900 })
4901 .take(max_entries)
4902 .collect();
4903
4904 if entries.is_empty() {
4905 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4906 } else {
4907 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4908 let mut last_hive = String::new();
4909 for (hive, name, value) in &entries {
4910 if *hive != last_hive {
4911 let _ = writeln!(out, "[{}]", hive);
4912 last_hive = hive.clone();
4913 }
4914 let display = if value.len() > 100 {
4916 format!("{}…", safe_head(value, 100))
4917 } else {
4918 value.clone()
4919 };
4920 let _ = writeln!(out, " {name}: {display}");
4921 }
4922 let _ = write!(out, "\nTotal startup entries: {}\n", entries.len());
4923 }
4924
4925 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4927 if let Ok(unified_out) = Command::new("powershell")
4928 .args(["-NoProfile", "-Command", unified_script])
4929 .output()
4930 {
4931 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4932 let trimmed = unified_text.trim();
4933 if !trimmed.is_empty() {
4934 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4935 out.push_str(trimmed);
4936 out.push('\n');
4937 }
4938 }
4939 }
4940
4941 #[cfg(not(target_os = "windows"))]
4942 {
4943 let output = Command::new("systemctl")
4945 .args([
4946 "list-unit-files",
4947 "--type=service",
4948 "--state=enabled",
4949 "--no-legend",
4950 "--no-pager",
4951 "--plain",
4952 ])
4953 .output();
4954
4955 match output {
4956 Ok(o) if o.status.success() => {
4957 let text = String::from_utf8_lossy(&o.stdout);
4958 let services: Vec<&str> = text
4959 .lines()
4960 .filter(|l| !l.trim().is_empty())
4961 .take(max_entries)
4962 .collect();
4963 if services.is_empty() {
4964 out.push_str("No enabled systemd services found.\n");
4965 } else {
4966 out.push_str("Enabled systemd services (run at boot):\n\n");
4967 for s in &services {
4968 let _ = write!(out, " {s}\n");
4969 }
4970 let _ = write!(out, "\nShowing {} of enabled services.\n", services.len());
4971 }
4972 }
4973 _ => {
4974 out.push_str(
4975 "systemctl not found on this system. Cannot enumerate startup services.\n",
4976 );
4977 }
4978 }
4979
4980 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4982 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4983 let reboot_entries: Vec<&str> = cron_text
4984 .lines()
4985 .filter(|l| l.trim_start().starts_with("@reboot"))
4986 .collect();
4987 if !reboot_entries.is_empty() {
4988 out.push_str("\nCron @reboot entries:\n");
4989 for e in reboot_entries {
4990 let _ = write!(out, " {e}\n");
4991 }
4992 }
4993 }
4994 }
4995
4996 Ok(out.trim_end().to_string())
4997}
4998
4999fn inspect_os_config() -> Result<String, String> {
5000 let mut out = String::from("Host inspection: OS Configuration\n\n");
5001
5002 #[cfg(target_os = "windows")]
5003 {
5004 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
5006 let power_str = String::from_utf8_lossy(&power_out.stdout);
5007 out.push_str("=== Power Plan ===\n");
5008 out.push_str(power_str.trim());
5009 out.push_str("\n\n");
5010 }
5011
5012 let fw_script =
5014 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
5015 if let Ok(fw_out) = Command::new("powershell")
5016 .args(["-NoProfile", "-Command", fw_script])
5017 .output()
5018 {
5019 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
5020 out.push_str("=== Firewall Profiles ===\n");
5021 out.push_str(fw_str.trim());
5022 out.push_str("\n\n");
5023 }
5024
5025 let uptime_script =
5027 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
5028 if let Ok(uptime_out) = Command::new("powershell")
5029 .args(["-NoProfile", "-Command", uptime_script])
5030 .output()
5031 {
5032 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5033 out.push_str("=== System Uptime (Last Boot) ===\n");
5034 out.push_str(uptime_str.trim());
5035 out.push_str("\n\n");
5036 }
5037 }
5038
5039 #[cfg(not(target_os = "windows"))]
5040 {
5041 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
5043 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5044 out.push_str("=== System Uptime ===\n");
5045 out.push_str(uptime_str.trim());
5046 out.push_str("\n\n");
5047 }
5048
5049 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
5051 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
5052 if !ufw_str.trim().is_empty() {
5053 out.push_str("=== Firewall (UFW) ===\n");
5054 out.push_str(ufw_str.trim());
5055 out.push_str("\n\n");
5056 }
5057 }
5058 }
5059 Ok(out.trim_end().to_string())
5060}
5061
5062pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5063 let action = args
5064 .get("action")
5065 .and_then(|v| v.as_str())
5066 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5067
5068 let target = args
5069 .get("target")
5070 .and_then(|v| v.as_str())
5071 .unwrap_or("")
5072 .trim();
5073
5074 if target.is_empty() && action != "clear_temp" {
5075 return Err("Missing required argument: 'target' for this action".to_string());
5076 }
5077
5078 match action {
5079 "install_package" => {
5080 #[cfg(target_os = "windows")]
5081 {
5082 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5083 match Command::new("powershell")
5084 .args(["-NoProfile", "-Command", &cmd])
5085 .output()
5086 {
5087 Ok(out) => Ok(format!(
5088 "Executed remediation (winget install):\n{}",
5089 String::from_utf8_lossy(&out.stdout)
5090 )),
5091 Err(e) => Err(format!("Failed to run winget: {}", e)),
5092 }
5093 }
5094 #[cfg(not(target_os = "windows"))]
5095 {
5096 Err(
5097 "install_package via wrapper is only supported on Windows currently (winget)"
5098 .to_string(),
5099 )
5100 }
5101 }
5102 "restart_service" => {
5103 #[cfg(target_os = "windows")]
5104 {
5105 let cmd = format!("Restart-Service -Name {} -Force", target);
5106 match Command::new("powershell")
5107 .args(["-NoProfile", "-Command", &cmd])
5108 .output()
5109 {
5110 Ok(out) => {
5111 let err_str = String::from_utf8_lossy(&out.stderr);
5112 if !err_str.is_empty() {
5113 return Err(format!("Error restarting service:\n{}", err_str));
5114 }
5115 Ok(format!("Successfully restarted service: {}", target))
5116 }
5117 Err(e) => Err(format!("Failed to restart service: {}", e)),
5118 }
5119 }
5120 #[cfg(not(target_os = "windows"))]
5121 {
5122 Err(
5123 "restart_service via wrapper is only supported on Windows currently"
5124 .to_string(),
5125 )
5126 }
5127 }
5128 "clear_temp" => {
5129 #[cfg(target_os = "windows")]
5130 {
5131 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5132 match Command::new("powershell")
5133 .args(["-NoProfile", "-Command", cmd])
5134 .output()
5135 {
5136 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5137 Err(e) => Err(format!("Failed to clear temp: {}", e)),
5138 }
5139 }
5140 #[cfg(not(target_os = "windows"))]
5141 {
5142 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5143 }
5144 }
5145 other => Err(format!("Unknown remediation action: {}", other)),
5146 }
5147}
5148
5149fn inspect_storage(max_entries: usize) -> Result<String, String> {
5152 let mut out = String::from("Host inspection: storage\n\n");
5153 let _ = max_entries; out.push_str("Drives:\n");
5157
5158 #[cfg(target_os = "windows")]
5159 {
5160 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5161 $free = $_.Free
5162 $used = $_.Used
5163 if ($free -eq $null) { $free = 0 }
5164 if ($used -eq $null) { $used = 0 }
5165 $total = $free + $used
5166 "$($_.Name)|$free|$used|$total"
5167}"#;
5168 match Command::new("powershell")
5169 .args(["-NoProfile", "-Command", script])
5170 .output()
5171 {
5172 Ok(o) => {
5173 let text = String::from_utf8_lossy(&o.stdout);
5174 let mut drive_count = 0usize;
5175 for line in text.lines() {
5176 let mut it = line.trim().splitn(5, '|');
5177 if let (Some(name), Some(p1), _, Some(p3)) =
5178 (it.next(), it.next(), it.next(), it.next())
5179 {
5180 let free: u64 = p1.parse().unwrap_or(0);
5181 let total: u64 = p3.parse().unwrap_or(0);
5182 if total == 0 {
5183 continue;
5184 }
5185 let free_gb = free / 1_073_741_824;
5186 let total_gb = total / 1_073_741_824;
5187 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5188 let bar_len = 20usize;
5189 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5190 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5191 let warn = if free_gb < 5 {
5192 " [!] CRITICALLY LOW"
5193 } else if free_gb < 15 {
5194 " [-] LOW"
5195 } else {
5196 ""
5197 };
5198 let _ = writeln!(out,
5199 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}"
5200 );
5201 drive_count += 1;
5202 }
5203 }
5204 if drive_count == 0 {
5205 out.push_str(" (could not enumerate drives)\n");
5206 }
5207 }
5208 Err(e) => {
5209 let _ = writeln!(out, " (drive scan failed: {e})");
5210 }
5211 }
5212
5213 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5215 match Command::new("powershell")
5216 .args(["-NoProfile", "-Command", latency_script])
5217 .output()
5218 {
5219 Ok(o) => {
5220 out.push_str("\nReal-time Disk Intensity:\n");
5221 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5222 if !text.is_empty() {
5223 let _ = writeln!(out, " Average Disk Queue Length: {text}");
5224 if let Ok(q) = text.parse::<f64>() {
5225 if q > 2.0 {
5226 out.push_str(
5227 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5228 );
5229 } else {
5230 out.push_str(" [~] Disk latency is within healthy bounds.\n");
5231 }
5232 }
5233 } else {
5234 out.push_str(" Average Disk Queue Length: unavailable\n");
5235 }
5236 }
5237 Err(_) => {
5238 out.push_str("\nReal-time Disk Intensity:\n");
5239 out.push_str(" Average Disk Queue Length: unavailable\n");
5240 }
5241 }
5242 }
5243
5244 #[cfg(not(target_os = "windows"))]
5245 {
5246 match Command::new("df")
5247 .args(["-h", "--output=target,size,avail,pcent"])
5248 .output()
5249 {
5250 Ok(o) => {
5251 let text = String::from_utf8_lossy(&o.stdout);
5252 let mut count = 0usize;
5253 for line in text.lines().skip(1) {
5254 let mut it = line.split_whitespace();
5255 if let (Some(fs), Some(size), Some(avail), Some(used)) =
5256 (it.next(), it.next(), it.next(), it.next())
5257 {
5258 if !fs.starts_with("tmpfs") {
5259 let _ = write!(
5260 out,
5261 " {} size: {} avail: {} used: {}\n",
5262 fs, size, avail, used
5263 );
5264 count += 1;
5265 if count >= max_entries {
5266 break;
5267 }
5268 }
5269 }
5270 }
5271 }
5272 Err(e) => {
5273 let _ = write!(out, " (df failed: {e})\n");
5274 }
5275 }
5276 }
5277
5278 out.push_str("\nLarge developer cache directories (if present):\n");
5280
5281 #[cfg(target_os = "windows")]
5282 {
5283 let home = std::env::var("USERPROFILE").unwrap_or_default();
5284 let check_dirs: &[(&str, &str)] = &[
5285 ("Temp", r"AppData\Local\Temp"),
5286 ("npm cache", r"AppData\Roaming\npm-cache"),
5287 ("Cargo registry", r".cargo\registry"),
5288 ("Cargo git", r".cargo\git"),
5289 ("pip cache", r"AppData\Local\pip\cache"),
5290 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5291 (".rustup toolchains", r".rustup\toolchains"),
5292 ("node_modules (home)", r"node_modules"),
5293 ];
5294
5295 let mut found_any = false;
5296 for (label, rel) in check_dirs {
5297 let full = format!(r"{}\{}", home, rel);
5298 let path = std::path::Path::new(&full);
5299 if path.exists() {
5300 let size_script = format!(
5302 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5303 full.replace('\'', "''")
5304 );
5305 let size_mb = Command::new("powershell")
5306 .args(["-NoProfile", "-Command", &size_script])
5307 .output()
5308 .ok()
5309 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5310 .unwrap_or_else(|| "?".to_string());
5311 let _ = writeln!(out, " {label}: {size_mb} MB ({full})");
5312 found_any = true;
5313 }
5314 }
5315 if !found_any {
5316 out.push_str(" (none of the common cache directories found)\n");
5317 }
5318
5319 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5320 }
5321
5322 #[cfg(not(target_os = "windows"))]
5323 {
5324 let home = std::env::var("HOME").unwrap_or_default();
5325 let check_dirs: &[(&str, &str)] = &[
5326 ("npm cache", ".npm"),
5327 ("Cargo registry", ".cargo/registry"),
5328 ("pip cache", ".cache/pip"),
5329 (".rustup toolchains", ".rustup/toolchains"),
5330 ("Yarn cache", ".cache/yarn"),
5331 ];
5332 let mut found_any = false;
5333 for (label, rel) in check_dirs {
5334 let full = format!("{}/{}", home, rel);
5335 if std::path::Path::new(&full).exists() {
5336 let size = Command::new("du")
5337 .args(["-sh", &full])
5338 .output()
5339 .ok()
5340 .map(|o| {
5341 let s = String::from_utf8_lossy(&o.stdout);
5342 s.split_whitespace().next().unwrap_or("?").to_string()
5343 })
5344 .unwrap_or_else(|| "?".to_string());
5345 let _ = write!(out, " {label}: {size} ({full})\n");
5346 found_any = true;
5347 }
5348 }
5349 if !found_any {
5350 out.push_str(" (none of the common cache directories found)\n");
5351 }
5352 }
5353
5354 Ok(out.trim_end().to_string())
5355}
5356
5357fn inspect_hardware() -> Result<String, String> {
5360 let mut out = String::from("Host inspection: hardware\n\n");
5361
5362 #[cfg(target_os = "windows")]
5363 {
5364 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5366 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5367} | Select-Object -First 1"#;
5368 if let Ok(o) = Command::new("powershell")
5369 .args(["-NoProfile", "-Command", cpu_script])
5370 .output()
5371 {
5372 let text = String::from_utf8_lossy(&o.stdout);
5373 let text = text.trim();
5374 let mut it = text.splitn(5, '|');
5375 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5376 (it.next(), it.next(), it.next(), it.next())
5377 {
5378 let _ = write!(
5379 out,
5380 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
5381 p0,
5382 p1,
5383 p2,
5384 p3.parse::<f32>().unwrap_or(0.0)
5385 );
5386 } else {
5387 let _ = write!(out, "CPU: {text}\n\n");
5388 }
5389 }
5390
5391 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5393$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5394$speed = ($sticks | Select-Object -First 1).Speed
5395"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5396 if let Ok(o) = Command::new("powershell")
5397 .args(["-NoProfile", "-Command", ram_script])
5398 .output()
5399 {
5400 let text = String::from_utf8_lossy(&o.stdout);
5401 let _ = write!(out, "RAM: {}\n\n", text.trim().trim_matches('"'));
5402 }
5403
5404 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5406 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5407}"#;
5408 if let Ok(o) = Command::new("powershell")
5409 .args(["-NoProfile", "-Command", gpu_script])
5410 .output()
5411 {
5412 let text = String::from_utf8_lossy(&o.stdout);
5413 let lines: Vec<&str> = text.lines().collect();
5414 if !lines.is_empty() {
5415 out.push_str("GPU(s):\n");
5416 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5417 let mut it = line.trim().splitn(4, '|');
5418 if let (Some(p0), Some(p1), Some(p2)) = (it.next(), it.next(), it.next()) {
5419 let res = if p2 == "x" || p2.starts_with('0') {
5420 String::new()
5421 } else {
5422 format!(" — {}@display", p2)
5423 };
5424 let _ = write!(out, " {}\n Driver: {}{}\n", p0, p1, res);
5425 } else {
5426 let _ = writeln!(out, " {}", line.trim());
5427 }
5428 }
5429 out.push('\n');
5430 }
5431 }
5432
5433 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5435$bios = Get-CimInstance Win32_BIOS
5436$cs = Get-CimInstance Win32_ComputerSystem
5437$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5438$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5439"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5440 if let Ok(o) = Command::new("powershell")
5441 .args(["-NoProfile", "-Command", mb_script])
5442 .output()
5443 {
5444 let text = String::from_utf8_lossy(&o.stdout);
5445 let text = text.trim().trim_matches('"');
5446 let mut it = text.splitn(5, '|');
5447 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5448 (it.next(), it.next(), it.next(), it.next())
5449 {
5450 let _ = write!(
5451 out,
5452 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5453 p0.trim(),
5454 p1.trim(),
5455 p2.trim(),
5456 p3.trim()
5457 );
5458 }
5459 }
5460
5461 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5463 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5464}"#;
5465 if let Ok(o) = Command::new("powershell")
5466 .args(["-NoProfile", "-Command", disp_script])
5467 .output()
5468 {
5469 let text = String::from_utf8_lossy(&o.stdout);
5470 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5471 if !lines.is_empty() {
5472 out.push_str("Display(s):\n");
5473 for line in &lines {
5474 let mut it = line.trim().splitn(3, '|');
5475 if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
5476 let _ = writeln!(out, " {} — {}", p0.trim(), p1);
5477 }
5478 }
5479 }
5480 }
5481 }
5482
5483 #[cfg(not(target_os = "windows"))]
5484 {
5485 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5487 let model = content
5488 .lines()
5489 .find(|l| l.starts_with("model name"))
5490 .and_then(|l| l.split(':').nth(1))
5491 .map(str::trim)
5492 .unwrap_or("unknown");
5493 let cores = content
5494 .lines()
5495 .filter(|l| l.starts_with("processor"))
5496 .count();
5497 let _ = write!(out, "CPU: {model}\n {cores} logical processors\n\n");
5498 }
5499
5500 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5502 let total_kb: u64 = content
5503 .lines()
5504 .find(|l| l.starts_with("MemTotal:"))
5505 .and_then(|l| l.split_whitespace().nth(1))
5506 .and_then(|v| v.parse().ok())
5507 .unwrap_or(0);
5508 let total_gb = total_kb / 1_048_576;
5509 let _ = write!(out, "RAM: {total_gb} GB total\n\n");
5510 }
5511
5512 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5514 let text = String::from_utf8_lossy(&o.stdout);
5515 let gpu_lines: Vec<&str> = text
5516 .lines()
5517 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5518 .collect();
5519 if !gpu_lines.is_empty() {
5520 out.push_str("GPU(s):\n");
5521 for l in gpu_lines {
5522 let _ = write!(out, " {l}\n");
5523 }
5524 out.push('\n');
5525 }
5526 }
5527
5528 if let Ok(o) = Command::new("dmidecode")
5530 .args(["-t", "baseboard", "-t", "bios"])
5531 .output()
5532 {
5533 let text = String::from_utf8_lossy(&o.stdout);
5534 out.push_str("Motherboard/BIOS:\n");
5535 for line in text
5536 .lines()
5537 .filter(|l| {
5538 l.contains("Manufacturer:")
5539 || l.contains("Product Name:")
5540 || l.contains("Version:")
5541 })
5542 .take(6)
5543 {
5544 let _ = write!(out, " {}\n", line.trim());
5545 }
5546 }
5547 }
5548
5549 Ok(out.trim_end().to_string())
5550}
5551
5552fn inspect_updates() -> Result<String, String> {
5555 let mut out = String::from("Host inspection: updates\n\n");
5556
5557 #[cfg(target_os = "windows")]
5558 {
5559 let script = r#"
5561try {
5562 $sess = New-Object -ComObject Microsoft.Update.Session
5563 $searcher = $sess.CreateUpdateSearcher()
5564 $count = $searcher.GetTotalHistoryCount()
5565 if ($count -gt 0) {
5566 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5567 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5568 } else { "NONE|LAST_INSTALL" }
5569} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5570"#;
5571 if let Ok(o) = Command::new("powershell")
5572 .args(["-NoProfile", "-Command", script])
5573 .output()
5574 {
5575 let raw = String::from_utf8_lossy(&o.stdout);
5576 let text = raw.trim();
5577 if text.starts_with("ERROR:") {
5578 out.push_str("Last update install: (unable to query)\n");
5579 } else if text.contains("NONE") {
5580 out.push_str("Last update install: No update history found\n");
5581 } else {
5582 let date = text.replace("|LAST_INSTALL", "");
5583 let _ = writeln!(out, "Last update install: {date}");
5584 }
5585 }
5586
5587 let pending_script = r#"
5589try {
5590 $sess = New-Object -ComObject Microsoft.Update.Session
5591 $searcher = $sess.CreateUpdateSearcher()
5592 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5593 $results.Updates.Count.ToString() + "|PENDING"
5594} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5595"#;
5596 if let Ok(o) = Command::new("powershell")
5597 .args(["-NoProfile", "-Command", pending_script])
5598 .output()
5599 {
5600 let raw = String::from_utf8_lossy(&o.stdout);
5601 let text = raw.trim();
5602 if text.starts_with("ERROR:") {
5603 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5604 } else {
5605 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5606 if count == 0 {
5607 out.push_str("Pending updates: Up to date — no updates waiting\n");
5608 } else if count > 0 {
5609 let _ = writeln!(out, "Pending updates: {count} update(s) available");
5610 out.push_str(
5611 " → Open Windows Update (Settings > Windows Update) to install\n",
5612 );
5613 }
5614 }
5615 }
5616
5617 let svc_script = r#"
5619$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5620if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5621"#;
5622 if let Ok(o) = Command::new("powershell")
5623 .args(["-NoProfile", "-Command", svc_script])
5624 .output()
5625 {
5626 let raw = String::from_utf8_lossy(&o.stdout);
5627 let status = raw.trim();
5628 let _ = writeln!(out, "Windows Update service: {status}");
5629 }
5630 }
5631
5632 #[cfg(not(target_os = "windows"))]
5633 {
5634 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5635 let mut found = false;
5636 if let Ok(o) = apt_out {
5637 let text = String::from_utf8_lossy(&o.stdout);
5638 let lines: Vec<&str> = text
5639 .lines()
5640 .filter(|l| l.contains('/') && !l.contains("Listing"))
5641 .collect();
5642 if !lines.is_empty() {
5643 let _ = write!(out, "{} package(s) can be upgraded (apt)\n", lines.len());
5644 out.push_str(" → Run: sudo apt upgrade\n");
5645 found = true;
5646 }
5647 }
5648 if !found {
5649 if let Ok(o) = Command::new("dnf")
5650 .args(["check-update", "--quiet"])
5651 .output()
5652 {
5653 let text = String::from_utf8_lossy(&o.stdout);
5654 let count = text
5655 .lines()
5656 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5657 .count();
5658 if count > 0 {
5659 let _ = write!(out, "{count} package(s) can be upgraded (dnf)\n");
5660 out.push_str(" → Run: sudo dnf upgrade\n");
5661 } else {
5662 out.push_str("System is up to date.\n");
5663 }
5664 } else {
5665 out.push_str("Could not query package manager for updates.\n");
5666 }
5667 }
5668 }
5669
5670 Ok(out.trim_end().to_string())
5671}
5672
5673fn inspect_security() -> Result<String, String> {
5676 let mut out = String::from("Host inspection: security\n\n");
5677
5678 #[cfg(target_os = "windows")]
5679 {
5680 let defender_script = r#"
5682try {
5683 $status = Get-MpComputerStatus -ErrorAction Stop
5684 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5685} catch { "ERROR:" + $_.Exception.Message }
5686"#;
5687 if let Ok(o) = Command::new("powershell")
5688 .args(["-NoProfile", "-Command", defender_script])
5689 .output()
5690 {
5691 let raw = String::from_utf8_lossy(&o.stdout);
5692 let text = raw.trim();
5693 if text.starts_with("ERROR:") {
5694 let _ = writeln!(out, "Windows Defender: unable to query — {text}");
5695 } else {
5696 let get = |key: &str| -> String {
5697 text.split('|')
5698 .find(|s| s.starts_with(key))
5699 .and_then(|s| s.split_once(':').map(|x| x.1))
5700 .unwrap_or("unknown")
5701 .to_string()
5702 };
5703 let rtp = get("RTP");
5704 let last_scan = {
5705 text.split('|')
5707 .find(|s| s.starts_with("SCAN:"))
5708 .and_then(|s| s.get(5..))
5709 .unwrap_or("unknown")
5710 .to_string()
5711 };
5712 let def_ver = get("VER");
5713 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5714
5715 let rtp_label = if rtp == "True" {
5716 "ENABLED"
5717 } else {
5718 "DISABLED [!]"
5719 };
5720 let _ = writeln!(out, "Windows Defender real-time protection: {rtp_label}");
5721 let _ = writeln!(out, "Last quick scan: {last_scan}");
5722 let _ = writeln!(out, "Signature version: {def_ver}");
5723 if age_days >= 0 {
5724 let freshness = if age_days == 0 {
5725 "up to date".to_string()
5726 } else if age_days <= 3 {
5727 format!("{age_days} day(s) old — OK")
5728 } else if age_days <= 7 {
5729 format!("{age_days} day(s) old — consider updating")
5730 } else {
5731 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5732 };
5733 let _ = writeln!(out, "Signature age: {freshness}");
5734 }
5735 if rtp != "True" {
5736 out.push_str(
5737 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5738 );
5739 out.push_str(
5740 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5741 );
5742 }
5743 }
5744 }
5745
5746 out.push('\n');
5747
5748 let fw_script = r#"
5750try {
5751 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5752} catch { "ERROR:" + $_.Exception.Message }
5753"#;
5754 if let Ok(o) = Command::new("powershell")
5755 .args(["-NoProfile", "-Command", fw_script])
5756 .output()
5757 {
5758 let raw = String::from_utf8_lossy(&o.stdout);
5759 let text = raw.trim();
5760 if !text.starts_with("ERROR:") && !text.is_empty() {
5761 out.push_str("Windows Firewall:\n");
5762 for line in text.lines() {
5763 if let Some((name, enabled)) = line.split_once(':') {
5764 let state = if enabled.trim() == "True" {
5765 "ON"
5766 } else {
5767 "OFF [!]"
5768 };
5769 let _ = writeln!(out, " {name}: {state}");
5770 }
5771 }
5772 out.push('\n');
5773 }
5774 }
5775
5776 let act_script = r#"
5778try {
5779 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5780 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5781} catch { "UNKNOWN" }
5782"#;
5783 if let Ok(o) = Command::new("powershell")
5784 .args(["-NoProfile", "-Command", act_script])
5785 .output()
5786 {
5787 let raw = String::from_utf8_lossy(&o.stdout);
5788 match raw.trim() {
5789 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5790 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5791 _ => out.push_str("Windows activation: Unable to determine\n"),
5792 }
5793 }
5794
5795 let uac_script = r#"
5797$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5798if ($val -eq 1) { "ON" } else { "OFF" }
5799"#;
5800 if let Ok(o) = Command::new("powershell")
5801 .args(["-NoProfile", "-Command", uac_script])
5802 .output()
5803 {
5804 let raw = String::from_utf8_lossy(&o.stdout);
5805 let state = raw.trim();
5806 let label = if state == "ON" {
5807 "Enabled"
5808 } else {
5809 "DISABLED [!] — recommended to re-enable via secpol.msc"
5810 };
5811 let _ = writeln!(out, "UAC (User Account Control): {label}");
5812 }
5813 }
5814
5815 #[cfg(not(target_os = "windows"))]
5816 {
5817 if let Ok(o) = Command::new("ufw").arg("status").output() {
5818 let text = String::from_utf8_lossy(&o.stdout);
5819 let _ = write!(out, "UFW: {}\n", text.lines().next().unwrap_or("unknown"));
5820 }
5821 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5822 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5823 let _ = write!(out, "{line}\n");
5824 }
5825 }
5826 }
5827
5828 Ok(out.trim_end().to_string())
5829}
5830
5831fn inspect_pending_reboot() -> Result<String, String> {
5834 let mut out = String::from("Host inspection: pending_reboot\n\n");
5835
5836 #[cfg(target_os = "windows")]
5837 {
5838 let script = r#"
5839$reasons = @()
5840if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5841 $reasons += "Windows Update requires a restart"
5842}
5843if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5844 $reasons += "Windows component install/update requires a restart"
5845}
5846$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5847if ($pfro -and $pfro.PendingFileRenameOperations) {
5848 $reasons += "Pending file rename operations (driver or system file replacement)"
5849}
5850if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5851"#;
5852 let output = Command::new("powershell")
5853 .args(["-NoProfile", "-Command", script])
5854 .output()
5855 .map_err(|e| format!("pending_reboot: {e}"))?;
5856
5857 let raw = String::from_utf8_lossy(&output.stdout);
5858 let text = raw.trim();
5859
5860 if text == "NO_REBOOT_NEEDED" {
5861 out.push_str("No restart required — system is up to date and stable.\n");
5862 } else if text.is_empty() {
5863 out.push_str("Could not determine reboot status.\n");
5864 } else {
5865 out.push_str("[!] A system restart is pending:\n\n");
5866 for reason in text.split("|REASON|") {
5867 let _ = writeln!(out, " • {}", reason.trim());
5868 }
5869 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5870 }
5871 }
5872
5873 #[cfg(not(target_os = "windows"))]
5874 {
5875 if std::path::Path::new("/var/run/reboot-required").exists() {
5876 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5877 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5878 out.push_str("Packages requiring restart:\n");
5879 for p in pkgs.lines().take(10) {
5880 let _ = write!(out, " • {p}\n");
5881 }
5882 }
5883 } else {
5884 out.push_str("No restart required.\n");
5885 }
5886 }
5887
5888 Ok(out.trim_end().to_string())
5889}
5890
5891fn inspect_disk_health() -> Result<String, String> {
5894 let mut out = String::from("Host inspection: disk_health\n\n");
5895
5896 #[cfg(target_os = "windows")]
5897 {
5898 let script = r#"
5899try {
5900 $disks = Get-PhysicalDisk -ErrorAction Stop
5901 foreach ($d in $disks) {
5902 $size_gb = [math]::Round($d.Size / 1GB, 0)
5903 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5904 }
5905} catch { "ERROR:" + $_.Exception.Message }
5906"#;
5907 let output = Command::new("powershell")
5908 .args(["-NoProfile", "-Command", script])
5909 .output()
5910 .map_err(|e| format!("disk_health: {e}"))?;
5911
5912 let raw = String::from_utf8_lossy(&output.stdout);
5913 let text = raw.trim();
5914
5915 if text.starts_with("ERROR:") {
5916 let _ = writeln!(out, "Unable to query disk health: {text}");
5917 out.push_str("This may require running as administrator.\n");
5918 } else if text.is_empty() {
5919 out.push_str("No physical disks found.\n");
5920 } else {
5921 out.push_str("Physical Drive Health:\n\n");
5922 for line in text.lines() {
5923 let mut it = line.splitn(5, '|');
5924 if let (Some(name), Some(media), Some(size), Some(health)) =
5925 (it.next(), it.next(), it.next(), it.next())
5926 {
5927 let op_status = it.next().unwrap_or("");
5928 let health_label = match health.trim() {
5929 "Healthy" => "OK",
5930 "Warning" => "[!] WARNING",
5931 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5932 other => other,
5933 };
5934 let _ = writeln!(out, " {name}");
5935 let _ = writeln!(out, " Type: {media} | Size: {size}");
5936 let _ = writeln!(out, " Health: {health_label}");
5937 if !op_status.is_empty() {
5938 let _ = writeln!(out, " Status: {op_status}");
5939 }
5940 out.push('\n');
5941 }
5942 }
5943 }
5944
5945 let smart_script = r#"
5947try {
5948 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5949 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5950} catch { "" }
5951"#;
5952 if let Ok(o) = Command::new("powershell")
5953 .args(["-NoProfile", "-Command", smart_script])
5954 .output()
5955 {
5956 let raw2 = String::from_utf8_lossy(&o.stdout);
5957 let text2 = raw2.trim();
5958 if !text2.is_empty() {
5959 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5960 if failures.is_empty() {
5961 out.push_str("SMART failure prediction: No failures predicted\n");
5962 } else {
5963 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5964 for f in failures {
5965 let name = f.split('|').next().unwrap_or(f);
5966 let _ = writeln!(out, " • {name}");
5967 }
5968 out.push_str(
5969 "\nBack up your data immediately and replace the failing drive.\n",
5970 );
5971 }
5972 }
5973 }
5974 }
5975
5976 #[cfg(not(target_os = "windows"))]
5977 {
5978 if let Ok(o) = Command::new("lsblk")
5979 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5980 .output()
5981 {
5982 let text = String::from_utf8_lossy(&o.stdout);
5983 out.push_str("Block devices:\n");
5984 out.push_str(text.trim());
5985 out.push('\n');
5986 }
5987 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5988 let devices = String::from_utf8_lossy(&scan.stdout);
5989 for dev_line in devices.lines().take(4) {
5990 let dev = dev_line.split_whitespace().next().unwrap_or("");
5991 if dev.is_empty() {
5992 continue;
5993 }
5994 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5995 let health = String::from_utf8_lossy(&o.stdout);
5996 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5997 {
5998 let _ = write!(out, "{dev}: {}\n", line.trim());
5999 }
6000 }
6001 }
6002 } else {
6003 out.push_str("(install smartmontools for SMART health data)\n");
6004 }
6005 }
6006
6007 Ok(out.trim_end().to_string())
6008}
6009
6010fn inspect_battery() -> Result<String, String> {
6013 let mut out = String::from("Host inspection: battery\n\n");
6014
6015 #[cfg(target_os = "windows")]
6016 {
6017 let script = r#"
6018try {
6019 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
6020 if (-not $bats) { "NO_BATTERY"; exit }
6021
6022 # Modern Battery Health (Cycle count + Capacity health)
6023 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
6024 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
6025 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
6026
6027 foreach ($b in $bats) {
6028 $state = switch ($b.BatteryStatus) {
6029 1 { "Discharging" }
6030 2 { "AC Power (Fully Charged)" }
6031 3 { "AC Power (Charging)" }
6032 default { "Status $($b.BatteryStatus)" }
6033 }
6034
6035 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
6036 $health = if ($static -and $full) {
6037 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
6038 } else { "unknown" }
6039
6040 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
6041 }
6042} catch { "ERROR:" + $_.Exception.Message }
6043"#;
6044 let output = Command::new("powershell")
6045 .args(["-NoProfile", "-Command", script])
6046 .output()
6047 .map_err(|e| format!("battery: {e}"))?;
6048
6049 let raw = String::from_utf8_lossy(&output.stdout);
6050 let text = raw.trim();
6051
6052 if text == "NO_BATTERY" {
6053 out.push_str("No battery detected — desktop or AC-only system.\n");
6054 return Ok(out.trim_end().to_string());
6055 }
6056 if text.starts_with("ERROR:") {
6057 let _ = writeln!(out, "Unable to query battery: {text}");
6058 return Ok(out.trim_end().to_string());
6059 }
6060
6061 for line in text.lines() {
6062 let mut it = line.splitn(6, '|');
6063 if let (Some(name), Some(p1), Some(state), Some(cycles), Some(health)) =
6064 (it.next(), it.next(), it.next(), it.next(), it.next())
6065 {
6066 let charge: i64 = p1.parse().unwrap_or(-1);
6067
6068 let _ = writeln!(out, "Battery: {name}");
6069 if charge >= 0 {
6070 let bar_filled = (charge as usize * 20) / 100;
6071 let _ = writeln!(
6072 out,
6073 " Charge: [{}{}] {}%",
6074 "#".repeat(bar_filled),
6075 ".".repeat(20 - bar_filled),
6076 charge
6077 );
6078 }
6079 let _ = writeln!(out, " Status: {state}");
6080 let _ = writeln!(out, " Cycles: {cycles}");
6081 let _ = write!(out, " Health: {health}% (Actual vs Design Capacity)\n\n");
6082 }
6083 }
6084 }
6085
6086 #[cfg(not(target_os = "windows"))]
6087 {
6088 let power_path = std::path::Path::new("/sys/class/power_supply");
6089 let mut found = false;
6090 if power_path.exists() {
6091 if let Ok(entries) = std::fs::read_dir(power_path) {
6092 for entry in entries.flatten() {
6093 let p = entry.path();
6094 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6095 if t.trim() == "Battery" {
6096 found = true;
6097 let name = p
6098 .file_name()
6099 .unwrap_or_default()
6100 .to_string_lossy()
6101 .to_string();
6102 let _ = write!(out, "Battery: {name}\n");
6103 let read = |f: &str| {
6104 std::fs::read_to_string(p.join(f))
6105 .ok()
6106 .map(|s| s.trim().to_string())
6107 };
6108 if let Some(cap) = read("capacity") {
6109 let _ = write!(out, " Charge: {cap}%\n");
6110 }
6111 if let Some(status) = read("status") {
6112 let _ = write!(out, " Status: {status}\n");
6113 }
6114 if let (Some(full), Some(design)) =
6115 (read("energy_full"), read("energy_full_design"))
6116 {
6117 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6118 {
6119 if d > 0.0 {
6120 let _ = write!(
6121 out,
6122 " Wear level: {:.1}% of design capacity\n",
6123 (f / d) * 100.0
6124 );
6125 }
6126 }
6127 }
6128 }
6129 }
6130 }
6131 }
6132 }
6133 if !found {
6134 out.push_str("No battery found.\n");
6135 }
6136 }
6137
6138 Ok(out.trim_end().to_string())
6139}
6140
6141fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6144 let mut out = String::from("Host inspection: recent_crashes\n\n");
6145 let n = max_entries.clamp(1, 30);
6146
6147 #[cfg(target_os = "windows")]
6148 {
6149 let bsod_script = format!(
6151 r#"
6152try {{
6153 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6154 if ($events) {{
6155 $events | ForEach-Object {{
6156 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6157 }}
6158 }} else {{ "NO_BSOD" }}
6159}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6160 );
6161
6162 if let Ok(o) = Command::new("powershell")
6163 .args(["-NoProfile", "-Command", &bsod_script])
6164 .output()
6165 {
6166 let raw = String::from_utf8_lossy(&o.stdout);
6167 let text = raw.trim();
6168 if text == "NO_BSOD" {
6169 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6170 } else if text.starts_with("ERROR:") {
6171 out.push_str("System crashes: unable to query\n");
6172 } else {
6173 out.push_str("System crashes / unexpected shutdowns:\n");
6174 for line in text.lines() {
6175 let mut it = line.splitn(3, '|');
6176 if let (Some(time), Some(id), Some(msg)) = (it.next(), it.next(), it.next()) {
6177 let label = if id == "41" {
6178 "Unexpected shutdown"
6179 } else {
6180 "BSOD (BugCheck)"
6181 };
6182 let _ = writeln!(out, " [{time}] {label}: {msg}");
6183 }
6184 }
6185 out.push('\n');
6186 }
6187 }
6188
6189 let app_script = format!(
6191 r#"
6192try {{
6193 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6194 if ($crashes) {{
6195 $crashes | ForEach-Object {{
6196 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6197 }}
6198 }} else {{ "NO_CRASHES" }}
6199}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6200 );
6201
6202 if let Ok(o) = Command::new("powershell")
6203 .args(["-NoProfile", "-Command", &app_script])
6204 .output()
6205 {
6206 let raw = String::from_utf8_lossy(&o.stdout);
6207 let text = raw.trim();
6208 if text == "NO_CRASHES" {
6209 out.push_str("Application crashes: None in recent history\n");
6210 } else if text.starts_with("ERROR_APP:") {
6211 out.push_str("Application crashes: unable to query\n");
6212 } else {
6213 out.push_str("Application crashes:\n");
6214 for line in text.lines().take(n) {
6215 let mut it = line.splitn(2, '|');
6216 if let (Some(a), Some(b)) = (it.next(), it.next()) {
6217 let _ = writeln!(out, " [{}] {}", a, b);
6218 }
6219 }
6220 }
6221 }
6222 }
6223
6224 #[cfg(not(target_os = "windows"))]
6225 {
6226 let n_str = n.to_string();
6227 if let Ok(o) = Command::new("journalctl")
6228 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6229 .output()
6230 {
6231 let text = String::from_utf8_lossy(&o.stdout);
6232 let trimmed = text.trim();
6233 if trimmed.is_empty() || trimmed.contains("No entries") {
6234 out.push_str("No kernel panics or critical crashes found.\n");
6235 } else {
6236 out.push_str("Kernel critical events:\n");
6237 out.push_str(trimmed);
6238 out.push('\n');
6239 }
6240 }
6241 if let Ok(o) = Command::new("coredumpctl")
6242 .args(["list", "--no-pager"])
6243 .output()
6244 {
6245 let text = String::from_utf8_lossy(&o.stdout);
6246 let count = text
6247 .lines()
6248 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6249 .count();
6250 if count > 0 {
6251 let _ = write!(
6252 out,
6253 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
6254 );
6255 }
6256 }
6257 }
6258
6259 Ok(out.trim_end().to_string())
6260}
6261
6262fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6265 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6266 let n = max_entries.clamp(1, 30);
6267
6268 #[cfg(target_os = "windows")]
6269 {
6270 let script = format!(
6271 r#"
6272try {{
6273 $tasks = Get-ScheduledTask -ErrorAction Stop |
6274 Where-Object {{ $_.State -ne 'Disabled' }} |
6275 ForEach-Object {{
6276 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6277 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6278 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6279 }} else {{ "never" }}
6280 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6281 $exec = ($_.Actions | Select-Object -First 1).Execute
6282 if (-not $exec) {{ $exec = "(no exec)" }}
6283 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6284 }}
6285 $tasks | Select-Object -First {n}
6286}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6287 );
6288
6289 let output = Command::new("powershell")
6290 .args(["-NoProfile", "-Command", &script])
6291 .output()
6292 .map_err(|e| format!("scheduled_tasks: {e}"))?;
6293
6294 let raw = String::from_utf8_lossy(&output.stdout);
6295 let text = raw.trim();
6296
6297 if text.starts_with("ERROR:") {
6298 let _ = writeln!(out, "Unable to query scheduled tasks: {text}");
6299 } else if text.is_empty() {
6300 out.push_str("No active scheduled tasks found.\n");
6301 } else {
6302 let _ = write!(out, "Active scheduled tasks (up to {n}):\n\n");
6303 for line in text.lines() {
6304 let mut it = line.splitn(6, '|');
6305 if let (Some(name), Some(path), Some(state), Some(last), Some(res)) =
6306 (it.next(), it.next(), it.next(), it.next(), it.next())
6307 {
6308 let exec = it.next().unwrap_or("").trim();
6309 let display_path = path.trim_matches('\\');
6310 let display_path = if display_path.is_empty() {
6311 "Root"
6312 } else {
6313 display_path
6314 };
6315 let _ = writeln!(out, " {name} [{display_path}]");
6316 let _ = writeln!(out, " State: {state} | Last run: {last} | Result: {res}");
6317 if !exec.is_empty() && exec != "(no exec)" {
6318 let short = if exec.len() > 80 {
6319 safe_head(exec, 80)
6320 } else {
6321 exec
6322 };
6323 let _ = writeln!(out, " Runs: {short}");
6324 }
6325 }
6326 }
6327 }
6328 }
6329
6330 #[cfg(not(target_os = "windows"))]
6331 {
6332 if let Ok(o) = Command::new("systemctl")
6333 .args(["list-timers", "--no-pager", "--all"])
6334 .output()
6335 {
6336 let text = String::from_utf8_lossy(&o.stdout);
6337 out.push_str("Systemd timers:\n");
6338 for l in text
6339 .lines()
6340 .filter(|l| {
6341 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6342 })
6343 .take(n)
6344 {
6345 let _ = write!(out, " {l}\n");
6346 }
6347 out.push('\n');
6348 }
6349 if let Ok(o) = Command::new("crontab").arg("-l").output() {
6350 let text = String::from_utf8_lossy(&o.stdout);
6351 let jobs: Vec<&str> = text
6352 .lines()
6353 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6354 .collect();
6355 if !jobs.is_empty() {
6356 out.push_str("User crontab:\n");
6357 for j in jobs.iter().take(n) {
6358 let _ = write!(out, " {j}\n");
6359 }
6360 }
6361 }
6362 }
6363
6364 Ok(out.trim_end().to_string())
6365}
6366
6367fn inspect_dev_conflicts() -> Result<String, String> {
6370 let mut out = String::from("Host inspection: dev_conflicts\n\n");
6371 let mut conflicts: Vec<String> = Vec::with_capacity(4);
6372 let mut notes: Vec<String> = Vec::with_capacity(4);
6373
6374 {
6376 let node_ver = Command::new("node")
6377 .arg("--version")
6378 .output()
6379 .ok()
6380 .and_then(|o| String::from_utf8(o.stdout).ok())
6381 .map(|s| s.trim().to_string());
6382 let nvm_active = Command::new("nvm")
6383 .arg("current")
6384 .output()
6385 .ok()
6386 .and_then(|o| String::from_utf8(o.stdout).ok())
6387 .map(|s| s.trim().to_string())
6388 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6389 let fnm_active = Command::new("fnm")
6390 .arg("current")
6391 .output()
6392 .ok()
6393 .and_then(|o| String::from_utf8(o.stdout).ok())
6394 .map(|s| s.trim().to_string())
6395 .filter(|s| !s.is_empty() && !s.contains("none"));
6396 let volta_active = Command::new("volta")
6397 .args(["which", "node"])
6398 .output()
6399 .ok()
6400 .and_then(|o| String::from_utf8(o.stdout).ok())
6401 .map(|s| s.trim().to_string())
6402 .filter(|s| !s.is_empty());
6403
6404 out.push_str("Node.js:\n");
6405 if let Some(ref v) = node_ver {
6406 let _ = writeln!(out, " Active: {v}");
6407 } else {
6408 out.push_str(" Not installed\n");
6409 }
6410 let managers: Vec<&str> = [
6411 nvm_active.as_deref(),
6412 fnm_active.as_deref(),
6413 volta_active.as_deref(),
6414 ]
6415 .iter()
6416 .filter_map(|x| *x)
6417 .collect();
6418 if managers.len() > 1 {
6419 conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6420 } else if !managers.is_empty() {
6421 let _ = writeln!(out, " Version manager: {}", managers[0]);
6422 }
6423 out.push('\n');
6424 }
6425
6426 {
6428 let py3 = Command::new("python3")
6429 .arg("--version")
6430 .output()
6431 .ok()
6432 .and_then(|o| {
6433 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6434 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6435 let v = if stdout.is_empty() { stderr } else { stdout };
6436 if v.is_empty() {
6437 None
6438 } else {
6439 Some(v)
6440 }
6441 });
6442 let py = Command::new("python")
6443 .arg("--version")
6444 .output()
6445 .ok()
6446 .and_then(|o| {
6447 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6448 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6449 let v = if stdout.is_empty() { stderr } else { stdout };
6450 if v.is_empty() {
6451 None
6452 } else {
6453 Some(v)
6454 }
6455 });
6456 let pyenv = Command::new("pyenv")
6457 .arg("version")
6458 .output()
6459 .ok()
6460 .and_then(|o| String::from_utf8(o.stdout).ok())
6461 .map(|s| s.trim().to_string())
6462 .filter(|s| !s.is_empty());
6463 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6464
6465 out.push_str("Python:\n");
6466 match (&py3, &py) {
6467 (Some(v3), Some(v)) if v3 != v => {
6468 let _ = write!(out, " python3: {v3}\n python: {v}\n");
6469 if v.contains("2.") {
6470 conflicts.push(
6471 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6472 );
6473 } else {
6474 notes.push(
6475 "python and python3 resolve to different minor versions.".to_string(),
6476 );
6477 }
6478 }
6479 (Some(v3), None) => {
6480 let _ = writeln!(out, " python3: {v3}");
6481 }
6482 (None, Some(v)) => {
6483 let _ = writeln!(out, " python: {v}");
6484 }
6485 (Some(v3), Some(_)) => {
6486 let _ = writeln!(out, " {v3}");
6487 }
6488 (None, None) => out.push_str(" Not installed\n"),
6489 }
6490 if let Some(ref pe) = pyenv {
6491 let _ = writeln!(out, " pyenv: {pe}");
6492 }
6493 if let Some(env) = conda_env {
6494 if env == "base" {
6495 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6496 } else {
6497 let _ = writeln!(out, " conda env: {env}");
6498 }
6499 }
6500 out.push('\n');
6501 }
6502
6503 {
6505 let toolchain = Command::new("rustup")
6506 .args(["show", "active-toolchain"])
6507 .output()
6508 .ok()
6509 .and_then(|o| String::from_utf8(o.stdout).ok())
6510 .map(|s| s.trim().to_string())
6511 .filter(|s| !s.is_empty());
6512 let cargo_ver = Command::new("cargo")
6513 .arg("--version")
6514 .output()
6515 .ok()
6516 .and_then(|o| String::from_utf8(o.stdout).ok())
6517 .map(|s| s.trim().to_string());
6518 let rustc_ver = Command::new("rustc")
6519 .arg("--version")
6520 .output()
6521 .ok()
6522 .and_then(|o| String::from_utf8(o.stdout).ok())
6523 .map(|s| s.trim().to_string());
6524
6525 out.push_str("Rust:\n");
6526 if let Some(ref t) = toolchain {
6527 let _ = writeln!(out, " Active toolchain: {t}");
6528 }
6529 if let Some(ref c) = cargo_ver {
6530 let _ = writeln!(out, " {c}");
6531 }
6532 if let Some(ref r) = rustc_ver {
6533 let _ = writeln!(out, " {r}");
6534 }
6535 if cargo_ver.is_none() && rustc_ver.is_none() {
6536 out.push_str(" Not installed\n");
6537 }
6538
6539 #[cfg(not(target_os = "windows"))]
6541 if let Ok(o) = Command::new("which").arg("rustc").output() {
6542 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6543 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6544 conflicts.push(format!(
6545 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6546 ));
6547 }
6548 }
6549 out.push('\n');
6550 }
6551
6552 {
6554 let git_ver = Command::new("git")
6555 .arg("--version")
6556 .output()
6557 .ok()
6558 .and_then(|o| String::from_utf8(o.stdout).ok())
6559 .map(|s| s.trim().to_string());
6560 out.push_str("Git:\n");
6561 if let Some(ref v) = git_ver {
6562 let _ = writeln!(out, " {v}");
6563 let email = Command::new("git")
6564 .args(["config", "--global", "user.email"])
6565 .output()
6566 .ok()
6567 .and_then(|o| String::from_utf8(o.stdout).ok())
6568 .map(|s| s.trim().to_string());
6569 if let Some(ref e) = email {
6570 if e.is_empty() {
6571 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6572 } else {
6573 let _ = writeln!(out, " user.email: {e}");
6574 }
6575 }
6576 let gpg_sign = Command::new("git")
6577 .args(["config", "--global", "commit.gpgsign"])
6578 .output()
6579 .ok()
6580 .and_then(|o| String::from_utf8(o.stdout).ok())
6581 .map(|s| s.trim().to_string());
6582 if gpg_sign.as_deref() == Some("true") {
6583 let key = Command::new("git")
6584 .args(["config", "--global", "user.signingkey"])
6585 .output()
6586 .ok()
6587 .and_then(|o| String::from_utf8(o.stdout).ok())
6588 .map(|s| s.trim().to_string());
6589 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6590 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6591 }
6592 }
6593 } else {
6594 out.push_str(" Not installed\n");
6595 }
6596 out.push('\n');
6597 }
6598
6599 {
6601 let path_env = std::env::var("PATH").unwrap_or_default();
6602 let sep = if cfg!(windows) { ';' } else { ':' };
6603 let mut seen = HashSet::new();
6604 let mut dupes: Vec<String> = Vec::new();
6605 for p in path_env.split(sep) {
6606 let norm = p.trim().to_lowercase();
6607 if !norm.is_empty() && !seen.insert(norm) {
6608 dupes.push(p.to_string());
6609 }
6610 }
6611 if !dupes.is_empty() {
6612 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6613 notes.push(format!(
6614 "Duplicate PATH entries: {} {}",
6615 shown.join(", "),
6616 if dupes.len() > 3 {
6617 format!("+{} more", dupes.len() - 3)
6618 } else {
6619 String::new()
6620 }
6621 ));
6622 }
6623 }
6624
6625 if conflicts.is_empty() && notes.is_empty() {
6627 out.push_str("No conflicts detected — dev environment looks clean.\n");
6628 } else {
6629 if !conflicts.is_empty() {
6630 out.push_str("CONFLICTS:\n");
6631 for c in &conflicts {
6632 let _ = writeln!(out, " [!] {c}");
6633 }
6634 out.push('\n');
6635 }
6636 if !notes.is_empty() {
6637 out.push_str("NOTES:\n");
6638 for n in ¬es {
6639 let _ = writeln!(out, " [-] {n}");
6640 }
6641 }
6642 }
6643
6644 Ok(out.trim_end().to_string())
6645}
6646
6647async fn inspect_public_ip() -> Result<String, String> {
6650 let mut out = String::from("Host inspection: public_ip\n\n");
6651
6652 let client = reqwest::Client::builder()
6653 .timeout(std::time::Duration::from_secs(5))
6654 .build()
6655 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
6656
6657 match client.get("https://api.ipify.org?format=json").send().await {
6658 Ok(resp) => {
6659 if let Ok(json) = resp.json::<serde_json::Value>().await {
6660 let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
6661 let _ = writeln!(out, "Public IP: {}", ip);
6662
6663 if let Ok(geo_resp) = client
6665 .get(format!("http://ip-api.com/json/{}", ip))
6666 .send()
6667 .await
6668 {
6669 if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
6670 if let (Some(city), Some(region), Some(country), Some(isp)) = (
6671 geo_json.get("city").and_then(|v| v.as_str()),
6672 geo_json.get("regionName").and_then(|v| v.as_str()),
6673 geo_json.get("country").and_then(|v| v.as_str()),
6674 geo_json.get("isp").and_then(|v| v.as_str()),
6675 ) {
6676 let _ = writeln!(out, "Location: {}, {} ({})", city, region, country);
6677 let _ = writeln!(out, "ISP: {}", isp);
6678 }
6679 }
6680 }
6681 } else {
6682 out.push_str("Error: Failed to parse public IP response.\n");
6683 }
6684 }
6685 Err(e) => {
6686 let _ = writeln!(
6687 out,
6688 "Error: Failed to fetch public IP ({}). Check internet connectivity.",
6689 e
6690 );
6691 }
6692 }
6693
6694 Ok(out)
6695}
6696
6697fn inspect_ssl_cert(host: &str) -> Result<String, String> {
6698 let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
6699
6700 #[cfg(target_os = "windows")]
6701 {
6702 use std::process::Command;
6703 let script = format!(
6704 r#"$domain = "{host}"
6705try {{
6706 $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
6707 $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
6708 $sslStream.AuthenticateAsClient($domain)
6709 $cert = $sslStream.RemoteCertificate
6710 $tcpClient.Close()
6711 if ($cert) {{
6712 $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
6713 $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
6714 }} else {{
6715 "null"
6716 }}
6717}} catch {{
6718 "ERROR:" + $_.Exception.Message
6719}}"#
6720 );
6721
6722 let ps_out = Command::new("powershell")
6723 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
6724 .output()
6725 .map_err(|e| format!("powershell launch failed: {e}"))?;
6726
6727 let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
6728 if text.starts_with("ERROR:") {
6729 let _ = writeln!(out, "Error: {}", text.trim_start_matches("ERROR:"));
6730 } else if text == "null" || text.is_empty() {
6731 out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
6732 } else if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
6733 if let Some(obj) = json.as_object() {
6734 for (k, v) in obj {
6735 let val_str = v.as_str().unwrap_or("");
6736 let _ = writeln!(out, "{:<12}: {}", k, val_str);
6737 }
6738
6739 if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
6740 if not_after_raw.starts_with("/Date(") {
6741 let ts = not_after_raw
6742 .trim_start_matches("/Date(")
6743 .trim_end_matches(")/")
6744 .parse::<i64>()
6745 .unwrap_or(0);
6746 let expiry =
6747 chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
6748 let now = chrono::Utc::now();
6749 let days_left = expiry.signed_duration_since(now).num_days();
6750 if days_left < 0 {
6751 out.push_str("\nSTATUS: [!!] EXPIRED\n");
6752 } else if days_left < 30 {
6753 let _ = write!(
6754 out,
6755 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6756 days_left
6757 );
6758 } else {
6759 let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
6760 }
6761 } else if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw) {
6762 let now = chrono::Utc::now();
6763 let days_left = expiry.signed_duration_since(now).num_days();
6764 if days_left < 0 {
6765 out.push_str("\nSTATUS: [!!] EXPIRED\n");
6766 } else if days_left < 30 {
6767 let _ = write!(
6768 out,
6769 "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6770 days_left
6771 );
6772 } else {
6773 let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
6774 }
6775 }
6776 }
6777 }
6778 } else {
6779 let _ = writeln!(out, "Raw Output: {}", text);
6780 }
6781 }
6782
6783 #[cfg(not(target_os = "windows"))]
6784 {
6785 out.push_str(
6786 "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
6787 );
6788 }
6789
6790 Ok(out)
6791}
6792
6793async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
6794 let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
6795
6796 if !path.exists() {
6797 return Err(format!("File not found: {:?}", path));
6798 }
6799 if !path.is_file() {
6800 return Err(format!("Not a file: {:?}", path));
6801 }
6802
6803 let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6804 let _ = writeln!(
6805 out,
6806 "File Size: {} bytes ({:.2} MB)",
6807 file_size,
6808 file_size as f64 / 1_048_576.0
6809 );
6810
6811 let ext = path
6812 .extension()
6813 .and_then(|s| s.to_str())
6814 .unwrap_or("")
6815 .to_lowercase();
6816 let _ = write!(out, "Format: {}\n\n", ext.to_uppercase());
6817
6818 match ext.as_str() {
6819 "csv" | "tsv" | "txt" | "log" => {
6820 let content = std::fs::read_to_string(&path)
6821 .map_err(|e| format!("Failed to read file: {}", e))?;
6822 let lines: Vec<&str> = content.lines().collect();
6823 let _ = writeln!(out, "Row Count: {} (total lines)", lines.len());
6824
6825 if let Some(header) = lines.first() {
6826 out.push_str("Columns (Guessed from header):\n");
6827 let delimiter = if ext == "tsv" {
6828 "\t"
6829 } else if header.contains(',') {
6830 ","
6831 } else {
6832 " "
6833 };
6834 for (i, col) in header.split(delimiter).map(|s| s.trim()).enumerate() {
6835 let _ = writeln!(out, " {}. {}", i + 1, col);
6836 }
6837 }
6838
6839 out.push_str("\nSample Data (First 5 rows):\n");
6840 for line in lines.iter().take(6) {
6841 let _ = writeln!(out, " {}", line);
6842 }
6843 }
6844 "json" => {
6845 let content = std::fs::read_to_string(&path)
6846 .map_err(|e| format!("Failed to read file: {}", e))?;
6847 if let Ok(json) = serde_json::from_str::<Value>(&content) {
6848 if let Some(arr) = json.as_array() {
6849 let _ = writeln!(out, "Record Count: {}", arr.len());
6850 if let Some(first) = arr.first() {
6851 if let Some(obj) = first.as_object() {
6852 out.push_str("Fields (from first record):\n");
6853 for k in obj.keys() {
6854 let _ = writeln!(out, " - {}", k);
6855 }
6856 }
6857 }
6858 out.push_str("\nSample Record:\n");
6859 out.push_str(&serde_json::to_string_pretty(&arr.first()).unwrap_or_default());
6860 } else if let Some(obj) = json.as_object() {
6861 out.push_str("Top-level Keys:\n");
6862 for k in obj.keys() {
6863 let _ = writeln!(out, " - {}", k);
6864 }
6865 }
6866 } else {
6867 out.push_str("Error: Failed to parse as JSON.\n");
6868 }
6869 }
6870 "db" | "sqlite" | "sqlite3" => {
6871 out.push_str("SQLite Database detected.\n");
6872 out.push_str("Use `query_data` to execute SQL against this database.\n");
6873 }
6874 _ => {
6875 out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
6876 let content = std::fs::read_to_string(&path)
6877 .map_err(|e| format!("Failed to read file: {}", e))?;
6878 for line in content.lines().take(10) {
6879 let _ = writeln!(out, " {}", line);
6880 }
6881 }
6882 }
6883
6884 Ok(out)
6885}
6886
6887fn inspect_connectivity() -> Result<String, String> {
6888 let mut out = String::from("Host inspection: connectivity\n\n");
6889
6890 #[cfg(target_os = "windows")]
6891 {
6892 let inet_script = r#"
6893try {
6894 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6895 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6896} catch { "ERROR:" + $_.Exception.Message }
6897"#;
6898 if let Ok(o) = Command::new("powershell")
6899 .args(["-NoProfile", "-Command", inet_script])
6900 .output()
6901 {
6902 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6903 match text.as_str() {
6904 "REACHABLE" => out.push_str("Internet: reachable\n"),
6905 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6906 _ => {
6907 let _ = writeln!(
6908 out,
6909 "Internet: {}",
6910 text.trim_start_matches("ERROR:").trim()
6911 );
6912 }
6913 }
6914 }
6915
6916 let dns_script = r#"
6917try {
6918 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6919 "DNS:ok"
6920} catch { "DNS:fail:" + $_.Exception.Message }
6921"#;
6922 if let Ok(o) = Command::new("powershell")
6923 .args(["-NoProfile", "-Command", dns_script])
6924 .output()
6925 {
6926 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6927 if text == "DNS:ok" {
6928 out.push_str("DNS: resolving correctly\n");
6929 } else {
6930 let detail = text.trim_start_matches("DNS:fail:").trim();
6931 let _ = writeln!(out, "DNS: failed — {}", detail);
6932 }
6933 }
6934
6935 let gw_script = r#"
6936(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6937"#;
6938 if let Ok(o) = Command::new("powershell")
6939 .args(["-NoProfile", "-Command", gw_script])
6940 .output()
6941 {
6942 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6943 if !gw.is_empty() && gw != "0.0.0.0" {
6944 let _ = writeln!(out, "Default gateway: {}", gw);
6945 }
6946 }
6947 }
6948
6949 #[cfg(not(target_os = "windows"))]
6950 {
6951 let reachable = Command::new("ping")
6952 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6953 .output()
6954 .map(|o| o.status.success())
6955 .unwrap_or(false);
6956 out.push_str(if reachable {
6957 "Internet: reachable\n"
6958 } else {
6959 "Internet: unreachable\n"
6960 });
6961 let dns_ok = Command::new("getent")
6962 .args(["hosts", "dns.google"])
6963 .output()
6964 .map(|o| o.status.success())
6965 .unwrap_or(false);
6966 out.push_str(if dns_ok {
6967 "DNS: resolving correctly\n"
6968 } else {
6969 "DNS: failed\n"
6970 });
6971 if let Ok(o) = Command::new("ip")
6972 .args(["route", "show", "default"])
6973 .output()
6974 {
6975 let text = String::from_utf8_lossy(&o.stdout);
6976 if let Some(line) = text.lines().next() {
6977 let _ = write!(out, "Default gateway: {}\n", line.trim());
6978 }
6979 }
6980 }
6981
6982 Ok(out.trim_end().to_string())
6983}
6984
6985fn inspect_wifi() -> Result<String, String> {
6988 let mut out = String::from("Host inspection: wifi\n\n");
6989
6990 #[cfg(target_os = "windows")]
6991 {
6992 let output = Command::new("netsh")
6993 .args(["wlan", "show", "interfaces"])
6994 .output()
6995 .map_err(|e| format!("wifi: {e}"))?;
6996 let text = String::from_utf8_lossy(&output.stdout).into_owned();
6997
6998 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6999 out.push_str("No wireless interface detected on this machine.\n");
7000 return Ok(out.trim_end().to_string());
7001 }
7002
7003 let fields = [
7004 ("SSID", "SSID"),
7005 ("State", "State"),
7006 ("Signal", "Signal"),
7007 ("Radio type", "Radio type"),
7008 ("Channel", "Channel"),
7009 ("Receive rate (Mbps)", "Download speed (Mbps)"),
7010 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
7011 ("Authentication", "Authentication"),
7012 ("Network type", "Network type"),
7013 ];
7014
7015 let mut any = false;
7016 for line in text.lines() {
7017 let trimmed = line.trim();
7018 for (key, label) in &fields {
7019 if trimmed.starts_with(key) && trimmed.contains(':') {
7020 let val = trimmed.split_once(':').map(|x| x.1).unwrap_or("").trim();
7021 if !val.is_empty() {
7022 let _ = writeln!(out, " {label}: {val}");
7023 any = true;
7024 }
7025 }
7026 }
7027 }
7028 if !any {
7029 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
7030 }
7031 }
7032
7033 #[cfg(not(target_os = "windows"))]
7034 {
7035 if let Ok(o) = Command::new("nmcli")
7036 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
7037 .output()
7038 {
7039 let text = String::from_utf8_lossy(&o.stdout).into_owned();
7040 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
7041 if lines.is_empty() {
7042 out.push_str("No Wi-Fi devices found.\n");
7043 } else {
7044 for l in lines {
7045 let _ = write!(out, " {l}\n");
7046 }
7047 }
7048 } else if let Ok(o) = Command::new("iwconfig").output() {
7049 let text = String::from_utf8_lossy(&o.stdout).into_owned();
7050 if !text.trim().is_empty() {
7051 out.push_str(text.trim());
7052 out.push('\n');
7053 }
7054 } else {
7055 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7056 }
7057 }
7058
7059 Ok(out.trim_end().to_string())
7060}
7061
7062fn inspect_connections(max_entries: usize) -> Result<String, String> {
7065 let mut out = String::from("Host inspection: connections\n\n");
7066 let n = max_entries.clamp(1, 25);
7067
7068 #[cfg(target_os = "windows")]
7069 {
7070 let script = format!(
7071 r#"
7072try {{
7073 $procs = @{{}}
7074 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7075 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7076 Sort-Object OwningProcess
7077 "TOTAL:" + $all.Count
7078 $all | Select-Object -First {n} | ForEach-Object {{
7079 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7080 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7081 }}
7082}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7083 );
7084
7085 let output = Command::new("powershell")
7086 .args(["-NoProfile", "-Command", &script])
7087 .output()
7088 .map_err(|e| format!("connections: {e}"))?;
7089
7090 let raw = String::from_utf8_lossy(&output.stdout);
7091 let text = raw.trim();
7092
7093 if text.starts_with("ERROR:") {
7094 let _ = writeln!(out, "Unable to query connections: {text}");
7095 } else {
7096 let mut total = 0usize;
7097 let mut rows = Vec::new();
7098 for line in text.lines() {
7099 if let Some(rest) = line.strip_prefix("TOTAL:") {
7100 total = rest.trim().parse().unwrap_or(0);
7101 } else {
7102 rows.push(line);
7103 }
7104 }
7105 let _ = write!(out, "Established TCP connections: {total}\n\n");
7106 for row in &rows {
7107 let mut it = row.splitn(4, '|');
7108 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
7109 (it.next(), it.next(), it.next(), it.next())
7110 {
7111 let _ = writeln!(out, " {:<15} (pid {:<5}) | {} → {}", p0, p1, p2, p3);
7112 }
7113 }
7114 if total > n {
7115 let _ = write!(
7116 out,
7117 "\n ... {} more connections not shown\n",
7118 total.saturating_sub(n)
7119 );
7120 }
7121 }
7122 }
7123
7124 #[cfg(not(target_os = "windows"))]
7125 {
7126 if let Ok(o) = Command::new("ss")
7127 .args(["-tnp", "state", "established"])
7128 .output()
7129 {
7130 let text = String::from_utf8_lossy(&o.stdout);
7131 let lines: Vec<&str> = text
7132 .lines()
7133 .skip(1)
7134 .filter(|l| !l.trim().is_empty())
7135 .collect();
7136 let _ = write!(out, "Established TCP connections: {}\n\n", lines.len());
7137 for line in lines.iter().take(n) {
7138 let _ = write!(out, " {}\n", line.trim());
7139 }
7140 if lines.len() > n {
7141 let _ = write!(out, "\n ... {} more not shown\n", lines.len() - n);
7142 }
7143 } else {
7144 out.push_str("ss not available — install iproute2\n");
7145 }
7146 }
7147
7148 Ok(out.trim_end().to_string())
7149}
7150
7151fn inspect_vpn() -> Result<String, String> {
7154 let mut out = String::from("Host inspection: vpn\n\n");
7155
7156 #[cfg(target_os = "windows")]
7157 {
7158 let script = r#"
7159try {
7160 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7161 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7162 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7163 }
7164 if ($vpn) {
7165 foreach ($a in $vpn) {
7166 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7167 }
7168 } else { "NONE" }
7169} catch { "ERROR:" + $_.Exception.Message }
7170"#;
7171 let output = Command::new("powershell")
7172 .args(["-NoProfile", "-Command", script])
7173 .output()
7174 .map_err(|e| format!("vpn: {e}"))?;
7175
7176 let raw = String::from_utf8_lossy(&output.stdout);
7177 let text = raw.trim();
7178
7179 if text == "NONE" {
7180 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7181 } else if text.starts_with("ERROR:") {
7182 let _ = writeln!(out, "Unable to query adapters: {text}");
7183 } else {
7184 out.push_str("VPN adapters:\n\n");
7185 for line in text.lines() {
7186 let mut it = line.splitn(4, '|');
7187 if let (Some(name), Some(desc), Some(status)) = (it.next(), it.next(), it.next()) {
7188 let media = it.next().unwrap_or("unknown");
7189 let label = if status.trim() == "Up" {
7190 "CONNECTED"
7191 } else {
7192 "disconnected"
7193 };
7194 let _ =
7195 write!(out,
7196 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
7197 );
7198 }
7199 }
7200 }
7201
7202 let ras_script = r#"
7204try {
7205 $c = Get-VpnConnection -ErrorAction Stop
7206 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7207 else { "NO_RAS" }
7208} catch { "NO_RAS" }
7209"#;
7210 if let Ok(o) = Command::new("powershell")
7211 .args(["-NoProfile", "-Command", ras_script])
7212 .output()
7213 {
7214 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7215 if t != "NO_RAS" && !t.is_empty() {
7216 out.push_str("Windows VPN connections:\n");
7217 for line in t.lines() {
7218 let mut it = line.splitn(3, '|');
7219 if let (Some(name), Some(status)) = (it.next(), it.next()) {
7220 let server = it.next().unwrap_or("");
7221 let _ = writeln!(out, " {name} → {server} [{status}]");
7222 }
7223 }
7224 }
7225 }
7226 }
7227
7228 #[cfg(not(target_os = "windows"))]
7229 {
7230 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7231 let text = String::from_utf8_lossy(&o.stdout);
7232 let vpn_ifaces: Vec<&str> = text
7233 .lines()
7234 .filter(|l| {
7235 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7236 })
7237 .collect();
7238 if vpn_ifaces.is_empty() {
7239 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7240 } else {
7241 let _ = write!(out, "VPN-like interfaces ({}):\n", vpn_ifaces.len());
7242 for l in vpn_ifaces {
7243 let _ = write!(out, " {}\n", l.trim());
7244 }
7245 }
7246 }
7247 }
7248
7249 Ok(out.trim_end().to_string())
7250}
7251
7252fn inspect_proxy() -> Result<String, String> {
7255 let mut out = String::from("Host inspection: proxy\n\n");
7256
7257 #[cfg(target_os = "windows")]
7258 {
7259 let script = r#"
7260$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7261if ($ie) {
7262 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7263} else { "NONE" }
7264"#;
7265 if let Ok(o) = Command::new("powershell")
7266 .args(["-NoProfile", "-Command", script])
7267 .output()
7268 {
7269 let raw = String::from_utf8_lossy(&o.stdout);
7270 let text = raw.trim();
7271 if text != "NONE" && !text.is_empty() {
7272 let get = |key: &str| -> &str {
7273 text.split('|')
7274 .find(|s| s.starts_with(key))
7275 .and_then(|s| s.split_once(':').map(|x| x.1))
7276 .unwrap_or("")
7277 };
7278 let enabled = get("ENABLE");
7279 let server = get("SERVER");
7280 let overrides = get("OVERRIDE");
7281 out.push_str("WinINET / IE proxy:\n");
7282 let _ = writeln!(
7283 out,
7284 " Enabled: {}",
7285 if enabled == "1" { "yes" } else { "no" }
7286 );
7287 if !server.is_empty() && server != "None" {
7288 let _ = writeln!(out, " Proxy server: {server}");
7289 }
7290 if !overrides.is_empty() && overrides != "None" {
7291 let _ = writeln!(out, " Bypass list: {overrides}");
7292 }
7293 out.push('\n');
7294 }
7295 }
7296
7297 if let Ok(o) = Command::new("netsh")
7298 .args(["winhttp", "show", "proxy"])
7299 .output()
7300 {
7301 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7302 out.push_str("WinHTTP proxy:\n");
7303 for line in text.lines() {
7304 let l = line.trim();
7305 if !l.is_empty() {
7306 let _ = writeln!(out, " {l}");
7307 }
7308 }
7309 out.push('\n');
7310 }
7311
7312 let mut env_found = false;
7313 for var in &[
7314 "http_proxy",
7315 "https_proxy",
7316 "HTTP_PROXY",
7317 "HTTPS_PROXY",
7318 "no_proxy",
7319 "NO_PROXY",
7320 ] {
7321 if let Ok(val) = std::env::var(var) {
7322 if !env_found {
7323 out.push_str("Environment proxy variables:\n");
7324 env_found = true;
7325 }
7326 let _ = writeln!(out, " {var}: {val}");
7327 }
7328 }
7329 if !env_found {
7330 out.push_str("No proxy environment variables set.\n");
7331 }
7332 }
7333
7334 #[cfg(not(target_os = "windows"))]
7335 {
7336 let mut found = false;
7337 for var in &[
7338 "http_proxy",
7339 "https_proxy",
7340 "HTTP_PROXY",
7341 "HTTPS_PROXY",
7342 "no_proxy",
7343 "NO_PROXY",
7344 "ALL_PROXY",
7345 "all_proxy",
7346 ] {
7347 if let Ok(val) = std::env::var(var) {
7348 if !found {
7349 out.push_str("Proxy environment variables:\n");
7350 found = true;
7351 }
7352 let _ = write!(out, " {var}: {val}\n");
7353 }
7354 }
7355 if !found {
7356 out.push_str("No proxy environment variables set.\n");
7357 }
7358 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7359 let proxy_lines: Vec<&str> = content
7360 .lines()
7361 .filter(|l| l.to_lowercase().contains("proxy"))
7362 .collect();
7363 if !proxy_lines.is_empty() {
7364 out.push_str("\nSystem proxy (/etc/environment):\n");
7365 for l in proxy_lines {
7366 let _ = write!(out, " {l}\n");
7367 }
7368 }
7369 }
7370 }
7371
7372 Ok(out.trim_end().to_string())
7373}
7374
7375fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7378 let mut out = String::from("Host inspection: firewall_rules\n\n");
7379 let n = max_entries.clamp(1, 20);
7380
7381 #[cfg(target_os = "windows")]
7382 {
7383 let script = format!(
7384 r#"
7385try {{
7386 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7387 Where-Object {{
7388 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7389 $_.Owner -eq $null
7390 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7391 "TOTAL:" + $rules.Count
7392 $rules | ForEach-Object {{
7393 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7394 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7395 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7396 }}
7397}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7398 );
7399
7400 let output = Command::new("powershell")
7401 .args(["-NoProfile", "-Command", &script])
7402 .output()
7403 .map_err(|e| format!("firewall_rules: {e}"))?;
7404
7405 let raw = String::from_utf8_lossy(&output.stdout);
7406 let text = raw.trim();
7407
7408 if text.starts_with("ERROR:") {
7409 let _ = writeln!(
7410 out,
7411 "Unable to query firewall rules: {}",
7412 text.trim_start_matches("ERROR:").trim()
7413 );
7414 out.push_str("This query may require running as administrator.\n");
7415 } else if text.is_empty() {
7416 out.push_str("No non-default enabled firewall rules found.\n");
7417 } else {
7418 let mut total = 0usize;
7419 for line in text.lines() {
7420 if let Some(rest) = line.strip_prefix("TOTAL:") {
7421 total = rest.trim().parse().unwrap_or(0);
7422 let _ = write!(out, "Non-default enabled rules (showing up to {n}):\n\n");
7423 } else {
7424 let mut it = line.splitn(4, '|');
7425 if let (Some(name), Some(dir), Some(action)) = (it.next(), it.next(), it.next())
7426 {
7427 let profile = it.next().unwrap_or("Any");
7428 let icon = if action == "Block" { "[!]" } else { " " };
7429 let _ = writeln!(
7430 out,
7431 " {icon} [{dir}] {action}: {name} (profile: {profile})"
7432 );
7433 }
7434 }
7435 }
7436 if total == 0 {
7437 out.push_str("No non-default enabled rules found.\n");
7438 }
7439 }
7440 }
7441
7442 #[cfg(not(target_os = "windows"))]
7443 {
7444 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7445 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7446 if !text.is_empty() {
7447 out.push_str(&text);
7448 out.push('\n');
7449 }
7450 } else if let Ok(o) = Command::new("iptables")
7451 .args(["-L", "-n", "--line-numbers"])
7452 .output()
7453 {
7454 let text = String::from_utf8_lossy(&o.stdout);
7455 for l in text.lines().take(n * 2) {
7456 let _ = write!(out, " {l}\n");
7457 }
7458 } else {
7459 out.push_str("ufw and iptables not available or insufficient permissions.\n");
7460 }
7461 }
7462
7463 Ok(out.trim_end().to_string())
7464}
7465
7466fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7469 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7470 let hops = max_entries.clamp(5, 30);
7471
7472 #[cfg(target_os = "windows")]
7473 {
7474 let output = Command::new("tracert")
7475 .args(["-d", "-h", &hops.to_string(), host])
7476 .output()
7477 .map_err(|e| format!("tracert: {e}"))?;
7478 let raw = String::from_utf8_lossy(&output.stdout);
7479 let mut hop_count = 0usize;
7480 for line in raw.lines() {
7481 let trimmed = line.trim();
7482 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7483 hop_count += 1;
7484 let _ = writeln!(out, " {trimmed}");
7485 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7486 let _ = writeln!(out, "{trimmed}");
7487 }
7488 }
7489 if hop_count == 0 {
7490 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7491 }
7492 }
7493
7494 #[cfg(not(target_os = "windows"))]
7495 {
7496 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7497 || std::path::Path::new("/usr/sbin/traceroute").exists()
7498 {
7499 "traceroute"
7500 } else {
7501 "tracepath"
7502 };
7503 let output = Command::new(cmd)
7504 .args(["-m", &hops.to_string(), "-n", host])
7505 .output()
7506 .map_err(|e| format!("{cmd}: {e}"))?;
7507 let raw = String::from_utf8_lossy(&output.stdout);
7508 let mut hop_count = 0usize;
7509 for line in raw.lines().take(hops + 2) {
7510 let trimmed = line.trim();
7511 if !trimmed.is_empty() {
7512 hop_count += 1;
7513 let _ = write!(out, " {trimmed}\n");
7514 }
7515 }
7516 if hop_count == 0 {
7517 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7518 }
7519 }
7520
7521 Ok(out.trim_end().to_string())
7522}
7523
7524fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7527 let mut out = String::from("Host inspection: dns_cache\n\n");
7528 let n = max_entries.clamp(10, 100);
7529
7530 #[cfg(target_os = "windows")]
7531 {
7532 let output = Command::new("powershell")
7533 .args([
7534 "-NoProfile",
7535 "-Command",
7536 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7537 ])
7538 .output()
7539 .map_err(|e| format!("dns_cache: {e}"))?;
7540
7541 let raw = String::from_utf8_lossy(&output.stdout);
7542 let lines: Vec<&str> = raw.lines().skip(1).collect();
7543 let total = lines.len();
7544
7545 if total == 0 {
7546 out.push_str("DNS cache is empty or could not be read.\n");
7547 } else {
7548 let _ = write!(out, "DNS cache entries (showing up to {n} of {total}):\n\n");
7549 let mut shown = 0usize;
7550 for line in lines.iter().take(n) {
7551 let mut it = line.splitn(4, ',');
7552 if let (Some(e), Some(rt), Some(d)) = (it.next(), it.next(), it.next()) {
7553 let entry = e.trim_matches('"');
7554 let rtype = rt.trim_matches('"');
7555 let data = d.trim_matches('"');
7556 let ttl = it.next().map(|s| s.trim_matches('"')).unwrap_or("?");
7557 let _ = writeln!(out, " {entry:<45} {rtype:<6} {data} (TTL {ttl}s)");
7558 shown += 1;
7559 }
7560 }
7561 if total > shown {
7562 let _ = write!(out, "\n ... and {} more entries\n", total - shown);
7563 }
7564 }
7565 }
7566
7567 #[cfg(not(target_os = "windows"))]
7568 {
7569 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7570 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7571 if !text.is_empty() {
7572 out.push_str("systemd-resolved statistics:\n");
7573 for line in text.lines().take(n) {
7574 let _ = write!(out, " {line}\n");
7575 }
7576 out.push('\n');
7577 }
7578 }
7579 if let Ok(o) = Command::new("dscacheutil")
7580 .args(["-cachedump", "-entries", "Host"])
7581 .output()
7582 {
7583 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7584 if !text.is_empty() {
7585 out.push_str("DNS cache (macOS dscacheutil):\n");
7586 for line in text.lines().take(n) {
7587 let _ = write!(out, " {line}\n");
7588 }
7589 } else {
7590 out.push_str("DNS cache is empty or not accessible on this platform.\n");
7591 }
7592 } else {
7593 out.push_str(
7594 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7595 );
7596 }
7597 }
7598
7599 Ok(out.trim_end().to_string())
7600}
7601
7602fn inspect_arp() -> Result<String, String> {
7605 let mut out = String::from("Host inspection: arp\n\n");
7606
7607 #[cfg(target_os = "windows")]
7608 {
7609 let output = Command::new("arp")
7610 .args(["-a"])
7611 .output()
7612 .map_err(|e| format!("arp: {e}"))?;
7613 let raw = String::from_utf8_lossy(&output.stdout);
7614 let mut count = 0usize;
7615 for line in raw.lines() {
7616 let t = line.trim();
7617 if t.is_empty() {
7618 continue;
7619 }
7620 let _ = writeln!(out, " {t}");
7621 if t.contains("dynamic") || t.contains("static") {
7622 count += 1;
7623 }
7624 }
7625 let _ = write!(out, "\nTotal entries: {count}\n");
7626 }
7627
7628 #[cfg(not(target_os = "windows"))]
7629 {
7630 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7631 let raw = String::from_utf8_lossy(&o.stdout);
7632 let mut count = 0usize;
7633 for line in raw.lines() {
7634 let t = line.trim();
7635 if !t.is_empty() {
7636 let _ = write!(out, " {t}\n");
7637 count += 1;
7638 }
7639 }
7640 let _ = write!(out, "\nTotal entries: {}\n", count.saturating_sub(1));
7641 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7642 let raw = String::from_utf8_lossy(&o.stdout);
7643 let mut count = 0usize;
7644 for line in raw.lines() {
7645 let t = line.trim();
7646 if !t.is_empty() {
7647 let _ = write!(out, " {t}\n");
7648 count += 1;
7649 }
7650 }
7651 let _ = write!(out, "\nTotal entries: {count}\n");
7652 } else {
7653 out.push_str("arp and ip neigh not available.\n");
7654 }
7655 }
7656
7657 Ok(out.trim_end().to_string())
7658}
7659
7660fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7663 let mut out = String::from("Host inspection: route_table\n\n");
7664 let n = max_entries.clamp(10, 50);
7665
7666 #[cfg(target_os = "windows")]
7667 {
7668 let script = r#"
7669try {
7670 $routes = Get-NetRoute -ErrorAction Stop |
7671 Where-Object { $_.RouteMetric -lt 9000 } |
7672 Sort-Object RouteMetric |
7673 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7674 "TOTAL:" + $routes.Count
7675 $routes | ForEach-Object {
7676 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7677 }
7678} catch { "ERROR:" + $_.Exception.Message }
7679"#;
7680 let output = Command::new("powershell")
7681 .args(["-NoProfile", "-Command", script])
7682 .output()
7683 .map_err(|e| format!("route_table: {e}"))?;
7684 let raw = String::from_utf8_lossy(&output.stdout);
7685 let text = raw.trim();
7686
7687 if text.starts_with("ERROR:") {
7688 let _ = writeln!(
7689 out,
7690 "Unable to read route table: {}",
7691 text.trim_start_matches("ERROR:").trim()
7692 );
7693 } else {
7694 let mut shown = 0usize;
7695 for line in text.lines() {
7696 if let Some(rest) = line.strip_prefix("TOTAL:") {
7697 let total: usize = rest.trim().parse().unwrap_or(0);
7698 let _ = write!(
7699 out,
7700 "Routing table (showing up to {n} of {total} routes):\n\n"
7701 );
7702 let _ = writeln!(
7703 out,
7704 " {:<22} {:<18} {:>8} Interface",
7705 "Destination", "Next Hop", "Metric"
7706 );
7707 let _ = writeln!(out, " {}", "-".repeat(70));
7708 } else if shown < n {
7709 let mut it = line.splitn(4, '|');
7710 if let (Some(dest), Some(p1), Some(metric), Some(iface)) =
7711 (it.next(), it.next(), it.next(), it.next())
7712 {
7713 let hop = if p1.is_empty() || p1 == "0.0.0.0" || p1 == "::" {
7714 "on-link"
7715 } else {
7716 p1
7717 };
7718 let _ = writeln!(out, " {dest:<22} {hop:<18} {metric:>8} {iface}");
7719 shown += 1;
7720 }
7721 }
7722 }
7723 }
7724 }
7725
7726 #[cfg(not(target_os = "windows"))]
7727 {
7728 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7729 let raw = String::from_utf8_lossy(&o.stdout);
7730 let lines: Vec<&str> = raw.lines().collect();
7731 let total = lines.len();
7732 let _ = write!(
7733 out,
7734 "Routing table (showing up to {n} of {total} routes):\n\n"
7735 );
7736 for line in lines.iter().take(n) {
7737 let _ = write!(out, " {line}\n");
7738 }
7739 if total > n {
7740 let _ = write!(out, "\n ... and {} more routes\n", total - n);
7741 }
7742 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7743 let raw = String::from_utf8_lossy(&o.stdout);
7744 for line in raw.lines().take(n) {
7745 let _ = write!(out, " {line}\n");
7746 }
7747 } else {
7748 out.push_str("ip route and netstat not available.\n");
7749 }
7750 }
7751
7752 Ok(out.trim_end().to_string())
7753}
7754
7755fn inspect_env(max_entries: usize) -> Result<String, String> {
7758 let mut out = String::from("Host inspection: env\n\n");
7759 let n = max_entries.clamp(10, 50);
7760
7761 fn looks_like_secret(name: &str) -> bool {
7762 let n = name.to_uppercase();
7763 n.contains("KEY")
7764 || n.contains("SECRET")
7765 || n.contains("TOKEN")
7766 || n.contains("PASSWORD")
7767 || n.contains("PASSWD")
7768 || n.contains("CREDENTIAL")
7769 || n.contains("AUTH")
7770 || n.contains("CERT")
7771 || n.contains("PRIVATE")
7772 }
7773
7774 let known_dev_vars: &[&str] = &[
7775 "CARGO_HOME",
7776 "RUSTUP_HOME",
7777 "GOPATH",
7778 "GOROOT",
7779 "GOBIN",
7780 "JAVA_HOME",
7781 "ANDROID_HOME",
7782 "ANDROID_SDK_ROOT",
7783 "PYTHONPATH",
7784 "PYTHONHOME",
7785 "VIRTUAL_ENV",
7786 "CONDA_DEFAULT_ENV",
7787 "CONDA_PREFIX",
7788 "NODE_PATH",
7789 "NVM_DIR",
7790 "NVM_BIN",
7791 "PNPM_HOME",
7792 "DENO_INSTALL",
7793 "DENO_DIR",
7794 "DOTNET_ROOT",
7795 "NUGET_PACKAGES",
7796 "CMAKE_HOME",
7797 "VCPKG_ROOT",
7798 "AWS_PROFILE",
7799 "AWS_REGION",
7800 "AWS_DEFAULT_REGION",
7801 "GCP_PROJECT",
7802 "GOOGLE_CLOUD_PROJECT",
7803 "GOOGLE_APPLICATION_CREDENTIALS",
7804 "AZURE_SUBSCRIPTION_ID",
7805 "DATABASE_URL",
7806 "REDIS_URL",
7807 "MONGO_URI",
7808 "EDITOR",
7809 "VISUAL",
7810 "SHELL",
7811 "TERM",
7812 "XDG_CONFIG_HOME",
7813 "XDG_DATA_HOME",
7814 "XDG_CACHE_HOME",
7815 "HOME",
7816 "USERPROFILE",
7817 "APPDATA",
7818 "LOCALAPPDATA",
7819 "TEMP",
7820 "TMP",
7821 "COMPUTERNAME",
7822 "USERNAME",
7823 "USERDOMAIN",
7824 "PROCESSOR_ARCHITECTURE",
7825 "NUMBER_OF_PROCESSORS",
7826 "OS",
7827 "HOMEDRIVE",
7828 "HOMEPATH",
7829 "HTTP_PROXY",
7830 "HTTPS_PROXY",
7831 "NO_PROXY",
7832 "ALL_PROXY",
7833 "http_proxy",
7834 "https_proxy",
7835 "no_proxy",
7836 "DOCKER_HOST",
7837 "DOCKER_BUILDKIT",
7838 "COMPOSE_PROJECT_NAME",
7839 "KUBECONFIG",
7840 "KUBE_CONTEXT",
7841 "CI",
7842 "GITHUB_ACTIONS",
7843 "GITLAB_CI",
7844 "LMSTUDIO_HOME",
7845 "HEMATITE_URL",
7846 ];
7847
7848 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7849 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7850 let total = all_vars.len();
7851
7852 let mut dev_found: Vec<String> = Vec::new();
7853 let mut secret_found: Vec<String> = Vec::new();
7854
7855 for (k, v) in &all_vars {
7856 if k == "PATH" {
7857 continue;
7858 }
7859 if looks_like_secret(k) {
7860 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7861 } else {
7862 let k_upper = k.to_uppercase();
7863 let is_known = known_dev_vars
7864 .iter()
7865 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7866 if is_known {
7867 let display = if v.len() > 120 {
7868 format!("{k} = {}…", safe_head(v, 117))
7869 } else {
7870 format!("{k} = {v}")
7871 };
7872 dev_found.push(display);
7873 }
7874 }
7875 }
7876
7877 let _ = write!(out, "Total environment variables: {total}\n\n");
7878
7879 if let Ok(p) = std::env::var("PATH") {
7880 let sep = if cfg!(target_os = "windows") {
7881 ';'
7882 } else {
7883 ':'
7884 };
7885 let count = p.split(sep).count();
7886 let _ = write!(
7887 out,
7888 "PATH: {count} entries (use topic=path for full audit)\n\n"
7889 );
7890 }
7891
7892 if !secret_found.is_empty() {
7893 let _ = writeln!(
7894 out,
7895 "=== Secret/credential variables ({} detected, values hidden) ===",
7896 secret_found.len()
7897 );
7898 for s in secret_found.iter().take(n) {
7899 let _ = writeln!(out, " {s}");
7900 }
7901 out.push('\n');
7902 }
7903
7904 if !dev_found.is_empty() {
7905 let _ = writeln!(
7906 out,
7907 "=== Developer & tool variables ({}) ===",
7908 dev_found.len()
7909 );
7910 for d in dev_found.iter().take(n) {
7911 let _ = writeln!(out, " {d}");
7912 }
7913 out.push('\n');
7914 }
7915
7916 let other_count = all_vars
7917 .iter()
7918 .filter(|(k, _)| {
7919 k != "PATH"
7920 && !looks_like_secret(k)
7921 && !known_dev_vars
7922 .iter()
7923 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7924 })
7925 .count();
7926 if other_count > 0 {
7927 let _ = writeln!(
7928 out,
7929 "Other variables: {other_count} (use 'env' in shell to see all)"
7930 );
7931 }
7932
7933 Ok(out.trim_end().to_string())
7934}
7935
7936fn inspect_hosts_file() -> Result<String, String> {
7939 let mut out = String::from("Host inspection: hosts_file\n\n");
7940
7941 let hosts_path = if cfg!(target_os = "windows") {
7942 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7943 } else {
7944 std::path::PathBuf::from("/etc/hosts")
7945 };
7946
7947 let _ = write!(out, "Path: {}\n\n", hosts_path.display());
7948
7949 match fs::read_to_string(&hosts_path) {
7950 Ok(content) => {
7951 let mut active_entries: Vec<String> = Vec::new();
7952 let mut comment_lines = 0usize;
7953 let mut blank_lines = 0usize;
7954
7955 for line in content.lines() {
7956 let t = line.trim();
7957 if t.is_empty() {
7958 blank_lines += 1;
7959 } else if t.starts_with('#') {
7960 comment_lines += 1;
7961 } else {
7962 active_entries.push(line.to_string());
7963 }
7964 }
7965
7966 let _ = write!(
7967 out,
7968 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7969 active_entries.len(),
7970 comment_lines,
7971 blank_lines
7972 );
7973
7974 if active_entries.is_empty() {
7975 out.push_str(
7976 "No active host entries (file contains only comments/blanks — standard default state).\n",
7977 );
7978 } else {
7979 out.push_str("=== Active entries ===\n");
7980 for entry in &active_entries {
7981 let _ = writeln!(out, " {entry}");
7982 }
7983 out.push('\n');
7984
7985 let custom: Vec<&String> = active_entries
7986 .iter()
7987 .filter(|e| {
7988 let t = e.trim_start();
7989 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7990 })
7991 .collect();
7992 if !custom.is_empty() {
7993 let _ = writeln!(out, "[!] Custom (non-loopback) entries: {}", custom.len());
7994 for e in &custom {
7995 let _ = writeln!(out, " {e}");
7996 }
7997 } else {
7998 out.push_str("All active entries are standard loopback or block entries.\n");
7999 }
8000 }
8001
8002 out.push_str("\n=== Full file ===\n");
8003 for line in content.lines() {
8004 let _ = writeln!(out, " {line}");
8005 }
8006 }
8007 Err(e) => {
8008 let _ = writeln!(out, "Could not read hosts file: {e}");
8009 if cfg!(target_os = "windows") {
8010 out.push_str(
8011 "On Windows, run Hematite as Administrator if permission is denied.\n",
8012 );
8013 }
8014 }
8015 }
8016
8017 Ok(out.trim_end().to_string())
8018}
8019
8020struct AuditFinding {
8023 finding: String,
8024 impact: String,
8025 fix: String,
8026}
8027
8028#[cfg(target_os = "windows")]
8029#[derive(Debug, Clone)]
8030struct WindowsPnpDevice {
8031 name: String,
8032 status: String,
8033 problem: Option<u64>,
8034 class_name: Option<String>,
8035 instance_id: Option<String>,
8036}
8037
8038#[cfg(target_os = "windows")]
8039#[derive(Debug, Clone)]
8040struct WindowsSoundDevice {
8041 name: String,
8042 status: String,
8043 manufacturer: Option<String>,
8044}
8045
8046struct DockerMountAudit {
8047 mount_type: String,
8048 source: Option<String>,
8049 destination: String,
8050 name: Option<String>,
8051 read_write: Option<bool>,
8052 driver: Option<String>,
8053 exists_on_host: Option<bool>,
8054}
8055
8056struct DockerContainerAudit {
8057 name: String,
8058 image: String,
8059 status: String,
8060 mounts: Vec<DockerMountAudit>,
8061}
8062
8063struct DockerVolumeAudit {
8064 name: String,
8065 driver: String,
8066 mountpoint: Option<String>,
8067 scope: Option<String>,
8068}
8069
8070#[cfg(target_os = "windows")]
8071struct WslDistroAudit {
8072 name: String,
8073 state: String,
8074 version: String,
8075}
8076
8077#[cfg(target_os = "windows")]
8078struct WslRootUsage {
8079 total_kb: u64,
8080 used_kb: u64,
8081 avail_kb: u64,
8082 use_percent: String,
8083 mnt_c_present: Option<bool>,
8084}
8085
8086fn docker_engine_version() -> Result<String, String> {
8087 let version_output = Command::new("docker")
8088 .args(["version", "--format", "{{.Server.Version}}"])
8089 .output();
8090
8091 match version_output {
8092 Err(_) => Err(
8093 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8094 ),
8095 Ok(o) if !o.status.success() => {
8096 let stderr = String::from_utf8_lossy(&o.stderr);
8097 if stderr.contains("cannot connect")
8098 || stderr.contains("Is the docker daemon running")
8099 || stderr.contains("pipe")
8100 || stderr.contains("socket")
8101 {
8102 Err(
8103 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8104 )
8105 } else {
8106 Err(format!("Docker: error - {}", stderr.trim()))
8107 }
8108 }
8109 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8110 }
8111}
8112
8113fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8114 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8115 return Vec::new();
8116 };
8117 let Value::Array(entries) = value else {
8118 return Vec::new();
8119 };
8120
8121 let mut mounts = Vec::with_capacity(entries.len());
8122 for entry in entries {
8123 let mount_type = entry
8124 .get("Type")
8125 .and_then(|v| v.as_str())
8126 .unwrap_or("unknown")
8127 .to_string();
8128 let source = entry
8129 .get("Source")
8130 .and_then(|v| v.as_str())
8131 .map(|v| v.to_string());
8132 let destination = entry
8133 .get("Destination")
8134 .and_then(|v| v.as_str())
8135 .unwrap_or("?")
8136 .to_string();
8137 let name = entry
8138 .get("Name")
8139 .and_then(|v| v.as_str())
8140 .map(|v| v.to_string());
8141 let read_write = entry.get("RW").and_then(|v| v.as_bool());
8142 let driver = entry
8143 .get("Driver")
8144 .and_then(|v| v.as_str())
8145 .map(|v| v.to_string());
8146 let exists_on_host = if mount_type == "bind" {
8147 source.as_deref().map(|path| Path::new(path).exists())
8148 } else {
8149 None
8150 };
8151 mounts.push(DockerMountAudit {
8152 mount_type,
8153 source,
8154 destination,
8155 name,
8156 read_write,
8157 driver,
8158 exists_on_host,
8159 });
8160 }
8161
8162 mounts
8163}
8164
8165fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8166 let mut audit = DockerVolumeAudit {
8167 name: name.to_string(),
8168 driver: "unknown".to_string(),
8169 mountpoint: None,
8170 scope: None,
8171 };
8172
8173 if let Ok(output) = Command::new("docker")
8174 .args(["volume", "inspect", name, "--format", "{{json .}}"])
8175 .output()
8176 {
8177 if output.status.success() {
8178 if let Ok(value) =
8179 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8180 {
8181 audit.driver = value
8182 .get("Driver")
8183 .and_then(|v| v.as_str())
8184 .unwrap_or("unknown")
8185 .to_string();
8186 audit.mountpoint = value
8187 .get("Mountpoint")
8188 .and_then(|v| v.as_str())
8189 .map(|v| v.to_string());
8190 audit.scope = value
8191 .get("Scope")
8192 .and_then(|v| v.as_str())
8193 .map(|v| v.to_string());
8194 }
8195 }
8196 }
8197
8198 audit
8199}
8200
8201#[cfg(target_os = "windows")]
8202fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8203 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8204 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8205 let path = local_app_data
8206 .join("Docker")
8207 .join("wsl")
8208 .join("disk")
8209 .join(file_name);
8210 if let Ok(metadata) = fs::metadata(&path) {
8211 return Some((path, metadata.len()));
8212 }
8213 }
8214 None
8215}
8216
8217#[cfg(target_os = "windows")]
8218fn clean_wsl_text(raw: &[u8]) -> String {
8219 String::from_utf8_lossy(raw)
8220 .chars()
8221 .filter(|c| *c != '\0')
8222 .collect()
8223}
8224
8225#[cfg(target_os = "windows")]
8226fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8227 let mut distros = Vec::new();
8228 for line in raw.lines() {
8229 let trimmed = line.trim();
8230 if trimmed.is_empty()
8231 || trimmed.to_uppercase().starts_with("NAME")
8232 || trimmed.starts_with("---")
8233 {
8234 continue;
8235 }
8236 let normalized = trimmed.trim_start_matches('*').trim();
8237 let cols: Vec<&str> = normalized.split_whitespace().collect();
8238 if cols.len() < 3 {
8239 continue;
8240 }
8241 let version = cols[cols.len() - 1].to_string();
8242 let state = cols[cols.len() - 2].to_string();
8243 let name = cols[..cols.len() - 2].join(" ");
8244 if !name.is_empty() {
8245 distros.push(WslDistroAudit {
8246 name,
8247 state,
8248 version,
8249 });
8250 }
8251 }
8252 distros
8253}
8254
8255#[cfg(target_os = "windows")]
8256fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8257 let output = Command::new("wsl")
8258 .args([
8259 "-d",
8260 distro_name,
8261 "--",
8262 "sh",
8263 "-lc",
8264 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8265 ])
8266 .output()
8267 .ok()?;
8268 if !output.status.success() {
8269 return None;
8270 }
8271
8272 let text = clean_wsl_text(&output.stdout);
8273 let mut total_kb = 0;
8274 let mut used_kb = 0;
8275 let mut avail_kb = 0;
8276 let mut use_percent = String::from("unknown");
8277 let mut mnt_c_present = None;
8278
8279 for line in text.lines() {
8280 let trimmed = line.trim();
8281 if trimmed.starts_with("__MNTC__:") {
8282 mnt_c_present = Some(trimmed.ends_with("ok"));
8283 continue;
8284 }
8285 let mut it = trimmed.split_whitespace();
8286 if let (Some(_), Some(total), Some(used), Some(avail), Some(pct), Some(_)) = (
8287 it.next(),
8288 it.next(),
8289 it.next(),
8290 it.next(),
8291 it.next(),
8292 it.next(),
8293 ) {
8294 total_kb = total.parse::<u64>().unwrap_or(0);
8295 used_kb = used.parse::<u64>().unwrap_or(0);
8296 avail_kb = avail.parse::<u64>().unwrap_or(0);
8297 use_percent = pct.to_string();
8298 }
8299 }
8300
8301 Some(WslRootUsage {
8302 total_kb,
8303 used_kb,
8304 avail_kb,
8305 use_percent,
8306 mnt_c_present,
8307 })
8308}
8309
8310#[cfg(target_os = "windows")]
8311fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8312 let mut vhds = Vec::new();
8313 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8314 return vhds;
8315 };
8316 let packages_dir = local_app_data.join("Packages");
8317 let Ok(entries) = fs::read_dir(packages_dir) else {
8318 return vhds;
8319 };
8320
8321 for entry in entries.flatten() {
8322 let path = entry.path().join("LocalState").join("ext4.vhdx");
8323 if let Ok(metadata) = fs::metadata(&path) {
8324 vhds.push((path, metadata.len()));
8325 }
8326 }
8327 vhds.sort_by_key(|b| std::cmp::Reverse(b.1));
8328 vhds
8329}
8330
8331fn inspect_docker(max_entries: usize) -> Result<String, String> {
8332 let mut out = String::from("Host inspection: docker\n\n");
8333 let n = max_entries.clamp(5, 25);
8334
8335 let version_output = Command::new("docker")
8336 .args(["version", "--format", "{{.Server.Version}}"])
8337 .output();
8338
8339 match version_output {
8340 Err(_) => {
8341 out.push_str("Docker: not found on PATH.\n");
8342 out.push_str(
8343 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8344 );
8345 return Ok(out.trim_end().to_string());
8346 }
8347 Ok(o) if !o.status.success() => {
8348 let stderr = String::from_utf8_lossy(&o.stderr);
8349 if stderr.contains("cannot connect")
8350 || stderr.contains("Is the docker daemon running")
8351 || stderr.contains("pipe")
8352 || stderr.contains("socket")
8353 {
8354 out.push_str("Docker: installed but daemon is NOT running.\n");
8355 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8356 } else {
8357 let _ = writeln!(out, "Docker: error — {}", stderr.trim());
8358 }
8359 return Ok(out.trim_end().to_string());
8360 }
8361 Ok(o) => {
8362 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8363 let _ = writeln!(out, "Docker Engine: {version}");
8364 }
8365 }
8366
8367 if let Ok(o) = Command::new("docker")
8368 .args([
8369 "info",
8370 "--format",
8371 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8372 ])
8373 .output()
8374 {
8375 let info = String::from_utf8_lossy(&o.stdout);
8376 for line in info.lines() {
8377 let t = line.trim();
8378 if !t.is_empty() {
8379 let _ = writeln!(out, " {t}");
8380 }
8381 }
8382 out.push('\n');
8383 }
8384
8385 if let Ok(o) = Command::new("docker")
8386 .args([
8387 "ps",
8388 "--format",
8389 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8390 ])
8391 .output()
8392 {
8393 let raw = String::from_utf8_lossy(&o.stdout);
8394 let lines: Vec<&str> = raw.lines().collect();
8395 if lines.len() <= 1 {
8396 out.push_str("Running containers: none\n\n");
8397 } else {
8398 let _ = writeln!(
8399 out,
8400 "=== Running containers ({}) ===",
8401 lines.len().saturating_sub(1)
8402 );
8403 for line in lines.iter().take(n + 1) {
8404 let _ = writeln!(out, " {line}");
8405 }
8406 if lines.len() > n + 1 {
8407 let _ = writeln!(out, " ... and {} more", lines.len() - n - 1);
8408 }
8409 out.push('\n');
8410 }
8411 }
8412
8413 if let Ok(o) = Command::new("docker")
8414 .args([
8415 "images",
8416 "--format",
8417 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8418 ])
8419 .output()
8420 {
8421 let raw = String::from_utf8_lossy(&o.stdout);
8422 let lines: Vec<&str> = raw.lines().collect();
8423 if lines.len() > 1 {
8424 let _ = writeln!(
8425 out,
8426 "=== Local images ({}) ===",
8427 lines.len().saturating_sub(1)
8428 );
8429 for line in lines.iter().take(n + 1) {
8430 let _ = writeln!(out, " {line}");
8431 }
8432 if lines.len() > n + 1 {
8433 let _ = writeln!(out, " ... and {} more", lines.len() - n - 1);
8434 }
8435 out.push('\n');
8436 }
8437 }
8438
8439 if let Ok(o) = Command::new("docker")
8440 .args([
8441 "compose",
8442 "ls",
8443 "--format",
8444 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8445 ])
8446 .output()
8447 {
8448 let raw = String::from_utf8_lossy(&o.stdout);
8449 let lines: Vec<&str> = raw.lines().collect();
8450 if lines.len() > 1 {
8451 let _ = writeln!(
8452 out,
8453 "=== Compose projects ({}) ===",
8454 lines.len().saturating_sub(1)
8455 );
8456 for line in lines.iter().take(n + 1) {
8457 let _ = writeln!(out, " {line}");
8458 }
8459 out.push('\n');
8460 }
8461 }
8462
8463 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8464 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8465 if !ctx.is_empty() {
8466 let _ = writeln!(out, "Active context: {ctx}");
8467 }
8468 }
8469
8470 Ok(out.trim_end().to_string())
8471}
8472
8473fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8476 let mut out = String::from("Host inspection: docker_filesystems\n\n");
8477 let n = max_entries.clamp(3, 12);
8478
8479 match docker_engine_version() {
8480 Ok(version) => {
8481 let _ = writeln!(out, "Docker Engine: {version}");
8482 }
8483 Err(message) => {
8484 out.push_str(&message);
8485 return Ok(out.trim_end().to_string());
8486 }
8487 }
8488
8489 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8490 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8491 if !ctx.is_empty() {
8492 let _ = writeln!(out, "Active context: {ctx}");
8493 }
8494 }
8495 out.push('\n');
8496
8497 let mut containers = Vec::with_capacity(n);
8498 if let Ok(o) = Command::new("docker")
8499 .args([
8500 "ps",
8501 "-a",
8502 "--format",
8503 "{{.Names}}\t{{.Image}}\t{{.Status}}",
8504 ])
8505 .output()
8506 {
8507 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8508 let mut it = line.splitn(3, '\t');
8509 let (Some(name_raw), Some(image_raw), Some(status_raw)) =
8510 (it.next(), it.next(), it.next())
8511 else {
8512 continue;
8513 };
8514 let name = name_raw.trim().to_string();
8515 if name.is_empty() {
8516 continue;
8517 }
8518 let inspect_output = Command::new("docker")
8519 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8520 .output();
8521 let mounts = match inspect_output {
8522 Ok(result) if result.status.success() => {
8523 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8524 }
8525 _ => Vec::new(),
8526 };
8527 containers.push(DockerContainerAudit {
8528 name,
8529 image: image_raw.trim().to_string(),
8530 status: status_raw.trim().to_string(),
8531 mounts,
8532 });
8533 }
8534 }
8535
8536 let mut volumes = Vec::with_capacity(n);
8537 if let Ok(o) = Command::new("docker")
8538 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8539 .output()
8540 {
8541 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8542 let mut it = line.split('\t');
8543 let Some(name) = it.next().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8544 continue;
8545 };
8546 let driver_hint = it.next().map(|v| v.trim()).filter(|v| !v.is_empty());
8547 let mut audit = inspect_docker_volume(name);
8548 if audit.driver == "unknown" {
8549 audit.driver = driver_hint.unwrap_or("unknown").to_string();
8550 }
8551 volumes.push(audit);
8552 }
8553 }
8554
8555 let mut findings = Vec::with_capacity(4);
8556 for container in &containers {
8557 for mount in &container.mounts {
8558 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8559 let source = mount.source.as_deref().unwrap_or("<unknown>");
8560 findings.push(AuditFinding {
8561 finding: format!(
8562 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8563 container.name, source, mount.destination
8564 ),
8565 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8566 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8567 });
8568 }
8569 }
8570 }
8571
8572 #[cfg(target_os = "windows")]
8573 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8574 if size_bytes >= 20 * 1024 * 1024 * 1024 {
8575 findings.push(AuditFinding {
8576 finding: format!(
8577 "Docker Desktop disk image is large: {} at {}",
8578 human_bytes(size_bytes),
8579 path.display()
8580 ),
8581 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8582 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(),
8583 });
8584 }
8585 }
8586
8587 out.push_str("=== Findings ===\n");
8588 if findings.is_empty() {
8589 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8590 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8591 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8592 } else {
8593 for finding in &findings {
8594 let _ = writeln!(out, "- Finding: {}", finding.finding);
8595 let _ = writeln!(out, " Impact: {}", finding.impact);
8596 let _ = writeln!(out, " Fix: {}", finding.fix);
8597 }
8598 }
8599
8600 out.push_str("\n=== Container mount summary ===\n");
8601 if containers.is_empty() {
8602 out.push_str("- No containers found.\n");
8603 } else {
8604 for container in &containers {
8605 let _ = writeln!(
8606 out,
8607 "- {} ({}) [{}]",
8608 container.name, container.image, container.status
8609 );
8610 if container.mounts.is_empty() {
8611 out.push_str(" - no mounts reported\n");
8612 continue;
8613 }
8614 for mount in &container.mounts {
8615 let mut source = mount
8616 .name
8617 .clone()
8618 .or_else(|| mount.source.clone())
8619 .unwrap_or_else(|| "<unknown>".to_string());
8620 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8621 source.push_str(" [missing]");
8622 }
8623 let mut extras = Vec::with_capacity(2);
8624 if let Some(rw) = mount.read_write {
8625 extras.push(if rw { "rw" } else { "ro" }.to_string());
8626 }
8627 if let Some(driver) = &mount.driver {
8628 extras.push(format!("driver={driver}"));
8629 }
8630 let extra_suffix = if extras.is_empty() {
8631 String::new()
8632 } else {
8633 format!(" ({})", extras.join(", "))
8634 };
8635 let _ = writeln!(
8636 out,
8637 " - {}: {} -> {}{}",
8638 mount.mount_type, source, mount.destination, extra_suffix
8639 );
8640 }
8641 }
8642 }
8643
8644 out.push_str("\n=== Named volumes ===\n");
8645 if volumes.is_empty() {
8646 out.push_str("- No named volumes found.\n");
8647 } else {
8648 for volume in &volumes {
8649 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8650 if let Some(scope) = &volume.scope {
8651 let _ = write!(detail, ", scope: {scope}");
8652 }
8653 if let Some(mountpoint) = &volume.mountpoint {
8654 let _ = write!(detail, ", mountpoint: {mountpoint}");
8655 }
8656 let _ = writeln!(out, "{detail}");
8657 }
8658 }
8659
8660 #[cfg(target_os = "windows")]
8661 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8662 out.push_str("\n=== Docker Desktop disk ===\n");
8663 let _ = writeln!(out, "- {} at {}", human_bytes(size_bytes), path.display());
8664 }
8665
8666 Ok(out.trim_end().to_string())
8667}
8668
8669fn inspect_wsl() -> Result<String, String> {
8670 let mut out = String::from("Host inspection: wsl\n\n");
8671
8672 #[cfg(target_os = "windows")]
8673 {
8674 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8675 let raw = String::from_utf8_lossy(&o.stdout);
8676 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8677 for line in cleaned.lines().take(4) {
8678 let t = line.trim();
8679 if !t.is_empty() {
8680 let _ = writeln!(out, " {t}");
8681 }
8682 }
8683 out.push('\n');
8684 }
8685
8686 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8687 match list_output {
8688 Err(e) => {
8689 let _ = writeln!(out, "WSL: wsl.exe error: {e}");
8690 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8691 }
8692 Ok(o) if !o.status.success() => {
8693 let stderr = String::from_utf8_lossy(&o.stderr);
8694 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8695 let _ = writeln!(out, "WSL: error — {}", cleaned.trim());
8696 out.push_str("Run: wsl --install\n");
8697 }
8698 Ok(o) => {
8699 let raw = String::from_utf8_lossy(&o.stdout);
8700 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8701 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8702 let distro_lines: Vec<&str> = lines
8703 .iter()
8704 .filter(|l| {
8705 let t = l.trim();
8706 !t.is_empty()
8707 && !t.to_uppercase().starts_with("NAME")
8708 && !t.starts_with("---")
8709 })
8710 .copied()
8711 .collect();
8712
8713 if distro_lines.is_empty() {
8714 out.push_str("WSL: installed but no distributions found.\n");
8715 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8716 } else {
8717 out.push_str("=== WSL Distributions ===\n");
8718 for line in &lines {
8719 let _ = writeln!(out, " {}", line.trim());
8720 }
8721 let _ = write!(out, "\nTotal distributions: {}\n", distro_lines.len());
8722 }
8723 }
8724 }
8725
8726 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8727 let raw = String::from_utf8_lossy(&o.stdout);
8728 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8729 let status_lines: Vec<&str> = cleaned
8730 .lines()
8731 .filter(|l| !l.trim().is_empty())
8732 .take(8)
8733 .collect();
8734 if !status_lines.is_empty() {
8735 out.push_str("\n=== WSL status ===\n");
8736 for line in status_lines {
8737 let _ = writeln!(out, " {}", line.trim());
8738 }
8739 }
8740 }
8741 }
8742
8743 #[cfg(not(target_os = "windows"))]
8744 {
8745 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8746 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8747 }
8748
8749 Ok(out.trim_end().to_string())
8750}
8751
8752fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8755 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8756
8757 #[cfg(target_os = "windows")]
8758 {
8759 let n = max_entries.clamp(3, 12);
8760 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8761 let distros = match list_output {
8762 Err(e) => {
8763 let _ = writeln!(out, "WSL: wsl.exe error: {e}");
8764 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8765 return Ok(out.trim_end().to_string());
8766 }
8767 Ok(o) if !o.status.success() => {
8768 let cleaned = clean_wsl_text(&o.stderr);
8769 let _ = writeln!(out, "WSL: error - {}", cleaned.trim());
8770 out.push_str("Run: wsl --install\n");
8771 return Ok(out.trim_end().to_string());
8772 }
8773 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8774 };
8775
8776 let _ = write!(out, "Distributions detected: {}\n\n", distros.len());
8777
8778 let vhdx_files = collect_wsl_vhdx_files();
8779 let mut findings = Vec::with_capacity(4);
8780 let mut live_usage = Vec::with_capacity(n);
8781
8782 for distro in distros.iter().take(n) {
8783 if distro.state.eq_ignore_ascii_case("Running") {
8784 if let Some(usage) = wsl_root_usage(&distro.name) {
8785 if let Some(false) = usage.mnt_c_present {
8786 findings.push(AuditFinding {
8787 finding: format!(
8788 "Distro '{}' is running without /mnt/c available",
8789 distro.name
8790 ),
8791 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8792 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8793 });
8794 }
8795
8796 let percent_num = usage
8797 .use_percent
8798 .trim_end_matches('%')
8799 .parse::<u32>()
8800 .unwrap_or(0);
8801 if percent_num >= 85 {
8802 findings.push(AuditFinding {
8803 finding: format!(
8804 "Distro '{}' root filesystem is {} full",
8805 distro.name, usage.use_percent
8806 ),
8807 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8808 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8809 });
8810 }
8811 live_usage.push((distro.name.clone(), usage));
8812 }
8813 }
8814 }
8815
8816 for (path, size_bytes) in vhdx_files.iter().take(n) {
8817 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8818 findings.push(AuditFinding {
8819 finding: format!(
8820 "Host-side WSL disk image is large: {} at {}",
8821 human_bytes(*size_bytes),
8822 path.display()
8823 ),
8824 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8825 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8826 });
8827 }
8828 }
8829
8830 out.push_str("=== Findings ===\n");
8831 if findings.is_empty() {
8832 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8833 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8834 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8835 } else {
8836 for finding in &findings {
8837 let _ = writeln!(out, "- Finding: {}", finding.finding);
8838 let _ = writeln!(out, " Impact: {}", finding.impact);
8839 let _ = writeln!(out, " Fix: {}", finding.fix);
8840 }
8841 }
8842
8843 out.push_str("\n=== Distro bridge and root usage ===\n");
8844 if distros.is_empty() {
8845 out.push_str("- No WSL distributions found.\n");
8846 } else {
8847 for distro in distros.iter().take(n) {
8848 let _ = writeln!(
8849 out,
8850 "- {} [state: {}, version: {}]",
8851 distro.name, distro.state, distro.version
8852 );
8853 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8854 let _ = writeln!(
8855 out,
8856 " - rootfs: {} used / {} total ({}), free: {}",
8857 human_bytes(usage.used_kb * 1024),
8858 human_bytes(usage.total_kb * 1024),
8859 usage.use_percent,
8860 human_bytes(usage.avail_kb * 1024)
8861 );
8862 match usage.mnt_c_present {
8863 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8864 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8865 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8866 }
8867 } else if distro.state.eq_ignore_ascii_case("Running") {
8868 out.push_str(" - live rootfs check: unavailable\n");
8869 } else {
8870 out.push_str(
8871 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8872 );
8873 }
8874 }
8875 }
8876
8877 out.push_str("\n=== Host-side VHDX files ===\n");
8878 if vhdx_files.is_empty() {
8879 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8880 } else {
8881 for (path, size_bytes) in vhdx_files.iter().take(n) {
8882 let _ = writeln!(out, "- {} at {}", human_bytes(*size_bytes), path.display());
8883 }
8884 }
8885 }
8886
8887 #[cfg(not(target_os = "windows"))]
8888 {
8889 let _ = max_entries;
8890 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8891 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8892 }
8893
8894 Ok(out.trim_end().to_string())
8895}
8896
8897fn dirs_home() -> Option<PathBuf> {
8898 std::env::var("HOME")
8899 .ok()
8900 .map(PathBuf::from)
8901 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8902}
8903
8904fn inspect_ssh() -> Result<String, String> {
8905 let mut out = String::from("Host inspection: ssh\n\n");
8906
8907 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8908 let ver = if o.stdout.is_empty() {
8909 String::from_utf8_lossy(&o.stderr).trim().to_string()
8910 } else {
8911 String::from_utf8_lossy(&o.stdout).trim().to_string()
8912 };
8913 if !ver.is_empty() {
8914 let _ = writeln!(out, "SSH client: {ver}");
8915 }
8916 } else {
8917 out.push_str("SSH client: not found on PATH.\n");
8918 }
8919
8920 #[cfg(target_os = "windows")]
8921 {
8922 let script = r#"
8923$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8924if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8925else { "SSHD:not_installed" }
8926"#;
8927 if let Ok(o) = Command::new("powershell")
8928 .args(["-NoProfile", "-Command", script])
8929 .output()
8930 {
8931 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8932 if text.contains("not_installed") {
8933 out.push_str("SSH server (sshd): not installed\n");
8934 } else {
8935 let _ = writeln!(
8936 out,
8937 "SSH server (sshd): {}",
8938 text.trim_start_matches("SSHD:")
8939 );
8940 }
8941 }
8942 }
8943
8944 #[cfg(not(target_os = "windows"))]
8945 {
8946 if let Ok(o) = Command::new("systemctl")
8947 .args(["is-active", "sshd"])
8948 .output()
8949 {
8950 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8951 let _ = write!(out, "SSH server (sshd): {status}\n");
8952 } else if let Ok(o) = Command::new("systemctl")
8953 .args(["is-active", "ssh"])
8954 .output()
8955 {
8956 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8957 let _ = write!(out, "SSH server (ssh): {status}\n");
8958 }
8959 }
8960
8961 out.push('\n');
8962
8963 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8964 if ssh_dir.exists() {
8965 let _ = writeln!(out, "~/.ssh: {}", ssh_dir.display());
8966
8967 let kh = ssh_dir.join("known_hosts");
8968 if kh.exists() {
8969 let count = fs::read_to_string(&kh)
8970 .map(|c| {
8971 c.lines()
8972 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8973 .count()
8974 })
8975 .unwrap_or(0);
8976 let _ = writeln!(out, " known_hosts: {count} entries");
8977 } else {
8978 out.push_str(" known_hosts: not present\n");
8979 }
8980
8981 let ak = ssh_dir.join("authorized_keys");
8982 if ak.exists() {
8983 let count = fs::read_to_string(&ak)
8984 .map(|c| {
8985 c.lines()
8986 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8987 .count()
8988 })
8989 .unwrap_or(0);
8990 let _ = writeln!(out, " authorized_keys: {count} public keys");
8991 } else {
8992 out.push_str(" authorized_keys: not present\n");
8993 }
8994
8995 let key_names = [
8996 "id_rsa",
8997 "id_ed25519",
8998 "id_ecdsa",
8999 "id_dsa",
9000 "id_ecdsa_sk",
9001 "id_ed25519_sk",
9002 ];
9003 let found_keys: Vec<&str> = key_names
9004 .iter()
9005 .filter(|k| ssh_dir.join(k).exists())
9006 .copied()
9007 .collect();
9008 if !found_keys.is_empty() {
9009 let _ = writeln!(out, " Private keys: {}", found_keys.join(", "));
9010 } else {
9011 out.push_str(" Private keys: none found\n");
9012 }
9013
9014 let config_path = ssh_dir.join("config");
9015 if config_path.exists() {
9016 out.push_str("\n=== SSH config hosts ===\n");
9017 match fs::read_to_string(&config_path) {
9018 Ok(content) => {
9019 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
9020 let mut current: Option<(String, Vec<String>)> = None;
9021 for line in content.lines() {
9022 let t = line.trim();
9023 if t.is_empty() || t.starts_with('#') {
9024 continue;
9025 }
9026 if let Some(host) = t.strip_prefix("Host ") {
9027 if let Some(prev) = current.take() {
9028 hosts.push(prev);
9029 }
9030 current = Some((host.trim().to_string(), Vec::new()));
9031 } else if let Some((_, ref mut details)) = current {
9032 let tu = t.to_uppercase();
9033 if tu.starts_with("HOSTNAME ")
9034 || tu.starts_with("USER ")
9035 || tu.starts_with("PORT ")
9036 || tu.starts_with("IDENTITYFILE ")
9037 {
9038 details.push(t.to_string());
9039 }
9040 }
9041 }
9042 if let Some(prev) = current {
9043 hosts.push(prev);
9044 }
9045
9046 if hosts.is_empty() {
9047 out.push_str(" No Host entries found.\n");
9048 } else {
9049 for (h, details) in &hosts {
9050 if details.is_empty() {
9051 let _ = writeln!(out, " Host {h}");
9052 } else {
9053 let _ = writeln!(out, " Host {h} [{}]", details.join(", "));
9054 }
9055 }
9056 let _ = write!(out, "\n Total configured hosts: {}\n", hosts.len());
9057 }
9058 }
9059 Err(e) => {
9060 let _ = writeln!(out, " Could not read config: {e}");
9061 }
9062 }
9063 } else {
9064 out.push_str(" SSH config: not present\n");
9065 }
9066 } else {
9067 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9068 }
9069 }
9070
9071 Ok(out.trim_end().to_string())
9072}
9073
9074fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9077 let mut out = String::from("Host inspection: installed_software\n\n");
9078 let n = max_entries.clamp(10, 50);
9079
9080 #[cfg(target_os = "windows")]
9081 {
9082 let winget_out = Command::new("winget")
9083 .args(["list", "--accept-source-agreements"])
9084 .output();
9085
9086 if let Ok(o) = winget_out {
9087 if o.status.success() {
9088 let raw = String::from_utf8_lossy(&o.stdout);
9089 let mut header_done = false;
9090 let mut packages: Vec<&str> = Vec::new();
9091 for line in raw.lines() {
9092 let t = line.trim();
9093 if t.starts_with("---") {
9094 header_done = true;
9095 continue;
9096 }
9097 if header_done && !t.is_empty() {
9098 packages.push(line);
9099 }
9100 }
9101 let total = packages.len();
9102 let _ = write!(
9103 out,
9104 "=== Installed software via winget ({total} packages) ===\n\n"
9105 );
9106 for line in packages.iter().take(n) {
9107 let _ = writeln!(out, " {line}");
9108 }
9109 if total > n {
9110 let _ = write!(out, "\n ... and {} more packages\n", total - n);
9111 }
9112 out.push_str("\nFor full list: winget list\n");
9113 return Ok(out.trim_end().to_string());
9114 }
9115 }
9116
9117 let script = format!(
9119 r#"
9120$apps = @()
9121$reg_paths = @(
9122 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9123 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9124 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9125)
9126foreach ($p in $reg_paths) {{
9127 try {{
9128 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9129 Where-Object {{ $_.DisplayName }} |
9130 Select-Object DisplayName, DisplayVersion, Publisher
9131 }} catch {{}}
9132}}
9133$sorted = $apps | Sort-Object DisplayName -Unique
9134"TOTAL:" + $sorted.Count
9135$sorted | Select-Object -First {n} | ForEach-Object {{
9136 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9137}}
9138"#
9139 );
9140 if let Ok(o) = Command::new("powershell")
9141 .args(["-NoProfile", "-Command", &script])
9142 .output()
9143 {
9144 let raw = String::from_utf8_lossy(&o.stdout);
9145 out.push_str("=== Installed software (registry scan) ===\n");
9146 let _ = writeln!(out, " {:<50} {:<18} Publisher", "Name", "Version");
9147 let _ = writeln!(out, " {}", "-".repeat(90));
9148 for line in raw.lines() {
9149 if let Some(rest) = line.strip_prefix("TOTAL:") {
9150 let total: usize = rest.trim().parse().unwrap_or(0);
9151 let _ = write!(out, " (Total: {total}, showing first {n})\n\n");
9152 } else if !line.trim().is_empty() {
9153 let mut it = line.splitn(3, '|');
9154 let name = it.next().map(str::trim).unwrap_or("");
9155 let ver = it.next().map(str::trim).unwrap_or("");
9156 let pub_ = it.next().map(str::trim).unwrap_or("");
9157 let _ = writeln!(out, " {:<50} {:<18} {pub_}", name, ver);
9158 }
9159 }
9160 } else {
9161 out.push_str(
9162 "Could not query installed software (winget and registry scan both failed).\n",
9163 );
9164 }
9165 }
9166
9167 #[cfg(target_os = "linux")]
9168 {
9169 let mut found = false;
9170 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9171 if o.status.success() {
9172 let raw = String::from_utf8_lossy(&o.stdout);
9173 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9174 let total = installed.len();
9175 let _ = write!(out, "=== Installed packages via dpkg ({total}) ===\n");
9176 for line in installed.iter().take(n) {
9177 let _ = write!(out, " {}\n", line.trim());
9178 }
9179 if total > n {
9180 let _ = write!(out, " ... and {} more\n", total - n);
9181 }
9182 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9183 found = true;
9184 }
9185 }
9186 if !found {
9187 if let Ok(o) = Command::new("rpm")
9188 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9189 .output()
9190 {
9191 if o.status.success() {
9192 let raw = String::from_utf8_lossy(&o.stdout);
9193 let lines: Vec<&str> = raw.lines().collect();
9194 let total = lines.len();
9195 let _ = write!(out, "=== Installed packages via rpm ({total}) ===\n");
9196 for line in lines.iter().take(n) {
9197 let _ = write!(out, " {line}\n");
9198 }
9199 if total > n {
9200 let _ = write!(out, " ... and {} more\n", total - n);
9201 }
9202 found = true;
9203 }
9204 }
9205 }
9206 if !found {
9207 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9208 if o.status.success() {
9209 let raw = String::from_utf8_lossy(&o.stdout);
9210 let lines: Vec<&str> = raw.lines().collect();
9211 let total = lines.len();
9212 let _ = write!(out, "=== Installed packages via pacman ({total}) ===\n");
9213 for line in lines.iter().take(n) {
9214 let _ = write!(out, " {line}\n");
9215 }
9216 if total > n {
9217 let _ = write!(out, " ... and {} more\n", total - n);
9218 }
9219 found = true;
9220 }
9221 }
9222 }
9223 if !found {
9224 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9225 }
9226 }
9227
9228 #[cfg(target_os = "macos")]
9229 {
9230 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9231 if o.status.success() {
9232 let raw = String::from_utf8_lossy(&o.stdout);
9233 let lines: Vec<&str> = raw.lines().collect();
9234 let total = lines.len();
9235 let _ = write!(out, "=== Homebrew packages ({total}) ===\n");
9236 for line in lines.iter().take(n) {
9237 let _ = write!(out, " {line}\n");
9238 }
9239 if total > n {
9240 let _ = write!(out, " ... and {} more\n", total - n);
9241 }
9242 out.push_str("\nFor full list: brew list --versions\n");
9243 }
9244 } else {
9245 out.push_str("Homebrew not found.\n");
9246 }
9247 if let Ok(o) = Command::new("mas").args(["list"]).output() {
9248 if o.status.success() {
9249 let raw = String::from_utf8_lossy(&o.stdout);
9250 let lines: Vec<&str> = raw.lines().collect();
9251 let _ = write!(out, "\n=== Mac App Store apps ({}) ===\n", lines.len());
9252 for line in lines.iter().take(n) {
9253 let _ = write!(out, " {line}\n");
9254 }
9255 }
9256 }
9257 }
9258
9259 Ok(out.trim_end().to_string())
9260}
9261
9262fn inspect_git_config() -> Result<String, String> {
9265 let mut out = String::from("Host inspection: git_config\n\n");
9266
9267 if let Ok(o) = Command::new("git").args(["--version"]).output() {
9268 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9269 let _ = write!(out, "Git: {ver}\n\n");
9270 } else {
9271 out.push_str("Git: not found on PATH.\n");
9272 return Ok(out.trim_end().to_string());
9273 }
9274
9275 if let Ok(o) = Command::new("git")
9276 .args(["config", "--global", "--list"])
9277 .output()
9278 {
9279 if o.status.success() {
9280 let raw = String::from_utf8_lossy(&o.stdout);
9281 let mut pairs: Vec<(String, String)> = raw
9282 .lines()
9283 .filter_map(|l| {
9284 let mut parts = l.splitn(2, '=');
9285 let k = parts.next()?.trim().to_string();
9286 let v = parts.next().unwrap_or("").trim().to_string();
9287 Some((k, v))
9288 })
9289 .collect();
9290 pairs.sort_by(|a, b| a.0.cmp(&b.0));
9291
9292 out.push_str("=== Global git config ===\n");
9293
9294 let sections: &[(&str, &[&str])] = &[
9295 ("Identity", &["user.name", "user.email", "user.signingkey"]),
9296 (
9297 "Core",
9298 &[
9299 "core.editor",
9300 "core.autocrlf",
9301 "core.eol",
9302 "core.ignorecase",
9303 "core.filemode",
9304 ],
9305 ),
9306 (
9307 "Commit/Signing",
9308 &[
9309 "commit.gpgsign",
9310 "tag.gpgsign",
9311 "gpg.format",
9312 "gpg.ssh.allowedsignersfile",
9313 ],
9314 ),
9315 (
9316 "Push/Pull",
9317 &[
9318 "push.default",
9319 "push.autosetupremote",
9320 "pull.rebase",
9321 "pull.ff",
9322 ],
9323 ),
9324 ("Credential", &["credential.helper"]),
9325 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9326 ];
9327
9328 let mut shown_keys: HashSet<String> = HashSet::new();
9329 for (section, keys) in sections {
9330 let mut section_lines: Vec<String> = Vec::new();
9331 for key in *keys {
9332 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9333 section_lines.push(format!(" {k} = {v}"));
9334 shown_keys.insert(k.clone());
9335 }
9336 }
9337 if !section_lines.is_empty() {
9338 let _ = write!(out, "\n[{section}]\n");
9339 for line in section_lines {
9340 let _ = writeln!(out, "{line}");
9341 }
9342 }
9343 }
9344
9345 let other: Vec<&(String, String)> = pairs
9346 .iter()
9347 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9348 .collect();
9349 if !other.is_empty() {
9350 out.push_str("\n[Other]\n");
9351 for (k, v) in other.iter().take(20) {
9352 let _ = writeln!(out, " {k} = {v}");
9353 }
9354 if other.len() > 20 {
9355 let _ = writeln!(out, " ... and {} more", other.len() - 20);
9356 }
9357 }
9358
9359 let _ = write!(out, "\nTotal global config keys: {}\n", pairs.len());
9360 } else {
9361 out.push_str("No global git config found.\n");
9362 out.push_str("Set up with:\n");
9363 out.push_str(" git config --global user.name \"Your Name\"\n");
9364 out.push_str(" git config --global user.email \"you@example.com\"\n");
9365 }
9366 }
9367
9368 if let Ok(o) = Command::new("git")
9369 .args(["config", "--local", "--list"])
9370 .output()
9371 {
9372 if o.status.success() {
9373 let raw = String::from_utf8_lossy(&o.stdout);
9374 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9375 if !lines.is_empty() {
9376 let _ = write!(out, "\n=== Local repo config ({} keys) ===\n", lines.len());
9377 for line in lines.iter().take(15) {
9378 let _ = writeln!(out, " {line}");
9379 }
9380 if lines.len() > 15 {
9381 let _ = writeln!(out, " ... and {} more", lines.len() - 15);
9382 }
9383 }
9384 }
9385 }
9386
9387 if let Ok(o) = Command::new("git")
9388 .args(["config", "--global", "--get-regexp", r"alias\."])
9389 .output()
9390 {
9391 if o.status.success() {
9392 let raw = String::from_utf8_lossy(&o.stdout);
9393 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9394 if !aliases.is_empty() {
9395 let _ = write!(out, "\n=== Git aliases ({}) ===\n", aliases.len());
9396 for a in aliases.iter().take(20) {
9397 let _ = writeln!(out, " {a}");
9398 }
9399 if aliases.len() > 20 {
9400 let _ = writeln!(out, " ... and {} more", aliases.len() - 20);
9401 }
9402 }
9403 }
9404 }
9405
9406 Ok(out.trim_end().to_string())
9407}
9408
9409fn inspect_databases() -> Result<String, String> {
9412 let mut out = String::from("Host inspection: databases\n\n");
9413 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9414
9415 struct DbEngine {
9416 name: &'static str,
9417 service_names: &'static [&'static str],
9418 default_port: u16,
9419 cli_name: &'static str,
9420 cli_version_args: &'static [&'static str],
9421 }
9422
9423 let engines: &[DbEngine] = &[
9424 DbEngine {
9425 name: "PostgreSQL",
9426 service_names: &[
9427 "postgresql",
9428 "postgresql-x64-14",
9429 "postgresql-x64-15",
9430 "postgresql-x64-16",
9431 "postgresql-x64-17",
9432 ],
9433
9434 default_port: 5432,
9435 cli_name: "psql",
9436 cli_version_args: &["--version"],
9437 },
9438 DbEngine {
9439 name: "MySQL",
9440 service_names: &["mysql", "mysql80", "mysql57"],
9441
9442 default_port: 3306,
9443 cli_name: "mysql",
9444 cli_version_args: &["--version"],
9445 },
9446 DbEngine {
9447 name: "MariaDB",
9448 service_names: &["mariadb", "mariadb.exe"],
9449
9450 default_port: 3306,
9451 cli_name: "mariadb",
9452 cli_version_args: &["--version"],
9453 },
9454 DbEngine {
9455 name: "MongoDB",
9456 service_names: &["mongodb", "mongod"],
9457
9458 default_port: 27017,
9459 cli_name: "mongod",
9460 cli_version_args: &["--version"],
9461 },
9462 DbEngine {
9463 name: "Redis",
9464 service_names: &["redis", "redis-server"],
9465
9466 default_port: 6379,
9467 cli_name: "redis-server",
9468 cli_version_args: &["--version"],
9469 },
9470 DbEngine {
9471 name: "SQL Server",
9472 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9473
9474 default_port: 1433,
9475 cli_name: "sqlcmd",
9476 cli_version_args: &["-?"],
9477 },
9478 DbEngine {
9479 name: "SQLite",
9480 service_names: &[], default_port: 0, cli_name: "sqlite3",
9484 cli_version_args: &["--version"],
9485 },
9486 DbEngine {
9487 name: "CouchDB",
9488 service_names: &["couchdb", "apache-couchdb"],
9489
9490 default_port: 5984,
9491 cli_name: "couchdb",
9492 cli_version_args: &["--version"],
9493 },
9494 DbEngine {
9495 name: "Cassandra",
9496 service_names: &["cassandra"],
9497
9498 default_port: 9042,
9499 cli_name: "cqlsh",
9500 cli_version_args: &["--version"],
9501 },
9502 DbEngine {
9503 name: "Elasticsearch",
9504 service_names: &["elasticsearch-service-x64", "elasticsearch"],
9505
9506 default_port: 9200,
9507 cli_name: "elasticsearch",
9508 cli_version_args: &["--version"],
9509 },
9510 ];
9511
9512 fn port_listening(port: u16) -> bool {
9514 if port == 0 {
9515 return false;
9516 }
9517 std::net::TcpStream::connect_timeout(
9519 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9520 std::time::Duration::from_millis(150),
9521 )
9522 .is_ok()
9523 }
9524
9525 let mut found_any = false;
9526
9527 for engine in engines {
9528 let mut status_parts: Vec<String> = Vec::new();
9529 let mut detected = false;
9530
9531 let version = Command::new(engine.cli_name)
9533 .args(engine.cli_version_args)
9534 .output()
9535 .ok()
9536 .and_then(|o| {
9537 let combined = if o.stdout.is_empty() {
9538 String::from_utf8_lossy(&o.stderr).trim().to_string()
9539 } else {
9540 String::from_utf8_lossy(&o.stdout).trim().to_string()
9541 };
9542 combined.lines().next().map(|l| l.trim().to_string())
9544 });
9545
9546 if let Some(ref ver) = version {
9547 if !ver.is_empty() {
9548 status_parts.push(format!("version: {ver}"));
9549 detected = true;
9550 }
9551 }
9552
9553 if engine.default_port > 0 && port_listening(engine.default_port) {
9555 status_parts.push(format!("listening on :{}", engine.default_port));
9556 detected = true;
9557 } else if engine.default_port > 0 && detected {
9558 status_parts.push(format!("not listening on :{}", engine.default_port));
9559 }
9560
9561 #[cfg(target_os = "windows")]
9563 {
9564 if !engine.service_names.is_empty() {
9565 let service_list = engine.service_names.join("','");
9566 let script = format!(
9567 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9568 service_list
9569 );
9570 if let Ok(o) = Command::new("powershell")
9571 .args(["-NoProfile", "-Command", &script])
9572 .output()
9573 {
9574 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9575 if !text.is_empty() {
9576 let mut it = text.splitn(2, ':');
9577 let svc_name = it.next().map(str::trim).unwrap_or("");
9578 let svc_state = it.next().map(str::trim).unwrap_or("unknown");
9579 status_parts.push(format!("service '{svc_name}': {svc_state}"));
9580 detected = true;
9581 }
9582 }
9583 }
9584 }
9585
9586 #[cfg(not(target_os = "windows"))]
9588 {
9589 for svc in engine.service_names {
9590 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9591 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9592 if !state.is_empty() && state != "inactive" {
9593 status_parts.push(format!("systemd '{svc}': {state}"));
9594 detected = true;
9595 break;
9596 }
9597 }
9598 }
9599 }
9600
9601 if detected {
9602 found_any = true;
9603 let label = if engine.default_port > 0 {
9604 format!("{} (default port: {})", engine.name, engine.default_port)
9605 } else {
9606 format!("{} (file-based, no port)", engine.name)
9607 };
9608 let _ = writeln!(out, "[FOUND] {label}");
9609 for part in &status_parts {
9610 let _ = writeln!(out, " {part}");
9611 }
9612 out.push('\n');
9613 }
9614 }
9615
9616 if !found_any {
9617 out.push_str("No local database engines detected.\n");
9618 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9619 out.push_str(
9620 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9621 );
9622 } else {
9623 out.push_str("---\n");
9624 out.push_str(
9625 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9626 );
9627 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9628 }
9629
9630 Ok(out.trim_end().to_string())
9631}
9632
9633fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9636 let mut out = String::from("Host inspection: user_accounts\n\n");
9637
9638 #[cfg(target_os = "windows")]
9639 {
9640 let users_out = Command::new("powershell")
9641 .args([
9642 "-NoProfile", "-NonInteractive", "-Command",
9643 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9644 ])
9645 .output()
9646 .ok()
9647 .and_then(|o| String::from_utf8(o.stdout).ok())
9648 .unwrap_or_default();
9649
9650 out.push_str("=== Local User Accounts ===\n");
9651 if users_out.trim().is_empty() {
9652 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
9653 } else {
9654 for line in users_out.lines().take(max_entries) {
9655 if !line.trim().is_empty() {
9656 out.push_str(line);
9657 out.push('\n');
9658 }
9659 }
9660 }
9661
9662 let admins_out = Command::new("powershell")
9663 .args([
9664 "-NoProfile", "-NonInteractive", "-Command",
9665 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
9666 ])
9667 .output()
9668 .ok()
9669 .and_then(|o| String::from_utf8(o.stdout).ok())
9670 .unwrap_or_default();
9671
9672 out.push_str("\n=== Administrators Group Members ===\n");
9673 if admins_out.trim().is_empty() {
9674 out.push_str(" (unable to retrieve)\n");
9675 } else {
9676 out.push_str(admins_out.trim());
9677 out.push('\n');
9678 }
9679
9680 let sessions_out = Command::new("powershell")
9681 .args([
9682 "-NoProfile",
9683 "-NonInteractive",
9684 "-Command",
9685 "query user 2>$null",
9686 ])
9687 .output()
9688 .ok()
9689 .and_then(|o| String::from_utf8(o.stdout).ok())
9690 .unwrap_or_default();
9691
9692 out.push_str("\n=== Active Logon Sessions ===\n");
9693 if sessions_out.trim().is_empty() {
9694 out.push_str(" (none or requires elevation)\n");
9695 } else {
9696 for line in sessions_out.lines().take(max_entries) {
9697 if !line.trim().is_empty() {
9698 let _ = writeln!(out, " {}", line);
9699 }
9700 }
9701 }
9702
9703 let is_admin = Command::new("powershell")
9704 .args([
9705 "-NoProfile", "-NonInteractive", "-Command",
9706 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9707 ])
9708 .output()
9709 .ok()
9710 .and_then(|o| String::from_utf8(o.stdout).ok())
9711 .map(|s| s.trim().to_lowercase())
9712 .unwrap_or_default();
9713
9714 out.push_str("\n=== Current Session Elevation ===\n");
9715 let _ = writeln!(
9716 out,
9717 " Running as Administrator: {}",
9718 if is_admin.contains("true") {
9719 "YES"
9720 } else {
9721 "no"
9722 }
9723 );
9724 }
9725
9726 #[cfg(not(target_os = "windows"))]
9727 {
9728 let who_out = Command::new("who")
9729 .output()
9730 .ok()
9731 .and_then(|o| String::from_utf8(o.stdout).ok())
9732 .unwrap_or_default();
9733 out.push_str("=== Active Sessions ===\n");
9734 if who_out.trim().is_empty() {
9735 out.push_str(" (none)\n");
9736 } else {
9737 for line in who_out.lines().take(max_entries) {
9738 let _ = write!(out, " {}\n", line);
9739 }
9740 }
9741 let id_out = Command::new("id")
9742 .output()
9743 .ok()
9744 .and_then(|o| String::from_utf8(o.stdout).ok())
9745 .unwrap_or_default();
9746 let _ = write!(out, "\n=== Current User ===\n {}\n", id_out.trim());
9747 }
9748
9749 Ok(out.trim_end().to_string())
9750}
9751
9752fn inspect_audit_policy() -> Result<String, String> {
9755 let mut out = String::from("Host inspection: audit_policy\n\n");
9756
9757 #[cfg(target_os = "windows")]
9758 {
9759 let auditpol_out = Command::new("auditpol")
9760 .args(["/get", "/category:*"])
9761 .output()
9762 .ok()
9763 .and_then(|o| String::from_utf8(o.stdout).ok())
9764 .unwrap_or_default();
9765
9766 if auditpol_out.trim().is_empty()
9767 || auditpol_out.to_lowercase().contains("access is denied")
9768 {
9769 out.push_str("Audit policy requires Administrator elevation to read.\n");
9770 out.push_str(
9771 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9772 );
9773 } else {
9774 out.push_str("=== Windows Audit Policy ===\n");
9775 let mut any_enabled = false;
9776 for line in auditpol_out.lines() {
9777 let trimmed = line.trim();
9778 if trimmed.is_empty() {
9779 continue;
9780 }
9781 if trimmed.contains("Success") || trimmed.contains("Failure") {
9782 let _ = writeln!(out, " [ENABLED] {}", trimmed);
9783 any_enabled = true;
9784 } else {
9785 let _ = writeln!(out, " {}", trimmed);
9786 }
9787 }
9788 if !any_enabled {
9789 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9790 out.push_str(
9791 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9792 );
9793 }
9794 }
9795
9796 let evtlog = Command::new("powershell")
9797 .args([
9798 "-NoProfile", "-NonInteractive", "-Command",
9799 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9800 ])
9801 .output()
9802 .ok()
9803 .and_then(|o| String::from_utf8(o.stdout).ok())
9804 .map(|s| s.trim().to_string())
9805 .unwrap_or_default();
9806
9807 let _ = write!(
9808 out,
9809 "\n=== Windows Event Log Service ===\n Status: {}\n",
9810 if evtlog.is_empty() {
9811 "unknown".to_string()
9812 } else {
9813 evtlog
9814 }
9815 );
9816 }
9817
9818 #[cfg(not(target_os = "windows"))]
9819 {
9820 let auditd_status = Command::new("systemctl")
9821 .args(["is-active", "auditd"])
9822 .output()
9823 .ok()
9824 .and_then(|o| String::from_utf8(o.stdout).ok())
9825 .map(|s| s.trim().to_string())
9826 .unwrap_or_else(|| "not found".to_string());
9827
9828 let _ = write!(out, "=== auditd service ===\n Status: {}\n", auditd_status);
9829
9830 if auditd_status == "active" {
9831 let rules = Command::new("auditctl")
9832 .args(["-l"])
9833 .output()
9834 .ok()
9835 .and_then(|o| String::from_utf8(o.stdout).ok())
9836 .unwrap_or_default();
9837 out.push_str("\n=== Active Audit Rules ===\n");
9838 if rules.trim().is_empty() || rules.contains("No rules") {
9839 out.push_str(" No rules configured.\n");
9840 } else {
9841 for line in rules.lines() {
9842 let _ = write!(out, " {}\n", line);
9843 }
9844 }
9845 }
9846 }
9847
9848 Ok(out.trim_end().to_string())
9849}
9850
9851fn inspect_shares(max_entries: usize) -> Result<String, String> {
9854 let mut out = String::from("Host inspection: shares\n\n");
9855
9856 #[cfg(target_os = "windows")]
9857 {
9858 let smb_out = Command::new("powershell")
9859 .args([
9860 "-NoProfile", "-NonInteractive", "-Command",
9861 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9862 ])
9863 .output()
9864 .ok()
9865 .and_then(|o| String::from_utf8(o.stdout).ok())
9866 .unwrap_or_default();
9867
9868 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9869 let smb_lines: Vec<&str> = smb_out
9870 .lines()
9871 .filter(|l| !l.trim().is_empty())
9872 .take(max_entries)
9873 .collect();
9874 if smb_lines.is_empty() {
9875 out.push_str(" No SMB shares or unable to retrieve.\n");
9876 } else {
9877 for line in &smb_lines {
9878 let name = line.trim().split('|').next().unwrap_or("").trim();
9879 if name.ends_with('$') {
9880 let _ = writeln!(out, " {}", line.trim());
9881 } else {
9882 let _ = writeln!(out, " [CUSTOM] {}", line.trim());
9883 }
9884 }
9885 }
9886
9887 let smb_security = Command::new("powershell")
9888 .args([
9889 "-NoProfile", "-NonInteractive", "-Command",
9890 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9891 ])
9892 .output()
9893 .ok()
9894 .and_then(|o| String::from_utf8(o.stdout).ok())
9895 .unwrap_or_default();
9896
9897 out.push_str("\n=== SMB Server Security Settings ===\n");
9898 if smb_security.trim().is_empty() {
9899 out.push_str(" (unable to retrieve)\n");
9900 } else {
9901 out.push_str(smb_security.trim());
9902 out.push('\n');
9903 if smb_security.to_lowercase().contains("smb1: true") {
9904 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9905 }
9906 }
9907
9908 let drives_out = Command::new("powershell")
9909 .args([
9910 "-NoProfile", "-NonInteractive", "-Command",
9911 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9912 ])
9913 .output()
9914 .ok()
9915 .and_then(|o| String::from_utf8(o.stdout).ok())
9916 .unwrap_or_default();
9917
9918 out.push_str("\n=== Mapped Network Drives ===\n");
9919 if drives_out.trim().is_empty() {
9920 out.push_str(" None.\n");
9921 } else {
9922 for line in drives_out.lines().take(max_entries) {
9923 if !line.trim().is_empty() {
9924 out.push_str(line);
9925 out.push('\n');
9926 }
9927 }
9928 }
9929 }
9930
9931 #[cfg(not(target_os = "windows"))]
9932 {
9933 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9934 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9935 if smb_conf.is_empty() {
9936 out.push_str(" Not found or Samba not installed.\n");
9937 } else {
9938 for line in smb_conf.lines().take(max_entries) {
9939 let _ = write!(out, " {}\n", line);
9940 }
9941 }
9942 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9943 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9944 if nfs_exports.is_empty() {
9945 out.push_str(" Not configured.\n");
9946 } else {
9947 for line in nfs_exports.lines().take(max_entries) {
9948 let _ = write!(out, " {}\n", line);
9949 }
9950 }
9951 }
9952
9953 Ok(out.trim_end().to_string())
9954}
9955
9956fn inspect_dns_servers() -> Result<String, String> {
9959 let mut out = String::from("Host inspection: dns_servers\n\n");
9960
9961 #[cfg(target_os = "windows")]
9962 {
9963 let dns_out = Command::new("powershell")
9964 .args([
9965 "-NoProfile", "-NonInteractive", "-Command",
9966 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9967 ])
9968 .output()
9969 .ok()
9970 .and_then(|o| String::from_utf8(o.stdout).ok())
9971 .unwrap_or_default();
9972
9973 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9974 if dns_out.trim().is_empty() {
9975 out.push_str(" (unable to retrieve)\n");
9976 } else {
9977 for line in dns_out.lines() {
9978 if line.trim().is_empty() {
9979 continue;
9980 }
9981 let mut annotation = "";
9982 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9983 annotation = " <- Google Public DNS";
9984 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9985 annotation = " <- Cloudflare DNS";
9986 } else if line.contains("9.9.9.9") {
9987 annotation = " <- Quad9";
9988 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9989 annotation = " <- OpenDNS";
9990 }
9991 out.push_str(line);
9992 out.push_str(annotation);
9993 out.push('\n');
9994 }
9995 }
9996
9997 let doh_out = Command::new("powershell")
9998 .args([
9999 "-NoProfile", "-NonInteractive", "-Command",
10000 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
10001 ])
10002 .output()
10003 .ok()
10004 .and_then(|o| String::from_utf8(o.stdout).ok())
10005 .unwrap_or_default();
10006
10007 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
10008 if doh_out.trim().is_empty() {
10009 out.push_str(" Not configured (plain DNS).\n");
10010 } else {
10011 out.push_str(doh_out.trim());
10012 out.push('\n');
10013 }
10014
10015 let suffixes = Command::new("powershell")
10016 .args([
10017 "-NoProfile", "-NonInteractive", "-Command",
10018 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
10019 ])
10020 .output()
10021 .ok()
10022 .and_then(|o| String::from_utf8(o.stdout).ok())
10023 .unwrap_or_default();
10024
10025 if !suffixes.trim().is_empty() {
10026 out.push_str("\n=== DNS Search Suffix List ===\n");
10027 out.push_str(suffixes.trim());
10028 out.push('\n');
10029 }
10030 }
10031
10032 #[cfg(not(target_os = "windows"))]
10033 {
10034 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
10035 out.push_str("=== /etc/resolv.conf ===\n");
10036 if resolv.is_empty() {
10037 out.push_str(" Not found.\n");
10038 } else {
10039 for line in resolv.lines() {
10040 if !line.trim().is_empty() && !line.starts_with('#') {
10041 let _ = write!(out, " {}\n", line);
10042 }
10043 }
10044 }
10045 let resolved_out = Command::new("resolvectl")
10046 .args(["status", "--no-pager"])
10047 .output()
10048 .ok()
10049 .and_then(|o| String::from_utf8(o.stdout).ok())
10050 .unwrap_or_default();
10051 if !resolved_out.is_empty() {
10052 out.push_str("\n=== systemd-resolved ===\n");
10053 for line in resolved_out.lines().take(30) {
10054 let _ = write!(out, " {}\n", line);
10055 }
10056 }
10057 }
10058
10059 Ok(out.trim_end().to_string())
10060}
10061
10062fn inspect_bitlocker() -> Result<String, String> {
10063 let mut out = String::from("Host inspection: bitlocker\n\n");
10064
10065 #[cfg(target_os = "windows")]
10066 {
10067 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10068 let output = Command::new("powershell")
10069 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10070 .output()
10071 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10072
10073 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10074 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10075
10076 if !stdout.trim().is_empty() {
10077 out.push_str("=== BitLocker Volumes ===\n");
10078 for line in stdout.lines() {
10079 let _ = writeln!(out, " {}", line);
10080 }
10081 } else if !stderr.trim().is_empty() {
10082 if stderr.contains("Access is denied") {
10083 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10084 } else {
10085 let _ = writeln!(out, "Error retrieving BitLocker info: {}", stderr.trim());
10086 }
10087 } else {
10088 out.push_str("No BitLocker volumes detected or access denied.\n");
10089 }
10090 }
10091
10092 #[cfg(not(target_os = "windows"))]
10093 {
10094 out.push_str(
10095 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10096 );
10097 let lsblk = Command::new("lsblk")
10098 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10099 .output()
10100 .ok()
10101 .and_then(|o| String::from_utf8(o.stdout).ok())
10102 .unwrap_or_default();
10103 if lsblk.contains("crypto_LUKS") {
10104 out.push_str("=== LUKS Encrypted Volumes ===\n");
10105 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10106 let _ = write!(out, " {}\n", line);
10107 }
10108 } else {
10109 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10110 }
10111 }
10112
10113 Ok(out.trim_end().to_string())
10114}
10115
10116fn inspect_rdp() -> Result<String, String> {
10117 let mut out = String::from("Host inspection: rdp\n\n");
10118
10119 #[cfg(target_os = "windows")]
10120 {
10121 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10122 let f_deny = Command::new("powershell")
10123 .args([
10124 "-NoProfile",
10125 "-Command",
10126 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_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
10135 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10136 let _ = writeln!(out, "=== RDP Status: {} ===", status);
10137
10138 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"])
10139 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10140 let _ = writeln!(
10141 out,
10142 " Port: {}",
10143 if port.is_empty() {
10144 "3389 (default)"
10145 } else {
10146 &port
10147 }
10148 );
10149
10150 let nla = Command::new("powershell")
10151 .args([
10152 "-NoProfile",
10153 "-Command",
10154 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10155 ])
10156 .output()
10157 .ok()
10158 .and_then(|o| String::from_utf8(o.stdout).ok())
10159 .unwrap_or_default()
10160 .trim()
10161 .to_string();
10162 let _ = writeln!(
10163 out,
10164 " NLA Required: {}",
10165 if nla == "1" { "Yes" } else { "No" }
10166 );
10167
10168 let rdp_tcp_path =
10169 "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10170 let sec_layer = Command::new("powershell")
10171 .args([
10172 "-NoProfile",
10173 "-Command",
10174 &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10175 ])
10176 .output()
10177 .ok()
10178 .and_then(|o| String::from_utf8(o.stdout).ok())
10179 .unwrap_or_default()
10180 .trim()
10181 .to_string();
10182 let sec_label = match sec_layer.as_str() {
10183 "0" => "RDP Security (no SSL)",
10184 "1" => "Negotiate (prefer TLS)",
10185 "2" => "SSL/TLS required",
10186 _ => &sec_layer,
10187 };
10188 let _ = writeln!(out, " Security Layer: {} ({})", sec_layer, sec_label);
10189
10190 let enc_level = Command::new("powershell")
10191 .args([
10192 "-NoProfile",
10193 "-Command",
10194 &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10195 ])
10196 .output()
10197 .ok()
10198 .and_then(|o| String::from_utf8(o.stdout).ok())
10199 .unwrap_or_default()
10200 .trim()
10201 .to_string();
10202 let enc_label = match enc_level.as_str() {
10203 "1" => "Low",
10204 "2" => "Client Compatible",
10205 "3" => "High",
10206 "4" => "FIPS Compliant",
10207 _ => "Unknown",
10208 };
10209 let _ = writeln!(out, " Encryption Level: {} ({})", enc_level, enc_label);
10210
10211 out.push_str("\n=== Active Sessions ===\n");
10212 let qwinsta = Command::new("qwinsta")
10213 .output()
10214 .ok()
10215 .and_then(|o| String::from_utf8(o.stdout).ok())
10216 .unwrap_or_default();
10217 if qwinsta.trim().is_empty() {
10218 out.push_str(" No active sessions listed.\n");
10219 } else {
10220 for line in qwinsta.lines() {
10221 let _ = writeln!(out, " {}", line);
10222 }
10223 }
10224
10225 out.push_str("\n=== Firewall Rule Check ===\n");
10226 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))\" }"])
10227 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10228 if fw.trim().is_empty() {
10229 out.push_str(" No enabled RDP firewall rules found.\n");
10230 } else {
10231 out.push_str(fw.trim_end());
10232 out.push('\n');
10233 }
10234 }
10235
10236 #[cfg(not(target_os = "windows"))]
10237 {
10238 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10239 let ss = Command::new("ss")
10240 .args(["-tlnp"])
10241 .output()
10242 .ok()
10243 .and_then(|o| String::from_utf8(o.stdout).ok())
10244 .unwrap_or_default();
10245 let matches: Vec<&str> = ss
10246 .lines()
10247 .filter(|l| l.contains(":3389") || l.contains(":590"))
10248 .collect();
10249 if matches.is_empty() {
10250 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
10251 } else {
10252 for m in matches {
10253 let _ = write!(out, " {}\n", m);
10254 }
10255 }
10256 }
10257
10258 Ok(out.trim_end().to_string())
10259}
10260
10261fn inspect_shadow_copies() -> Result<String, String> {
10262 let mut out = String::from("Host inspection: shadow_copies\n\n");
10263
10264 #[cfg(target_os = "windows")]
10265 {
10266 let output = Command::new("vssadmin")
10267 .args(["list", "shadows"])
10268 .output()
10269 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10270 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10271
10272 if stdout.contains("No items found") || stdout.trim().is_empty() {
10273 out.push_str("No Volume Shadow Copies found.\n");
10274 } else {
10275 out.push_str("=== Volume Shadow Copies ===\n");
10276 for line in stdout.lines().take(50) {
10277 if line.contains("Creation Time:")
10278 || line.contains("Contents:")
10279 || line.contains("Volume Name:")
10280 {
10281 let _ = writeln!(out, " {}", line.trim());
10282 }
10283 }
10284 }
10285
10286 let age_script = r#"
10288try {
10289 $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10290 if ($snaps) {
10291 $newest = $snaps[0]
10292 $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10293 $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10294 $count = @($snaps).Count
10295 "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm')) ($age days ago) — $count total snapshots"
10296 } else { "No snapshots found via WMI." }
10297} catch { "WMI snapshot query unavailable: $_" }
10298"#;
10299 if let Ok(age_out) = Command::new("powershell")
10300 .args(["-NoProfile", "-Command", age_script])
10301 .output()
10302 {
10303 let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10304 if !age_text.is_empty() {
10305 out.push_str("\n=== Snapshot Age ===\n");
10306 let _ = writeln!(out, " {}", age_text);
10307 }
10308 }
10309
10310 out.push_str("\n=== Shadow Copy Storage ===\n");
10311 let storage_out = Command::new("vssadmin")
10312 .args(["list", "shadowstorage"])
10313 .output()
10314 .ok();
10315 if let Some(o) = storage_out {
10316 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10317 for line in stdout.lines() {
10318 if line.contains("Used Shadow Copy Storage space:")
10319 || line.contains("Max Shadow Copy Storage space:")
10320 {
10321 let _ = writeln!(out, " {}", line.trim());
10322 }
10323 }
10324 }
10325 }
10326
10327 #[cfg(not(target_os = "windows"))]
10328 {
10329 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10330 let lvs = Command::new("lvs")
10331 .output()
10332 .ok()
10333 .and_then(|o| String::from_utf8(o.stdout).ok())
10334 .unwrap_or_default();
10335 if !lvs.is_empty() {
10336 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10337 out.push_str(&lvs);
10338 } else {
10339 out.push_str("No LVM volumes detected.\n");
10340 }
10341 }
10342
10343 Ok(out.trim_end().to_string())
10344}
10345
10346fn inspect_pagefile() -> Result<String, String> {
10347 let mut out = String::from("Host inspection: pagefile\n\n");
10348
10349 #[cfg(target_os = "windows")]
10350 {
10351 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)\" }";
10352 let output = Command::new("powershell")
10353 .args(["-NoProfile", "-Command", ps_cmd])
10354 .output()
10355 .ok()
10356 .and_then(|o| String::from_utf8(o.stdout).ok())
10357 .unwrap_or_default();
10358
10359 if output.trim().is_empty() {
10360 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10361 let managed = Command::new("powershell")
10362 .args([
10363 "-NoProfile",
10364 "-Command",
10365 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10366 ])
10367 .output()
10368 .ok()
10369 .and_then(|o| String::from_utf8(o.stdout).ok())
10370 .unwrap_or_default()
10371 .trim()
10372 .to_string();
10373 let _ = writeln!(out, "Automatic Managed Pagefile: {}", managed);
10374 } else {
10375 out.push_str("=== Page File Usage ===\n");
10376 out.push_str(&output);
10377 }
10378 }
10379
10380 #[cfg(not(target_os = "windows"))]
10381 {
10382 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10383 let swap = Command::new("swapon")
10384 .args(["--show"])
10385 .output()
10386 .ok()
10387 .and_then(|o| String::from_utf8(o.stdout).ok())
10388 .unwrap_or_default();
10389 if swap.is_empty() {
10390 let free = Command::new("free")
10391 .args(["-h"])
10392 .output()
10393 .ok()
10394 .and_then(|o| String::from_utf8(o.stdout).ok())
10395 .unwrap_or_default();
10396 out.push_str(&free);
10397 } else {
10398 out.push_str(&swap);
10399 }
10400 }
10401
10402 Ok(out.trim_end().to_string())
10403}
10404
10405fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10406 let mut out = String::from("Host inspection: windows_features\n\n");
10407
10408 #[cfg(target_os = "windows")]
10409 {
10410 out.push_str("=== Quick Check: Notable Features ===\n");
10411 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10412 let output = Command::new("powershell")
10413 .args(["-NoProfile", "-Command", quick_ps])
10414 .output()
10415 .ok();
10416
10417 if let Some(o) = output {
10418 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10419 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10420
10421 if !stdout.trim().is_empty() {
10422 for f in stdout.lines() {
10423 let _ = writeln!(out, " [ENABLED] {}", f);
10424 }
10425 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10426 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10427 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10428 out.push_str(
10429 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10430 );
10431 }
10432 }
10433
10434 let _ = write!(
10435 out,
10436 "\n=== All Enabled Features (capped at {}) ===\n",
10437 max_entries
10438 );
10439 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10440 let all_out = Command::new("powershell")
10441 .args(["-NoProfile", "-Command", &all_ps])
10442 .output()
10443 .ok();
10444 if let Some(o) = all_out {
10445 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10446 if !stdout.trim().is_empty() {
10447 out.push_str(&stdout);
10448 }
10449 }
10450 }
10451
10452 #[cfg(not(target_os = "windows"))]
10453 {
10454 let _ = max_entries;
10455 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10456 }
10457
10458 Ok(out.trim_end().to_string())
10459}
10460
10461fn inspect_audio(max_entries: usize) -> Result<String, String> {
10462 let mut out = String::from("Host inspection: audio\n\n");
10463
10464 #[cfg(target_os = "windows")]
10465 {
10466 let n = max_entries.clamp(5, 20);
10467 let services = collect_services().unwrap_or_default();
10468 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10469 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10470
10471 let core_services: Vec<&ServiceEntry> = services
10472 .iter()
10473 .filter(|entry| {
10474 core_service_names
10475 .iter()
10476 .any(|name| entry.name.eq_ignore_ascii_case(name))
10477 })
10478 .collect();
10479 let bluetooth_audio_services: Vec<&ServiceEntry> = services
10480 .iter()
10481 .filter(|entry| {
10482 bluetooth_audio_service_names
10483 .iter()
10484 .any(|name| entry.name.eq_ignore_ascii_case(name))
10485 })
10486 .collect();
10487
10488 let probe_script = r#"
10489$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10490 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10491$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10492 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10493$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10494 Select-Object Name, Status, Manufacturer, PNPDeviceID)
10495[pscustomobject]@{
10496 Media = $media
10497 Endpoints = $endpoints
10498 SoundDevices = $sound
10499} | ConvertTo-Json -Compress -Depth 4
10500"#;
10501 let probe_raw = Command::new("powershell")
10502 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10503 .output()
10504 .ok()
10505 .and_then(|o| String::from_utf8(o.stdout).ok())
10506 .unwrap_or_default();
10507 let probe_loaded = !probe_raw.trim().is_empty();
10508 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10509
10510 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10511 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10512 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10513
10514 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10515 .iter()
10516 .filter(|device| !is_microphone_like_name(&device.name))
10517 .collect();
10518 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10519 .iter()
10520 .filter(|device| is_microphone_like_name(&device.name))
10521 .collect();
10522 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10523 .iter()
10524 .filter(|device| is_bluetooth_like_name(&device.name))
10525 .collect();
10526 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10527 .iter()
10528 .filter(|device| windows_device_has_issue(device))
10529 .collect();
10530 let media_problems: Vec<&WindowsPnpDevice> = media_devices
10531 .iter()
10532 .filter(|device| windows_device_has_issue(device))
10533 .collect();
10534 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10535 .iter()
10536 .filter(|device| windows_sound_device_has_issue(device))
10537 .collect();
10538
10539 let mut findings = Vec::with_capacity(4);
10540
10541 let stopped_core_services: Vec<&ServiceEntry> = core_services
10542 .iter()
10543 .copied()
10544 .filter(|service| !service_is_running(service))
10545 .collect();
10546 if !stopped_core_services.is_empty() {
10547 let names = {
10548 let mut s = String::new();
10549 for (i, svc) in stopped_core_services.iter().enumerate() {
10550 if i > 0 {
10551 s.push_str(", ");
10552 }
10553 s.push_str(&svc.name);
10554 }
10555 s
10556 };
10557 findings.push(AuditFinding {
10558 finding: format!("Core audio services are not running: {names}"),
10559 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10560 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10561 });
10562 }
10563
10564 if probe_loaded
10565 && endpoints.is_empty()
10566 && media_devices.is_empty()
10567 && sound_devices.is_empty()
10568 {
10569 findings.push(AuditFinding {
10570 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10571 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(),
10572 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(),
10573 });
10574 }
10575
10576 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10577 {
10578 let mut problem_labels = Vec::with_capacity(9);
10579 problem_labels.extend(
10580 endpoint_problems
10581 .iter()
10582 .take(3)
10583 .map(|device| device.name.clone()),
10584 );
10585 problem_labels.extend(
10586 media_problems
10587 .iter()
10588 .take(3)
10589 .map(|device| device.name.clone()),
10590 );
10591 problem_labels.extend(
10592 sound_problems
10593 .iter()
10594 .take(3)
10595 .map(|device| device.name.clone()),
10596 );
10597 findings.push(AuditFinding {
10598 finding: format!(
10599 "Windows reports audio device issues for: {}",
10600 problem_labels.join(", ")
10601 ),
10602 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10603 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(),
10604 });
10605 }
10606
10607 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10608 .iter()
10609 .copied()
10610 .filter(|service| !service_is_running(service))
10611 .collect();
10612 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10613 let names = {
10614 let mut s = String::new();
10615 for (i, svc) in stopped_bt_audio_services.iter().enumerate() {
10616 if i > 0 {
10617 s.push_str(", ");
10618 }
10619 s.push_str(&svc.name);
10620 }
10621 s
10622 };
10623 findings.push(AuditFinding {
10624 finding: format!(
10625 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10626 ),
10627 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10628 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10629 });
10630 }
10631
10632 out.push_str("=== Findings ===\n");
10633 if findings.is_empty() {
10634 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10635 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
10636 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");
10637 } else {
10638 for finding in &findings {
10639 let _ = writeln!(out, "- Finding: {}", finding.finding);
10640 let _ = writeln!(out, " Impact: {}", finding.impact);
10641 let _ = writeln!(out, " Fix: {}", finding.fix);
10642 }
10643 }
10644
10645 out.push_str("\n=== Audio services ===\n");
10646 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10647 out.push_str(
10648 "- No Windows audio services were retrieved from the service inventory.\n",
10649 );
10650 } else {
10651 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10652 let _ = writeln!(
10653 out,
10654 "- {} | Status: {} | Startup: {}",
10655 service.name,
10656 service.status,
10657 service.startup.as_deref().unwrap_or("Unknown")
10658 );
10659 }
10660 }
10661
10662 out.push_str("\n=== Playback and recording endpoints ===\n");
10663 if !probe_loaded {
10664 out.push_str("- Windows endpoint inventory probe returned no data.\n");
10665 } else if endpoints.is_empty() {
10666 out.push_str("- No audio endpoints detected.\n");
10667 } else {
10668 let _ = writeln!(
10669 out,
10670 "- Playback-style endpoints: {} | Recording-style endpoints: {}",
10671 playback_endpoints.len(),
10672 recording_endpoints.len()
10673 );
10674 for device in playback_endpoints.iter().take(n) {
10675 let _ = writeln!(
10676 out,
10677 "- [PLAYBACK] {} | Status: {}{}",
10678 device.name,
10679 device.status,
10680 device
10681 .problem
10682 .filter(|problem| *problem != 0)
10683 .map(|problem| format!(" | ProblemCode: {problem}"))
10684 .unwrap_or_default()
10685 );
10686 }
10687 for device in recording_endpoints.iter().take(n) {
10688 let _ = writeln!(
10689 out,
10690 "- [MIC] {} | Status: {}{}",
10691 device.name,
10692 device.status,
10693 device
10694 .problem
10695 .filter(|problem| *problem != 0)
10696 .map(|problem| format!(" | ProblemCode: {problem}"))
10697 .unwrap_or_default()
10698 );
10699 }
10700 }
10701
10702 out.push_str("\n=== Sound hardware devices ===\n");
10703 if sound_devices.is_empty() {
10704 out.push_str("- No Win32_SoundDevice entries were returned.\n");
10705 } else {
10706 for device in sound_devices.iter().take(n) {
10707 let _ = writeln!(
10708 out,
10709 "- {} | Status: {}{}",
10710 device.name,
10711 device.status,
10712 device
10713 .manufacturer
10714 .as_deref()
10715 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10716 .unwrap_or_default()
10717 );
10718 }
10719 }
10720
10721 out.push_str("\n=== Media-class device inventory ===\n");
10722 if media_devices.is_empty() {
10723 out.push_str("- No media-class PnP devices were returned.\n");
10724 } else {
10725 for device in media_devices.iter().take(n) {
10726 let _ = writeln!(
10727 out,
10728 "- {} | Status: {}{}",
10729 device.name,
10730 device.status,
10731 device
10732 .class_name
10733 .as_deref()
10734 .map(|class_name| format!(" | Class: {class_name}"))
10735 .unwrap_or_default()
10736 );
10737 }
10738 }
10739 }
10740
10741 #[cfg(not(target_os = "windows"))]
10742 {
10743 let _ = max_entries;
10744 out.push_str(
10745 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10746 );
10747 out.push_str(
10748 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10749 );
10750 }
10751
10752 Ok(out.trim_end().to_string())
10753}
10754
10755fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10756 let mut out = String::from("Host inspection: bluetooth\n\n");
10757
10758 #[cfg(target_os = "windows")]
10759 {
10760 let n = max_entries.clamp(5, 20);
10761 let services = collect_services().unwrap_or_default();
10762 let bluetooth_services: Vec<&ServiceEntry> = services
10763 .iter()
10764 .filter(|entry| {
10765 entry.name.eq_ignore_ascii_case("bthserv")
10766 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10767 || entry.name.eq_ignore_ascii_case("BTAGService")
10768 || entry.name.starts_with("BluetoothUserService")
10769 || entry
10770 .display_name
10771 .as_deref()
10772 .unwrap_or("")
10773 .to_ascii_lowercase()
10774 .contains("bluetooth")
10775 })
10776 .collect();
10777
10778 let probe_script = r#"
10779$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10780 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10781$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10782 Where-Object {
10783 $_.Class -eq 'Bluetooth' -or
10784 $_.FriendlyName -match 'Bluetooth' -or
10785 $_.InstanceId -like 'BTH*'
10786 } |
10787 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10788$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10789 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10790 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10791[pscustomobject]@{
10792 Radios = $radios
10793 Devices = $devices
10794 AudioEndpoints = $audio
10795} | ConvertTo-Json -Compress -Depth 4
10796"#;
10797 let probe_raw = Command::new("powershell")
10798 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10799 .output()
10800 .ok()
10801 .and_then(|o| String::from_utf8(o.stdout).ok())
10802 .unwrap_or_default();
10803 let probe_loaded = !probe_raw.trim().is_empty();
10804 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10805
10806 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10807 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10808 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10809 let radio_problems: Vec<&WindowsPnpDevice> = radios
10810 .iter()
10811 .filter(|device| windows_device_has_issue(device))
10812 .collect();
10813 let device_problems: Vec<&WindowsPnpDevice> = devices
10814 .iter()
10815 .filter(|device| windows_device_has_issue(device))
10816 .collect();
10817
10818 let mut findings = Vec::with_capacity(4);
10819
10820 if probe_loaded && radios.is_empty() {
10821 findings.push(AuditFinding {
10822 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10823 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10824 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10825 });
10826 }
10827
10828 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10829 .iter()
10830 .copied()
10831 .filter(|service| !service_is_running(service))
10832 .collect();
10833 if !stopped_bluetooth_services.is_empty() {
10834 let names = {
10835 let mut s = String::new();
10836 for (i, svc) in stopped_bluetooth_services.iter().enumerate() {
10837 if i > 0 {
10838 s.push_str(", ");
10839 }
10840 s.push_str(&svc.name);
10841 }
10842 s
10843 };
10844 findings.push(AuditFinding {
10845 finding: format!("Bluetooth-related services are not fully running: {names}"),
10846 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10847 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10848 });
10849 }
10850
10851 if !radio_problems.is_empty() || !device_problems.is_empty() {
10852 let problem_labels = {
10853 let mut s = String::new();
10854 for (i, device) in radio_problems
10855 .iter()
10856 .chain(device_problems.iter())
10857 .take(5)
10858 .enumerate()
10859 {
10860 if i > 0 {
10861 s.push_str(", ");
10862 }
10863 s.push_str(&device.name);
10864 }
10865 s
10866 };
10867 findings.push(AuditFinding {
10868 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10869 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10870 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(),
10871 });
10872 }
10873
10874 if !audio_endpoints.is_empty()
10875 && bluetooth_services
10876 .iter()
10877 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10878 && bluetooth_services
10879 .iter()
10880 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10881 .any(|service| !service_is_running(service))
10882 {
10883 findings.push(AuditFinding {
10884 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10885 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10886 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10887 });
10888 }
10889
10890 out.push_str("=== Findings ===\n");
10891 if findings.is_empty() {
10892 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10893 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10894 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");
10895 } else {
10896 for finding in &findings {
10897 let _ = writeln!(out, "- Finding: {}", finding.finding);
10898 let _ = writeln!(out, " Impact: {}", finding.impact);
10899 let _ = writeln!(out, " Fix: {}", finding.fix);
10900 }
10901 }
10902
10903 out.push_str("\n=== Bluetooth services ===\n");
10904 if bluetooth_services.is_empty() {
10905 out.push_str(
10906 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10907 );
10908 } else {
10909 for service in bluetooth_services.iter().take(n) {
10910 let _ = writeln!(
10911 out,
10912 "- {} | Status: {} | Startup: {}",
10913 service.name,
10914 service.status,
10915 service.startup.as_deref().unwrap_or("Unknown")
10916 );
10917 }
10918 }
10919
10920 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10921 if !probe_loaded {
10922 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10923 } else if radios.is_empty() {
10924 out.push_str("- No Bluetooth radios detected.\n");
10925 } else {
10926 for device in radios.iter().take(n) {
10927 let _ = writeln!(
10928 out,
10929 "- {} | Status: {}{}",
10930 device.name,
10931 device.status,
10932 device
10933 .problem
10934 .filter(|problem| *problem != 0)
10935 .map(|problem| format!(" | ProblemCode: {problem}"))
10936 .unwrap_or_default()
10937 );
10938 }
10939 }
10940
10941 out.push_str("\n=== Bluetooth-associated devices ===\n");
10942 if devices.is_empty() {
10943 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10944 } else {
10945 for device in devices.iter().take(n) {
10946 let _ = writeln!(
10947 out,
10948 "- {} | Status: {}{}",
10949 device.name,
10950 device.status,
10951 device
10952 .class_name
10953 .as_deref()
10954 .map(|class_name| format!(" | Class: {class_name}"))
10955 .unwrap_or_default()
10956 );
10957 }
10958 }
10959
10960 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10961 if audio_endpoints.is_empty() {
10962 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10963 } else {
10964 for device in audio_endpoints.iter().take(n) {
10965 let _ = writeln!(
10966 out,
10967 "- {} | Status: {}{}",
10968 device.name,
10969 device.status,
10970 device
10971 .instance_id
10972 .as_deref()
10973 .map(|instance_id| format!(" | Instance: {instance_id}"))
10974 .unwrap_or_default()
10975 );
10976 }
10977 }
10978 }
10979
10980 #[cfg(not(target_os = "windows"))]
10981 {
10982 let _ = max_entries;
10983 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10984 out.push_str(
10985 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10986 );
10987 }
10988
10989 Ok(out.trim_end().to_string())
10990}
10991
10992fn inspect_printers(max_entries: usize) -> Result<String, String> {
10993 let mut out = String::from("Host inspection: printers\n\n");
10994
10995 #[cfg(target_os = "windows")]
10996 {
10997 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)])
10998 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10999 if list.trim().is_empty() {
11000 out.push_str("No printers detected.\n");
11001 } else {
11002 out.push_str("=== Installed Printers ===\n");
11003 out.push_str(&list);
11004 }
11005
11006 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
11007 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11008 if !jobs.trim().is_empty() {
11009 out.push_str("\n=== Active Print Jobs ===\n");
11010 out.push_str(&jobs);
11011 }
11012 }
11013
11014 #[cfg(not(target_os = "windows"))]
11015 {
11016 let _ = max_entries;
11017 out.push_str("Checking LPSTAT for printers...\n");
11018 let lpstat = Command::new("lpstat")
11019 .args(["-p", "-d"])
11020 .output()
11021 .ok()
11022 .and_then(|o| String::from_utf8(o.stdout).ok())
11023 .unwrap_or_default();
11024 if lpstat.is_empty() {
11025 out.push_str(" No CUPS/LP printers found.\n");
11026 } else {
11027 out.push_str(&lpstat);
11028 }
11029 }
11030
11031 Ok(out.trim_end().to_string())
11032}
11033
11034fn inspect_winrm() -> Result<String, String> {
11035 let mut out = String::from("Host inspection: winrm\n\n");
11036
11037 #[cfg(target_os = "windows")]
11038 {
11039 let svc = Command::new("powershell")
11040 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
11041 .output()
11042 .ok()
11043 .and_then(|o| String::from_utf8(o.stdout).ok())
11044 .unwrap_or_default()
11045 .trim()
11046 .to_string();
11047 let _ = write!(
11048 out,
11049 "WinRM Service Status: {}\n\n",
11050 if svc.is_empty() { "NOT_FOUND" } else { &svc }
11051 );
11052
11053 out.push_str("=== WinRM Listeners ===\n");
11054 let output = Command::new("powershell")
11055 .args([
11056 "-NoProfile",
11057 "-Command",
11058 "winrm enumerate winrm/config/listener 2>$null",
11059 ])
11060 .output()
11061 .ok();
11062 if let Some(o) = output {
11063 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11064 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11065
11066 if !stdout.trim().is_empty() {
11067 for line in stdout.lines() {
11068 if line.contains("Address =")
11069 || line.contains("Transport =")
11070 || line.contains("Port =")
11071 {
11072 let _ = writeln!(out, " {}", line.trim());
11073 }
11074 }
11075 } else if stderr.contains("Access is denied") {
11076 out.push_str(" Error: Access denied to WinRM configuration.\n");
11077 } else {
11078 out.push_str(" No listeners configured.\n");
11079 }
11080 }
11081
11082 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11083 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))\" }"])
11084 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11085 if test_out.trim().is_empty() {
11086 out.push_str(" WinRM not responding to local WS-Man requests.\n");
11087 } else {
11088 out.push_str(&test_out);
11089 }
11090 }
11091
11092 #[cfg(not(target_os = "windows"))]
11093 {
11094 out.push_str(
11095 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11096 );
11097 let ss = Command::new("ss")
11098 .args(["-tln"])
11099 .output()
11100 .ok()
11101 .and_then(|o| String::from_utf8(o.stdout).ok())
11102 .unwrap_or_default();
11103 if ss.contains(":5985") || ss.contains(":5986") {
11104 out.push_str(" WinRM ports (5985/5986) are listening.\n");
11105 } else {
11106 out.push_str(" WinRM ports not detected.\n");
11107 }
11108 }
11109
11110 Ok(out.trim_end().to_string())
11111}
11112
11113fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11114 let mut out = String::from("Host inspection: network_stats\n\n");
11115
11116 #[cfg(target_os = "windows")]
11117 {
11118 let ps_cmd = format!(
11119 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11120 Start-Sleep -Milliseconds 250; \
11121 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11122 $s2 | ForEach-Object {{ \
11123 $name = $_.Name; \
11124 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11125 if ($prev) {{ \
11126 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11127 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11128 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11129 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11130 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11131 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11132 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11133 }} \
11134 }}",
11135 max_entries
11136 );
11137 let output = Command::new("powershell")
11138 .args(["-NoProfile", "-Command", &ps_cmd])
11139 .output()
11140 .ok()
11141 .and_then(|o| String::from_utf8(o.stdout).ok())
11142 .unwrap_or_default();
11143 if output.trim().is_empty() {
11144 out.push_str("No network adapter statistics available.\n");
11145 } else {
11146 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11147 out.push_str(&output);
11148 }
11149
11150 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)\" } }"])
11151 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11152 if !discards.trim().is_empty() {
11153 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11154 out.push_str(&discards);
11155 }
11156 }
11157
11158 #[cfg(not(target_os = "windows"))]
11159 {
11160 let _ = max_entries;
11161 out.push_str("=== Network Stats (ip -s link) ===\n");
11162 let ip_s = Command::new("ip")
11163 .args(["-s", "link"])
11164 .output()
11165 .ok()
11166 .and_then(|o| String::from_utf8(o.stdout).ok())
11167 .unwrap_or_default();
11168 if ip_s.is_empty() {
11169 let netstat = Command::new("netstat")
11170 .args(["-i"])
11171 .output()
11172 .ok()
11173 .and_then(|o| String::from_utf8(o.stdout).ok())
11174 .unwrap_or_default();
11175 out.push_str(&netstat);
11176 } else {
11177 out.push_str(&ip_s);
11178 }
11179 }
11180
11181 Ok(out.trim_end().to_string())
11182}
11183
11184fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11185 let mut out = String::from("Host inspection: udp_ports\n\n");
11186
11187 #[cfg(target_os = "windows")]
11188 {
11189 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);
11190 let output = Command::new("powershell")
11191 .args(["-NoProfile", "-Command", &ps_cmd])
11192 .output()
11193 .ok();
11194
11195 if let Some(o) = output {
11196 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11197 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11198
11199 if !stdout.trim().is_empty() {
11200 out.push_str("=== UDP Listeners (Local:Port) ===\n");
11201 for line in stdout.lines() {
11202 let mut note = "";
11203 if line.contains(":53 ") {
11204 note = " [DNS]";
11205 } else if line.contains(":67 ") || line.contains(":68 ") {
11206 note = " [DHCP]";
11207 } else if line.contains(":123 ") {
11208 note = " [NTP]";
11209 } else if line.contains(":161 ") {
11210 note = " [SNMP]";
11211 } else if line.contains(":1900 ") {
11212 note = " [SSDP/UPnP]";
11213 } else if line.contains(":5353 ") {
11214 note = " [mDNS]";
11215 }
11216
11217 let _ = writeln!(out, "{}{}", line, note);
11218 }
11219 } else if stderr.contains("Access is denied") {
11220 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11221 } else {
11222 out.push_str("No UDP listeners detected.\n");
11223 }
11224 }
11225 }
11226
11227 #[cfg(not(target_os = "windows"))]
11228 {
11229 let ss_out = Command::new("ss")
11230 .args(["-ulnp"])
11231 .output()
11232 .ok()
11233 .and_then(|o| String::from_utf8(o.stdout).ok())
11234 .unwrap_or_default();
11235 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11236 if ss_out.is_empty() {
11237 let netstat_out = Command::new("netstat")
11238 .args(["-ulnp"])
11239 .output()
11240 .ok()
11241 .and_then(|o| String::from_utf8(o.stdout).ok())
11242 .unwrap_or_default();
11243 if netstat_out.is_empty() {
11244 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
11245 } else {
11246 for line in netstat_out.lines().take(max_entries) {
11247 let _ = write!(out, " {}\n", line);
11248 }
11249 }
11250 } else {
11251 for line in ss_out.lines().take(max_entries) {
11252 let _ = write!(out, " {}\n", line);
11253 }
11254 }
11255 }
11256
11257 Ok(out.trim_end().to_string())
11258}
11259
11260fn inspect_gpo() -> Result<String, String> {
11261 let mut out = String::from("Host inspection: gpo\n\n");
11262
11263 #[cfg(target_os = "windows")]
11264 {
11265 let output = Command::new("gpresult")
11266 .args(["/r", "/scope", "computer"])
11267 .output()
11268 .ok();
11269
11270 if let Some(o) = output {
11271 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11272 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11273
11274 if stdout.contains("Applied Group Policy Objects") {
11275 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11276 let mut capture = false;
11277 for line in stdout.lines() {
11278 if line.contains("Applied Group Policy Objects") {
11279 capture = true;
11280 } else if capture && line.contains("The following GPOs were not applied") {
11281 break;
11282 }
11283 if capture && !line.trim().is_empty() {
11284 let _ = writeln!(out, " {}", line.trim());
11285 }
11286 }
11287 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11288 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11289 } else {
11290 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11291 }
11292 }
11293 }
11294
11295 #[cfg(not(target_os = "windows"))]
11296 {
11297 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11298 }
11299
11300 Ok(out.trim_end().to_string())
11301}
11302
11303fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11304 let mut out = String::from("Host inspection: certificates\n\n");
11305
11306 #[cfg(target_os = "windows")]
11307 {
11308 let ps_cmd = format!(
11309 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11310 $days = ($_.NotAfter - (Get-Date)).Days; \
11311 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11312 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11313 }}",
11314 max_entries
11315 );
11316 let output = Command::new("powershell")
11317 .args(["-NoProfile", "-Command", &ps_cmd])
11318 .output()
11319 .ok();
11320
11321 if let Some(o) = output {
11322 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11323 if !stdout.trim().is_empty() {
11324 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11325 out.push_str(&stdout);
11326 } else {
11327 out.push_str("No certificates found in the Local Machine Personal store.\n");
11328 }
11329 }
11330 }
11331
11332 #[cfg(not(target_os = "windows"))]
11333 {
11334 let _ = max_entries;
11335 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11336 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11338 if Path::new(path).exists() {
11339 let _ = write!(out, " Cert directory found: {}\n", path);
11340 }
11341 }
11342 }
11343
11344 Ok(out.trim_end().to_string())
11345}
11346
11347fn inspect_integrity() -> Result<String, String> {
11348 let mut out = String::from("Host inspection: integrity\n\n");
11349
11350 #[cfg(target_os = "windows")]
11351 {
11352 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11353 let output = Command::new("powershell")
11354 .args(["-NoProfile", "-Command", ps_cmd])
11355 .output()
11356 .ok();
11357
11358 if let Some(o) = output {
11359 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11360 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11361 out.push_str("=== Windows Component Store Health (CBS) ===\n");
11362 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11363 let repair = val
11364 .get("AutoRepairNeeded")
11365 .and_then(|v| v.as_u64())
11366 .unwrap_or(0);
11367
11368 let _ = writeln!(
11369 out,
11370 " Corruption Detected: {}",
11371 if corrupt != 0 {
11372 "YES (SFC/DISM recommended)"
11373 } else {
11374 "No"
11375 }
11376 );
11377 let _ = writeln!(
11378 out,
11379 " Auto-Repair Needed: {}",
11380 if repair != 0 { "YES" } else { "No" }
11381 );
11382
11383 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11384 let _ = writeln!(out, " Last Repair Attempt: (Raw code: {})", last);
11385 }
11386 } else {
11387 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11388 }
11389 }
11390
11391 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11392 out.push_str(
11393 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11394 );
11395 }
11396 }
11397
11398 #[cfg(not(target_os = "windows"))]
11399 {
11400 out.push_str("System integrity check (Linux)\n\n");
11401 let pkg_check = Command::new("rpm")
11402 .args(["-Va"])
11403 .output()
11404 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11405 .ok();
11406 if let Some(o) = pkg_check {
11407 out.push_str(" Package verification system active.\n");
11408 if o.status.success() {
11409 out.push_str(" No major package integrity issues detected.\n");
11410 }
11411 }
11412 }
11413
11414 Ok(out.trim_end().to_string())
11415}
11416
11417fn inspect_domain() -> Result<String, String> {
11418 let mut out = String::from("Host inspection: domain\n\n");
11419
11420 #[cfg(target_os = "windows")]
11421 {
11422 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11423 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11424 let output = Command::new("powershell")
11425 .args(["-NoProfile", "-Command", ps_cmd])
11426 .output()
11427 .ok();
11428
11429 if let Some(o) = output {
11430 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11431 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11432 let part_of_domain = val
11433 .get("PartOfDomain")
11434 .and_then(|v| v.as_bool())
11435 .unwrap_or(false);
11436 let domain = val
11437 .get("Domain")
11438 .and_then(|v| v.as_str())
11439 .unwrap_or("Unknown");
11440 let workgroup = val
11441 .get("Workgroup")
11442 .and_then(|v| v.as_str())
11443 .unwrap_or("Unknown");
11444
11445 let _ = writeln!(
11446 out,
11447 " Join Status: {}",
11448 if part_of_domain {
11449 "DOMAIN JOINED"
11450 } else {
11451 "WORKGROUP"
11452 }
11453 );
11454 if part_of_domain {
11455 let _ = writeln!(out, " Active Directory Domain: {}", domain);
11456 } else {
11457 let _ = writeln!(out, " Workgroup Name: {}", workgroup);
11458 }
11459
11460 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11461 let _ = writeln!(out, " NetBIOS Name: {}", name);
11462 }
11463 } else {
11464 out.push_str(" Domain identity data unavailable from WMI.\n");
11465 }
11466 } else {
11467 out.push_str(" Domain identity data unavailable from WMI.\n");
11468 }
11469 }
11470
11471 #[cfg(not(target_os = "windows"))]
11472 {
11473 let domainname = Command::new("domainname")
11474 .output()
11475 .ok()
11476 .and_then(|o| String::from_utf8(o.stdout).ok())
11477 .unwrap_or_default();
11478 out.push_str("=== Linux Domain Identity ===\n");
11479 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11480 let _ = write!(out, " NIS/YP Domain: {}\n", domainname.trim());
11481 } else {
11482 out.push_str(" No NIS domain configured.\n");
11483 }
11484 }
11485
11486 Ok(out.trim_end().to_string())
11487}
11488
11489fn inspect_device_health() -> Result<String, String> {
11490 let mut out = String::from("Host inspection: device_health\n\n");
11491
11492 #[cfg(target_os = "windows")]
11493 {
11494 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)\" }";
11495 let output = Command::new("powershell")
11496 .args(["-NoProfile", "-Command", ps_cmd])
11497 .output()
11498 .ok()
11499 .and_then(|o| String::from_utf8(o.stdout).ok())
11500 .unwrap_or_default();
11501
11502 if output.trim().is_empty() {
11503 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11504 } else {
11505 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11506 out.push_str(&output);
11507 out.push_str(
11508 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11509 );
11510 }
11511 }
11512
11513 #[cfg(not(target_os = "windows"))]
11514 {
11515 out.push_str("Checking dmesg for hardware errors...\n");
11516 let dmesg = Command::new("dmesg")
11517 .args(["--level=err,crit,alert"])
11518 .output()
11519 .ok()
11520 .and_then(|o| String::from_utf8(o.stdout).ok())
11521 .unwrap_or_default();
11522 if dmesg.is_empty() {
11523 out.push_str(" No critical hardware errors found in dmesg.\n");
11524 } else {
11525 for (i, line) in dmesg.lines().take(20).enumerate() {
11526 if i > 0 {
11527 out.push('\n');
11528 }
11529 out.push_str(line);
11530 }
11531 }
11532 }
11533
11534 Ok(out.trim_end().to_string())
11535}
11536
11537fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11538 let mut out = String::from("Host inspection: drivers\n\n");
11539
11540 #[cfg(target_os = "windows")]
11541 {
11542 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11543 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);
11544 let output = Command::new("powershell")
11545 .args(["-NoProfile", "-Command", &ps_cmd])
11546 .output()
11547 .ok()
11548 .and_then(|o| String::from_utf8(o.stdout).ok())
11549 .unwrap_or_default();
11550
11551 if output.trim().is_empty() {
11552 out.push_str(" No drivers retrieved via WMI.\n");
11553 } else {
11554 out.push_str(&output);
11555 }
11556 }
11557
11558 #[cfg(not(target_os = "windows"))]
11559 {
11560 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11561 let lsmod = Command::new("lsmod")
11562 .output()
11563 .ok()
11564 .and_then(|o| String::from_utf8(o.stdout).ok())
11565 .unwrap_or_default();
11566 for (i, line) in lsmod.lines().take(max_entries).enumerate() {
11567 if i > 0 {
11568 out.push('\n');
11569 }
11570 out.push_str(line);
11571 }
11572 }
11573
11574 Ok(out.trim_end().to_string())
11575}
11576
11577fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11578 let mut out = String::from("Host inspection: peripherals\n\n");
11579
11580 #[cfg(target_os = "windows")]
11581 {
11582 let _ = max_entries;
11583 out.push_str("=== USB Controllers & Hubs ===\n");
11584 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
11585 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11586 out.push_str(if usb.is_empty() {
11587 " None detected.\n"
11588 } else {
11589 &usb
11590 });
11591
11592 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11593 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
11594 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11595 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
11596 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11597 out.push_str(&kb);
11598 out.push_str(&mouse);
11599
11600 out.push_str("\n=== Connected Monitors (WMI) ===\n");
11601 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11602 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11603 out.push_str(if mon.is_empty() {
11604 " No active monitors identified via WMI.\n"
11605 } else {
11606 &mon
11607 });
11608 }
11609
11610 #[cfg(not(target_os = "windows"))]
11611 {
11612 out.push_str("=== Connected USB Devices (lsusb) ===\n");
11613 let lsusb = Command::new("lsusb")
11614 .output()
11615 .ok()
11616 .and_then(|o| String::from_utf8(o.stdout).ok())
11617 .unwrap_or_default();
11618 for (i, line) in lsusb.lines().take(max_entries).enumerate() {
11619 if i > 0 {
11620 out.push('\n');
11621 }
11622 out.push_str(line);
11623 }
11624 }
11625
11626 Ok(out.trim_end().to_string())
11627}
11628
11629fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11630 let mut out = String::from("Host inspection: sessions\n\n");
11631
11632 #[cfg(target_os = "windows")]
11633 {
11634 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11635 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11636 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11637}"#;
11638 if let Ok(o) = Command::new("powershell")
11639 .args(["-NoProfile", "-Command", script])
11640 .output()
11641 {
11642 let text = String::from_utf8_lossy(&o.stdout);
11643 let lines: Vec<&str> = text.lines().collect();
11644 if lines.is_empty() {
11645 out.push_str(" No active logon sessions enumerated via WMI.\n");
11646 } else {
11647 for line in lines
11648 .iter()
11649 .take(max_entries)
11650 .filter(|l| !l.trim().is_empty())
11651 {
11652 let mut it = line.trim().splitn(5, '|');
11653 if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
11654 (it.next(), it.next(), it.next(), it.next())
11655 {
11656 let logon_type = match p2 {
11657 "2" => "Interactive",
11658 "3" => "Network",
11659 "4" => "Batch",
11660 "5" => "Service",
11661 "7" => "Unlock",
11662 "8" => "NetworkCleartext",
11663 "9" => "NewCredentials",
11664 "10" => "RemoteInteractive",
11665 "11" => "CachedInteractive",
11666 _ => "Other",
11667 };
11668 let _ = writeln!(
11669 out,
11670 "- ID: {} | Type: {} | Started: {} | Auth: {}",
11671 p0, logon_type, p1, p3
11672 );
11673 }
11674 }
11675 }
11676 } else {
11677 out.push_str(" Active logon session data unavailable from WMI.\n");
11678 }
11679 }
11680
11681 #[cfg(not(target_os = "windows"))]
11682 {
11683 out.push_str("=== Logged-in Users (who) ===\n");
11684 let who = Command::new("who")
11685 .output()
11686 .ok()
11687 .and_then(|o| String::from_utf8(o.stdout).ok())
11688 .unwrap_or_default();
11689 for (i, line) in who.lines().take(max_entries).enumerate() {
11690 if i > 0 {
11691 out.push('\n');
11692 }
11693 out.push_str(line);
11694 }
11695 }
11696
11697 Ok(out.trim_end().to_string())
11698}
11699
11700async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11701 let mut out = String::from("Host inspection: disk_benchmark\n\n");
11702 let mut final_path = path;
11703
11704 if !final_path.exists() {
11705 if let Ok(current_exe) = std::env::current_exe() {
11706 let _ = writeln!(out,
11707 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.",
11708 final_path.display()
11709 );
11710 final_path = current_exe;
11711 } else {
11712 return Err(format!("Target not found: {}", final_path.display()));
11713 }
11714 }
11715
11716 let target = if final_path.is_dir() {
11717 let mut target_file = final_path.join("Cargo.toml");
11719 if !target_file.exists() {
11720 target_file = final_path.join("README.md");
11721 }
11722 if !target_file.exists() {
11723 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11724 }
11725 target_file
11726 } else {
11727 final_path
11728 };
11729
11730 let _ = writeln!(out, "Target: {}", target.display());
11731 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11732
11733 #[cfg(target_os = "windows")]
11734 {
11735 let script = format!(
11736 r#"
11737$target = "{}"
11738if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11739
11740$diskQueue = @()
11741$readStats = @()
11742$startTime = Get-Date
11743$duration = 5
11744
11745# Background reader job
11746$job = Start-Job -ScriptBlock {{
11747 param($t, $d)
11748 $stop = (Get-Date).AddSeconds($d)
11749 while ((Get-Date) -lt $stop) {{
11750 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11751 }}
11752}} -ArgumentList $target, $duration
11753
11754# Metrics collector loop
11755$stopTime = (Get-Date).AddSeconds($duration)
11756while ((Get-Date) -lt $stopTime) {{
11757 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11758 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11759
11760 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11761 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11762
11763 Start-Sleep -Milliseconds 250
11764}}
11765
11766Stop-Job $job
11767Receive-Job $job | Out-Null
11768Remove-Job $job
11769
11770$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11771$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11772$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11773
11774"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11775"#,
11776 target.display()
11777 );
11778
11779 let output = Command::new("powershell")
11780 .args(["-NoProfile", "-Command", &script])
11781 .output()
11782 .map_err(|e| format!("Benchmark failed: {e}"))?;
11783
11784 let raw = String::from_utf8_lossy(&output.stdout);
11785 let text = raw.trim();
11786
11787 if text.starts_with("ERROR") {
11788 return Err(text.to_string());
11789 }
11790
11791 let mut lines = text.lines();
11792 if let Some(metrics_line) = lines.next() {
11793 let mut avg_q = "unknown".to_string();
11794 let mut max_q = "unknown".to_string();
11795 let mut avg_r = "unknown".to_string();
11796
11797 for p in metrics_line.split('|') {
11798 if let Some((k, v)) = p.split_once(':') {
11799 match k {
11800 "AVG_Q" => avg_q = v.to_string(),
11801 "MAX_Q" => max_q = v.to_string(),
11802 "AVG_R" => avg_r = v.to_string(),
11803 _ => {}
11804 }
11805 }
11806 }
11807
11808 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11809 let _ = writeln!(out, "- Active Disk Queue (Avg): {}", avg_q);
11810 let _ = writeln!(out, "- Active Disk Queue (Max): {}", max_q);
11811 let _ = writeln!(out, "- Disk Throughput (Avg): {} reads/sec", avg_r);
11812 out.push_str("\nVerdict: ");
11813 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11814 if q_num > 1.0 {
11815 out.push_str(
11816 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11817 );
11818 } else if q_num > 0.1 {
11819 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11820 } else {
11821 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11822 }
11823 }
11824 }
11825
11826 #[cfg(not(target_os = "windows"))]
11827 {
11828 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11829 out.push_str("Generic disk load simulated.\n");
11830 }
11831
11832 Ok(out)
11833}
11834
11835fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11836 let mut out = String::from("Host inspection: permissions\n\n");
11837 let _ = write!(out, "Auditing access control for: {}\n\n", path.display());
11838
11839 #[cfg(target_os = "windows")]
11840 {
11841 let script = format!(
11842 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11843 path.display()
11844 );
11845 let output = Command::new("powershell")
11846 .args(["-NoProfile", "-Command", &script])
11847 .output()
11848 .map_err(|e| format!("ACL check failed: {e}"))?;
11849
11850 let text = String::from_utf8_lossy(&output.stdout);
11851 if text.trim().is_empty() {
11852 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11853 } else {
11854 out.push_str("=== Windows NTFS Permissions ===\n");
11855 out.push_str(&text);
11856 }
11857 }
11858
11859 #[cfg(not(target_os = "windows"))]
11860 {
11861 let output = Command::new("ls")
11862 .args(["-ld", &path.to_string_lossy()])
11863 .output()
11864 .map_err(|e| format!("ls check failed: {e}"))?;
11865 out.push_str("=== Unix File Permissions ===\n");
11866 out.push_str(&String::from_utf8_lossy(&output.stdout));
11867 }
11868
11869 Ok(out.trim_end().to_string())
11870}
11871
11872fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11873 let mut out = String::from("Host inspection: login_history\n\n");
11874
11875 #[cfg(target_os = "windows")]
11876 {
11877 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11878 out.push_str("Note: This typically requires Administrator elevation.\n\n");
11879
11880 let n = max_entries.clamp(1, 50);
11881 let script = format!(
11882 r#"try {{
11883 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11884 $events | ForEach-Object {{
11885 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11886 # Extract target user name from the XML/Properties if possible
11887 $user = $_.Properties[5].Value
11888 $type = $_.Properties[8].Value
11889 "[$time] User: $user | Type: $type"
11890 }}
11891}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11892 );
11893
11894 let output = Command::new("powershell")
11895 .args(["-NoProfile", "-Command", &script])
11896 .output()
11897 .map_err(|e| format!("Login history query failed: {e}"))?;
11898
11899 let text = String::from_utf8_lossy(&output.stdout);
11900 if text.starts_with("ERROR:") {
11901 let _ = writeln!(out, "Unable to query Security Log: {}", text);
11902 } else if text.trim().is_empty() {
11903 out.push_str("No recent logon events found or access denied.\n");
11904 } else {
11905 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11906 out.push_str(&text);
11907 }
11908 }
11909
11910 #[cfg(not(target_os = "windows"))]
11911 {
11912 let output = Command::new("last")
11913 .args(["-n", &max_entries.to_string()])
11914 .output()
11915 .map_err(|e| format!("last command failed: {e}"))?;
11916 out.push_str("=== Unix Login History (last) ===\n");
11917 out.push_str(&String::from_utf8_lossy(&output.stdout));
11918 }
11919
11920 Ok(out.trim_end().to_string())
11921}
11922
11923fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11924 let mut out = String::from("Host inspection: share_access\n\n");
11925 let _ = write!(out, "Testing accessibility of: {}\n\n", path.display());
11926
11927 #[cfg(target_os = "windows")]
11928 {
11929 let script = format!(
11930 r#"
11931$p = '{}'
11932$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11933if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11934 $res.Reachable = $true
11935 try {{
11936 $null = Get-ChildItem -Path $p -ErrorAction Stop
11937 $res.Readable = $true
11938 }} catch {{
11939 $res.Error = $_.Exception.Message
11940 }}
11941}} else {{
11942 $res.Error = "Server unreachable (Ping failed)"
11943}}
11944"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11945 path.display()
11946 );
11947
11948 let output = Command::new("powershell")
11949 .args(["-NoProfile", "-Command", &script])
11950 .output()
11951 .map_err(|e| format!("Share test failed: {e}"))?;
11952
11953 let text = String::from_utf8_lossy(&output.stdout);
11954 out.push_str("=== Share Triage Results ===\n");
11955 out.push_str(&text);
11956 }
11957
11958 #[cfg(not(target_os = "windows"))]
11959 {
11960 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11961 }
11962
11963 Ok(out.trim_end().to_string())
11964}
11965
11966fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11967 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11968 let _ = write!(out, "Issue: {}\n\n", issue);
11969 out.push_str("Proposed Remediation Steps:\n");
11970 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11971 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11972 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11973 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11974 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11975 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11976 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11977 out.push_str(
11978 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11979 );
11980
11981 Ok(out)
11982}
11983
11984fn inspect_registry_audit() -> Result<String, String> {
11985 let mut out = String::from("Host inspection: registry_audit\n\n");
11986 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11987
11988 #[cfg(target_os = "windows")]
11989 {
11990 let script = r#"
11991$findings = @()
11992
11993# 1. Image File Execution Options (Debugger Hijacking)
11994$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11995if (Test-Path $ifeo) {
11996 Get-ChildItem $ifeo | ForEach-Object {
11997 $p = Get-ItemProperty $_.PSPath
11998 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11999 }
12000}
12001
12002# 2. Winlogon Shell Integrity
12003$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
12004$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
12005if ($shell -and $shell -ne "explorer.exe") {
12006 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
12007}
12008
12009# 3. Session Manager BootExecute
12010$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
12011$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
12012if ($boot -and $boot -notcontains "autocheck autochk *") {
12013 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
12014}
12015
12016if ($findings.Count -eq 0) {
12017 "PASS: No common registry hijacking or shell overrides detected."
12018} else {
12019 $findings -join "`n"
12020}
12021"#;
12022 let output = Command::new("powershell")
12023 .args(["-NoProfile", "-Command", script])
12024 .output()
12025 .map_err(|e| format!("Registry audit failed: {e}"))?;
12026
12027 let text = String::from_utf8_lossy(&output.stdout);
12028 out.push_str("=== Persistence & Integrity Check ===\n");
12029 out.push_str(&text);
12030 }
12031
12032 #[cfg(not(target_os = "windows"))]
12033 {
12034 out.push_str("Registry auditing is specific to Windows environments.\n");
12035 }
12036
12037 Ok(out.trim_end().to_string())
12038}
12039
12040fn inspect_thermal() -> Result<String, String> {
12041 let mut out = String::from("Host inspection: thermal\n\n");
12042 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
12043
12044 #[cfg(target_os = "windows")]
12045 {
12046 let script = r#"
12047$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
12048if ($thermal) {
12049 $thermal | ForEach-Object {
12050 $temp = [math]::Round(($_.Temperature - 273.15), 1)
12051 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
12052 }
12053} else {
12054 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
12055 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
12056 "Current CPU Load: $throttling%"
12057}
12058"#;
12059 let output = Command::new("powershell")
12060 .args(["-NoProfile", "-Command", script])
12061 .output()
12062 .map_err(|e| format!("Thermal check failed: {e}"))?;
12063 out.push_str("=== Windows Thermal State ===\n");
12064 out.push_str(&String::from_utf8_lossy(&output.stdout));
12065 }
12066
12067 #[cfg(not(target_os = "windows"))]
12068 {
12069 out.push_str(
12070 "Thermal inspection is currently optimized for Windows performance counters.\n",
12071 );
12072 }
12073
12074 Ok(out.trim_end().to_string())
12075}
12076
12077fn inspect_activation() -> Result<String, String> {
12078 let mut out = String::from("Host inspection: activation\n\n");
12079 out.push_str("Auditing Windows activation and license state...\n\n");
12080
12081 #[cfg(target_os = "windows")]
12082 {
12083 let script = r#"
12084$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
12085$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
12086"Status: $($xpr.Trim())"
12087"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12088"#;
12089 let output = Command::new("powershell")
12090 .args(["-NoProfile", "-Command", script])
12091 .output()
12092 .map_err(|e| format!("Activation check failed: {e}"))?;
12093 out.push_str("=== Windows License Report ===\n");
12094 out.push_str(&String::from_utf8_lossy(&output.stdout));
12095 }
12096
12097 #[cfg(not(target_os = "windows"))]
12098 {
12099 out.push_str("Windows activation check is specific to the Windows platform.\n");
12100 }
12101
12102 Ok(out.trim_end().to_string())
12103}
12104
12105fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12106 let mut out = String::from("Host inspection: patch_history\n\n");
12107 let _ = write!(
12108 out,
12109 "Listing the last {} installed Windows updates (KBs)...\n\n",
12110 max_entries
12111 );
12112
12113 #[cfg(target_os = "windows")]
12114 {
12115 let n = max_entries.clamp(1, 50);
12116 let script = format!(
12117 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12118 n
12119 );
12120 let output = Command::new("powershell")
12121 .args(["-NoProfile", "-Command", &script])
12122 .output()
12123 .map_err(|e| format!("Patch history query failed: {e}"))?;
12124 out.push_str("=== Recent HotFixes (KBs) ===\n");
12125 out.push_str(&String::from_utf8_lossy(&output.stdout));
12126 }
12127
12128 #[cfg(not(target_os = "windows"))]
12129 {
12130 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12131 }
12132
12133 Ok(out.trim_end().to_string())
12134}
12135
12136fn inspect_ad_user(identity: &str) -> Result<String, String> {
12139 let mut out = String::from("Host inspection: ad_user\n\n");
12140 let ident = identity.trim();
12141 if ident.is_empty() {
12142 out.push_str("Status: No identity specified. Performing self-discovery...\n");
12143 #[cfg(target_os = "windows")]
12144 {
12145 let script = r#"
12146$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12147"USER: " + $u.Name
12148"SID: " + $u.User.Value
12149"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12150"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12151"#;
12152 let output = Command::new("powershell")
12153 .args(["-NoProfile", "-Command", script])
12154 .output()
12155 .ok();
12156 if let Some(o) = output {
12157 out.push_str(&String::from_utf8_lossy(&o.stdout));
12158 }
12159 }
12160 return Ok(out);
12161 }
12162
12163 #[cfg(target_os = "windows")]
12164 {
12165 let script = format!(
12166 r#"
12167try {{
12168 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12169 "NAME: " + $u.Name
12170 "SID: " + $u.SID
12171 "ENABLED: " + $u.Enabled
12172 "EXPIRED: " + $u.PasswordExpired
12173 "LOGON: " + $u.LastLogonDate
12174 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
12175}} catch {{
12176 # Fallback to net user if AD module is missing or fails
12177 $net = net user "{ident}" /domain 2>&1
12178 if ($LASTEXITCODE -eq 0) {{
12179 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12180 }} else {{
12181 "ERROR: " + $_.Exception.Message
12182 }}
12183}}"#
12184 );
12185
12186 let output = Command::new("powershell")
12187 .args(["-NoProfile", "-Command", &script])
12188 .output()
12189 .ok();
12190
12191 if let Some(o) = output {
12192 let stdout = String::from_utf8_lossy(&o.stdout);
12193 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12194 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12195 }
12196 out.push_str(&stdout);
12197 }
12198 }
12199
12200 #[cfg(not(target_os = "windows"))]
12201 {
12202 let _ = ident;
12203 out.push_str("(AD User lookup only available on Windows nodes)\n");
12204 }
12205
12206 Ok(out.trim_end().to_string())
12207}
12208
12209fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12212 let mut out = String::from("Host inspection: dns_lookup\n\n");
12213 let target = name.trim();
12214 if target.is_empty() {
12215 return Err("Missing required target name for dns_lookup.".to_string());
12216 }
12217
12218 #[cfg(target_os = "windows")]
12219 {
12220 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12221 let output = Command::new("powershell")
12222 .args(["-NoProfile", "-Command", &script])
12223 .output()
12224 .ok();
12225 if let Some(o) = output {
12226 let stdout = String::from_utf8_lossy(&o.stdout);
12227 if stdout.trim().is_empty() {
12228 let _ = writeln!(out, "No {record_type} records found for {target}.");
12229 } else {
12230 out.push_str(&stdout);
12231 }
12232 }
12233 }
12234
12235 #[cfg(not(target_os = "windows"))]
12236 {
12237 let output = Command::new("dig")
12238 .args([target, record_type, "+short"])
12239 .output()
12240 .ok();
12241 if let Some(o) = output {
12242 out.push_str(&String::from_utf8_lossy(&o.stdout));
12243 }
12244 }
12245
12246 Ok(out.trim_end().to_string())
12247}
12248
12249#[cfg(target_os = "windows")]
12252fn ps_exec(script: &str) -> String {
12253 Command::new("powershell")
12254 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12255 .output()
12256 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12257 .unwrap_or_default()
12258}
12259
12260fn inspect_mdm_enrollment() -> Result<String, String> {
12261 #[cfg(target_os = "windows")]
12262 {
12263 let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12264
12265 out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12267 let ps_dsreg = r#"
12268$raw = dsregcmd /status 2>$null
12269$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12270 'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12271foreach ($line in $raw) {
12272 $t = $line.Trim()
12273 foreach ($f in $fields) {
12274 if ($t -like "$f :*") {
12275 $val = ($t -split ':',2)[1].Trim()
12276 "$f`: $val"
12277 }
12278 }
12279}
12280"#;
12281 match run_powershell(ps_dsreg) {
12282 Ok(o) if !o.trim().is_empty() => {
12283 for line in o.lines() {
12284 let l = line.trim();
12285 if !l.is_empty() {
12286 let _ = writeln!(out, "- {l}");
12287 }
12288 }
12289 }
12290 Ok(_) => out.push_str(
12291 "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12292 ),
12293 Err(e) => {
12294 let _ = writeln!(out, "- dsregcmd error: {e}");
12295 }
12296 }
12297
12298 out.push_str("\n=== Enrollment accounts (registry) ===\n");
12300 let ps_enroll = r#"
12301$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12302if (Test-Path $base) {
12303 $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12304 if ($accounts) {
12305 foreach ($acct in $accounts) {
12306 $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12307 $upn = if ($p.UPN) { $p.UPN } else { '(none)' }
12308 $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12309 $type = switch ($p.EnrollmentType) {
12310 6 { 'MDM' }
12311 13 { 'MAM' }
12312 default { "Type=$($p.EnrollmentType)" }
12313 }
12314 $state = switch ($p.EnrollmentState) {
12315 1 { 'Enrolled' }
12316 2 { 'InProgress' }
12317 6 { 'Unenrolled' }
12318 default { "State=$($p.EnrollmentState)" }
12319 }
12320 "Account: $upn | $type | $state | $server"
12321 }
12322 } else { "No enrollment accounts found under $base" }
12323} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12324"#;
12325 match run_powershell(ps_enroll) {
12326 Ok(o) => {
12327 for line in o.lines() {
12328 let l = line.trim();
12329 if !l.is_empty() {
12330 let _ = writeln!(out, "- {l}");
12331 }
12332 }
12333 }
12334 Err(e) => {
12335 let _ = writeln!(out, "- Registry read error: {e}");
12336 }
12337 }
12338
12339 out.push_str("\n=== MDM services ===\n");
12341 let ps_svc = r#"
12342$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12343foreach ($n in $names) {
12344 $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12345 if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12346}
12347"#;
12348 match run_powershell(ps_svc) {
12349 Ok(o) if !o.trim().is_empty() => {
12350 for line in o.lines() {
12351 let l = line.trim();
12352 if !l.is_empty() {
12353 let _ = writeln!(out, "- {l}");
12354 }
12355 }
12356 }
12357 Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12358 Err(e) => { let _ = writeln!(out, "- Service query error: {e}"); }
12359 }
12360
12361 out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12363 let ps_evt = r#"
12364$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12365 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12366$cutoff = (Get-Date).AddHours(-24)
12367$found = $false
12368foreach ($log in $logs) {
12369 $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12370 Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12371 foreach ($e in $evts) {
12372 $found = $true
12373 $ts = $e.TimeCreated.ToString('HH:mm')
12374 $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12375 "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12376 }
12377}
12378if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12379"#;
12380 match run_powershell(ps_evt) {
12381 Ok(o) => {
12382 for line in o.lines() {
12383 let l = line.trim();
12384 if !l.is_empty() {
12385 let _ = writeln!(out, "- {l}");
12386 }
12387 }
12388 }
12389 Err(e) => {
12390 let _ = writeln!(out, "- Event log read error: {e}");
12391 }
12392 }
12393
12394 out.push_str("\n=== Findings ===\n");
12396 let body = out.clone();
12397 let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12398 let intune_running = body.contains("IntuneManagementExtension: Running");
12399 let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12400
12401 if !enrolled {
12402 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");
12403 } else {
12404 out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12405 if !intune_running {
12406 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");
12407 }
12408 }
12409 if has_errors {
12410 out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12411 }
12412 if !enrolled && !has_errors {
12413 out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12414 }
12415
12416 Ok(out)
12417 }
12418
12419 #[cfg(not(target_os = "windows"))]
12420 {
12421 Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12422 }
12423}
12424
12425fn inspect_hyperv() -> Result<String, String> {
12426 #[cfg(target_os = "windows")]
12427 {
12428 let mut findings: Vec<String> = Vec::with_capacity(4);
12429 let mut out = String::with_capacity(2048);
12430
12431 let ps_role = r#"
12433$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12434$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12435$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12436$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12437"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12438 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12439 $(if ($feature) { $feature.State } else { "Unknown" }),
12440 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12441 $(if ($ram) { $ram } else { "0" })
12442"#;
12443 let role_out = ps_exec(ps_role);
12444 out.push_str("=== Hyper-V role state ===\n");
12445
12446 let mut vmms_running = false;
12447 let mut host_ram_bytes: u64 = 0;
12448
12449 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12450 let kv: std::collections::HashMap<&str, &str> = line
12451 .split('|')
12452 .filter_map(|p| {
12453 let mut it = p.splitn(2, ':');
12454 Some((it.next()?, it.next()?))
12455 })
12456 .collect();
12457 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12458 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12459 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12460 host_ram_bytes = kv
12461 .get("HostRAMBytes")
12462 .and_then(|v| v.parse().ok())
12463 .unwrap_or(0);
12464
12465 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12466 vmms_running = vmms_status.starts_with("Running");
12467
12468 let _ = writeln!(out, "- Host: {host_name}");
12469 let _ = writeln!(
12470 out,
12471 "- Hyper-V feature: {}",
12472 if hyperv_installed {
12473 "Enabled"
12474 } else {
12475 "Not installed"
12476 }
12477 );
12478 let _ = writeln!(out, "- VMMS service: {vmms_status}");
12479 if host_ram_bytes > 0 {
12480 let _ = writeln!(
12481 out,
12482 "- Host physical RAM: {} GB",
12483 host_ram_bytes / 1_073_741_824
12484 );
12485 }
12486
12487 if !hyperv_installed {
12488 findings.push(
12489 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12490 );
12491 } else if !vmms_running {
12492 findings.push(
12493 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12494 );
12495 }
12496 } else {
12497 out.push_str("- Could not determine Hyper-V role state\n");
12498 findings.push("Hyper-V does not appear to be installed on this machine.".into());
12499 }
12500
12501 out.push_str("\n=== Virtual machines ===\n");
12503 if vmms_running {
12504 let ps_vms = r#"
12505Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12506 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12507 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12508 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12509 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12510 $_.Status, $_.Generation
12511}
12512"#;
12513 let vms_out = ps_exec(ps_vms);
12514 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12515
12516 if vm_lines.is_empty() {
12517 out.push_str("- No virtual machines found on this host\n");
12518 } else {
12519 let mut total_ram_bytes: u64 = 0;
12520 let mut saved_vms: Vec<String> = Vec::new();
12521 for line in &vm_lines {
12522 let kv: std::collections::HashMap<&str, &str> = line
12523 .split('|')
12524 .filter_map(|p| {
12525 let mut it = p.splitn(2, ':');
12526 Some((it.next()?, it.next()?))
12527 })
12528 .collect();
12529 let name = kv.get("VM").copied().unwrap_or("Unknown");
12530 let state = kv.get("State").copied().unwrap_or("Unknown");
12531 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12532 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12533 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12534 let status = kv.get("Status").copied().unwrap_or("");
12535 let gen = kv.get("Generation").copied().unwrap_or("?");
12536
12537 if let Ok(r) = ram.parse::<f64>() {
12538 total_ram_bytes += (r * 1_073_741_824.0) as u64;
12539 }
12540 if state.eq_ignore_ascii_case("Saved") {
12541 saved_vms.push(name.to_string());
12542 }
12543
12544 let _ = writeln!(out,
12545 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}"
12546 );
12547 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12548 let _ = writeln!(out, " Status: {status}");
12549 }
12550 }
12551
12552 let _ = write!(out, "\n- Total VMs: {}\n", vm_lines.len());
12553 if total_ram_bytes > 0 && host_ram_bytes > 0 {
12554 let pct = (total_ram_bytes * 100) / host_ram_bytes;
12555 let _ = writeln!(
12556 out,
12557 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)",
12558 total_ram_bytes / 1_073_741_824
12559 );
12560 if pct > 90 {
12561 findings.push(format!(
12562 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12563 ));
12564 }
12565 }
12566 if !saved_vms.is_empty() {
12567 findings.push(format!(
12568 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12569 saved_vms.join(", ")
12570 ));
12571 }
12572 }
12573 } else {
12574 out.push_str("- VMMS not running — cannot enumerate VMs\n");
12575 }
12576
12577 out.push_str("\n=== VM network switches ===\n");
12579 if vmms_running {
12580 let ps_switches = r#"
12581Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12582 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12583 $_.Name, $_.SwitchType,
12584 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12585}
12586"#;
12587 let sw_out = ps_exec(ps_switches);
12588 let switch_lines: Vec<&str> = sw_out
12589 .lines()
12590 .filter(|l| l.starts_with("Switch:"))
12591 .collect();
12592
12593 if switch_lines.is_empty() {
12594 out.push_str("- No VM switches configured\n");
12595 } else {
12596 for line in &switch_lines {
12597 let kv: std::collections::HashMap<&str, &str> = line
12598 .split('|')
12599 .filter_map(|p| {
12600 let mut it = p.splitn(2, ':');
12601 Some((it.next()?, it.next()?))
12602 })
12603 .collect();
12604 let name = kv.get("Switch").copied().unwrap_or("Unknown");
12605 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12606 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12607 let _ = writeln!(out, "- {name} | Type: {sw_type} | NIC: {adapter}");
12608 }
12609 }
12610 } else {
12611 out.push_str("- VMMS not running — cannot enumerate switches\n");
12612 }
12613
12614 out.push_str("\n=== VM checkpoints ===\n");
12616 if vmms_running {
12617 let ps_checkpoints = r#"
12618$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12619if ($all) {
12620 $all | ForEach-Object {
12621 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12622 $_.Name, $_.VMName,
12623 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12624 $_.SnapshotType
12625 }
12626} else {
12627 "NONE"
12628}
12629"#;
12630 let cp_out = ps_exec(ps_checkpoints);
12631 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12632 out.push_str("- No checkpoints found\n");
12633 } else {
12634 let cp_lines: Vec<&str> = cp_out
12635 .lines()
12636 .filter(|l| l.starts_with("Checkpoint:"))
12637 .collect();
12638 let mut per_vm: std::collections::HashMap<&str, usize> =
12639 std::collections::HashMap::new();
12640 for line in &cp_lines {
12641 let kv: std::collections::HashMap<&str, &str> = line
12642 .split('|')
12643 .filter_map(|p| {
12644 let mut it = p.splitn(2, ':');
12645 Some((it.next()?, it.next()?))
12646 })
12647 .collect();
12648 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12649 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12650 let created = kv.get("Created").copied().unwrap_or("");
12651 let cp_type = kv.get("Type").copied().unwrap_or("");
12652 let _ = writeln!(
12653 out,
12654 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}"
12655 );
12656 *per_vm.entry(vm_name).or_insert(0) += 1;
12657 }
12658 for (vm, count) in &per_vm {
12659 if *count >= 3 {
12660 findings.push(format!(
12661 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12662 ));
12663 }
12664 }
12665 }
12666 } else {
12667 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12668 }
12669
12670 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12671 if findings.is_empty() {
12672 result.push_str("- No Hyper-V health issues detected.\n");
12673 } else {
12674 for f in &findings {
12675 let _ = writeln!(result, "- Finding: {f}");
12676 }
12677 }
12678 result.push('\n');
12679 result.push_str(&out);
12680 Ok(result.trim_end().to_string())
12681 }
12682
12683 #[cfg(not(target_os = "windows"))]
12684 Ok(
12685 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12686 .into(),
12687 )
12688}
12689
12690fn inspect_ip_config() -> Result<String, String> {
12693 let mut out = String::from("Host inspection: ip_config\n\n");
12694
12695 #[cfg(target_os = "windows")]
12696 {
12697 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12698 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12699 '\\n Status: ' + $_.NetAdapter.Status + \
12700 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12701 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12702 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12703 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12704 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12705 }";
12706 let output = Command::new("powershell")
12707 .args(["-NoProfile", "-Command", script])
12708 .output()
12709 .ok();
12710 if let Some(o) = output {
12711 out.push_str(&String::from_utf8_lossy(&o.stdout));
12712 }
12713 }
12714
12715 #[cfg(not(target_os = "windows"))]
12716 {
12717 let output = Command::new("ip").args(["addr", "show"]).output().ok();
12718 if let Some(o) = output {
12719 out.push_str(&String::from_utf8_lossy(&o.stdout));
12720 }
12721 }
12722
12723 Ok(out.trim_end().to_string())
12724}
12725
12726fn inspect_event_query(
12729 event_id: Option<u32>,
12730 log_name: Option<&str>,
12731 source: Option<&str>,
12732 hours: u32,
12733 level: Option<&str>,
12734 max_entries: usize,
12735) -> Result<String, String> {
12736 #[cfg(target_os = "windows")]
12737 {
12738 let mut findings: Vec<String> = Vec::with_capacity(4);
12739
12740 let log = log_name.unwrap_or("*");
12742 let cap = max_entries.min(50);
12743
12744 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12746 Some("error") | Some("errors") => Some(2u8),
12747 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12748 Some("information") | Some("info") => Some(4u8),
12749 _ => None,
12750 };
12751
12752 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12754 if log != "*" {
12755 filter_parts.push(format!("LogName = '{log}'"));
12756 }
12757 if let Some(id) = event_id {
12758 filter_parts.push(format!("Id = {id}"));
12759 }
12760 if let Some(src) = source {
12761 filter_parts.push(format!("ProviderName = '{src}'"));
12762 }
12763 if let Some(lvl) = level_filter {
12764 filter_parts.push(format!("Level = {lvl}"));
12765 }
12766
12767 let filter_ht = filter_parts.join("; ");
12768
12769 let ps = format!(
12770 r#"
12771$filter = @{{ {filter_ht} }}
12772try {{
12773 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12774 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12775 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12776 if ($events) {{
12777 $events | ForEach-Object {{
12778 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12779 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12780 $_.Id, $_.LevelDisplayName, $_.ProviderName,
12781 ($_.Msg -replace '\|','/')
12782 }}
12783 }} else {{
12784 "NONE"
12785 }}
12786}} catch {{
12787 "ERROR:$($_.Exception.Message)"
12788}}
12789"#
12790 );
12791
12792 let raw = ps_exec(&ps);
12793 let lines: Vec<&str> = raw.lines().collect();
12794
12795 let mut query_desc = format!("last {hours}h");
12797 if let Some(id) = event_id {
12798 let _ = write!(query_desc, ", Event ID {id}");
12799 }
12800 if let Some(src) = source {
12801 let _ = write!(query_desc, ", source '{src}'");
12802 }
12803 if log != "*" {
12804 let _ = write!(query_desc, ", log '{log}'");
12805 }
12806 if let Some(l) = level {
12807 let _ = write!(query_desc, ", level '{l}'");
12808 }
12809
12810 let mut out = format!("=== Event query: {query_desc} ===\n");
12811
12812 if lines
12813 .iter()
12814 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12815 {
12816 out.push_str("- No matching events found.\n");
12817 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12818 let msg = err_line.trim_start_matches("ERROR:").trim();
12819 if is_event_query_no_results_message(msg) {
12820 out.push_str("- No matching events found.\n");
12821 } else {
12822 let _ = writeln!(out, "- Query error: {msg}");
12823 findings.push(format!("Event query failed: {msg}"));
12824 }
12825 } else {
12826 let event_lines: Vec<&str> = lines
12827 .iter()
12828 .filter(|l| l.starts_with("TIME:"))
12829 .copied()
12830 .collect();
12831 if event_lines.is_empty() {
12832 out.push_str("- No matching events found.\n");
12833 } else {
12834 let mut error_count = 0usize;
12836 let mut warning_count = 0usize;
12837
12838 for line in &event_lines {
12839 let kv: std::collections::HashMap<&str, &str> = line
12840 .split('|')
12841 .filter_map(|p| {
12842 let mut it = p.splitn(2, ':');
12843 Some((it.next()?, it.next()?))
12844 })
12845 .collect();
12846 let time = kv.get("TIME").copied().unwrap_or("?");
12847 let id = kv.get("ID").copied().unwrap_or("?");
12848 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12849 let src = kv.get("SOURCE").copied().unwrap_or("?");
12850 let msg = kv.get("MSG").copied().unwrap_or("").trim();
12851
12852 let msg_display = if msg.len() > 120 {
12854 format!("{}…", safe_head(msg, 120))
12855 } else {
12856 msg.to_string()
12857 };
12858
12859 let _ = write!(out, "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n");
12860
12861 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12862 error_count += 1;
12863 } else if lvl.eq_ignore_ascii_case("warning") {
12864 warning_count += 1;
12865 }
12866 }
12867
12868 let _ = write!(out, "\n- Total shown: {} event(s)\n", event_lines.len());
12869
12870 if error_count > 0 {
12871 findings.push(format!(
12872 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12873 ));
12874 }
12875 if warning_count > 5 {
12876 findings.push(format!(
12877 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12878 ));
12879 }
12880 }
12881 }
12882
12883 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12884 if findings.is_empty() {
12885 result.push_str("- No actionable findings from this event query.\n");
12886 } else {
12887 for f in &findings {
12888 let _ = writeln!(result, "- Finding: {f}");
12889 }
12890 }
12891 result.push('\n');
12892 result.push_str(&out);
12893 Ok(result.trim_end().to_string())
12894 }
12895
12896 #[cfg(not(target_os = "windows"))]
12897 {
12898 let _ = (event_id, log_name, source, hours, level, max_entries);
12899 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12900 }
12901}
12902
12903fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12906 let n = max_entries.clamp(5, 50);
12907 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12908 let mut findings: Vec<String> = Vec::with_capacity(4);
12909 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12910 let mut sections = String::with_capacity(2048);
12911
12912 #[cfg(target_os = "windows")]
12913 {
12914 let proc_filter_ps = match process_filter {
12915 Some(proc) => format!(
12916 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12917 proc.replace('\'', "''")
12918 ),
12919 None => String::new(),
12920 };
12921
12922 let ps = format!(
12923 r#"
12924$results = @()
12925try {{
12926 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12927 if ($events) {{
12928 foreach ($e in $events) {{
12929 $msg = $e.Message
12930 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12931 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12932 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12933 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12934 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12935 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12936 }}
12937 $results
12938 }} else {{ 'NONE' }}
12939}} catch {{ 'ERROR:' + $_.Exception.Message }}
12940"#
12941 );
12942
12943 let raw = ps_exec(&ps);
12944 let text = raw.trim();
12945
12946 let wer_ps = r#"
12948$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12949$count = 0
12950if (Test-Path $wer) {
12951 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12952}
12953$count
12954"#;
12955 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12956
12957 if text == "NONE" {
12958 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12959 } else if text.starts_with("ERROR:") {
12960 let msg = text.trim_start_matches("ERROR:").trim();
12961 let _ = write!(
12962 sections,
12963 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12964 );
12965 } else {
12966 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12967 let crash_count = events
12968 .iter()
12969 .filter(|l| l.split('|').nth(1) == Some("CRASH"))
12970 .count();
12971 let hang_count = events
12972 .iter()
12973 .filter(|l| l.split('|').nth(1) == Some("HANG"))
12974 .count();
12975
12976 let mut app_counts: std::collections::HashMap<String, usize> =
12978 std::collections::HashMap::new();
12979 for line in &events {
12980 let mut it = line.splitn(6, '|');
12981 if let (Some(_), Some(_), Some(app)) = (it.next(), it.next(), it.next()) {
12982 *app_counts.entry(app.to_string()).or_insert(0) += 1;
12983 }
12984 }
12985
12986 if crash_count > 0 {
12987 findings.push(format!(
12988 "{crash_count} application crash event(s) — review below for faulting app and exception code."
12989 ));
12990 }
12991 if hang_count > 0 {
12992 findings.push(format!(
12993 "{hang_count} application hang event(s) — process stopped responding."
12994 ));
12995 }
12996 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12997 if count > 1 {
12998 findings.push(format!(
12999 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
13000 ));
13001 }
13002 }
13003 if wer_count > 10 {
13004 findings.push(format!(
13005 "{wer_count} WER reports archived — elevated crash history on this machine."
13006 ));
13007 }
13008
13009 let filter_note = match process_filter {
13010 Some(p) => format!(" (filtered: {p})"),
13011 None => String::new(),
13012 };
13013 let _ = writeln!(
13014 sections,
13015 "=== Application crashes and hangs{filter_note} ==="
13016 );
13017
13018 for line in &events {
13019 let mut it = line.splitn(6, '|');
13020 if let (Some(time), Some(kind), Some(app), Some(ver), Some(module), Some(exc)) = (
13021 it.next(),
13022 it.next(),
13023 it.next(),
13024 it.next(),
13025 it.next(),
13026 it.next(),
13027 ) {
13028 let ver_note = if !ver.is_empty() {
13029 format!(" v{ver}")
13030 } else {
13031 String::new()
13032 };
13033 let _ = writeln!(sections, " [{time}] {kind}: {app}{ver_note}");
13034 if !module.is_empty() && module != "?" {
13035 let exc_note = if !exc.is_empty() {
13036 format!(" (exc {exc})")
13037 } else {
13038 String::new()
13039 };
13040 let _ = writeln!(sections, " faulting module: {module}{exc_note}");
13041 } else if !exc.is_empty() {
13042 let _ = writeln!(sections, " exception: {exc}");
13043 }
13044 }
13045 }
13046 let _ = write!(
13047 sections,
13048 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
13049 );
13050
13051 if wer_count > 0 {
13052 let _ = write!(sections,
13053 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
13054 );
13055 }
13056 }
13057 }
13058
13059 #[cfg(not(target_os = "windows"))]
13060 {
13061 let _ = (process_filter, n);
13062 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
13063 }
13064
13065 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
13066 if findings.is_empty() {
13067 result.push_str("- No actionable findings.\n");
13068 } else {
13069 for f in &findings {
13070 let _ = writeln!(result, "- Finding: {f}");
13071 }
13072 }
13073 result.push('\n');
13074 result.push_str(§ions);
13075 Ok(result.trim_end().to_string())
13076}
13077
13078#[cfg(target_os = "windows")]
13079fn gpu_voltage_telemetry_note() -> String {
13080 let output = Command::new("nvidia-smi")
13081 .args(["--help-query-gpu"])
13082 .output();
13083
13084 match output {
13085 Ok(o) => {
13086 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
13087 if text.contains("\"voltage\"") || text.contains("voltage.") {
13088 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
13089 } else {
13090 "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()
13091 }
13092 }
13093 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
13094 }
13095}
13096
13097#[cfg(target_os = "windows")]
13098fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13099 if raw == 0 {
13100 return None;
13101 }
13102 if raw & 0x80 != 0 {
13103 let tenths = raw & 0x7f;
13104 return Some(format!(
13105 "{:.1} V (firmware-reported WMI current voltage)",
13106 tenths as f64 / 10.0
13107 ));
13108 }
13109
13110 let legacy = match raw {
13111 1 => Some("5.0 V"),
13112 2 => Some("3.3 V"),
13113 4 => Some("2.9 V"),
13114 _ => None,
13115 }?;
13116 Some(format!(
13117 "{} (legacy WMI voltage capability flag, not live telemetry)",
13118 legacy
13119 ))
13120}
13121
13122async fn inspect_overclocker() -> Result<String, String> {
13123 let mut out = String::from("Host inspection: overclocker\n\n");
13124
13125 #[cfg(target_os = "windows")]
13126 {
13127 out.push_str(
13128 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13129 );
13130
13131 let nvidia = Command::new("nvidia-smi")
13133 .args([
13134 "--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",
13135 "--format=csv,noheader,nounits",
13136 ])
13137 .output();
13138
13139 if let Ok(o) = nvidia {
13140 let stdout = String::from_utf8_lossy(&o.stdout);
13141 if !stdout.trim().is_empty() {
13142 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13143 let mut parts = Vec::with_capacity(16);
13144 parts.extend(stdout.trim().split(',').map(|s| s.trim()));
13145 if parts.len() >= 10 {
13146 let _ = writeln!(out, "- Model: {}", parts[0]);
13147 let _ = writeln!(out, "- Graphics: {} MHz", parts[1]);
13148 let _ = writeln!(out, "- Memory: {} MHz", parts[2]);
13149 let _ = writeln!(out, "- Fan Speed: {}%", parts[3]);
13150 let _ = writeln!(out, "- Power Draw: {} W", parts[4]);
13151 if !parts[6].eq_ignore_ascii_case("[N/A]") {
13152 let _ = writeln!(out, "- Power Avg: {} W", parts[6]);
13153 }
13154 if !parts[7].eq_ignore_ascii_case("[N/A]") {
13155 let _ = writeln!(out, "- Power Inst: {} W", parts[7]);
13156 }
13157 if !parts[8].eq_ignore_ascii_case("[N/A]") {
13158 let _ = writeln!(out, "- Power Cap: {} W requested", parts[8]);
13159 }
13160 if !parts[9].eq_ignore_ascii_case("[N/A]") {
13161 let _ = writeln!(out, "- Power Enf: {} W enforced", parts[9]);
13162 }
13163 let _ = writeln!(out, "- Temperature: {}°C", parts[5]);
13164
13165 if parts.len() > 10 {
13166 let throttle_hex = parts[10];
13167 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13168 if !reasons.is_empty() {
13169 let _ = writeln!(out, "- Throttling: YES [Reason: {}]", reasons);
13170 } else {
13171 out.push_str("- Throttling: None (Performance State: Max)\n");
13172 }
13173 }
13174 }
13175 out.push('\n');
13176 }
13177 }
13178
13179 out.push_str("=== VOLTAGE TELEMETRY ===\n");
13180 let _ = write!(out, "- GPU Voltage: {}\n\n", gpu_voltage_telemetry_note());
13181
13182 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13184 let history = gpu_state.history.read().unwrap();
13185 if history.len() >= 2 {
13186 out.push_str("=== SILICON TRENDS (Session) ===\n");
13187 let first = history.front().unwrap();
13188 let last = history.back().unwrap();
13189
13190 let temp_diff = last.temperature as i32 - first.temperature as i32;
13191 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13192
13193 let temp_trend = if temp_diff > 1 {
13194 "Rising"
13195 } else if temp_diff < -1 {
13196 "Falling"
13197 } else {
13198 "Stable"
13199 };
13200 let clock_trend = if clock_diff > 10 {
13201 "Increasing"
13202 } else if clock_diff < -10 {
13203 "Decreasing"
13204 } else {
13205 "Stable"
13206 };
13207
13208 let _ = writeln!(
13209 out,
13210 "- Temperature: {} ({}°C anomaly)",
13211 temp_trend, temp_diff
13212 );
13213 let _ = writeln!(
13214 out,
13215 "- Core Clock: {} ({} MHz delta)",
13216 clock_trend, clock_diff
13217 );
13218 out.push('\n');
13219 }
13220
13221 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))\" }";
13223 let cpu_stats = Command::new("powershell")
13224 .args(["-NoProfile", "-Command", ps_cmd])
13225 .output();
13226
13227 if let Ok(o) = cpu_stats {
13228 let stdout = String::from_utf8_lossy(&o.stdout);
13229 if !stdout.trim().is_empty() {
13230 out.push_str("=== SILICON CORE (CPU) ===\n");
13231 for line in stdout.lines() {
13232 if let Some((path, val)) = line.split_once(':') {
13233 let path_lower = path.to_lowercase();
13234 if path_lower.contains("processor frequency") {
13235 let _ = writeln!(out, "- Current Freq: {} MHz (2s Avg)", val);
13236 } else if path_lower.contains("% of maximum frequency") {
13237 let _ = writeln!(out, "- Throttling: {}% of Max Capacity", val);
13238 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13239 if throttle_num < 95.0 {
13240 out.push_str(
13241 " [WARNING] Active downclocking or power-saving detected.\n",
13242 );
13243 }
13244 }
13245 }
13246 }
13247 }
13248 }
13249
13250 let thermal = Command::new("powershell")
13252 .args([
13253 "-NoProfile",
13254 "-Command",
13255 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13256 ])
13257 .output();
13258 if let Ok(o) = thermal {
13259 let stdout = String::from_utf8_lossy(&o.stdout);
13260 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13261 let temp = if v.is_array() {
13262 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13263 } else {
13264 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13265 };
13266 if temp > 1.0 {
13267 let _ = writeln!(out, "- CPU Package: {}°C (ACPI Zone)", temp);
13268 }
13269 }
13270 }
13271
13272 let wmi = Command::new("powershell")
13274 .args([
13275 "-NoProfile",
13276 "-Command",
13277 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13278 ])
13279 .output();
13280
13281 if let Ok(o) = wmi {
13282 let stdout = String::from_utf8_lossy(&o.stdout);
13283 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13284 out.push_str("\n=== HARDWARE DNA ===\n");
13285 let _ = writeln!(
13286 out,
13287 "- Rated Max: {} MHz",
13288 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13289 );
13290 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13291 Some(raw) => {
13292 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13293 let _ = writeln!(out, "- CPU Voltage: {}", decoded);
13294 } else {
13295 out.push_str(
13296 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
13297 );
13298 }
13299 }
13300 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
13301 }
13302 }
13303 }
13304 }
13305
13306 #[cfg(not(target_os = "windows"))]
13307 {
13308 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13309 }
13310
13311 Ok(out.trim_end().to_string())
13312}
13313
13314#[cfg(target_os = "windows")]
13316fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13317 let hex = hex.trim().trim_start_matches("0x");
13318 let val = match u64::from_str_radix(hex, 16) {
13319 Ok(v) => v,
13320 Err(_) => return String::new(),
13321 };
13322
13323 if val == 0 {
13324 return String::new();
13325 }
13326
13327 let mut reasons = Vec::with_capacity(9);
13328 if val & 0x01 != 0 {
13329 reasons.push("GPU Idle");
13330 }
13331 if val & 0x02 != 0 {
13332 reasons.push("Applications Clocks Setting");
13333 }
13334 if val & 0x04 != 0 {
13335 reasons.push("SW Power Cap (PL1/PL2)");
13336 }
13337 if val & 0x08 != 0 {
13338 reasons.push("HW Slowdown (Thermal/Power)");
13339 }
13340 if val & 0x10 != 0 {
13341 reasons.push("Sync Boost");
13342 }
13343 if val & 0x20 != 0 {
13344 reasons.push("SW Thermal Slowdown");
13345 }
13346 if val & 0x40 != 0 {
13347 reasons.push("HW Thermal Slowdown");
13348 }
13349 if val & 0x80 != 0 {
13350 reasons.push("HW Power Brake Slowdown");
13351 }
13352 if val & 0x100 != 0 {
13353 reasons.push("Display Clock Setting");
13354 }
13355
13356 reasons.join(", ")
13357}
13358
13359#[cfg(windows)]
13362fn run_powershell(script: &str) -> Result<String, String> {
13363 use std::process::Command;
13364 let out = Command::new("powershell")
13365 .args(["-NoProfile", "-NonInteractive", "-Command", script])
13366 .output()
13367 .map_err(|e| format!("powershell launch failed: {e}"))?;
13368 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13369}
13370
13371#[cfg(windows)]
13374fn inspect_camera(max_entries: usize) -> Result<String, String> {
13375 let mut out = String::from("=== Camera devices ===\n");
13376
13377 let ps_devices = r#"
13379Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13380ForEach-Object {
13381 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13382 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13383}
13384"#;
13385 match run_powershell(ps_devices) {
13386 Ok(o) if !o.trim().is_empty() => {
13387 for line in o.lines().take(max_entries) {
13388 let l = line.trim();
13389 if !l.is_empty() {
13390 let _ = writeln!(out, "- {l}");
13391 }
13392 }
13393 }
13394 _ => out.push_str("- No camera devices found via PnP\n"),
13395 }
13396
13397 out.push_str("\n=== Windows camera privacy ===\n");
13399 let ps_privacy = r#"
13400$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13401$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13402"Global: $global"
13403$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13404 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13405 ForEach-Object {
13406 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13407 if ($v) { " $($_.PSChildName): $v" }
13408 }
13409$apps
13410"#;
13411 match run_powershell(ps_privacy) {
13412 Ok(o) if !o.trim().is_empty() => {
13413 for line in o.lines().take(max_entries) {
13414 let l = line.trim_end();
13415 if !l.is_empty() {
13416 let _ = writeln!(out, "{l}");
13417 }
13418 }
13419 }
13420 _ => out.push_str("- Could not read camera privacy registry\n"),
13421 }
13422
13423 out.push_str("\n=== Biometric / Hello camera ===\n");
13425 let ps_bio = r#"
13426Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13427ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13428"#;
13429 match run_powershell(ps_bio) {
13430 Ok(o) if !o.trim().is_empty() => {
13431 for line in o.lines().take(max_entries) {
13432 let l = line.trim();
13433 if !l.is_empty() {
13434 let _ = writeln!(out, "- {l}");
13435 }
13436 }
13437 }
13438 _ => out.push_str("- No biometric devices found\n"),
13439 }
13440
13441 let mut findings: Vec<String> = Vec::with_capacity(4);
13443 if out.contains("Status: Error") || out.contains("Status: Unknown") {
13444 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13445 }
13446 if out.contains("Global: Deny") {
13447 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());
13448 }
13449
13450 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13451 if findings.is_empty() {
13452 result.push_str("- No obvious camera or privacy gate issue detected.\n");
13453 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13454 } else {
13455 for f in &findings {
13456 let _ = writeln!(result, "- Finding: {f}");
13457 }
13458 }
13459 result.push('\n');
13460 result.push_str(&out);
13461 Ok(result)
13462}
13463
13464#[cfg(not(windows))]
13465fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13466 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13467}
13468
13469#[cfg(windows)]
13472fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13473 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13474
13475 let ps_hello = r#"
13477$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13478$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13479$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13480"PIN-style logon path: $helloKey"
13481"WbioSrvc start type: $faceConfigured"
13482"FingerPrint key present: $pinConfigured"
13483"#;
13484 match run_powershell(ps_hello) {
13485 Ok(o) => {
13486 for line in o.lines().take(max_entries) {
13487 let l = line.trim();
13488 if !l.is_empty() {
13489 let _ = writeln!(out, "- {l}");
13490 }
13491 }
13492 }
13493 Err(e) => {
13494 let _ = writeln!(out, "- Hello query error: {e}");
13495 }
13496 }
13497
13498 out.push_str("\n=== Biometric service ===\n");
13500 let ps_bio_svc = r#"
13501$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13502if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13503else { "WbioSrvc not found" }
13504"#;
13505 match run_powershell(ps_bio_svc) {
13506 Ok(o) => {
13507 let _ = writeln!(out, "- {}", o.trim());
13508 }
13509 Err(_) => out.push_str("- Could not query biometric service\n"),
13510 }
13511
13512 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13514 let ps_events = r#"
13515$cutoff = (Get-Date).AddHours(-24)
13516Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13517ForEach-Object {
13518 $xml = [xml]$_.ToXml()
13519 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13520 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13521 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13522} | Select-Object -First 10
13523"#;
13524 match run_powershell(ps_events) {
13525 Ok(o) if !o.trim().is_empty() => {
13526 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13527 let _ = writeln!(out, "- {count} recent logon failure(s) detected:");
13528 for line in o.lines().take(max_entries) {
13529 let l = line.trim();
13530 if !l.is_empty() {
13531 let _ = writeln!(out, " {l}");
13532 }
13533 }
13534 }
13535 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13536 }
13537
13538 out.push_str("\n=== Active credential providers ===\n");
13540 let ps_cp = r#"
13541Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13542ForEach-Object {
13543 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13544 if ($name) { $name }
13545} | Select-Object -First 15
13546"#;
13547 match run_powershell(ps_cp) {
13548 Ok(o) if !o.trim().is_empty() => {
13549 for line in o.lines().take(max_entries) {
13550 let l = line.trim();
13551 if !l.is_empty() {
13552 let _ = writeln!(out, "- {l}");
13553 }
13554 }
13555 }
13556 _ => out.push_str("- Could not enumerate credential providers\n"),
13557 }
13558
13559 let mut findings: Vec<String> = Vec::with_capacity(4);
13560 if out.contains("WbioSrvc | Status: Stopped") {
13561 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13562 }
13563 if out.contains("recent logon failure") && !out.contains("0 recent") {
13564 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13565 }
13566
13567 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13568 if findings.is_empty() {
13569 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13570 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13571 } else {
13572 for f in &findings {
13573 let _ = writeln!(result, "- Finding: {f}");
13574 }
13575 }
13576 result.push('\n');
13577 result.push_str(&out);
13578 Ok(result)
13579}
13580
13581#[cfg(not(windows))]
13582fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13583 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13584}
13585
13586#[cfg(windows)]
13589fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13590 let mut out = String::from("=== Installer engines ===\n");
13591
13592 let ps_engines = r#"
13593$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13594foreach ($name in $services) {
13595 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13596 if ($svc) {
13597 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13598 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13599 "$name | Status: $($svc.Status) | StartType: $startType"
13600 } else {
13601 "$name | Not present"
13602 }
13603}
13604if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13605 "msiexec.exe | Present: Yes"
13606} else {
13607 "msiexec.exe | Present: No"
13608}
13609"#;
13610 match run_powershell(ps_engines) {
13611 Ok(o) if !o.trim().is_empty() => {
13612 for line in o.lines().take(max_entries + 6) {
13613 let l = line.trim();
13614 if !l.is_empty() {
13615 let _ = writeln!(out, "- {l}");
13616 }
13617 }
13618 }
13619 _ => out.push_str("- Could not inspect installer engine services\n"),
13620 }
13621
13622 out.push_str("\n=== winget and App Installer ===\n");
13623 let ps_winget = r#"
13624$cmd = Get-Command winget -ErrorAction SilentlyContinue
13625if ($cmd) {
13626 try {
13627 $v = & winget --version 2>$null
13628 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13629 } catch { "winget | Present but invocation failed" }
13630} else {
13631 "winget | Missing"
13632}
13633$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13634if ($appInstaller) {
13635 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13636} else {
13637 "DesktopAppInstaller | Status: Missing"
13638}
13639"#;
13640 match run_powershell(ps_winget) {
13641 Ok(o) if !o.trim().is_empty() => {
13642 for line in o.lines().take(max_entries) {
13643 let l = line.trim();
13644 if !l.is_empty() {
13645 let _ = writeln!(out, "- {l}");
13646 }
13647 }
13648 }
13649 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13650 }
13651
13652 out.push_str("\n=== Microsoft Store packages ===\n");
13653 let ps_store = r#"
13654$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13655if ($store) {
13656 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13657} else {
13658 "Microsoft.WindowsStore | Status: Missing"
13659}
13660"#;
13661 match run_powershell(ps_store) {
13662 Ok(o) if !o.trim().is_empty() => {
13663 for line in o.lines().take(max_entries) {
13664 let l = line.trim();
13665 if !l.is_empty() {
13666 let _ = writeln!(out, "- {l}");
13667 }
13668 }
13669 }
13670 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13671 }
13672
13673 out.push_str("\n=== Reboot and transaction blockers ===\n");
13674 let ps_blockers = r#"
13675$pending = $false
13676if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13677 "RebootPending: CBS"
13678 $pending = $true
13679}
13680if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13681 "RebootPending: WindowsUpdate"
13682 $pending = $true
13683}
13684$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13685if ($rename) {
13686 "PendingFileRenameOperations: Yes"
13687 $pending = $true
13688}
13689if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13690 "InstallerInProgress: Yes"
13691 $pending = $true
13692}
13693if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13694"#;
13695 match run_powershell(ps_blockers) {
13696 Ok(o) if !o.trim().is_empty() => {
13697 for line in o.lines().take(max_entries) {
13698 let l = line.trim();
13699 if !l.is_empty() {
13700 let _ = writeln!(out, "- {l}");
13701 }
13702 }
13703 }
13704 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13705 }
13706
13707 out.push_str("\n=== Recent installer failures (7d) ===\n");
13708 let ps_failures = r#"
13709$cutoff = (Get-Date).AddDays(-7)
13710$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13711 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13712$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13713 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13714 Select-Object -First 6 |
13715 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13716$all = @($msi) + @($appx)
13717if ($all.Count -eq 0) {
13718 "No recent MSI/AppX installer errors detected"
13719} else {
13720 $all | Select-Object -First 8
13721}
13722"#;
13723 match run_powershell(ps_failures) {
13724 Ok(o) if !o.trim().is_empty() => {
13725 for line in o.lines().take(max_entries + 2) {
13726 let l = line.trim();
13727 if !l.is_empty() {
13728 let _ = writeln!(out, "- {l}");
13729 }
13730 }
13731 }
13732 _ => out.push_str("- Could not inspect recent installer failure events\n"),
13733 }
13734
13735 let mut findings: Vec<String> = Vec::with_capacity(4);
13736 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13737 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13738 }
13739 if out.contains("msiexec.exe | Present: No") {
13740 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13741 }
13742 if out.contains("winget | Missing") {
13743 findings.push(
13744 "winget is missing - App Installer may not be installed or registered for this user."
13745 .into(),
13746 );
13747 }
13748 if out.contains("DesktopAppInstaller | Status: Missing") {
13749 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13750 }
13751 if out.contains("Microsoft.WindowsStore | Status: Missing") {
13752 findings.push(
13753 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13754 .into(),
13755 );
13756 }
13757 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13758 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13759 }
13760 if out.contains("InstallerInProgress: Yes") {
13761 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13762 }
13763 if out.contains("MSI | ") || out.contains("AppX | ") {
13764 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());
13765 }
13766
13767 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13768 if findings.is_empty() {
13769 result.push_str("- No obvious installer-platform blocker detected.\n");
13770 } else {
13771 for finding in &findings {
13772 let _ = writeln!(result, "- Finding: {finding}");
13773 }
13774 }
13775 result.push('\n');
13776 result.push_str(&out);
13777 Ok(result)
13778}
13779
13780#[cfg(not(windows))]
13781fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13782 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())
13783}
13784
13785#[cfg(windows)]
13788fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13789 let mut out = String::from("=== OneDrive client ===\n");
13790
13791 let ps_client = r#"
13792$candidatePaths = @(
13793 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13794 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13795 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13796) | Where-Object { $_ -and (Test-Path $_) }
13797$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13798$exe = $candidatePaths | Select-Object -First 1
13799if (-not $exe -and $proc) {
13800 try { $exe = $proc.Path } catch {}
13801}
13802if ($exe) {
13803 "Installed: Yes"
13804 "Executable: $exe"
13805 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13806} else {
13807 "Installed: Unknown"
13808}
13809if ($proc) {
13810 "Process: Running | PID: $($proc.Id)"
13811} else {
13812 "Process: Not running"
13813}
13814"#;
13815 match run_powershell(ps_client) {
13816 Ok(o) if !o.trim().is_empty() => {
13817 for line in o.lines().take(max_entries) {
13818 let l = line.trim();
13819 if !l.is_empty() {
13820 let _ = writeln!(out, "- {l}");
13821 }
13822 }
13823 }
13824 _ => out.push_str("- Could not inspect OneDrive client state\n"),
13825 }
13826
13827 out.push_str("\n=== OneDrive accounts ===\n");
13828 let ps_accounts = r#"
13829function MaskEmail([string]$Email) {
13830 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13831 $parts = $Email.Split('@', 2)
13832 $local = $parts[0]
13833 $domain = $parts[1]
13834 if ($local.Length -le 1) { return "*@$domain" }
13835 return ($local.Substring(0,1) + "***@" + $domain)
13836}
13837$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13838if (Test-Path $base) {
13839 Get-ChildItem $base -ErrorAction SilentlyContinue |
13840 Sort-Object PSChildName |
13841 Select-Object -First 12 |
13842 ForEach-Object {
13843 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13844 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13845 $mail = MaskEmail ([string]$p.UserEmail)
13846 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13847 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13848 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13849 }
13850} else {
13851 "No OneDrive accounts configured"
13852}
13853"#;
13854 match run_powershell(ps_accounts) {
13855 Ok(o) if !o.trim().is_empty() => {
13856 for line in o.lines().take(max_entries) {
13857 let l = line.trim();
13858 if !l.is_empty() {
13859 let _ = writeln!(out, "- {l}");
13860 }
13861 }
13862 }
13863 _ => out.push_str("- Could not read OneDrive account registry state\n"),
13864 }
13865
13866 out.push_str("\n=== OneDrive policy overrides ===\n");
13867 let ps_policy = r#"
13868$paths = @(
13869 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13870 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13871)
13872$names = @(
13873 'DisableFileSyncNGSC',
13874 'DisableLibrariesDefaultSaveToOneDrive',
13875 'KFMSilentOptIn',
13876 'KFMBlockOptIn',
13877 'SilentAccountConfig'
13878)
13879$found = $false
13880foreach ($path in $paths) {
13881 if (Test-Path $path) {
13882 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13883 foreach ($name in $names) {
13884 $value = $p.$name
13885 if ($null -ne $value -and [string]$value -ne '') {
13886 "$path | $name=$value"
13887 $found = $true
13888 }
13889 }
13890 }
13891}
13892if (-not $found) { "No OneDrive policy overrides detected" }
13893"#;
13894 match run_powershell(ps_policy) {
13895 Ok(o) if !o.trim().is_empty() => {
13896 for line in o.lines().take(max_entries) {
13897 let l = line.trim();
13898 if !l.is_empty() {
13899 let _ = writeln!(out, "- {l}");
13900 }
13901 }
13902 }
13903 _ => out.push_str("- Could not read OneDrive policy state\n"),
13904 }
13905
13906 out.push_str("\n=== Known Folder Backup ===\n");
13907 let ps_kfm = r#"
13908$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13909$roots = @()
13910if (Test-Path $base) {
13911 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13912 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13913 if ($p.UserFolder) {
13914 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13915 }
13916 }
13917}
13918$roots = $roots | Select-Object -Unique
13919$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13920if (Test-Path $shell) {
13921 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13922 $folders = @(
13923 @{ Name='Desktop'; Value=$props.Desktop },
13924 @{ Name='Documents'; Value=$props.Personal },
13925 @{ Name='Pictures'; Value=$props.'My Pictures' }
13926 )
13927 foreach ($folder in $folders) {
13928 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13929 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13930 $protected = $false
13931 foreach ($root in $roots) {
13932 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13933 $protected = $true
13934 break
13935 }
13936 }
13937 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13938 }
13939} else {
13940 "Explorer shell folders unavailable"
13941}
13942"#;
13943 match run_powershell(ps_kfm) {
13944 Ok(o) if !o.trim().is_empty() => {
13945 for line in o.lines().take(max_entries) {
13946 let l = line.trim();
13947 if !l.is_empty() {
13948 let _ = writeln!(out, "- {l}");
13949 }
13950 }
13951 }
13952 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13953 }
13954
13955 let mut findings: Vec<String> = Vec::with_capacity(4);
13956 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13957 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13958 }
13959 if out.contains("No OneDrive accounts configured") {
13960 findings.push(
13961 "No OneDrive accounts are configured - sync cannot start until the user signs in."
13962 .into(),
13963 );
13964 }
13965 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13966 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13967 }
13968 if out.contains("Exists: No") {
13969 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13970 }
13971 if out.contains("DisableFileSyncNGSC=1") {
13972 findings
13973 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13974 }
13975 if out.contains("KFMBlockOptIn=1") {
13976 findings
13977 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13978 }
13979 if out.contains("SyncRoot: C:\\") {
13980 let mut missing_kfm: Vec<&str> = Vec::new();
13981 for folder in ["Desktop", "Documents", "Pictures"] {
13982 if out.lines().any(|line| {
13983 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13984 }) {
13985 missing_kfm.push(folder);
13986 }
13987 }
13988 if !missing_kfm.is_empty() {
13989 findings.push(format!(
13990 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13991 missing_kfm.join(", ")
13992 ));
13993 }
13994 }
13995
13996 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13997 if findings.is_empty() {
13998 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13999 } else {
14000 for finding in &findings {
14001 let _ = writeln!(result, "- Finding: {finding}");
14002 }
14003 }
14004 result.push('\n');
14005 result.push_str(&out);
14006 Ok(result)
14007}
14008
14009#[cfg(not(windows))]
14010fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
14011 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
14012}
14013
14014#[cfg(windows)]
14015fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
14016 let mut out = String::from("=== Browser inventory ===\n");
14017
14018 let ps_inventory = r#"
14019$browsers = @(
14020 @{ Name='Edge'; Paths=@(
14021 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
14022 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
14023 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
14024 @{ Name='Chrome'; Paths=@(
14025 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
14026 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
14027 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
14028 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
14029 @{ Name='Firefox'; Paths=@(
14030 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
14031 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
14032 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
14033)
14034foreach ($browser in $browsers) {
14035 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14036 if ($exe) {
14037 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14038 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
14039 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
14040 } else {
14041 "$($browser.Name) | Installed: No"
14042 }
14043}
14044$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14045$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14046$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
14047"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
14048"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
14049"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
14050"#;
14051 match run_powershell(ps_inventory) {
14052 Ok(o) if !o.trim().is_empty() => {
14053 for line in o.lines().take(max_entries + 6) {
14054 let l = line.trim();
14055 if !l.is_empty() {
14056 let _ = writeln!(out, "- {l}");
14057 }
14058 }
14059 }
14060 _ => out.push_str("- Could not inspect installed browser inventory\n"),
14061 }
14062
14063 out.push_str("\n=== Runtime state ===\n");
14064 let ps_runtime = r#"
14065$targets = 'msedge','chrome','firefox','msedgewebview2'
14066foreach ($name in $targets) {
14067 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14068 if ($procs) {
14069 $count = @($procs).Count
14070 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14071 "$name | Processes: $count | WorkingSetMB: $wsMb"
14072 } else {
14073 "$name | Processes: 0 | WorkingSetMB: 0"
14074 }
14075}
14076"#;
14077 match run_powershell(ps_runtime) {
14078 Ok(o) if !o.trim().is_empty() => {
14079 for line in o.lines().take(max_entries + 4) {
14080 let l = line.trim();
14081 if !l.is_empty() {
14082 let _ = writeln!(out, "- {l}");
14083 }
14084 }
14085 }
14086 _ => out.push_str("- Could not inspect browser runtime state\n"),
14087 }
14088
14089 out.push_str("\n=== WebView2 runtime ===\n");
14090 let ps_webview = r#"
14091$paths = @(
14092 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14093 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14094) | Where-Object { $_ -and (Test-Path $_) }
14095$runtimeDir = $paths | ForEach-Object {
14096 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14097 Where-Object { $_.Name -match '^\d+\.' } |
14098 Sort-Object Name -Descending |
14099 Select-Object -First 1
14100} | Select-Object -First 1
14101if ($runtimeDir) {
14102 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14103 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14104 "Installed: Yes"
14105 "Version: $version"
14106 "Executable: $exe"
14107} else {
14108 "Installed: No"
14109}
14110$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14111"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14112"#;
14113 match run_powershell(ps_webview) {
14114 Ok(o) if !o.trim().is_empty() => {
14115 for line in o.lines().take(max_entries) {
14116 let l = line.trim();
14117 if !l.is_empty() {
14118 let _ = writeln!(out, "- {l}");
14119 }
14120 }
14121 }
14122 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14123 }
14124
14125 out.push_str("\n=== Policy and proxy surface ===\n");
14126 let ps_policy = r#"
14127$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14128$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14129$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14130$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14131$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14132"UserProxyEnabled: $proxyEnabled"
14133"UserProxyServer: $proxyServer"
14134"UserAutoConfigURL: $autoConfig"
14135"UserAutoDetect: $autoDetect"
14136$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14137if ($winhttp) {
14138 $normalized = ($winhttp -replace '\s+', ' ').Trim()
14139 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14140 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14141 "WinHTTP: $normalized"
14142}
14143$policyTargets = @(
14144 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14145 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14146)
14147foreach ($policy in $policyTargets) {
14148 if (Test-Path $policy.Path) {
14149 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14150 foreach ($key in $policy.Keys) {
14151 $value = $item.$key
14152 if ($null -ne $value -and [string]$value -ne '') {
14153 if ($value -is [array]) {
14154 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14155 } else {
14156 "$($policy.Name)Policy | $key=$value"
14157 }
14158 }
14159 }
14160 }
14161}
14162"#;
14163 match run_powershell(ps_policy) {
14164 Ok(o) if !o.trim().is_empty() => {
14165 for line in o.lines().take(max_entries + 8) {
14166 let l = line.trim();
14167 if !l.is_empty() {
14168 let _ = writeln!(out, "- {l}");
14169 }
14170 }
14171 }
14172 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14173 }
14174
14175 out.push_str("\n=== Profile and cache pressure ===\n");
14176 let ps_profiles = r#"
14177$profiles = @(
14178 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14179 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14180 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14181)
14182foreach ($profile in $profiles) {
14183 if (Test-Path $profile.Root) {
14184 if ($profile.Name -eq 'Firefox') {
14185 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14186 } else {
14187 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14188 Where-Object {
14189 $_.Name -eq 'Default' -or
14190 $_.Name -eq 'Guest Profile' -or
14191 $_.Name -eq 'System Profile' -or
14192 $_.Name -like 'Profile *'
14193 }
14194 }
14195 $profileCount = @($dirs).Count
14196 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14197 if (-not $sizeBytes) { $sizeBytes = 0 }
14198 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14199 $extCount = 'Unknown'
14200 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14201 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14202 }
14203 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14204 } else {
14205 "$($profile.Name) | ProfileRoot: Missing"
14206 }
14207}
14208"#;
14209 match run_powershell(ps_profiles) {
14210 Ok(o) if !o.trim().is_empty() => {
14211 for line in o.lines().take(max_entries + 4) {
14212 let l = line.trim();
14213 if !l.is_empty() {
14214 let _ = writeln!(out, "- {l}");
14215 }
14216 }
14217 }
14218 _ => out.push_str("- Could not inspect browser profile pressure\n"),
14219 }
14220
14221 out.push_str("\n=== Recent browser failures (7d) ===\n");
14222 let ps_failures = r#"
14223$cutoff = (Get-Date).AddDays(-7)
14224$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14225$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14226 Where-Object {
14227 $msg = [string]$_.Message
14228 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14229 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14230 } |
14231 Select-Object -First 6
14232if ($events) {
14233 foreach ($event in $events) {
14234 $msg = ($event.Message -replace '\s+', ' ')
14235 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14236 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14237 }
14238} else {
14239 "No recent browser crash or WER events detected"
14240}
14241"#;
14242 match run_powershell(ps_failures) {
14243 Ok(o) if !o.trim().is_empty() => {
14244 for line in o.lines().take(max_entries + 2) {
14245 let l = line.trim();
14246 if !l.is_empty() {
14247 let _ = writeln!(out, "- {l}");
14248 }
14249 }
14250 }
14251 _ => out.push_str("- Could not inspect recent browser failure events\n"),
14252 }
14253
14254 let mut findings: Vec<String> = Vec::with_capacity(4);
14255 if out.contains("Edge | Installed: No")
14256 && out.contains("Chrome | Installed: No")
14257 && out.contains("Firefox | Installed: No")
14258 {
14259 findings.push(
14260 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14261 .into(),
14262 );
14263 }
14264 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14265 findings.push(
14266 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14267 .into(),
14268 );
14269 }
14270 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14271 findings.push(
14272 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14273 .into(),
14274 );
14275 }
14276 if out.contains("EdgePolicy | Proxy")
14277 || out.contains("ChromePolicy | Proxy")
14278 || out.contains("ExtensionInstallForcelist=")
14279 {
14280 findings.push(
14281 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14282 .into(),
14283 );
14284 }
14285 for browser in ["msedge", "chrome", "firefox"] {
14286 let process_marker = format!("{browser} | Processes: ");
14287 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14288 let count = line
14289 .split("| Processes: ")
14290 .nth(1)
14291 .and_then(|rest| rest.split(" |").next())
14292 .and_then(|value| value.trim().parse::<usize>().ok())
14293 .unwrap_or(0);
14294 let ws_mb = line
14295 .split("| WorkingSetMB: ")
14296 .nth(1)
14297 .and_then(|value| value.trim().parse::<f64>().ok())
14298 .unwrap_or(0.0);
14299 if count >= 25 {
14300 findings.push(format!(
14301 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14302 ));
14303 } else if ws_mb >= 2500.0 {
14304 findings.push(format!(
14305 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14306 ));
14307 }
14308 }
14309 }
14310 if out.contains("=== WebView2 runtime ===\n- Installed: No")
14311 || (out.contains("=== WebView2 runtime ===")
14312 && out.contains("- Installed: No")
14313 && out.contains("- ProcessCount: 0"))
14314 {
14315 findings.push(
14316 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14317 .into(),
14318 );
14319 }
14320 for browser in ["Edge", "Chrome", "Firefox"] {
14321 let prefix = format!("{browser} | ProfileRoot:");
14322 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14323 let size_gb = line
14324 .split("| SizeGB: ")
14325 .nth(1)
14326 .and_then(|rest| rest.split(" |").next())
14327 .and_then(|value| value.trim().parse::<f64>().ok())
14328 .unwrap_or(0.0);
14329 let ext_count = line
14330 .split("| Extensions: ")
14331 .nth(1)
14332 .and_then(|value| value.trim().parse::<usize>().ok())
14333 .unwrap_or(0);
14334 if size_gb >= 2.5 {
14335 findings.push(format!(
14336 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14337 ));
14338 }
14339 if ext_count >= 20 {
14340 findings.push(format!(
14341 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14342 ));
14343 }
14344 }
14345 }
14346 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14347 findings.push(
14348 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14349 .into(),
14350 );
14351 }
14352
14353 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14354 if findings.is_empty() {
14355 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14356 } else {
14357 for finding in &findings {
14358 let _ = writeln!(result, "- Finding: {finding}");
14359 }
14360 }
14361 result.push('\n');
14362 result.push_str(&out);
14363 Ok(result)
14364}
14365
14366#[cfg(not(windows))]
14367fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14368 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())
14369}
14370
14371#[cfg(windows)]
14372fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14373 let mut out = String::from("=== Outlook install inventory ===\n");
14374
14375 let ps_install = r#"
14376$installPaths = @(
14377 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14378 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14379 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14380 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14381 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14382 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14383)
14384$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14385if ($exe) {
14386 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14387 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14388 "Installed: Yes"
14389 "Executable: $exe"
14390 "Version: $version"
14391 "Product: $productName"
14392} else {
14393 "Installed: No"
14394}
14395$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14396if ($newOutlook) {
14397 "NewOutlook: Installed | Version: $($newOutlook.Version)"
14398} else {
14399 "NewOutlook: Not installed"
14400}
14401"#;
14402 match run_powershell(ps_install) {
14403 Ok(o) if !o.trim().is_empty() => {
14404 for line in o.lines().take(max_entries + 4) {
14405 let l = line.trim();
14406 if !l.is_empty() {
14407 let _ = writeln!(out, "- {l}");
14408 }
14409 }
14410 }
14411 _ => out.push_str("- Could not inspect Outlook install paths\n"),
14412 }
14413
14414 out.push_str("\n=== Runtime state ===\n");
14415 let ps_runtime = r#"
14416$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14417if ($proc) {
14418 $count = @($proc).Count
14419 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14420 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14421 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14422} else {
14423 "Running: No"
14424}
14425"#;
14426 match run_powershell(ps_runtime) {
14427 Ok(o) if !o.trim().is_empty() => {
14428 for line in o.lines().take(4) {
14429 let l = line.trim();
14430 if !l.is_empty() {
14431 let _ = writeln!(out, "- {l}");
14432 }
14433 }
14434 }
14435 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14436 }
14437
14438 out.push_str("\n=== Mail profiles ===\n");
14439 let ps_profiles = r#"
14440$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14441if (-not (Test-Path $profileKey)) {
14442 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14443}
14444if (Test-Path $profileKey) {
14445 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14446 $count = @($profiles).Count
14447 "ProfileCount: $count"
14448 foreach ($p in $profiles | Select-Object -First 10) {
14449 "Profile: $($p.PSChildName)"
14450 }
14451} else {
14452 "ProfileCount: 0"
14453 "No Outlook profiles found in registry"
14454}
14455"#;
14456 match run_powershell(ps_profiles) {
14457 Ok(o) if !o.trim().is_empty() => {
14458 for line in o.lines().take(max_entries + 2) {
14459 let l = line.trim();
14460 if !l.is_empty() {
14461 let _ = writeln!(out, "- {l}");
14462 }
14463 }
14464 }
14465 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14466 }
14467
14468 out.push_str("\n=== OST and PST data files ===\n");
14469 let ps_datafiles = r#"
14470$searchRoots = @(
14471 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14472 (Join-Path $env:USERPROFILE 'Documents'),
14473 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14474) | Where-Object { $_ -and (Test-Path $_) }
14475$files = foreach ($root in $searchRoots) {
14476 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14477 Select-Object FullName,
14478 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14479 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14480 LastWriteTime
14481}
14482if ($files) {
14483 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14484 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14485 }
14486} else {
14487 "No OST or PST files found in standard locations"
14488}
14489"#;
14490 match run_powershell(ps_datafiles) {
14491 Ok(o) if !o.trim().is_empty() => {
14492 for line in o.lines().take(max_entries + 4) {
14493 let l = line.trim();
14494 if !l.is_empty() {
14495 let _ = writeln!(out, "- {l}");
14496 }
14497 }
14498 }
14499 _ => out.push_str("- Could not inspect OST/PST data files\n"),
14500 }
14501
14502 out.push_str("\n=== Add-in pressure ===\n");
14503 let ps_addins = r#"
14504$addinPaths = @(
14505 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14506 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14507 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14508)
14509$addins = foreach ($path in $addinPaths) {
14510 if (Test-Path $path) {
14511 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14512 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14513 $loadBehavior = $item.LoadBehavior
14514 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14515 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14516 }
14517 }
14518}
14519$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14520$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14521"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14522foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14523 $state = switch ($a.LoadBehavior) {
14524 0 { 'Disabled' }
14525 2 { 'LoadOnStart(inactive)' }
14526 3 { 'ActiveOnStart' }
14527 8 { 'DemandLoad' }
14528 9 { 'ActiveDemand' }
14529 16 { 'ConnectedFirst' }
14530 default { "LoadBehavior=$($a.LoadBehavior)" }
14531 }
14532 "$($a.Name) | $state"
14533}
14534$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14535$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14536if (Test-Path $disabledByResiliency) {
14537 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14538 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14539 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14540}
14541"#;
14542 match run_powershell(ps_addins) {
14543 Ok(o) if !o.trim().is_empty() => {
14544 for line in o.lines().take(max_entries + 8) {
14545 let l = line.trim();
14546 if !l.is_empty() {
14547 let _ = writeln!(out, "- {l}");
14548 }
14549 }
14550 }
14551 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14552 }
14553
14554 out.push_str("\n=== Authentication and cache friction ===\n");
14555 let ps_auth = r#"
14556$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14557$tokenCount = if (Test-Path $tokenCache) {
14558 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14559} else { 0 }
14560"TokenBrokerCacheFiles: $tokenCount"
14561$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14562$credsCount = @($credentialManager).Count
14563"OfficeCredentialsInVault: $credsCount"
14564$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14565if (Test-Path $samlKey) {
14566 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14567 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14568 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14569 "WAMOverride: $connected"
14570 "SignedInUserId: $signedIn"
14571}
14572$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14573if (Test-Path $outlookReg) {
14574 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14575 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14576}
14577"#;
14578 match run_powershell(ps_auth) {
14579 Ok(o) if !o.trim().is_empty() => {
14580 for line in o.lines().take(max_entries + 4) {
14581 let l = line.trim();
14582 if !l.is_empty() {
14583 let _ = writeln!(out, "- {l}");
14584 }
14585 }
14586 }
14587 _ => out.push_str("- Could not inspect Outlook auth state\n"),
14588 }
14589
14590 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14591 let ps_events = r#"
14592$cutoff = (Get-Date).AddDays(-7)
14593$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14594 Where-Object {
14595 $msg = [string]$_.Message
14596 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14597 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14598 } |
14599 Select-Object -First 8
14600if ($events) {
14601 foreach ($event in $events) {
14602 $msg = ($event.Message -replace '\s+', ' ')
14603 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14604 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14605 }
14606} else {
14607 "No recent Outlook crash or error events detected in Application log"
14608}
14609"#;
14610 match run_powershell(ps_events) {
14611 Ok(o) if !o.trim().is_empty() => {
14612 for line in o.lines().take(max_entries + 4) {
14613 let l = line.trim();
14614 if !l.is_empty() {
14615 let _ = writeln!(out, "- {l}");
14616 }
14617 }
14618 }
14619 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14620 }
14621
14622 let mut findings: Vec<String> = Vec::with_capacity(4);
14623
14624 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14625 findings.push(
14626 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14627 .into(),
14628 );
14629 }
14630
14631 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14632 let ws_mb = line
14633 .split("WorkingSetMB: ")
14634 .nth(1)
14635 .and_then(|r| r.split(" |").next())
14636 .and_then(|v| v.trim().parse::<f64>().ok())
14637 .unwrap_or(0.0);
14638 if ws_mb >= 1500.0 {
14639 findings.push(format!(
14640 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14641 ));
14642 }
14643 }
14644
14645 let large_ost: Vec<String> = out
14646 .lines()
14647 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14648 .filter_map(|l| {
14649 let mb = l
14650 .split("SizeMB: ")
14651 .nth(1)
14652 .and_then(|r| r.split(" |").next())
14653 .and_then(|v| v.trim().parse::<f64>().ok())
14654 .unwrap_or(0.0);
14655 if mb >= 10_000.0 {
14656 Some(format!("{mb:.0} MB OST file detected"))
14657 } else {
14658 None
14659 }
14660 })
14661 .collect();
14662 for msg in large_ost {
14663 findings.push(format!(
14664 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14665 ));
14666 }
14667
14668 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14669 let active_count = line
14670 .split("Active: ")
14671 .nth(1)
14672 .and_then(|r| r.split(" |").next())
14673 .and_then(|v| v.trim().parse::<usize>().ok())
14674 .unwrap_or(0);
14675 if active_count >= 8 {
14676 findings.push(format!(
14677 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14678 ));
14679 }
14680 }
14681
14682 if out.contains("ResiliencyDisabledItems:") {
14683 findings.push(
14684 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14685 .into(),
14686 );
14687 }
14688
14689 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14690 findings.push(
14691 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14692 .into(),
14693 );
14694 }
14695
14696 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14697 findings.push(
14698 "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)."
14699 .into(),
14700 );
14701 }
14702
14703 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14704 if findings.is_empty() {
14705 result.push_str("- No obvious Outlook health blocker detected.\n");
14706 } else {
14707 for finding in &findings {
14708 let _ = writeln!(result, "- Finding: {finding}");
14709 }
14710 }
14711 result.push('\n');
14712 result.push_str(&out);
14713 Ok(result)
14714}
14715
14716#[cfg(not(windows))]
14717fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14718 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14719}
14720
14721#[cfg(windows)]
14722fn inspect_teams(max_entries: usize) -> Result<String, String> {
14723 let mut out = String::from("=== Teams install inventory ===\n");
14724
14725 let ps_install = r#"
14726# Classic Teams (Teams 1.0)
14727$classicExe = @(
14728 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14729 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14730) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14731
14732if ($classicExe) {
14733 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14734 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14735} else {
14736 "ClassicTeams: Not installed"
14737}
14738
14739# New Teams (Teams 2.0 / ms-teams.exe)
14740$newTeamsExe = @(
14741 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14742 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14743) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14744
14745$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14746if ($newTeamsPkg) {
14747 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14748} elseif ($newTeamsExe) {
14749 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14750 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14751} else {
14752 "NewTeams: Not installed"
14753}
14754
14755# Teams Machine-Wide Installer (MSI/per-machine)
14756$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14757 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14758 Select-Object -First 1
14759if ($mwi) {
14760 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14761} else {
14762 "MachineWideInstaller: Not found"
14763}
14764"#;
14765 match run_powershell(ps_install) {
14766 Ok(o) if !o.trim().is_empty() => {
14767 for line in o.lines().take(max_entries + 4) {
14768 let l = line.trim();
14769 if !l.is_empty() {
14770 let _ = writeln!(out, "- {l}");
14771 }
14772 }
14773 }
14774 _ => out.push_str("- Could not inspect Teams install paths\n"),
14775 }
14776
14777 out.push_str("\n=== Runtime state ===\n");
14778 let ps_runtime = r#"
14779$targets = @('Teams','ms-teams')
14780foreach ($name in $targets) {
14781 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14782 if ($procs) {
14783 $count = @($procs).Count
14784 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14785 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14786 } else {
14787 "$name | Running: No"
14788 }
14789}
14790"#;
14791 match run_powershell(ps_runtime) {
14792 Ok(o) if !o.trim().is_empty() => {
14793 for line in o.lines().take(6) {
14794 let l = line.trim();
14795 if !l.is_empty() {
14796 let _ = writeln!(out, "- {l}");
14797 }
14798 }
14799 }
14800 _ => out.push_str("- Could not inspect Teams runtime state\n"),
14801 }
14802
14803 out.push_str("\n=== Cache directory sizing ===\n");
14804 let ps_cache = r#"
14805$cachePaths = @(
14806 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14807 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14808 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14809 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14810)
14811foreach ($entry in $cachePaths) {
14812 if (Test-Path $entry.Path) {
14813 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14814 if (-not $sizeBytes) { $sizeBytes = 0 }
14815 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14816 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14817 } else {
14818 "$($entry.Name) | Path: $($entry.Path) | Not found"
14819 }
14820}
14821"#;
14822 match run_powershell(ps_cache) {
14823 Ok(o) if !o.trim().is_empty() => {
14824 for line in o.lines().take(max_entries + 4) {
14825 let l = line.trim();
14826 if !l.is_empty() {
14827 let _ = writeln!(out, "- {l}");
14828 }
14829 }
14830 }
14831 _ => out.push_str("- Could not inspect Teams cache directories\n"),
14832 }
14833
14834 out.push_str("\n=== WebView2 runtime ===\n");
14835 let ps_webview = r#"
14836$paths = @(
14837 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14838 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14839) | Where-Object { $_ -and (Test-Path $_) }
14840$runtimeDir = $paths | ForEach-Object {
14841 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14842 Where-Object { $_.Name -match '^\d+\.' } |
14843 Sort-Object Name -Descending |
14844 Select-Object -First 1
14845} | Select-Object -First 1
14846if ($runtimeDir) {
14847 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14848 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14849 "Installed: Yes | Version: $version"
14850} else {
14851 "Installed: No -- New Teams and some Office features require WebView2"
14852}
14853"#;
14854 match run_powershell(ps_webview) {
14855 Ok(o) if !o.trim().is_empty() => {
14856 for line in o.lines().take(4) {
14857 let l = line.trim();
14858 if !l.is_empty() {
14859 let _ = writeln!(out, "- {l}");
14860 }
14861 }
14862 }
14863 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14864 }
14865
14866 out.push_str("\n=== Account and sign-in state ===\n");
14867 let ps_auth = r#"
14868# Classic Teams account registry
14869$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14870if (Test-Path $classicAcct) {
14871 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14872 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14873 "ClassicTeamsAccount: $email"
14874} else {
14875 "ClassicTeamsAccount: Not configured"
14876}
14877# WAM / token broker state for Teams
14878$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14879$tokenCount = if (Test-Path $tokenCache) {
14880 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14881} else { 0 }
14882"TokenBrokerCacheFiles: $tokenCount"
14883# Office identity
14884$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14885if (Test-Path $officeId) {
14886 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14887 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14888 "OfficeSignedInUserId: $signedIn"
14889}
14890# Check if Teams is in startup
14891$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14892$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14893"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14894"#;
14895 match run_powershell(ps_auth) {
14896 Ok(o) if !o.trim().is_empty() => {
14897 for line in o.lines().take(max_entries + 4) {
14898 let l = line.trim();
14899 if !l.is_empty() {
14900 let _ = writeln!(out, "- {l}");
14901 }
14902 }
14903 }
14904 _ => out.push_str("- Could not inspect Teams account state\n"),
14905 }
14906
14907 out.push_str("\n=== Audio and video device binding ===\n");
14908 let ps_devices = r#"
14909# Teams stores device prefs in the settings file
14910$settingsPaths = @(
14911 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14912 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14913)
14914$found = $false
14915foreach ($sp in $settingsPaths) {
14916 if (Test-Path $sp) {
14917 $found = $true
14918 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14919 if ($raw) {
14920 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14921 if ($json) {
14922 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14923 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14924 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14925 "ConfigFile: $sp"
14926 "Microphone: $mic"
14927 "Speaker: $spk"
14928 "Camera: $cam"
14929 } else {
14930 "ConfigFile: $sp (not parseable as JSON)"
14931 }
14932 } else {
14933 "ConfigFile: $sp (empty)"
14934 }
14935 break
14936 }
14937}
14938if (-not $found) {
14939 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14940}
14941"#;
14942 match run_powershell(ps_devices) {
14943 Ok(o) if !o.trim().is_empty() => {
14944 for line in o.lines().take(max_entries + 4) {
14945 let l = line.trim();
14946 if !l.is_empty() {
14947 let _ = writeln!(out, "- {l}");
14948 }
14949 }
14950 }
14951 _ => out.push_str("- Could not inspect Teams device binding\n"),
14952 }
14953
14954 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14955 let ps_events = r#"
14956$cutoff = (Get-Date).AddDays(-7)
14957$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14958 Where-Object {
14959 $msg = [string]$_.Message
14960 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14961 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14962 } |
14963 Select-Object -First 8
14964if ($events) {
14965 foreach ($event in $events) {
14966 $msg = ($event.Message -replace '\s+', ' ')
14967 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14968 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14969 }
14970} else {
14971 "No recent Teams crash or error events detected in Application log"
14972}
14973"#;
14974 match run_powershell(ps_events) {
14975 Ok(o) if !o.trim().is_empty() => {
14976 for line in o.lines().take(max_entries + 4) {
14977 let l = line.trim();
14978 if !l.is_empty() {
14979 let _ = writeln!(out, "- {l}");
14980 }
14981 }
14982 }
14983 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14984 }
14985
14986 let mut findings: Vec<String> = Vec::with_capacity(4);
14987
14988 let classic_installed = out.contains("- ClassicTeams: Installed");
14989 let new_installed = out.contains("- NewTeams: Installed");
14990 if !classic_installed && !new_installed {
14991 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14992 }
14993
14994 for name in ["Teams", "ms-teams"] {
14995 let marker = format!("{name} | Running: Yes | Processes:");
14996 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14997 let ws_mb = line
14998 .split("WorkingSetMB: ")
14999 .nth(1)
15000 .and_then(|v| v.trim().parse::<f64>().ok())
15001 .unwrap_or(0.0);
15002 if ws_mb >= 1000.0 {
15003 findings.push(format!(
15004 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
15005 ));
15006 }
15007 }
15008 }
15009
15010 for (label, threshold_mb) in [
15011 ("ClassicTeamsCache", 500.0_f64),
15012 ("ClassicTeamsSquirrel", 2000.0),
15013 ("NewTeamsCache", 500.0),
15014 ("NewTeamsAppData", 3000.0),
15015 ] {
15016 let marker = format!("{label} |");
15017 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
15018 let mb = line
15019 .split("SizeMB: ")
15020 .nth(1)
15021 .and_then(|v| v.trim().parse::<f64>().ok())
15022 .unwrap_or(0.0);
15023 if mb >= threshold_mb {
15024 findings.push(format!(
15025 "{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."
15026 ));
15027 }
15028 }
15029 }
15030
15031 if out.contains("- Installed: No -- New Teams") {
15032 findings.push(
15033 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
15034 .into(),
15035 );
15036 }
15037
15038 if out.contains("- ClassicTeamsAccount: Not configured")
15039 && out.contains("- OfficeSignedInUserId: None")
15040 {
15041 findings.push(
15042 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
15043 .into(),
15044 );
15045 }
15046
15047 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
15048 findings.push(
15049 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
15050 .into(),
15051 );
15052 }
15053
15054 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
15055 if findings.is_empty() {
15056 result.push_str("- No obvious Teams health blocker detected.\n");
15057 } else {
15058 for finding in &findings {
15059 let _ = writeln!(result, "- Finding: {finding}");
15060 }
15061 }
15062 result.push('\n');
15063 result.push_str(&out);
15064 Ok(result)
15065}
15066
15067#[cfg(not(windows))]
15068fn inspect_teams(_max_entries: usize) -> Result<String, String> {
15069 Ok(
15070 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
15071 .into(),
15072 )
15073}
15074
15075#[cfg(windows)]
15076fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
15077 let mut out = String::from("=== Identity broker services ===\n");
15078
15079 let ps_services = r#"
15080$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
15081foreach ($name in $serviceNames) {
15082 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
15083 if ($svc) {
15084 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
15085 } else {
15086 "$name | Not found"
15087 }
15088}
15089"#;
15090 match run_powershell(ps_services) {
15091 Ok(o) if !o.trim().is_empty() => {
15092 for line in o.lines().take(max_entries) {
15093 let l = line.trim();
15094 if !l.is_empty() {
15095 let _ = writeln!(out, "- {l}");
15096 }
15097 }
15098 }
15099 _ => out.push_str("- Could not inspect identity broker services\n"),
15100 }
15101
15102 out.push_str("\n=== Device registration ===\n");
15103 let ps_device = r#"
15104$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15105if ($dsreg) {
15106 try {
15107 $raw = & $dsreg.Source /status 2>$null
15108 $text = ($raw -join "`n")
15109 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15110 $seen = $false
15111 foreach ($key in $keys) {
15112 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15113 if ($match.Success) {
15114 "${key}: $($match.Groups[1].Value.Trim())"
15115 $seen = $true
15116 }
15117 }
15118 if (-not $seen) {
15119 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15120 }
15121 } catch {
15122 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15123 }
15124} else {
15125 "DeviceRegistration: dsregcmd unavailable"
15126}
15127"#;
15128 match run_powershell(ps_device) {
15129 Ok(o) if !o.trim().is_empty() => {
15130 for line in o.lines().take(max_entries + 4) {
15131 let l = line.trim();
15132 if !l.is_empty() {
15133 let _ = writeln!(out, "- {l}");
15134 }
15135 }
15136 }
15137 _ => out.push_str(
15138 "- DeviceRegistration: Could not inspect device registration state in this session\n",
15139 ),
15140 }
15141
15142 out.push_str("\n=== Broker packages and caches ===\n");
15143 let ps_broker = r#"
15144$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15145if ($pkg) {
15146 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15147} else {
15148 "AADBrokerPlugin: Not installed"
15149}
15150$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15151$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15152"TokenBrokerCacheFiles: $tokenCount"
15153$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15154$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15155"IdentityCacheFiles: $identityCount"
15156$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15157$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15158"OneAuthFiles: $oneAuthCount"
15159"#;
15160 match run_powershell(ps_broker) {
15161 Ok(o) if !o.trim().is_empty() => {
15162 for line in o.lines().take(max_entries + 4) {
15163 let l = line.trim();
15164 if !l.is_empty() {
15165 let _ = writeln!(out, "- {l}");
15166 }
15167 }
15168 }
15169 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15170 }
15171
15172 out.push_str("\n=== Microsoft app account signals ===\n");
15173 let ps_accounts = r#"
15174function MaskEmail([string]$Email) {
15175 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15176 $parts = $Email.Split('@', 2)
15177 $local = $parts[0]
15178 $domain = $parts[1]
15179 if ($local.Length -le 1) { return "*@$domain" }
15180 return ($local.Substring(0,1) + "***@" + $domain)
15181}
15182$allAccounts = @()
15183$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15184if (Test-Path $officeId) {
15185 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15186 if ($id.SignedInUserId) {
15187 $allAccounts += [string]$id.SignedInUserId
15188 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15189 } else {
15190 "OfficeSignedInUserId: None"
15191 }
15192} else {
15193 "OfficeSignedInUserId: Not configured"
15194}
15195$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15196if (Test-Path $teamsAcct) {
15197 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15198 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15199 if (-not [string]::IsNullOrWhiteSpace($email)) {
15200 $allAccounts += $email
15201 "TeamsAccount: $(MaskEmail $email)"
15202 } else {
15203 "TeamsAccount: Unknown"
15204 }
15205} else {
15206 "TeamsAccount: Not configured"
15207}
15208$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15209$oneDriveEmails = @()
15210if (Test-Path $oneDriveBase) {
15211 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15212 ForEach-Object {
15213 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15214 if ($p.UserEmail) { [string]$p.UserEmail }
15215 } |
15216 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15217 Sort-Object -Unique
15218}
15219$allAccounts += $oneDriveEmails
15220"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15221if (@($oneDriveEmails).Count -gt 0) {
15222 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15223}
15224$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15225"DistinctIdentityCount: $($distinct.Count)"
15226if ($distinct.Count -gt 0) {
15227 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15228}
15229"#;
15230 match run_powershell(ps_accounts) {
15231 Ok(o) if !o.trim().is_empty() => {
15232 for line in o.lines().take(max_entries + 6) {
15233 let l = line.trim();
15234 if !l.is_empty() {
15235 let _ = writeln!(out, "- {l}");
15236 }
15237 }
15238 }
15239 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15240 }
15241
15242 out.push_str("\n=== WebView2 auth dependency ===\n");
15243 let ps_webview = r#"
15244$paths = @(
15245 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15246 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15247) | Where-Object { $_ -and (Test-Path $_) }
15248$runtimeDir = $paths | ForEach-Object {
15249 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15250 Where-Object { $_.Name -match '^\d+\.' } |
15251 Sort-Object Name -Descending |
15252 Select-Object -First 1
15253} | Select-Object -First 1
15254if ($runtimeDir) {
15255 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15256 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15257 "WebView2: Installed | Version: $version"
15258} else {
15259 "WebView2: Not installed"
15260}
15261"#;
15262 match run_powershell(ps_webview) {
15263 Ok(o) if !o.trim().is_empty() => {
15264 for line in o.lines().take(4) {
15265 let l = line.trim();
15266 if !l.is_empty() {
15267 let _ = writeln!(out, "- {l}");
15268 }
15269 }
15270 }
15271 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15272 }
15273
15274 out.push_str("\n=== Recent auth-related events (24h) ===\n");
15275 let ps_events = r#"
15276try {
15277 $cutoff = (Get-Date).AddHours(-24)
15278 $events = @()
15279 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15280 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15281 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15282 Select-Object -First 4
15283 }
15284 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15285 Where-Object {
15286 ($_.LevelDisplayName -in @('Error','Warning')) -and (
15287 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15288 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15289 )
15290 } |
15291 Select-Object -First 6
15292 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15293 "AuthEventCount: $(@($events).Count)"
15294 if ($events) {
15295 foreach ($e in $events) {
15296 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15297 'No message'
15298 } else {
15299 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15300 }
15301 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15302 }
15303 } else {
15304 "No auth-related warning/error events detected"
15305 }
15306} catch {
15307 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15308}
15309"#;
15310 match run_powershell(ps_events) {
15311 Ok(o) if !o.trim().is_empty() => {
15312 for line in o.lines().take(max_entries + 8) {
15313 let l = line.trim();
15314 if !l.is_empty() {
15315 let _ = writeln!(out, "- {l}");
15316 }
15317 }
15318 }
15319 _ => out
15320 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15321 }
15322
15323 let parse_count = |prefix: &str| -> Option<u64> {
15324 out.lines().find_map(|line| {
15325 line.trim()
15326 .strip_prefix(prefix)
15327 .and_then(|value| value.trim().parse::<u64>().ok())
15328 })
15329 };
15330
15331 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15332 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15333
15334 let mut findings: Vec<String> = Vec::with_capacity(4);
15335 if out.contains("TokenBroker | Status: Stopped")
15336 || out.contains("wlidsvc | Status: Stopped")
15337 || out.contains("OneAuth | Status: Stopped")
15338 {
15339 findings.push(
15340 "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."
15341 .into(),
15342 );
15343 }
15344 if out.contains("AADBrokerPlugin: Not installed") {
15345 findings.push(
15346 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15347 .into(),
15348 );
15349 }
15350 if out.contains("WebView2: Not installed") {
15351 findings.push(
15352 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15353 .into(),
15354 );
15355 }
15356 if distinct_identity_count > 1 {
15357 findings.push(format!(
15358 "{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."
15359 ));
15360 }
15361 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15362 && distinct_identity_count > 0
15363 {
15364 findings.push(
15365 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15366 .into(),
15367 );
15368 }
15369 if out.contains("DeviceRegistration: dsregcmd")
15370 || out.contains("DeviceRegistration: Could not inspect device registration state")
15371 {
15372 findings.push(
15373 "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."
15374 .into(),
15375 );
15376 }
15377 if auth_event_count > 0 {
15378 findings.push(format!(
15379 "{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."
15380 ));
15381 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15382 findings.push(
15383 "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."
15384 .into(),
15385 );
15386 }
15387
15388 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15389 if findings.is_empty() {
15390 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15391 } else {
15392 for finding in &findings {
15393 let _ = writeln!(result, "- Finding: {finding}");
15394 }
15395 }
15396 result.push('\n');
15397 result.push_str(&out);
15398 Ok(result)
15399}
15400
15401#[cfg(not(windows))]
15402fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15403 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())
15404}
15405
15406#[cfg(windows)]
15407fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15408 let mut out = String::from("=== File History ===\n");
15409
15410 let ps_fh = r#"
15411$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15412if ($svc) {
15413 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15414} else {
15415 "FileHistoryService: Not found"
15416}
15417# File History config in registry
15418$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15419$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15420if (Test-Path $fhUser) {
15421 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15422 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15423 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15424 $lastBackup = if ($fh.ProtectedUpToTime) {
15425 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15426 } else { 'Never' }
15427 "Enabled: $enabled"
15428 "BackupDrive: $target"
15429 "LastBackup: $lastBackup"
15430} else {
15431 "Enabled: Not configured"
15432 "BackupDrive: Not configured"
15433 "LastBackup: Never"
15434}
15435"#;
15436 match run_powershell(ps_fh) {
15437 Ok(o) if !o.trim().is_empty() => {
15438 for line in o.lines().take(6) {
15439 let l = line.trim();
15440 if !l.is_empty() {
15441 let _ = writeln!(out, "- {l}");
15442 }
15443 }
15444 }
15445 _ => out.push_str("- Could not inspect File History state\n"),
15446 }
15447
15448 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15449 let ps_wbadmin = r#"
15450$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15451"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15452# Last backup from wbadmin
15453$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15454if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15455 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15456 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15457 if ($lastDate) { $lastDate.Trim() }
15458 if ($lastTarget) { $lastTarget.Trim() }
15459} else {
15460 "LastWbadminBackup: No backup versions found"
15461}
15462# Task-based backup
15463$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15464foreach ($t in $task) {
15465 "BackupTask: $($t.TaskName) | State: $($t.State)"
15466}
15467"#;
15468 match run_powershell(ps_wbadmin) {
15469 Ok(o) if !o.trim().is_empty() => {
15470 for line in o.lines().take(8) {
15471 let l = line.trim();
15472 if !l.is_empty() {
15473 let _ = writeln!(out, "- {l}");
15474 }
15475 }
15476 }
15477 _ => out.push_str("- Could not inspect Windows Backup state\n"),
15478 }
15479
15480 out.push_str("\n=== System Restore ===\n");
15481 let ps_sr = r#"
15482$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15483 Select-Object -ExpandProperty DeviceID
15484foreach ($drive in $drives) {
15485 $protection = try {
15486 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15487 } catch { $null }
15488 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15489 $rpConf = try {
15490 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15491 } catch { $null }
15492 # Check if SR is disabled for this drive
15493 $disabled = $false
15494 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15495 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15496}
15497# Most recent restore point
15498$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15499if ($points) {
15500 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15501 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15502 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15503} else {
15504 "MostRecentRestorePoint: None found"
15505}
15506$srEnabled = try {
15507 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15508 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15509} catch { 'Unknown' }
15510"SystemRestoreState: $srEnabled"
15511"#;
15512 match run_powershell(ps_sr) {
15513 Ok(o) if !o.trim().is_empty() => {
15514 for line in o.lines().take(8) {
15515 let l = line.trim();
15516 if !l.is_empty() {
15517 let _ = writeln!(out, "- {l}");
15518 }
15519 }
15520 }
15521 _ => out.push_str("- Could not inspect System Restore state\n"),
15522 }
15523
15524 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15525 let ps_kfm = r#"
15526$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15527if (Test-Path $kfmKey) {
15528 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15529 foreach ($acct in $accounts | Select-Object -First 3) {
15530 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15531 $email = $props.UserEmail
15532 $kfmDesktop = $props.'KFMSilentOptInDesktop'
15533 $kfmDocs = $props.'KFMSilentOptInDocuments'
15534 $kfmPics = $props.'KFMSilentOptInPictures'
15535 "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' })"
15536 }
15537} else {
15538 "OneDriveKFM: No OneDrive accounts found"
15539}
15540"#;
15541 match run_powershell(ps_kfm) {
15542 Ok(o) if !o.trim().is_empty() => {
15543 for line in o.lines().take(6) {
15544 let l = line.trim();
15545 if !l.is_empty() {
15546 let _ = writeln!(out, "- {l}");
15547 }
15548 }
15549 }
15550 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15551 }
15552
15553 out.push_str("\n=== Recent backup failure events (7d) ===\n");
15554 let ps_events = r#"
15555$cutoff = (Get-Date).AddDays(-7)
15556$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15557 Where-Object {
15558 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15559 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15560 } |
15561 Where-Object { $_.Level -le 3 } |
15562 Select-Object -First 6
15563if ($events) {
15564 foreach ($event in $events) {
15565 $msg = ($event.Message -replace '\s+', ' ')
15566 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15567 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15568 }
15569} else {
15570 "No recent backup failure events detected"
15571}
15572"#;
15573 match run_powershell(ps_events) {
15574 Ok(o) if !o.trim().is_empty() => {
15575 for line in o.lines().take(8) {
15576 let l = line.trim();
15577 if !l.is_empty() {
15578 let _ = writeln!(out, "- {l}");
15579 }
15580 }
15581 }
15582 _ => out.push_str("- Could not inspect backup failure events\n"),
15583 }
15584
15585 let mut findings: Vec<String> = Vec::with_capacity(4);
15586
15587 let fh_enabled = out.contains("- Enabled: Enabled");
15588 let fh_never =
15589 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15590 let no_wbadmin = out.contains("No backup versions found");
15591 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15592
15593 if !fh_enabled && no_wbadmin {
15594 findings.push(
15595 "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(),
15596 );
15597 } else if fh_enabled && fh_never {
15598 findings.push(
15599 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15600 );
15601 }
15602
15603 if no_restore_point {
15604 findings.push(
15605 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15606 );
15607 }
15608
15609 if out.contains("- FileHistoryService: Stopped")
15610 || out.contains("- FileHistoryService: Not found")
15611 {
15612 findings.push(
15613 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15614 );
15615 }
15616
15617 if out.contains("Application Error |")
15618 || out.contains("Microsoft-Windows-Backup |")
15619 || out.contains("wbengine |")
15620 {
15621 findings.push(
15622 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15623 );
15624 }
15625
15626 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15627 if findings.is_empty() {
15628 result.push_str("- No obvious backup health blocker detected.\n");
15629 } else {
15630 for finding in &findings {
15631 let _ = writeln!(result, "- Finding: {finding}");
15632 }
15633 }
15634 result.push('\n');
15635 result.push_str(&out);
15636 Ok(result)
15637}
15638
15639#[cfg(not(windows))]
15640fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15641 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15642}
15643
15644#[cfg(windows)]
15645fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15646 let mut out = String::from("=== Windows Search service ===\n");
15647
15648 let ps_svc = r#"
15650$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15651if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15652else { "WSearch service not found" }
15653"#;
15654 match run_powershell(ps_svc) {
15655 Ok(o) => {
15656 let _ = writeln!(out, "- {}", o.trim());
15657 }
15658 Err(_) => out.push_str("- Could not query WSearch service\n"),
15659 }
15660
15661 out.push_str("\n=== Indexer state ===\n");
15663 let ps_idx = r#"
15664$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15665$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15666if ($props) {
15667 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15668 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15669 "DataDirectory: $($props.DataDirectory)"
15670} else { "Registry key not found" }
15671"#;
15672 match run_powershell(ps_idx) {
15673 Ok(o) => {
15674 for line in o.lines() {
15675 let l = line.trim();
15676 if !l.is_empty() {
15677 let _ = writeln!(out, "- {l}");
15678 }
15679 }
15680 }
15681 Err(_) => out.push_str("- Could not read indexer registry\n"),
15682 }
15683
15684 out.push_str("\n=== Indexed locations ===\n");
15686 let ps_locs = r#"
15687$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15688if ($comObj) {
15689 $catalog = $comObj.GetCatalog('SystemIndex')
15690 $manager = $catalog.GetCrawlScopeManager()
15691 $rules = $manager.EnumerateRoots()
15692 while ($true) {
15693 try {
15694 $root = $rules.Next(1)
15695 if ($root.Count -eq 0) { break }
15696 $r = $root[0]
15697 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15698 } catch { break }
15699 }
15700} else { " COM admin interface not available (normal on non-admin sessions)" }
15701"#;
15702 match run_powershell(ps_locs) {
15703 Ok(o) if !o.trim().is_empty() => {
15704 for line in o.lines() {
15705 let l = line.trim_end();
15706 if !l.is_empty() {
15707 let _ = writeln!(out, "{l}");
15708 }
15709 }
15710 }
15711 _ => {
15712 let ps_reg = r#"
15714Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15715ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
15716"#;
15717 match run_powershell(ps_reg) {
15718 Ok(o) if !o.trim().is_empty() => {
15719 for line in o.lines() {
15720 let l = line.trim_end();
15721 if !l.is_empty() {
15722 let _ = writeln!(out, "{l}");
15723 }
15724 }
15725 }
15726 _ => out.push_str(" - Could not enumerate indexed locations\n"),
15727 }
15728 }
15729 }
15730
15731 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15733 let ps_evts = r#"
15734Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15735Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15736ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15737"#;
15738 match run_powershell(ps_evts) {
15739 Ok(o) if !o.trim().is_empty() => {
15740 for line in o.lines() {
15741 let l = line.trim();
15742 if !l.is_empty() {
15743 let _ = writeln!(out, "- {l}");
15744 }
15745 }
15746 }
15747 _ => out.push_str("- No recent indexer errors found\n"),
15748 }
15749
15750 let mut findings: Vec<String> = Vec::with_capacity(4);
15751 if out.contains("Status: Stopped") {
15752 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15753 }
15754 if out.contains("IsContentIndexingEnabled: 0")
15755 || out.contains("IsContentIndexingEnabled: False")
15756 {
15757 findings.push(
15758 "Content indexing is disabled — file content won't be searchable, only filenames."
15759 .into(),
15760 );
15761 }
15762 if out.contains("SetupCompletedSuccessfully: 0")
15763 || out.contains("SetupCompletedSuccessfully: False")
15764 {
15765 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15766 }
15767
15768 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15769 if findings.is_empty() {
15770 result.push_str("- Windows Search service and indexer appear healthy.\n");
15771 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15772 } else {
15773 for f in &findings {
15774 let _ = writeln!(result, "- Finding: {f}");
15775 }
15776 }
15777 result.push('\n');
15778 result.push_str(&out);
15779 Ok(result)
15780}
15781
15782#[cfg(not(windows))]
15783fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15784 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15785}
15786
15787#[cfg(windows)]
15790fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15791 let mut out = String::with_capacity(1024);
15792
15793 out.push_str("=== Active displays ===\n");
15795 let ps_displays = r#"
15796Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15797Select-Object -First 20 |
15798ForEach-Object {
15799 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15800}
15801"#;
15802 match run_powershell(ps_displays) {
15803 Ok(o) if !o.trim().is_empty() => {
15804 for line in o.lines().take(max_entries) {
15805 let l = line.trim();
15806 if !l.is_empty() {
15807 let _ = writeln!(out, "- {l}");
15808 }
15809 }
15810 }
15811 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15812 }
15813
15814 out.push_str("\n=== Video adapters ===\n");
15816 let ps_gpu = r#"
15817Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15818ForEach-Object {
15819 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15820 $hz = "$($_.CurrentRefreshRate) Hz"
15821 $bits = "$($_.CurrentBitsPerPixel) bpp"
15822 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15823}
15824"#;
15825 match run_powershell(ps_gpu) {
15826 Ok(o) if !o.trim().is_empty() => {
15827 for line in o.lines().take(max_entries) {
15828 let l = line.trim();
15829 if !l.is_empty() {
15830 let _ = writeln!(out, "- {l}");
15831 }
15832 }
15833 }
15834 _ => out.push_str("- Could not query video adapter info\n"),
15835 }
15836
15837 out.push_str("\n=== Connected monitors ===\n");
15839 let ps_monitors = r#"
15840Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15841ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15842"#;
15843 match run_powershell(ps_monitors) {
15844 Ok(o) if !o.trim().is_empty() => {
15845 for line in o.lines().take(max_entries) {
15846 let l = line.trim();
15847 if !l.is_empty() {
15848 let _ = writeln!(out, "- {l}");
15849 }
15850 }
15851 }
15852 _ => out.push_str("- No monitor info available via WMI\n"),
15853 }
15854
15855 out.push_str("\n=== DPI / scaling ===\n");
15857 let ps_dpi = r#"
15858Add-Type -TypeDefinition @'
15859using System; using System.Runtime.InteropServices;
15860public class DPI {
15861 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15862 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15863 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15864}
15865'@ -ErrorAction SilentlyContinue
15866try {
15867 $hdc = [DPI]::GetDC([IntPtr]::Zero)
15868 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15869 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15870 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15871 $scale = [Math]::Round($dpiX / 96.0 * 100)
15872 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15873} catch { "DPI query unavailable" }
15874"#;
15875 match run_powershell(ps_dpi) {
15876 Ok(o) if !o.trim().is_empty() => {
15877 let _ = writeln!(out, "- {}", o.trim());
15878 }
15879 _ => out.push_str("- DPI info unavailable\n"),
15880 }
15881
15882 let mut findings: Vec<String> = Vec::with_capacity(4);
15883 if out.contains("0x0") || out.contains("@ 0 Hz") {
15884 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15885 }
15886
15887 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15888 if findings.is_empty() {
15889 result.push_str("- Display configuration appears normal.\n");
15890 } else {
15891 for f in &findings {
15892 let _ = writeln!(result, "- Finding: {f}");
15893 }
15894 }
15895 result.push('\n');
15896 result.push_str(&out);
15897 Ok(result)
15898}
15899
15900#[cfg(not(windows))]
15901fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15902 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15903}
15904
15905#[cfg(windows)]
15908fn inspect_ntp() -> Result<String, String> {
15909 let mut out = String::with_capacity(1024);
15910
15911 out.push_str("=== Windows Time service ===\n");
15913 let ps_svc = r#"
15914$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15915if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15916else { "W32Time service not found" }
15917"#;
15918 match run_powershell(ps_svc) {
15919 Ok(o) => {
15920 let _ = writeln!(out, "- {}", o.trim());
15921 }
15922 Err(_) => out.push_str("- Could not query W32Time service\n"),
15923 }
15924
15925 out.push_str("\n=== NTP source and sync status ===\n");
15927 let ps_sync = r#"
15928$q = w32tm /query /status 2>$null
15929if ($q) { $q } else { "w32tm query unavailable" }
15930"#;
15931 match run_powershell(ps_sync) {
15932 Ok(o) if !o.trim().is_empty() => {
15933 for line in o.lines() {
15934 let l = line.trim();
15935 if !l.is_empty() {
15936 let _ = writeln!(out, " {l}");
15937 }
15938 }
15939 }
15940 _ => out.push_str(" - Could not query w32tm status\n"),
15941 }
15942
15943 out.push_str("\n=== Configured NTP servers ===\n");
15945 let ps_peers = r#"
15946w32tm /query /peers 2>$null | Select-Object -First 10
15947"#;
15948 match run_powershell(ps_peers) {
15949 Ok(o) if !o.trim().is_empty() => {
15950 for line in o.lines() {
15951 let l = line.trim();
15952 if !l.is_empty() {
15953 let _ = writeln!(out, " {l}");
15954 }
15955 }
15956 }
15957 _ => {
15958 let ps_reg = r#"
15960(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15961"#;
15962 match run_powershell(ps_reg) {
15963 Ok(o) if !o.trim().is_empty() => {
15964 let _ = writeln!(out, " NtpServer (registry): {}", o.trim());
15965 }
15966 _ => out.push_str(" - Could not enumerate NTP peers\n"),
15967 }
15968 }
15969 }
15970
15971 let mut findings: Vec<String> = Vec::with_capacity(4);
15972 if out.contains("W32Time | Status: Stopped") {
15973 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15974 }
15975 if out.contains("The computer did not resync") || out.contains("Error") {
15976 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15977 }
15978
15979 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15980 if findings.is_empty() {
15981 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15982 } else {
15983 for f in &findings {
15984 let _ = writeln!(result, "- Finding: {f}");
15985 }
15986 }
15987 result.push('\n');
15988 result.push_str(&out);
15989 Ok(result)
15990}
15991
15992#[cfg(not(windows))]
15993fn inspect_ntp() -> Result<String, String> {
15994 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15996
15997 let timedatectl = std::process::Command::new("timedatectl")
15998 .arg("status")
15999 .output();
16000
16001 if let Ok(o) = timedatectl {
16002 let text = String::from_utf8_lossy(&o.stdout);
16003 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
16004 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
16005 } else {
16006 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
16007 }
16008 for line in text.lines() {
16009 let l = line.trim();
16010 if !l.is_empty() {
16011 let _ = write!(out, " {l}\n");
16012 }
16013 }
16014 return Ok(out);
16015 }
16016
16017 let sntp = std::process::Command::new("sntp")
16019 .args(["-d", "time.apple.com"])
16020 .output();
16021 if let Ok(o) = sntp {
16022 out.push_str("- NTP check via sntp:\n");
16023 out.push_str(&String::from_utf8_lossy(&o.stdout));
16024 return Ok(out);
16025 }
16026
16027 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
16028 Ok(out)
16029}
16030
16031#[cfg(windows)]
16034fn inspect_cpu_power() -> Result<String, String> {
16035 let mut out = String::with_capacity(1024);
16036
16037 out.push_str("=== Active power plan ===\n");
16039 let ps_plan = r#"
16040$plan = powercfg /getactivescheme 2>$null
16041if ($plan) { $plan } else { "Could not query power scheme" }
16042"#;
16043 match run_powershell(ps_plan) {
16044 Ok(o) if !o.trim().is_empty() => {
16045 let _ = writeln!(out, "- {}", o.trim());
16046 }
16047 _ => out.push_str("- Could not read active power plan\n"),
16048 }
16049
16050 out.push_str("\n=== Processor performance policy ===\n");
16052 let ps_proc = r#"
16053$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
16054$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16055$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16056$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16057if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
16058if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
16059if ($boost) {
16060 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
16061 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
16062 "Turbo boost mode: $bname"
16063}
16064"#;
16065 match run_powershell(ps_proc) {
16066 Ok(o) if !o.trim().is_empty() => {
16067 for line in o.lines() {
16068 let l = line.trim();
16069 if !l.is_empty() {
16070 let _ = writeln!(out, "- {l}");
16071 }
16072 }
16073 }
16074 _ => out.push_str("- Could not query processor performance settings\n"),
16075 }
16076
16077 out.push_str("\n=== CPU frequency ===\n");
16079 let ps_freq = r#"
16080Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
16081ForEach-Object {
16082 $cur = $_.CurrentClockSpeed
16083 $max = $_.MaxClockSpeed
16084 $load = $_.LoadPercentage
16085 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
16086}
16087"#;
16088 match run_powershell(ps_freq) {
16089 Ok(o) if !o.trim().is_empty() => {
16090 for line in o.lines() {
16091 let l = line.trim();
16092 if !l.is_empty() {
16093 let _ = writeln!(out, "- {l}");
16094 }
16095 }
16096 }
16097 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
16098 }
16099
16100 out.push_str("\n=== Throttling indicators ===\n");
16102 let ps_throttle = r#"
16103$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
16104if ($pwr) {
16105 $pwr | Select-Object -First 4 | ForEach-Object {
16106 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
16107 "Thermal zone $($_.InstanceName): ${c}°C"
16108 }
16109} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16110"#;
16111 match run_powershell(ps_throttle) {
16112 Ok(o) if !o.trim().is_empty() => {
16113 for line in o.lines() {
16114 let l = line.trim();
16115 if !l.is_empty() {
16116 let _ = writeln!(out, "- {l}");
16117 }
16118 }
16119 }
16120 _ => out.push_str("- Thermal zone info unavailable\n"),
16121 }
16122
16123 let mut findings: Vec<String> = Vec::with_capacity(4);
16124 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
16125 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16126 }
16127 if out.contains("Turbo boost mode: Disabled") {
16128 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16129 }
16130 if out.contains("Min processor state: 100%") {
16131 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16132 }
16133
16134 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16135 if findings.is_empty() {
16136 result.push_str("- CPU power and frequency settings appear normal.\n");
16137 } else {
16138 for f in &findings {
16139 let _ = writeln!(result, "- Finding: {f}");
16140 }
16141 }
16142 result.push('\n');
16143 result.push_str(&out);
16144 Ok(result)
16145}
16146
16147#[cfg(windows)]
16148fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16149 let mut out = String::with_capacity(1024);
16150
16151 out.push_str("=== Credential vault summary ===\n");
16152 let ps_summary = r#"
16153$raw = cmdkey /list 2>&1
16154$lines = $raw -split "`n"
16155$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16156"Total stored credentials: $total"
16157$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16158$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16159$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16160" Windows credentials: $windows"
16161" Generic credentials: $generic"
16162" Certificate-based: $cert"
16163"#;
16164 match run_powershell(ps_summary) {
16165 Ok(o) => {
16166 for line in o.lines() {
16167 let l = line.trim();
16168 if !l.is_empty() {
16169 let _ = writeln!(out, "- {l}");
16170 }
16171 }
16172 }
16173 Err(e) => {
16174 let _ = writeln!(out, "- Credential summary error: {e}");
16175 }
16176 }
16177
16178 out.push_str("\n=== Credential targets (up to 20) ===\n");
16179 let ps_list = r#"
16180$raw = cmdkey /list 2>&1
16181$entries = @(); $cur = @{}
16182foreach ($line in ($raw -split "`n")) {
16183 $l = $line.Trim()
16184 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
16185 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
16186 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16187}
16188$entries | Select-Object -Last 20 | ForEach-Object {
16189 "[$($_.Type)] $($_.Target) (user: $($_.User))"
16190}
16191"#;
16192 match run_powershell(ps_list) {
16193 Ok(o) => {
16194 let lines: Vec<&str> = o
16195 .lines()
16196 .map(|l| l.trim())
16197 .filter(|l| !l.is_empty())
16198 .collect();
16199 if lines.is_empty() {
16200 out.push_str("- No credential entries found\n");
16201 } else {
16202 for l in &lines {
16203 let _ = writeln!(out, "- {l}");
16204 }
16205 }
16206 }
16207 Err(e) => {
16208 let _ = writeln!(out, "- Credential list error: {e}");
16209 }
16210 }
16211
16212 let total_creds: usize = {
16213 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16214 run_powershell(ps_count)
16215 .ok()
16216 .and_then(|s| s.trim().parse().ok())
16217 .unwrap_or(0)
16218 };
16219
16220 let mut findings: Vec<String> = Vec::with_capacity(4);
16221 if total_creds > 30 {
16222 findings.push(format!(
16223 "{total_creds} stored credentials found — consider auditing for stale entries."
16224 ));
16225 }
16226
16227 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16228 if findings.is_empty() {
16229 result.push_str("- Credential store looks normal.\n");
16230 } else {
16231 for f in &findings {
16232 let _ = writeln!(result, "- Finding: {f}");
16233 }
16234 }
16235 result.push('\n');
16236 result.push_str(&out);
16237 Ok(result)
16238}
16239
16240#[cfg(not(windows))]
16241fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16242 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16243}
16244
16245#[cfg(windows)]
16246fn inspect_tpm() -> Result<String, String> {
16247 let mut out = String::with_capacity(1024);
16248
16249 out.push_str("=== TPM state ===\n");
16250 let ps_tpm = r#"
16251function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16252 $text = if ($null -eq $Value) { "" } else { [string]$Value }
16253 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16254 "$Name$text"
16255}
16256$t = Get-Tpm -ErrorAction SilentlyContinue
16257if ($t) {
16258 Emit-Field "TpmPresent: " $t.TpmPresent
16259 Emit-Field "TpmReady: " $t.TpmReady
16260 Emit-Field "TpmEnabled: " $t.TpmEnabled
16261 Emit-Field "TpmOwned: " $t.TpmOwned
16262 Emit-Field "RestartPending: " $t.RestartPending
16263 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
16264 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16265} else { "TPM module unavailable" }
16266"#;
16267 match run_powershell(ps_tpm) {
16268 Ok(o) => {
16269 for line in o.lines() {
16270 let l = line.trim();
16271 if !l.is_empty() {
16272 let _ = writeln!(out, "- {l}");
16273 }
16274 }
16275 }
16276 Err(e) => {
16277 let _ = writeln!(out, "- Get-Tpm error: {e}");
16278 }
16279 }
16280
16281 out.push_str("\n=== TPM spec version (WMI) ===\n");
16282 let ps_spec = r#"
16283$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16284if ($wmi) {
16285 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16286 "SpecVersion: $spec"
16287 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16288 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16289 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16290} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16291"#;
16292 match run_powershell(ps_spec) {
16293 Ok(o) => {
16294 for line in o.lines() {
16295 let l = line.trim();
16296 if !l.is_empty() {
16297 let _ = writeln!(out, "- {l}");
16298 }
16299 }
16300 }
16301 Err(e) => {
16302 let _ = writeln!(out, "- Win32_Tpm WMI error: {e}");
16303 }
16304 }
16305
16306 out.push_str("\n=== Secure Boot state ===\n");
16307 let ps_sb = r#"
16308try {
16309 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16310 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16311} catch {
16312 $msg = $_.Exception.Message
16313 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16314 "Secure Boot: Unknown (administrator privileges required)"
16315 } elseif ($msg -match "Cmdlet not supported on this platform") {
16316 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16317 } else {
16318 "Secure Boot: N/A ($msg)"
16319 }
16320}
16321"#;
16322 match run_powershell(ps_sb) {
16323 Ok(o) => {
16324 for line in o.lines() {
16325 let l = line.trim();
16326 if !l.is_empty() {
16327 let _ = writeln!(out, "- {l}");
16328 }
16329 }
16330 }
16331 Err(e) => {
16332 let _ = writeln!(out, "- Secure Boot check error: {e}");
16333 }
16334 }
16335
16336 out.push_str("\n=== Firmware type ===\n");
16337 let ps_fw = r#"
16338$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16339switch ($fw) {
16340 1 { "Firmware type: BIOS (Legacy)" }
16341 2 { "Firmware type: UEFI" }
16342 default {
16343 $bcd = bcdedit /enum firmware 2>$null
16344 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16345 else { "Firmware type: Unknown or not set" }
16346 }
16347}
16348"#;
16349 match run_powershell(ps_fw) {
16350 Ok(o) => {
16351 for line in o.lines() {
16352 let l = line.trim();
16353 if !l.is_empty() {
16354 let _ = writeln!(out, "- {l}");
16355 }
16356 }
16357 }
16358 Err(e) => {
16359 let _ = writeln!(out, "- Firmware type error: {e}");
16360 }
16361 }
16362
16363 let mut findings: Vec<String> = Vec::with_capacity(4);
16364 let mut indeterminate = false;
16365 if out.contains("TpmPresent: False") {
16366 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16367 }
16368 if out.contains("TpmReady: False") {
16369 findings.push(
16370 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16371 );
16372 }
16373 if out.contains("SpecVersion: 1.2") {
16374 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16375 }
16376 if out.contains("Secure Boot: DISABLED") {
16377 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16378 }
16379 if out.contains("Firmware type: BIOS (Legacy)") {
16380 findings.push(
16381 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16382 );
16383 }
16384
16385 if out.contains("TPM module unavailable")
16386 || out.contains("Win32_Tpm WMI class unavailable")
16387 || out.contains("Secure Boot: N/A")
16388 || out.contains("Secure Boot: Unknown")
16389 || out.contains("Firmware type: Unknown or not set")
16390 || out.contains("TpmPresent: Unknown")
16391 || out.contains("TpmReady: Unknown")
16392 || out.contains("TpmEnabled: Unknown")
16393 {
16394 indeterminate = true;
16395 }
16396 if indeterminate {
16397 findings.push(
16398 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16399 .into(),
16400 );
16401 }
16402
16403 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16404 if findings.is_empty() {
16405 result.push_str("- TPM and Secure Boot appear healthy.\n");
16406 } else {
16407 for f in &findings {
16408 let _ = writeln!(result, "- Finding: {f}");
16409 }
16410 }
16411 result.push('\n');
16412 result.push_str(&out);
16413 Ok(result)
16414}
16415
16416#[cfg(not(windows))]
16417fn inspect_tpm() -> Result<String, String> {
16418 Ok(
16419 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16420 .into(),
16421 )
16422}
16423
16424#[cfg(windows)]
16425fn inspect_latency() -> Result<String, String> {
16426 let mut out = String::with_capacity(1024);
16427
16428 let ps_gw = r#"
16430$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16431 Sort-Object RouteMetric | Select-Object -First 1).NextHop
16432if ($gw) { $gw } else { "" }
16433"#;
16434 let gateway = run_powershell(ps_gw)
16435 .ok()
16436 .map(|s| s.trim().to_string())
16437 .filter(|s| !s.is_empty());
16438
16439 let targets: Vec<(&str, String)> = {
16440 let mut t = Vec::with_capacity(3);
16441 if let Some(ref gw) = gateway {
16442 t.push(("Default gateway", gw.clone()));
16443 }
16444 t.push(("Cloudflare DNS", "1.1.1.1".into()));
16445 t.push(("Google DNS", "8.8.8.8".into()));
16446 t
16447 };
16448
16449 let mut findings: Vec<String> = Vec::with_capacity(4);
16450
16451 for (label, host) in &targets {
16452 let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16453 let ps_ping = format!(
16455 r#"
16456$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16457if ($r) {{
16458 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16459 $min = ($rtts | Measure-Object -Minimum).Minimum
16460 $max = ($rtts | Measure-Object -Maximum).Maximum
16461 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16462 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16463 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16464 "Packet loss: ${{loss}}%"
16465 "Sent: 4 Received: $($r.Count)"
16466}} else {{
16467 "UNREACHABLE — 100% packet loss"
16468}}
16469"#
16470 );
16471 match run_powershell(&ps_ping) {
16472 Ok(o) => {
16473 let body = o.trim().to_string();
16474 for line in body.lines() {
16475 let l = line.trim();
16476 if !l.is_empty() {
16477 let _ = writeln!(out, "- {l}");
16478 }
16479 }
16480 if body.contains("UNREACHABLE") {
16481 findings.push(format!(
16482 "{label} ({host}) is unreachable — possible routing or firewall issue."
16483 ));
16484 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16485 let pct: u32 = loss_line
16486 .chars()
16487 .filter(|c| c.is_ascii_digit())
16488 .collect::<String>()
16489 .parse()
16490 .unwrap_or(0);
16491 if pct >= 25 {
16492 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16493 }
16494 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16496 if let Some(avg_field) = rtt_line.split('/').nth(1) {
16498 let avg_str: String =
16499 avg_field.chars().filter(|c| c.is_ascii_digit()).collect();
16500 let avg: u32 = avg_str.parse().unwrap_or(0);
16501 if avg > 150 {
16502 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16503 }
16504 }
16505 }
16506 }
16507 }
16508 Err(e) => {
16509 let _ = writeln!(out, "- Ping error: {e}");
16510 }
16511 }
16512 }
16513
16514 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16515 if findings.is_empty() {
16516 result.push_str("- Latency and reachability look normal.\n");
16517 } else {
16518 for f in &findings {
16519 let _ = writeln!(result, "- Finding: {f}");
16520 }
16521 }
16522 result.push('\n');
16523 result.push_str(&out);
16524 Ok(result)
16525}
16526
16527#[cfg(not(windows))]
16528fn inspect_latency() -> Result<String, String> {
16529 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16530 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16531 let mut findings: Vec<String> = Vec::with_capacity(4);
16532
16533 for (label, host) in &targets {
16534 let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16535 let ping = std::process::Command::new("ping")
16536 .args(["-c", "4", "-W", "2", host])
16537 .output();
16538 match ping {
16539 Ok(o) => {
16540 let body = String::from_utf8_lossy(&o.stdout).into_owned();
16541 for line in body.lines() {
16542 let l = line.trim();
16543 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16544 let _ = write!(out, "- {l}\n");
16545 }
16546 }
16547 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16548 findings.push(format!("{label} ({host}) is unreachable."));
16549 }
16550 }
16551 Err(e) => {
16552 let _ = write!(out, "- ping error: {e}\n");
16553 }
16554 }
16555 }
16556
16557 if findings.is_empty() {
16558 out.insert_str(
16559 "Host inspection: latency\n\n=== Findings ===\n".len(),
16560 "- Latency and reachability look normal.\n",
16561 );
16562 } else {
16563 let mut prefix = String::new();
16564 for f in &findings {
16565 let _ = write!(prefix, "- Finding: {f}\n");
16566 }
16567 out.insert_str(
16568 "Host inspection: latency\n\n=== Findings ===\n".len(),
16569 &prefix,
16570 );
16571 }
16572 Ok(out)
16573}
16574
16575#[cfg(windows)]
16576fn inspect_network_adapter() -> Result<String, String> {
16577 let mut out = String::with_capacity(1024);
16578
16579 out.push_str("=== Network adapters ===\n");
16580 let ps_adapters = r#"
16581Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16582 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16583 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16584}
16585"#;
16586 match run_powershell(ps_adapters) {
16587 Ok(o) => {
16588 for line in o.lines() {
16589 let l = line.trim();
16590 if !l.is_empty() {
16591 let _ = writeln!(out, "- {l}");
16592 }
16593 }
16594 }
16595 Err(e) => {
16596 let _ = writeln!(out, "- Adapter query error: {e}");
16597 }
16598 }
16599
16600 out.push_str("\n=== Duplex and negotiated speed ===\n");
16601 let ps_duplex = r#"
16602Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16603 $name = $_.Name
16604 $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16605 Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16606 Select-Object DisplayName, DisplayValue
16607 if ($duplex) {
16608 "--- $name ---"
16609 $duplex | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16610 } else {
16611 "--- $name --- (no duplex/speed property exposed by driver)"
16612 }
16613}
16614"#;
16615 match run_powershell(ps_duplex) {
16616 Ok(o) => {
16617 let lines: Vec<&str> = o
16618 .lines()
16619 .map(|l| l.trim())
16620 .filter(|l| !l.is_empty())
16621 .collect();
16622 for l in &lines {
16623 let _ = writeln!(out, "- {l}");
16624 }
16625 }
16626 Err(e) => {
16627 let _ = writeln!(out, "- Duplex query error: {e}");
16628 }
16629 }
16630
16631 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16632 let ps_offload = r#"
16633Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16634 $name = $_.Name
16635 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16636 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16637 Select-Object DisplayName, DisplayValue
16638 if ($props) {
16639 "--- $name ---"
16640 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
16641 }
16642}
16643"#;
16644 match run_powershell(ps_offload) {
16645 Ok(o) => {
16646 let lines: Vec<&str> = o
16647 .lines()
16648 .map(|l| l.trim())
16649 .filter(|l| !l.is_empty())
16650 .collect();
16651 if lines.is_empty() {
16652 out.push_str(
16653 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16654 );
16655 } else {
16656 for l in &lines {
16657 let _ = writeln!(out, "- {l}");
16658 }
16659 }
16660 }
16661 Err(e) => {
16662 let _ = writeln!(out, "- Offload query error: {e}");
16663 }
16664 }
16665
16666 out.push_str("\n=== Adapter error counters ===\n");
16667 let ps_errors = r#"
16668Get-NetAdapterStatistics | ForEach-Object {
16669 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16670 if ($errs -gt 0) {
16671 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16672 }
16673}
16674"#;
16675 match run_powershell(ps_errors) {
16676 Ok(o) => {
16677 let lines: Vec<&str> = o
16678 .lines()
16679 .map(|l| l.trim())
16680 .filter(|l| !l.is_empty())
16681 .collect();
16682 if lines.is_empty() {
16683 out.push_str("- No adapter errors or discards detected.\n");
16684 } else {
16685 for l in &lines {
16686 let _ = writeln!(out, "- {l}");
16687 }
16688 }
16689 }
16690 Err(e) => {
16691 let _ = writeln!(out, "- Error counter query: {e}");
16692 }
16693 }
16694
16695 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16696 let ps_wol = r#"
16697Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16698 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16699 if ($wol) {
16700 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16701 }
16702}
16703"#;
16704 match run_powershell(ps_wol) {
16705 Ok(o) => {
16706 let lines: Vec<&str> = o
16707 .lines()
16708 .map(|l| l.trim())
16709 .filter(|l| !l.is_empty())
16710 .collect();
16711 if lines.is_empty() {
16712 out.push_str("- Power management data unavailable for active adapters.\n");
16713 } else {
16714 for l in &lines {
16715 let _ = writeln!(out, "- {l}");
16716 }
16717 }
16718 }
16719 Err(e) => {
16720 let _ = writeln!(out, "- WoL query error: {e}");
16721 }
16722 }
16723
16724 let mut findings: Vec<String> = Vec::with_capacity(4);
16725 if out.contains("RX errors:") || out.contains("TX errors:") {
16727 findings
16728 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16729 }
16730 if out.contains("Half") {
16732 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16733 }
16734
16735 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16736 if findings.is_empty() {
16737 result.push_str("- Network adapter configuration looks normal.\n");
16738 } else {
16739 for f in &findings {
16740 let _ = writeln!(result, "- Finding: {f}");
16741 }
16742 }
16743 result.push('\n');
16744 result.push_str(&out);
16745 Ok(result)
16746}
16747
16748#[cfg(not(windows))]
16749fn inspect_network_adapter() -> Result<String, String> {
16750 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16751
16752 out.push_str("=== Network adapters (ip link) ===\n");
16753 let ip_link = std::process::Command::new("ip")
16754 .args(["link", "show"])
16755 .output();
16756 if let Ok(o) = ip_link {
16757 for line in String::from_utf8_lossy(&o.stdout).lines() {
16758 let l = line.trim();
16759 if !l.is_empty() {
16760 let _ = write!(out, "- {l}\n");
16761 }
16762 }
16763 }
16764
16765 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16766 let ip_stats = std::process::Command::new("ip")
16767 .args(["-s", "link", "show"])
16768 .output();
16769 if let Ok(o) = ip_stats {
16770 for line in String::from_utf8_lossy(&o.stdout).lines() {
16771 let l = line.trim();
16772 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16773 {
16774 let _ = write!(out, "- {l}\n");
16775 }
16776 }
16777 }
16778 Ok(out)
16779}
16780
16781#[cfg(windows)]
16782fn inspect_dhcp() -> Result<String, String> {
16783 let mut out = String::with_capacity(1024);
16784
16785 out.push_str("=== DHCP lease details (per adapter) ===\n");
16786 let ps_dhcp = r#"
16787$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16788 Where-Object { $_.IPEnabled -eq $true }
16789foreach ($a in $adapters) {
16790 "--- $($a.Description) ---"
16791 " DHCP Enabled: $($a.DHCPEnabled)"
16792 if ($a.DHCPEnabled) {
16793 " DHCP Server: $($a.DHCPServer)"
16794 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16795 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
16796 " Lease Obtained: $obtained"
16797 " Lease Expires: $expires"
16798 }
16799 " IP Address: $($a.IPAddress -join ', ')"
16800 " Subnet Mask: $($a.IPSubnet -join ', ')"
16801 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
16802 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
16803 " MAC Address: $($a.MACAddress)"
16804 ""
16805}
16806"#;
16807 match run_powershell(ps_dhcp) {
16808 Ok(o) => {
16809 for line in o.lines() {
16810 let l = line.trim_end();
16811 if !l.is_empty() {
16812 let _ = writeln!(out, "{l}");
16813 }
16814 }
16815 }
16816 Err(e) => {
16817 let _ = writeln!(out, "- DHCP query error: {e}");
16818 }
16819 }
16820
16821 let mut findings: Vec<String> = Vec::with_capacity(4);
16823 let ps_expiry = r#"
16824$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16825foreach ($a in $adapters) {
16826 try {
16827 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16828 $now = Get-Date
16829 $hrs = ($exp - $now).TotalHours
16830 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16831 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16832 } catch {}
16833}
16834"#;
16835 if let Ok(o) = run_powershell(ps_expiry) {
16836 for line in o.lines() {
16837 let l = line.trim();
16838 if !l.is_empty() {
16839 if l.contains("EXPIRED") {
16840 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16841 } else if l.contains("expires in") {
16842 findings.push(format!("DHCP lease expiring soon — {l}"));
16843 }
16844 }
16845 }
16846 }
16847
16848 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16849 if findings.is_empty() {
16850 result.push_str("- DHCP leases look healthy.\n");
16851 } else {
16852 for f in &findings {
16853 let _ = writeln!(result, "- Finding: {f}");
16854 }
16855 }
16856 result.push('\n');
16857 result.push_str(&out);
16858 Ok(result)
16859}
16860
16861#[cfg(not(windows))]
16862fn inspect_dhcp() -> Result<String, String> {
16863 let mut out = String::from(
16864 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16865 );
16866 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16867 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16868 if std::path::Path::new(path).exists() {
16869 let cat = std::process::Command::new("cat").arg(path).output();
16870 if let Ok(o) = cat {
16871 let text = String::from_utf8_lossy(&o.stdout);
16872 for line in text.lines().take(40) {
16873 let l = line.trim();
16874 if l.contains("lease")
16875 || l.contains("expire")
16876 || l.contains("server")
16877 || l.contains("address")
16878 {
16879 let _ = write!(out, "- {l}\n");
16880 }
16881 }
16882 }
16883 }
16884 }
16885 let ip = std::process::Command::new("ip")
16887 .args(["addr", "show"])
16888 .output();
16889 if let Ok(o) = ip {
16890 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16891 for line in String::from_utf8_lossy(&o.stdout).lines() {
16892 let l = line.trim();
16893 if l.starts_with("inet") || l.contains("dynamic") {
16894 let _ = write!(out, "- {l}\n");
16895 }
16896 }
16897 }
16898 Ok(out)
16899}
16900
16901#[cfg(windows)]
16902fn inspect_mtu() -> Result<String, String> {
16903 let mut out = String::with_capacity(1024);
16904
16905 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16906 let ps_mtu = r#"
16907Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16908 Sort-Object ConnectionState, InterfaceAlias |
16909 ForEach-Object {
16910 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16911 }
16912"#;
16913 match run_powershell(ps_mtu) {
16914 Ok(o) => {
16915 for line in o.lines() {
16916 let l = line.trim();
16917 if !l.is_empty() {
16918 let _ = writeln!(out, "- {l}");
16919 }
16920 }
16921 }
16922 Err(e) => {
16923 let _ = writeln!(out, "- MTU query error: {e}");
16924 }
16925 }
16926
16927 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16928 let ps_mtu6 = r#"
16929Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16930 Sort-Object ConnectionState, InterfaceAlias |
16931 ForEach-Object {
16932 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16933 }
16934"#;
16935 match run_powershell(ps_mtu6) {
16936 Ok(o) => {
16937 for line in o.lines() {
16938 let l = line.trim();
16939 if !l.is_empty() {
16940 let _ = writeln!(out, "- {l}");
16941 }
16942 }
16943 }
16944 Err(e) => {
16945 let _ = writeln!(out, "- IPv6 MTU query error: {e}");
16946 }
16947 }
16948
16949 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16950 let ps_pmtu = r#"
16952$sizes = @(1472, 1400, 1280, 576)
16953$result = $null
16954foreach ($s in $sizes) {
16955 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16956 if ($r) { $result = $s; break }
16957}
16958if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16959else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16960"#;
16961 match run_powershell(ps_pmtu) {
16962 Ok(o) => {
16963 for line in o.lines() {
16964 let l = line.trim();
16965 if !l.is_empty() {
16966 let _ = writeln!(out, "- {l}");
16967 }
16968 }
16969 }
16970 Err(e) => {
16971 let _ = writeln!(out, "- Path MTU test error: {e}");
16972 }
16973 }
16974
16975 let mut findings: Vec<String> = Vec::with_capacity(4);
16976 if out.contains("MTU: 576 bytes") {
16977 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16978 }
16979 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16980 findings.push(
16981 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16982 .into(),
16983 );
16984 }
16985 if out.contains("All test sizes failed") {
16986 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16987 }
16988
16989 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16990 if findings.is_empty() {
16991 result.push_str("- MTU configuration looks normal.\n");
16992 } else {
16993 for f in &findings {
16994 let _ = writeln!(result, "- Finding: {f}");
16995 }
16996 }
16997 result.push('\n');
16998 result.push_str(&out);
16999 Ok(result)
17000}
17001
17002#[cfg(not(windows))]
17003fn inspect_mtu() -> Result<String, String> {
17004 let mut out = String::from(
17005 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
17006 );
17007
17008 out.push_str("=== Per-interface MTU (ip link) ===\n");
17009 let ip = std::process::Command::new("ip")
17010 .args(["link", "show"])
17011 .output();
17012 if let Ok(o) = ip {
17013 for line in String::from_utf8_lossy(&o.stdout).lines() {
17014 let l = line.trim();
17015 if l.contains("mtu") || l.starts_with("\\d") {
17016 let _ = write!(out, "- {l}\n");
17017 }
17018 }
17019 }
17020
17021 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
17022 let ping = std::process::Command::new("ping")
17023 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
17024 .output();
17025 match ping {
17026 Ok(o) => {
17027 let body = String::from_utf8_lossy(&o.stdout);
17028 for line in body.lines() {
17029 let l = line.trim();
17030 if !l.is_empty() {
17031 let _ = write!(out, "- {l}\n");
17032 }
17033 }
17034 }
17035 Err(e) => {
17036 let _ = write!(out, "- Ping error: {e}\n");
17037 }
17038 }
17039 Ok(out)
17040}
17041
17042#[cfg(not(windows))]
17043fn inspect_cpu_power() -> Result<String, String> {
17044 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
17045
17046 out.push_str("=== CPU frequency (Linux) ===\n");
17048 let cat_scaling = std::process::Command::new("cat")
17049 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
17050 .output();
17051 if let Ok(o) = cat_scaling {
17052 let khz: u64 = String::from_utf8_lossy(&o.stdout)
17053 .trim()
17054 .parse()
17055 .unwrap_or(0);
17056 if khz > 0 {
17057 let _ = write!(out, "- Current: {} MHz\n", khz / 1000);
17058 }
17059 }
17060 let cat_max = std::process::Command::new("cat")
17061 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
17062 .output();
17063 if let Ok(o) = cat_max {
17064 let khz: u64 = String::from_utf8_lossy(&o.stdout)
17065 .trim()
17066 .parse()
17067 .unwrap_or(0);
17068 if khz > 0 {
17069 let _ = write!(out, "- Max: {} MHz\n", khz / 1000);
17070 }
17071 }
17072 let governor = std::process::Command::new("cat")
17073 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
17074 .output();
17075 if let Ok(o) = governor {
17076 let g = String::from_utf8_lossy(&o.stdout);
17077 let g = g.trim();
17078 if !g.is_empty() {
17079 let _ = write!(out, "- Governor: {g}\n");
17080 }
17081 }
17082 Ok(out)
17083}
17084
17085#[cfg(windows)]
17088fn inspect_ipv6() -> Result<String, String> {
17089 let script = r#"
17090$result = [System.Text.StringBuilder]::new()
17091
17092# Per-adapter IPv6 addresses
17093$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
17094$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17095 Where-Object { $_.IPAddress -notmatch '^::1$' } |
17096 Sort-Object InterfaceAlias
17097foreach ($a in $adapters) {
17098 $prefix = $a.PrefixOrigin
17099 $suffix = $a.SuffixOrigin
17100 $scope = $a.AddressState
17101 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
17102}
17103if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
17104
17105# Default gateway IPv6
17106$result.AppendLine("") | Out-Null
17107$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
17108$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
17109if ($gw6) {
17110 foreach ($g in $gw6) {
17111 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
17112 }
17113} else {
17114 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
17115}
17116
17117# DHCPv6 lease info
17118$result.AppendLine("") | Out-Null
17119$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
17120$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17121 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
17122if ($dhcpv6) {
17123 foreach ($d in $dhcpv6) {
17124 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
17125 }
17126} else {
17127 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
17128}
17129
17130# Privacy extensions
17131$result.AppendLine("") | Out-Null
17132$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
17133try {
17134 $priv = netsh interface ipv6 show privacy
17135 $result.AppendLine(($priv -join "`n")) | Out-Null
17136} catch {
17137 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
17138}
17139
17140# Tunnel adapters
17141$result.AppendLine("") | Out-Null
17142$result.AppendLine("=== Tunnel adapters ===") | Out-Null
17143$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17144if ($tunnels) {
17145 foreach ($t in $tunnels) {
17146 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
17147 }
17148} else {
17149 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17150}
17151
17152# Findings
17153$findings = [System.Collections.Generic.List[string]]::new()
17154$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17155 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17156if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17157$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17158if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17159
17160$result.AppendLine("") | Out-Null
17161$result.AppendLine("=== Findings ===") | Out-Null
17162if ($findings.Count -eq 0) {
17163 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17164} else {
17165 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17166}
17167
17168Write-Output $result.ToString()
17169"#;
17170 let out = run_powershell(script)?;
17171 Ok(format!("Host inspection: ipv6\n\n{out}"))
17172}
17173
17174#[cfg(not(windows))]
17175fn inspect_ipv6() -> Result<String, String> {
17176 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17177 if let Ok(o) = std::process::Command::new("ip")
17178 .args(["-6", "addr", "show"])
17179 .output()
17180 {
17181 out.push_str(&String::from_utf8_lossy(&o.stdout));
17182 }
17183 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17184 if let Ok(o) = std::process::Command::new("ip")
17185 .args(["-6", "route"])
17186 .output()
17187 {
17188 out.push_str(&String::from_utf8_lossy(&o.stdout));
17189 }
17190 Ok(out)
17191}
17192
17193#[cfg(windows)]
17196fn inspect_tcp_params() -> Result<String, String> {
17197 let script = r#"
17198$result = [System.Text.StringBuilder]::new()
17199
17200# Autotuning and global TCP settings
17201$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17202try {
17203 $global = netsh interface tcp show global
17204 foreach ($line in $global) {
17205 $l = $line.Trim()
17206 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17207 $result.AppendLine(" $l") | Out-Null
17208 }
17209 }
17210} catch {
17211 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
17212}
17213
17214# Supplemental params via Get-NetTCPSetting
17215$result.AppendLine("") | Out-Null
17216$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17217try {
17218 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17219 foreach ($s in $tcpSettings) {
17220 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
17221 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
17222 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17223 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
17224 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
17225 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
17226 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17227 $result.AppendLine("") | Out-Null
17228 }
17229} catch {
17230 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
17231}
17232
17233# Chimney offload state
17234$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17235try {
17236 $chimney = netsh interface tcp show chimney
17237 $result.AppendLine(($chimney -join "`n ")) | Out-Null
17238} catch {
17239 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
17240}
17241
17242# ECN state
17243$result.AppendLine("") | Out-Null
17244$result.AppendLine("=== ECN capability ===") | Out-Null
17245try {
17246 $ecn = netsh interface tcp show ecncapability
17247 $result.AppendLine(($ecn -join "`n ")) | Out-Null
17248} catch {
17249 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
17250}
17251
17252# Findings
17253$findings = [System.Collections.Generic.List[string]]::new()
17254try {
17255 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17256 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17257 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17258 }
17259 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17260 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17261 }
17262} catch {}
17263
17264$result.AppendLine("") | Out-Null
17265$result.AppendLine("=== Findings ===") | Out-Null
17266if ($findings.Count -eq 0) {
17267 $result.AppendLine("- TCP parameters look normal.") | Out-Null
17268} else {
17269 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17270}
17271
17272Write-Output $result.ToString()
17273"#;
17274 let out = run_powershell(script)?;
17275 Ok(format!("Host inspection: tcp_params\n\n{out}"))
17276}
17277
17278#[cfg(not(windows))]
17279fn inspect_tcp_params() -> Result<String, String> {
17280 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17281 for key in &[
17282 "net.ipv4.tcp_congestion_control",
17283 "net.ipv4.tcp_rmem",
17284 "net.ipv4.tcp_wmem",
17285 "net.ipv4.tcp_window_scaling",
17286 "net.ipv4.tcp_ecn",
17287 "net.ipv4.tcp_timestamps",
17288 ] {
17289 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17290 let _ = write!(out, " {}\n", String::from_utf8_lossy(&o.stdout).trim());
17291 }
17292 }
17293 Ok(out)
17294}
17295
17296#[cfg(windows)]
17299fn inspect_wlan_profiles() -> Result<String, String> {
17300 let script = r#"
17301$result = [System.Text.StringBuilder]::new()
17302
17303# List all saved profiles
17304$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17305try {
17306 $profilesRaw = netsh wlan show profiles
17307 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17308 $_.Matches[0].Groups[1].Value.Trim()
17309 }
17310
17311 if (-not $profiles) {
17312 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
17313 } else {
17314 foreach ($p in $profiles) {
17315 $result.AppendLine("") | Out-Null
17316 $result.AppendLine(" Profile: $p") | Out-Null
17317 # Get detail for each profile
17318 $detail = netsh wlan show profile name="$p" key=clear 2>$null
17319 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17320 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17321 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17322 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17323 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17324 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17325 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17326 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17327 }
17328 }
17329} catch {
17330 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17331}
17332
17333# Currently connected SSID
17334$result.AppendLine("") | Out-Null
17335$result.AppendLine("=== Currently connected ===") | Out-Null
17336try {
17337 $conn = netsh wlan show interfaces
17338 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17339 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17340 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17341 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17342 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17343 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17344 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17345 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17346 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
17347} catch {
17348 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
17349}
17350
17351# Findings
17352$findings = [System.Collections.Generic.List[string]]::new()
17353try {
17354 $allDetail = netsh wlan show profiles 2>$null
17355 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17356 $_.Matches[0].Groups[1].Value.Trim()
17357 }
17358 foreach ($pn in $profileNames) {
17359 $det = netsh wlan show profile name="$pn" key=clear 2>$null
17360 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17361 if ($authLine) {
17362 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17363 if ($authVal -match 'Open|WEP|None') {
17364 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17365 }
17366 }
17367 }
17368} catch {}
17369
17370$result.AppendLine("") | Out-Null
17371$result.AppendLine("=== Findings ===") | Out-Null
17372if ($findings.Count -eq 0) {
17373 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17374} else {
17375 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17376}
17377
17378Write-Output $result.ToString()
17379"#;
17380 let out = run_powershell(script)?;
17381 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17382}
17383
17384#[cfg(not(windows))]
17385fn inspect_wlan_profiles() -> Result<String, String> {
17386 let mut out =
17387 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17388 if let Ok(o) = std::process::Command::new("nmcli")
17390 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17391 .output()
17392 {
17393 for line in String::from_utf8_lossy(&o.stdout).lines() {
17394 if line.contains("wireless") || line.contains("wifi") {
17395 let _ = write!(out, " {line}\n");
17396 }
17397 }
17398 } else {
17399 out.push_str(" nmcli not available.\n");
17400 }
17401 Ok(out)
17402}
17403
17404#[cfg(windows)]
17407fn inspect_ipsec() -> Result<String, String> {
17408 let script = r#"
17409$result = [System.Text.StringBuilder]::new()
17410
17411# IPSec rules (firewall-integrated)
17412$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17413try {
17414 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17415 if ($rules) {
17416 foreach ($r in $rules) {
17417 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
17418 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
17419 $result.AppendLine(" Action: $($r.Action)") | Out-Null
17420 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
17421 }
17422 } else {
17423 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
17424 }
17425} catch {
17426 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
17427}
17428
17429# Active main-mode SAs
17430$result.AppendLine("") | Out-Null
17431$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17432try {
17433 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17434 if ($mmSAs) {
17435 foreach ($sa in $mmSAs) {
17436 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17437 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
17438 }
17439 } else {
17440 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
17441 }
17442} catch {
17443 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
17444}
17445
17446# Active quick-mode SAs
17447$result.AppendLine("") | Out-Null
17448$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17449try {
17450 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17451 if ($qmSAs) {
17452 foreach ($sa in $qmSAs) {
17453 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
17454 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17455 }
17456 } else {
17457 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
17458 }
17459} catch {
17460 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17461}
17462
17463# IKE service state
17464$result.AppendLine("") | Out-Null
17465$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17466$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17467if ($ikeAgentSvc) {
17468 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17469} else {
17470 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
17471}
17472
17473# Findings
17474$findings = [System.Collections.Generic.List[string]]::new()
17475$mmSACount = 0
17476try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17477if ($mmSACount -gt 0) {
17478 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17479}
17480
17481$result.AppendLine("") | Out-Null
17482$result.AppendLine("=== Findings ===") | Out-Null
17483if ($findings.Count -eq 0) {
17484 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17485} else {
17486 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17487}
17488
17489Write-Output $result.ToString()
17490"#;
17491 let out = run_powershell(script)?;
17492 Ok(format!("Host inspection: ipsec\n\n{out}"))
17493}
17494
17495#[cfg(not(windows))]
17496fn inspect_ipsec() -> Result<String, String> {
17497 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17498 if let Ok(o) = std::process::Command::new("ip")
17499 .args(["xfrm", "state"])
17500 .output()
17501 {
17502 let body = String::from_utf8_lossy(&o.stdout);
17503 if body.trim().is_empty() {
17504 out.push_str(" No active IPSec SAs.\n");
17505 } else {
17506 out.push_str(&body);
17507 }
17508 }
17509 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17510 if let Ok(o) = std::process::Command::new("ip")
17511 .args(["xfrm", "policy"])
17512 .output()
17513 {
17514 let body = String::from_utf8_lossy(&o.stdout);
17515 if body.trim().is_empty() {
17516 out.push_str(" No IPSec policies.\n");
17517 } else {
17518 out.push_str(&body);
17519 }
17520 }
17521 Ok(out)
17522}
17523
17524#[cfg(windows)]
17527fn inspect_netbios() -> Result<String, String> {
17528 let script = r#"
17529$result = [System.Text.StringBuilder]::new()
17530
17531# NetBIOS node type and WINS per adapter
17532$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17533try {
17534 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17535 Where-Object { $_.IPEnabled -eq $true }
17536 foreach ($a in $adapters) {
17537 $nodeType = switch ($a.TcpipNetbiosOptions) {
17538 0 { "EnableNetBIOSViaDHCP" }
17539 1 { "Enabled" }
17540 2 { "Disabled" }
17541 default { "Unknown ($($a.TcpipNetbiosOptions))" }
17542 }
17543 $result.AppendLine(" [$($a.Description)]") | Out-Null
17544 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
17545 if ($a.WINSPrimaryServer) {
17546 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
17547 }
17548 if ($a.WINSSecondaryServer) {
17549 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
17550 }
17551 }
17552} catch {
17553 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
17554}
17555
17556# nbtstat -n — registered local NetBIOS names
17557$result.AppendLine("") | Out-Null
17558$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17559try {
17560 $nbt = nbtstat -n 2>$null
17561 foreach ($line in $nbt) {
17562 $l = $line.Trim()
17563 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17564 $result.AppendLine(" $l") | Out-Null
17565 }
17566 }
17567} catch {
17568 $result.AppendLine(" nbtstat not available.") | Out-Null
17569}
17570
17571# NetBIOS session table
17572$result.AppendLine("") | Out-Null
17573$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17574try {
17575 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17576 if ($sessions) {
17577 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
17578 } else {
17579 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
17580 }
17581} catch {
17582 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
17583}
17584
17585# Findings
17586$findings = [System.Collections.Generic.List[string]]::new()
17587try {
17588 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17589 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17590 if ($enabled) {
17591 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17592 }
17593 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17594 Where-Object { $_.WINSPrimaryServer }
17595 if ($wins) {
17596 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17597 }
17598} catch {}
17599
17600$result.AppendLine("") | Out-Null
17601$result.AppendLine("=== Findings ===") | Out-Null
17602if ($findings.Count -eq 0) {
17603 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17604} else {
17605 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17606}
17607
17608Write-Output $result.ToString()
17609"#;
17610 let out = run_powershell(script)?;
17611 Ok(format!("Host inspection: netbios\n\n{out}"))
17612}
17613
17614#[cfg(not(windows))]
17615fn inspect_netbios() -> Result<String, String> {
17616 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17617 if let Ok(o) = std::process::Command::new("nmblookup")
17618 .arg("-A")
17619 .arg("localhost")
17620 .output()
17621 {
17622 out.push_str(&String::from_utf8_lossy(&o.stdout));
17623 } else {
17624 out.push_str(" nmblookup not available (Samba not installed).\n");
17625 }
17626 Ok(out)
17627}
17628
17629#[cfg(windows)]
17632fn inspect_nic_teaming() -> Result<String, String> {
17633 let script = r#"
17634$result = [System.Text.StringBuilder]::new()
17635
17636# Team inventory
17637$result.AppendLine("=== NIC teams ===") | Out-Null
17638try {
17639 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17640 if ($teams) {
17641 foreach ($t in $teams) {
17642 $result.AppendLine(" Team: $($t.Name)") | Out-Null
17643 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
17644 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
17645 $result.AppendLine(" Status: $($t.Status)") | Out-Null
17646 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
17647 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17648 }
17649 } else {
17650 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
17651 }
17652} catch {
17653 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17654}
17655
17656# Team members detail
17657$result.AppendLine("") | Out-Null
17658$result.AppendLine("=== Team member detail ===") | Out-Null
17659try {
17660 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17661 if ($members) {
17662 foreach ($m in $members) {
17663 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
17664 }
17665 } else {
17666 $result.AppendLine(" No team members found.") | Out-Null
17667 }
17668} catch {
17669 $result.AppendLine(" Could not query team members.") | Out-Null
17670}
17671
17672# Findings
17673$findings = [System.Collections.Generic.List[string]]::new()
17674try {
17675 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17676 if ($degraded) {
17677 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17678 }
17679 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17680 if ($downMembers) {
17681 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17682 }
17683} catch {}
17684
17685$result.AppendLine("") | Out-Null
17686$result.AppendLine("=== Findings ===") | Out-Null
17687if ($findings.Count -eq 0) {
17688 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17689} else {
17690 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17691}
17692
17693Write-Output $result.ToString()
17694"#;
17695 let out = run_powershell(script)?;
17696 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17697}
17698
17699#[cfg(not(windows))]
17700fn inspect_nic_teaming() -> Result<String, String> {
17701 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17702 if let Ok(o) = std::process::Command::new("cat")
17703 .arg("/proc/net/bonding/bond0")
17704 .output()
17705 {
17706 if o.status.success() {
17707 out.push_str(&String::from_utf8_lossy(&o.stdout));
17708 } else {
17709 out.push_str(" No bond0 interface found.\n");
17710 }
17711 }
17712 if let Ok(o) = std::process::Command::new("ip")
17713 .args(["link", "show", "type", "bond"])
17714 .output()
17715 {
17716 let body = String::from_utf8_lossy(&o.stdout);
17717 if !body.trim().is_empty() {
17718 out.push_str("\n=== Bond links (ip link) ===\n");
17719 out.push_str(&body);
17720 }
17721 }
17722 Ok(out)
17723}
17724
17725#[cfg(windows)]
17728fn inspect_snmp() -> Result<String, String> {
17729 let script = r#"
17730$result = [System.Text.StringBuilder]::new()
17731
17732# SNMP service state
17733$result.AppendLine("=== SNMP service state ===") | Out-Null
17734$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17735if ($svc) {
17736 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17737} else {
17738 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
17739}
17740
17741$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17742if ($svcTrap) {
17743 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17744}
17745
17746# Community strings (presence only — values redacted)
17747$result.AppendLine("") | Out-Null
17748$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17749try {
17750 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17751 if ($communities) {
17752 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17753 if ($names) {
17754 foreach ($n in $names) {
17755 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
17756 }
17757 } else {
17758 $result.AppendLine(" No community strings configured.") | Out-Null
17759 }
17760 } else {
17761 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
17762 }
17763} catch {
17764 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
17765}
17766
17767# Permitted managers
17768$result.AppendLine("") | Out-Null
17769$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17770try {
17771 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17772 if ($managers) {
17773 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17774 if ($mgrs) {
17775 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
17776 } else {
17777 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
17778 }
17779 } else {
17780 $result.AppendLine(" No manager restrictions configured.") | Out-Null
17781 }
17782} catch {
17783 $result.AppendLine(" Could not read permitted managers.") | Out-Null
17784}
17785
17786# Findings
17787$findings = [System.Collections.Generic.List[string]]::new()
17788$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17789if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17790 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17791 try {
17792 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17793 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17794 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17795 } catch {}
17796}
17797
17798$result.AppendLine("") | Out-Null
17799$result.AppendLine("=== Findings ===") | Out-Null
17800if ($findings.Count -eq 0) {
17801 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17802} else {
17803 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17804}
17805
17806Write-Output $result.ToString()
17807"#;
17808 let out = run_powershell(script)?;
17809 Ok(format!("Host inspection: snmp\n\n{out}"))
17810}
17811
17812#[cfg(not(windows))]
17813fn inspect_snmp() -> Result<String, String> {
17814 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17815 for svc in &["snmpd", "snmp"] {
17816 if let Ok(o) = std::process::Command::new("systemctl")
17817 .args(["is-active", svc])
17818 .output()
17819 {
17820 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17821 let _ = write!(out, " {svc}: {status}\n");
17822 }
17823 }
17824 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17825 if let Ok(o) = std::process::Command::new("grep")
17826 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17827 .output()
17828 {
17829 if o.status.success() {
17830 for line in String::from_utf8_lossy(&o.stdout).lines() {
17831 let _ = write!(out, " {line}\n");
17832 }
17833 } else {
17834 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
17835 }
17836 }
17837 Ok(out)
17838}
17839
17840#[cfg(windows)]
17843fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17844 let target_host = host.unwrap_or("8.8.8.8");
17845 let target_port = port.unwrap_or(443);
17846
17847 let script = format!(
17848 r#"
17849$result = [System.Text.StringBuilder]::new()
17850$result.AppendLine("=== Port reachability test ===") | Out-Null
17851$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
17852$result.AppendLine("") | Out-Null
17853
17854try {{
17855 $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17856 if ($test) {{
17857 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17858 $result.AppendLine(" Result: $status") | Out-Null
17859 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
17860 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
17861 if ($test.PingSucceeded) {{
17862 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17863 }} else {{
17864 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
17865 }}
17866 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
17867 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
17868
17869 $result.AppendLine("") | Out-Null
17870 $result.AppendLine("=== Findings ===") | Out-Null
17871 if ($test.TcpTestSucceeded) {{
17872 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17873 }} else {{
17874 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17875 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17876 }}
17877 }}
17878}} catch {{
17879 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17880}}
17881
17882Write-Output $result.ToString()
17883"#
17884 );
17885 let out = run_powershell(&script)?;
17886 Ok(format!("Host inspection: port_test\n\n{out}"))
17887}
17888
17889#[cfg(not(windows))]
17890fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17891 let target_host = host.unwrap_or("8.8.8.8");
17892 let target_port = port.unwrap_or(443);
17893 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
17894 let nc = std::process::Command::new("nc")
17896 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17897 .output();
17898 match nc {
17899 Ok(o) => {
17900 let stderr = String::from_utf8_lossy(&o.stderr);
17901 let stdout = String::from_utf8_lossy(&o.stdout);
17902 let body = if !stdout.trim().is_empty() {
17903 stdout.as_ref()
17904 } else {
17905 stderr.as_ref()
17906 };
17907 let _ = write!(out, " {}\n", body.trim());
17908 out.push_str("\n=== Findings ===\n");
17909 if o.status.success() {
17910 let _ = write!(out, "- Port {target_port} on {target_host} is OPEN.\n");
17911 } else {
17912 let _ = write!(
17913 out,
17914 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17915 );
17916 }
17917 }
17918 Err(e) => {
17919 let _ = write!(out, " nc not available: {e}\n");
17920 }
17921 }
17922 Ok(out)
17923}
17924
17925#[cfg(windows)]
17928fn inspect_network_profile() -> Result<String, String> {
17929 let script = r#"
17930$result = [System.Text.StringBuilder]::new()
17931
17932$result.AppendLine("=== Network location profiles ===") | Out-Null
17933try {
17934 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17935 if ($profiles) {
17936 foreach ($p in $profiles) {
17937 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
17938 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
17939 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
17940 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
17941 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
17942 $result.AppendLine("") | Out-Null
17943 }
17944 } else {
17945 $result.AppendLine(" No network connection profiles found.") | Out-Null
17946 }
17947} catch {
17948 $result.AppendLine(" Could not query network profiles.") | Out-Null
17949}
17950
17951# Findings
17952$findings = [System.Collections.Generic.List[string]]::new()
17953try {
17954 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17955 if ($pub) {
17956 foreach ($p in $pub) {
17957 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17958 }
17959 }
17960 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17961 if ($domain) {
17962 foreach ($d in $domain) {
17963 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17964 }
17965 }
17966} catch {}
17967
17968$result.AppendLine("=== Findings ===") | Out-Null
17969if ($findings.Count -eq 0) {
17970 $result.AppendLine("- Network profiles look normal.") | Out-Null
17971} else {
17972 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17973}
17974
17975Write-Output $result.ToString()
17976"#;
17977 let out = run_powershell(script)?;
17978 Ok(format!("Host inspection: network_profile\n\n{out}"))
17979}
17980
17981#[cfg(not(windows))]
17982fn inspect_network_profile() -> Result<String, String> {
17983 let mut out = String::from(
17984 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17985 );
17986 if let Ok(o) = std::process::Command::new("nmcli")
17987 .args([
17988 "-t",
17989 "-f",
17990 "NAME,TYPE,STATE,DEVICE",
17991 "connection",
17992 "show",
17993 "--active",
17994 ])
17995 .output()
17996 {
17997 out.push_str(&String::from_utf8_lossy(&o.stdout));
17998 } else {
17999 out.push_str(" nmcli not available.\n");
18000 }
18001 Ok(out)
18002}
18003
18004#[cfg(windows)]
18007fn inspect_storage_spaces() -> Result<String, String> {
18008 let script = r#"
18009$result = [System.Text.StringBuilder]::new()
18010
18011# Storage Pools
18012try {
18013 $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
18014 if ($pools) {
18015 $result.AppendLine("=== Storage Pools ===") | Out-Null
18016 foreach ($pool in $pools) {
18017 $health = $pool.HealthStatus
18018 $oper = $pool.OperationalStatus
18019 $sizGB = [math]::Round($pool.Size / 1GB, 1)
18020 $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
18021 $result.AppendLine(" Pool: $($pool.FriendlyName) Size: ${sizGB}GB Allocated: ${allocGB}GB Health: $health Status: $oper") | Out-Null
18022 }
18023 $result.AppendLine("") | Out-Null
18024 } else {
18025 $result.AppendLine("=== Storage Pools ===") | Out-Null
18026 $result.AppendLine(" No Storage Spaces pools configured.") | Out-Null
18027 $result.AppendLine("") | Out-Null
18028 }
18029} catch {
18030 $result.AppendLine("=== Storage Pools ===") | Out-Null
18031 $result.AppendLine(" Unable to query storage pools (may require elevation).") | Out-Null
18032 $result.AppendLine("") | Out-Null
18033}
18034
18035# Virtual Disks
18036try {
18037 $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
18038 if ($vdisks) {
18039 $result.AppendLine("=== Virtual Disks ===") | Out-Null
18040 foreach ($vd in $vdisks) {
18041 $health = $vd.HealthStatus
18042 $oper = $vd.OperationalStatus
18043 $layout = $vd.ResiliencySettingName
18044 $sizGB = [math]::Round($vd.Size / 1GB, 1)
18045 $result.AppendLine(" VDisk: $($vd.FriendlyName) Layout: $layout Size: ${sizGB}GB Health: $health Status: $oper") | Out-Null
18046 }
18047 $result.AppendLine("") | Out-Null
18048 } else {
18049 $result.AppendLine("=== Virtual Disks ===") | Out-Null
18050 $result.AppendLine(" No Storage Spaces virtual disks configured.") | Out-Null
18051 $result.AppendLine("") | Out-Null
18052 }
18053} catch {
18054 $result.AppendLine("=== Virtual Disks ===") | Out-Null
18055 $result.AppendLine(" Unable to query virtual disks.") | Out-Null
18056 $result.AppendLine("") | Out-Null
18057}
18058
18059# Physical Disks in pools
18060try {
18061 $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
18062 if ($pdisks) {
18063 $result.AppendLine("=== Physical Disks ===") | Out-Null
18064 foreach ($pd in $pdisks) {
18065 $sizGB = [math]::Round($pd.Size / 1GB, 1)
18066 $health = $pd.HealthStatus
18067 $usage = $pd.Usage
18068 $media = $pd.MediaType
18069 $result.AppendLine(" $($pd.FriendlyName) ${sizGB}GB $media Usage: $usage Health: $health") | Out-Null
18070 }
18071 $result.AppendLine("") | Out-Null
18072 }
18073} catch {}
18074
18075# Findings
18076$findings = @()
18077try {
18078 $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18079 foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
18080 $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18081 foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
18082 $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
18083 foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
18084} catch {}
18085
18086if ($findings.Count -gt 0) {
18087 $result.AppendLine("=== Findings ===") | Out-Null
18088 foreach ($f in $findings) { $result.AppendLine(" $f") | Out-Null }
18089} else {
18090 $result.AppendLine("=== Findings ===") | Out-Null
18091 $result.AppendLine(" All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
18092}
18093
18094Write-Output $result.ToString().TrimEnd()
18095"#;
18096 let out = run_powershell(script)?;
18097 Ok(format!("Host inspection: storage_spaces\n\n{out}"))
18098}
18099
18100#[cfg(not(windows))]
18101fn inspect_storage_spaces() -> Result<String, String> {
18102 let mut out = String::from("Host inspection: storage_spaces\n\n");
18103 let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
18105 if !mdstat.is_empty() {
18106 out.push_str("=== Software RAID (/proc/mdstat) ===\n");
18107 out.push_str(&mdstat);
18108 } else {
18109 out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
18110 }
18111 if let Ok(o) = Command::new("lvs")
18113 .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
18114 .output()
18115 {
18116 let lvs = String::from_utf8_lossy(&o.stdout).into_owned();
18117 if !lvs.trim().is_empty() {
18118 out.push_str("\n=== LVM Logical Volumes ===\n");
18119 out.push_str(&lvs);
18120 }
18121 }
18122 Ok(out)
18123}
18124
18125#[cfg(windows)]
18128fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
18129 let limit = max_entries.min(50);
18130 let script = format!(
18131 r#"
18132$result = [System.Text.StringBuilder]::new()
18133
18134# Current threat detections (active + quarantined)
18135try {{
18136 $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
18137 if ($threats) {{
18138 $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
18139 foreach ($t in $threats) {{
18140 $name = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
18141 if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
18142 $time = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
18143 $action = $t.ActionSuccess
18144 $status = $t.CurrentThreatExecutionStatusID
18145 $result.AppendLine(" [$time] $name ActionSuccess:$action Status:$status") | Out-Null
18146 }}
18147 $result.AppendLine("") | Out-Null
18148 }} else {{
18149 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18150 $result.AppendLine(" No threat detections on record — Defender history is clean.") | Out-Null
18151 $result.AppendLine("") | Out-Null
18152 }}
18153}} catch {{
18154 $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18155 $result.AppendLine(" Unable to query threat detections: $_") | Out-Null
18156 $result.AppendLine("") | Out-Null
18157}}
18158
18159# Quarantine items
18160try {{
18161 $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18162 if ($quarantine) {{
18163 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18164 foreach ($q in $quarantine) {{
18165 $result.AppendLine(" $($q.ThreatName) Severity:$($q.SeverityID) Category:$($q.CategoryID) Active:$($q.IsActive)") | Out-Null
18166 }}
18167 $result.AppendLine("") | Out-Null
18168 }} else {{
18169 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18170 $result.AppendLine(" No quarantined threats found.") | Out-Null
18171 $result.AppendLine("") | Out-Null
18172 }}
18173}} catch {{
18174 $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18175 $result.AppendLine(" Unable to query quarantine list: $_") | Out-Null
18176 $result.AppendLine("") | Out-Null
18177}}
18178
18179# Defender scan stats
18180try {{
18181 $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18182 if ($status) {{
18183 $lastScan = $status.QuickScanStartTime
18184 $lastFull = $status.FullScanStartTime
18185 $sigDate = $status.AntivirusSignatureLastUpdated
18186 $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18187 $result.AppendLine(" Last quick scan : $lastScan") | Out-Null
18188 $result.AppendLine(" Last full scan : $lastFull") | Out-Null
18189 $result.AppendLine(" Signature date : $sigDate") | Out-Null
18190 }}
18191}} catch {{}}
18192
18193Write-Output $result.ToString().TrimEnd()
18194"#,
18195 limit = limit
18196 );
18197 let out = run_powershell(&script)?;
18198 Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18199}
18200
18201#[cfg(windows)]
18204fn inspect_domain_health() -> Result<String, String> {
18205 let script = r#"
18206$result = [System.Text.StringBuilder]::new()
18207
18208# Domain membership
18209try {
18210 $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18211 $joined = $cs.PartOfDomain
18212 $domain = $cs.Domain
18213 $result.AppendLine("=== Domain Membership ===") | Out-Null
18214 $result.AppendLine(" Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18215 if ($joined) { $result.AppendLine(" Domain : $domain") | Out-Null }
18216 $result.AppendLine(" Computer : $($cs.Name)") | Out-Null
18217} catch {
18218 $result.AppendLine(" Domain membership check failed: $_") | Out-Null
18219}
18220
18221# dsregcmd device registration state
18222try {
18223 $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18224 if ($dsreg) {
18225 $result.AppendLine("") | Out-Null
18226 $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18227 foreach ($line in $dsreg) { $result.AppendLine(" $($line.Trim())") | Out-Null }
18228 }
18229} catch {}
18230
18231# DC discovery via nltest
18232$result.AppendLine("") | Out-Null
18233$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18234try {
18235 $nl = nltest /dsgetdc:. 2>&1
18236 $dc_name = $null
18237 foreach ($line in $nl) {
18238 if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18239 $result.AppendLine(" $($line.Trim())") | Out-Null
18240 }
18241 if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18242 }
18243 if ($dc_name) {
18244 $result.AppendLine("") | Out-Null
18245 $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18246 foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18247 try {
18248 $tcp = New-Object System.Net.Sockets.TcpClient
18249 $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18250 $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18251 $tcp.Close()
18252 $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18253 } catch { $status = 'FAILED' }
18254 $result.AppendLine(" Port $($entry.p) ($($entry.n)): $status") | Out-Null
18255 }
18256 }
18257} catch {
18258 $result.AppendLine(" nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18259}
18260
18261# Last GPO machine refresh time
18262try {
18263 $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18264 if (Test-Path $gpoKey) {
18265 $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18266 $result.AppendLine("") | Out-Null
18267 $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18268 $result.AppendLine(" Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18269 }
18270} catch {}
18271
18272Write-Output $result.ToString().TrimEnd()
18273"#;
18274 let out = run_powershell(script)?;
18275 Ok(format!("Host inspection: domain_health\n\n{out}"))
18276}
18277
18278#[cfg(not(windows))]
18279fn inspect_domain_health() -> Result<String, String> {
18280 let mut out = String::from("Host inspection: domain_health\n\n");
18281 for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18282 if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18283 let s = String::from_utf8_lossy(&o.stdout);
18284 if !s.trim().is_empty() {
18285 let _ = write!(out, "$ {}\n{}\n", cmd_args.join(" "), s.trim_end());
18286 }
18287 }
18288 }
18289 if out.trim_end().ends_with("domain_health") {
18290 out.push_str("Not domain-joined or realm/sssd not installed.\n");
18291 }
18292 Ok(out)
18293}
18294
18295#[cfg(windows)]
18298fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18299 let limit = max_entries.min(60);
18300 let script = format!(
18301 r#"
18302$result = [System.Text.StringBuilder]::new()
18303$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18304$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18305$result.AppendLine("") | Out-Null
18306$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18307foreach ($s in $svc) {{
18308 $req = if ($s.RequiredServices.Count -gt 0) {{ "requires: $($s.RequiredServices.Name -join ', ')" }} else {{ "" }}
18309 $dep = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18310 $parts = @($req, $dep) | Where-Object {{ $_ }}
18311 if ($parts) {{
18312 $result.AppendLine(" [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18313 }}
18314}}
18315Write-Output $result.ToString().TrimEnd()
18316"#,
18317 limit = limit
18318 );
18319 let out = run_powershell(&script)?;
18320 Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18321}
18322
18323#[cfg(not(windows))]
18324fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18325 let out = Command::new("systemctl")
18326 .args(["list-dependencies", "--no-pager", "--plain"])
18327 .output()
18328 .ok()
18329 .and_then(|o| String::from_utf8(o.stdout).ok())
18330 .unwrap_or_else(|| "systemctl not available.\n".to_string());
18331 Ok(format!(
18332 "Host inspection: service_dependencies\n\n{}",
18333 out.trim_end()
18334 ))
18335}
18336
18337#[cfg(windows)]
18340fn inspect_wmi_health() -> Result<String, String> {
18341 let script = r#"
18342$result = [System.Text.StringBuilder]::new()
18343$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18344
18345# Basic WMI query test
18346try {
18347 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18348 $result.AppendLine(" Query (Win32_OperatingSystem): OK") | Out-Null
18349 $result.AppendLine(" OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18350} catch {
18351 $result.AppendLine(" Query FAILED: $_") | Out-Null
18352 $result.AppendLine(" FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18353}
18354
18355# Repository integrity
18356try {
18357 $verify = & winmgmt /verifyrepository 2>&1
18358 $result.AppendLine(" winmgmt /verifyrepository: $verify") | Out-Null
18359} catch {
18360 $result.AppendLine(" winmgmt check unavailable: $_") | Out-Null
18361}
18362
18363# WMI service state
18364$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18365if ($svc) {
18366 $result.AppendLine(" Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18367}
18368
18369# Repository folder size
18370$repPath = "$env:SystemRoot\System32\wbem\Repository"
18371if (Test-Path $repPath) {
18372 $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18373 $mb = [math]::Round($bytes / 1MB, 1)
18374 $result.AppendLine(" Repository size: $mb MB ($repPath)") | Out-Null
18375 if ($mb -gt 200) {
18376 $result.AppendLine(" FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18377 }
18378}
18379
18380$result.AppendLine("") | Out-Null
18381$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18382$result.AppendLine(" 1. net stop winmgmt") | Out-Null
18383$result.AppendLine(" 2. winmgmt /salvagerepository (try first)") | Out-Null
18384$result.AppendLine(" 3. winmgmt /resetrepository (last resort — loses custom namespaces)") | Out-Null
18385$result.AppendLine(" 4. net start winmgmt") | Out-Null
18386
18387Write-Output $result.ToString().TrimEnd()
18388"#;
18389 let out = run_powershell(script)?;
18390 Ok(format!("Host inspection: wmi_health\n\n{out}"))
18391}
18392
18393#[cfg(not(windows))]
18394fn inspect_wmi_health() -> Result<String, String> {
18395 Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18396}
18397
18398#[cfg(windows)]
18401fn inspect_local_security_policy() -> Result<String, String> {
18402 let script = r#"
18403$result = [System.Text.StringBuilder]::new()
18404$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18405$na = net accounts 2>&1
18406foreach ($line in $na) {
18407 if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18408 $result.AppendLine(" $($line.Trim())") | Out-Null
18409 }
18410}
18411
18412$result.AppendLine("") | Out-Null
18413$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18414try {
18415 $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18416 if ($null -eq $lmLevel) { $lmLevel = 3 }
18417 $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'}
18418 $result.AppendLine(" LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18419 if ($lmLevel -lt 3) {
18420 $result.AppendLine(" FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18421 }
18422} catch {}
18423
18424$result.AppendLine("") | Out-Null
18425$result.AppendLine("=== UAC Settings ===") | Out-Null
18426try {
18427 $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18428 if ($uac) {
18429 $result.AppendLine(" UAC Enabled : $($uac.EnableLUA) (1=on, 0=disabled)") | Out-Null
18430 $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18431 $bval = $uac.ConsentPromptBehaviorAdmin
18432 $result.AppendLine(" Admin Prompt Behavior : $bval — $($behavMap[$bval])") | Out-Null
18433 if ($uac.EnableLUA -eq 0) {
18434 $result.AppendLine(" FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18435 }
18436 }
18437} catch {}
18438
18439Write-Output $result.ToString().TrimEnd()
18440"#;
18441 let out = run_powershell(script)?;
18442 Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18443}
18444
18445#[cfg(not(windows))]
18446fn inspect_local_security_policy() -> Result<String, String> {
18447 let mut out = String::from("Host inspection: local_security_policy\n\n");
18448 if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18449 out.push_str("=== /etc/login.defs ===\n");
18450 for line in content.lines() {
18451 let t = line.trim();
18452 if !t.is_empty() && !t.starts_with('#') {
18453 let _ = write!(out, " {t}\n");
18454 }
18455 }
18456 }
18457 Ok(out)
18458}
18459
18460#[cfg(windows)]
18463fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18464 let limit = max_entries.min(50);
18465 let script = format!(
18466 r#"
18467$result = [System.Text.StringBuilder]::new()
18468$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18469$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18470if (Test-Path $usbPath) {{
18471 $count = 0
18472 $seen = @{{}}
18473 $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18474 foreach ($class in $classes) {{
18475 $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18476 foreach ($inst in $instances) {{
18477 if ($count -ge {limit}) {{ break }}
18478 try {{
18479 $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18480 $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18481 if (-not $seen[$fn]) {{
18482 $seen[$fn] = $true
18483 $result.AppendLine(" $fn") | Out-Null
18484 $count++
18485 }}
18486 }} catch {{}}
18487 }}
18488 }}
18489 if ($count -eq 0) {{
18490 $result.AppendLine(" No USB storage devices found in registry.") | Out-Null
18491 }} else {{
18492 $result.AppendLine("") | Out-Null
18493 $result.AppendLine(" ($count unique devices; requires elevation for full history)") | Out-Null
18494 }}
18495}} else {{
18496 $result.AppendLine(" USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18497}}
18498Write-Output $result.ToString().TrimEnd()
18499"#,
18500 limit = limit
18501 );
18502 let out = run_powershell(&script)?;
18503 Ok(format!("Host inspection: usb_history\n\n{out}"))
18504}
18505
18506#[cfg(not(windows))]
18507fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18508 let mut out = String::from("Host inspection: usb_history\n\n");
18509 if let Ok(o) = Command::new("journalctl")
18510 .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18511 .output()
18512 {
18513 let s = String::from_utf8_lossy(&o.stdout);
18514 let usb_lines: Vec<&str> = s
18515 .lines()
18516 .filter(|l| l.to_ascii_lowercase().contains("usb"))
18517 .take(30)
18518 .collect();
18519 if !usb_lines.is_empty() {
18520 out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18521 for line in usb_lines {
18522 let _ = write!(out, " {line}\n");
18523 }
18524 }
18525 } else {
18526 out.push_str("USB history via journalctl not available.\n");
18527 }
18528 Ok(out)
18529}
18530
18531#[cfg(windows)]
18534fn inspect_print_spooler() -> Result<String, String> {
18535 let script = r#"
18536$result = [System.Text.StringBuilder]::new()
18537
18538$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18539$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18540if ($svc) {
18541 $result.AppendLine(" Status : $($svc.Status)") | Out-Null
18542 $result.AppendLine(" Start Type : $($svc.StartType)") | Out-Null
18543} else {
18544 $result.AppendLine(" Spooler service not found.") | Out-Null
18545}
18546
18547# PrintNightmare mitigations (CVE-2021-34527)
18548$result.AppendLine("") | Out-Null
18549$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18550try {
18551 $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18552 if ($val -eq 1) {
18553 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18554 } else {
18555 $result.AppendLine(" RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18556 $result.AppendLine(" FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18557 }
18558} catch { $result.AppendLine(" Mitigation key not readable: $_") | Out-Null }
18559
18560try {
18561 $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18562 if (Test-Path $pnpPath) {
18563 $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18564 $result.AppendLine(" RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18565 $result.AppendLine(" NoWarningNoElevationOnInstall : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18566 if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18567 $result.AppendLine(" FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18568 }
18569 } else {
18570 $result.AppendLine(" No Point and Print policy (using Windows defaults).") | Out-Null
18571 }
18572} catch {}
18573
18574# Pending print jobs
18575$result.AppendLine("") | Out-Null
18576$result.AppendLine("=== Print Queue ===") | Out-Null
18577try {
18578 $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18579 if ($jobs) {
18580 foreach ($j in $jobs | Select-Object -First 5) {
18581 $result.AppendLine(" $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18582 }
18583 } else {
18584 $result.AppendLine(" No pending print jobs.") | Out-Null
18585 }
18586} catch {
18587 $result.AppendLine(" Print queue check requires elevation.") | Out-Null
18588}
18589
18590Write-Output $result.ToString().TrimEnd()
18591"#;
18592 let out = run_powershell(script)?;
18593 Ok(format!("Host inspection: print_spooler\n\n{out}"))
18594}
18595
18596#[cfg(not(windows))]
18597fn inspect_print_spooler() -> Result<String, String> {
18598 let mut out = String::from("Host inspection: print_spooler\n\n");
18599 if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18600 let s = String::from_utf8_lossy(&o.stdout);
18601 if !s.trim().is_empty() {
18602 out.push_str("=== CUPS Status (lpstat -s) ===\n");
18603 out.push_str(s.trim_end());
18604 out.push('\n');
18605 }
18606 } else {
18607 out.push_str("CUPS not detected (lpstat not found).\n");
18608 }
18609 Ok(out)
18610}
18611
18612#[cfg(not(windows))]
18613fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18614 let mut out = String::from("Host inspection: defender_quarantine\n\n");
18615 out.push_str("Windows Defender is Windows-only.\n");
18616 if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18618 if o.status.success() {
18619 out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18620 if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18621 out.push_str("\n=== ClamAV Recent Log ===\n");
18622 for line in log.lines().rev().take(20) {
18623 let _ = write!(out, " {line}\n");
18624 }
18625 }
18626 }
18627 } else {
18628 out.push_str("No AV tool detected (ClamAV not found).\n");
18629 }
18630 Ok(out)
18631}