1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12 let mut topic = args
13 .get("topic")
14 .and_then(|v| v.as_str())
15 .unwrap_or("summary")
16 .to_string();
17 let max_entries = parse_max_entries(args);
18 let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20 if (topic == "processes" || topic == "network" || topic == "summary")
22 && (filter.contains("ad")
23 || filter.contains("sid")
24 || filter.contains("administrator")
25 || filter.contains("domain"))
26 {
27 topic = "ad_user".to_string();
28 }
29
30 let result = match topic.as_str() {
31 "summary" => inspect_summary(max_entries),
32 "toolchains" => inspect_toolchains(),
33 "path" => inspect_path(max_entries),
34 "env_doctor" => inspect_env_doctor(max_entries),
35 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
36 "network" => inspect_network(max_entries),
37 "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
38 inspect_lan_discovery(max_entries)
39 }
40 "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
41 inspect_audio(max_entries)
42 }
43 "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
44 inspect_bluetooth(max_entries)
45 }
46 "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
47 "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
48 inspect_sign_in(max_entries)
49 }
50 "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
51 inspect_installer_health(max_entries)
52 }
53 "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
54 inspect_onedrive(max_entries)
55 }
56 "browser_health" | "browser" | "webview2" | "default_browser" => {
57 inspect_browser_health(max_entries)
58 }
59 "identity_auth"
60 | "office_auth"
61 | "m365_auth"
62 | "microsoft_365_auth"
63 | "auth_broker" => inspect_identity_auth(max_entries),
64 "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
65 "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
66 "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
67 inspect_windows_backup(max_entries)
68 }
69 "search_index" | "windows_search" | "indexing" | "search" => {
70 inspect_search_index(max_entries)
71 }
72 "services" => inspect_services(parse_name_filter(args), max_entries),
73 "processes" => inspect_processes(parse_name_filter(args), max_entries),
74 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
75 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
76 "disk" => {
77 let path = resolve_optional_path(args)?;
78 inspect_disk(path, max_entries).await
79 }
80 "ports" => inspect_ports(parse_port_filter(args), max_entries),
81 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
82 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
83 "health_report" | "system_health" => inspect_health_report(),
84 "storage" => inspect_storage(max_entries),
85 "hardware" => inspect_hardware(),
86 "updates" | "windows_update" => inspect_updates(),
87 "security" | "antivirus" | "defender" => inspect_security(),
88 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
89 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
90 "battery" => inspect_battery(),
91 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
92 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
93 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
94 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
95 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
96 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
97 "vpn" => inspect_vpn(),
98 "proxy" | "proxy_settings" => inspect_proxy(),
99 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
100 "traceroute" | "tracert" | "trace_route" | "trace" => {
101 let host = args
102 .get("host")
103 .and_then(|v| v.as_str())
104 .unwrap_or("8.8.8.8")
105 .to_string();
106 inspect_traceroute(&host, max_entries)
107 }
108 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
109 "arp" | "arp_table" => inspect_arp(),
110 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
111 "os_config" | "system_config" => inspect_os_config(),
112 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
113 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
114 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
115 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
116 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
117 inspect_docker_filesystems(max_entries)
118 }
119 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
120 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
121 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
122 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
123 "git_config" | "git_global" => inspect_git_config(),
124 "databases" | "database" | "db_services" | "db" => inspect_databases(),
125 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
126 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
127 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
128 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
129 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
130 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
131 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
132 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
133 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
134 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
135 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
136 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
137 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
138 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
139 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
140 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
141 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
142 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
143 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
144 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
145 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
146 "repo_doctor" => {
147 let path = resolve_optional_path(args)?;
148 inspect_repo_doctor(path, max_entries)
149 }
150 "directory" => {
151 let raw_path = args
152 .get("path")
153 .and_then(|v| v.as_str())
154 .ok_or_else(|| {
155 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
156 .to_string()
157 })?;
158 let resolved = resolve_path(raw_path)?;
159 inspect_directory("Directory", resolved, max_entries).await
160 }
161 "disk_benchmark" | "stress_test" | "io_intensity" => {
162 let path = resolve_optional_path(args)?;
163 inspect_disk_benchmark(path).await
164 }
165 "permissions" | "acl" | "access_control" => {
166 let path = resolve_optional_path(args)?;
167 inspect_permissions(path, max_entries)
168 }
169 "login_history" | "logon_history" | "user_logins" => {
170 inspect_login_history(max_entries)
171 }
172 "share_access" | "unc_access" | "remote_share" => {
173 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
174 inspect_share_access(path)
175 }
176 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
177 "thermal" | "throttling" | "overheating" => inspect_thermal(),
178 "activation" | "license_status" | "slmgr" => inspect_activation(),
179 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
180 "ad_user" | "ad" | "domain_user" => {
181 let identity = parse_name_filter(args).unwrap_or_default();
182 inspect_ad_user(&identity)
183 }
184 "dns_lookup" | "dig" | "nslookup" => {
185 let name = parse_name_filter(args).unwrap_or_default();
186 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
187 inspect_dns_lookup(&name, record_type)
188 }
189 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
190 "ip_config" | "ip_detail" => inspect_ip_config(),
191 "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
192 "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
193 "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
194 "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
195 "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
196 "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
197 "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
198 "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
199 "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
200 "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
201 let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
202 let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
203 inspect_port_test(pt_host.as_deref(), pt_port)
204 }
205 "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
206 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
207 "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
208 inspect_display_config(max_entries)
209 }
210 "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
211 inspect_ntp()
212 }
213 "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
214 inspect_cpu_power()
215 }
216 "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
217 inspect_credentials(max_entries)
218 }
219 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
220 inspect_tpm()
221 }
222 "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
223 inspect_latency()
224 }
225 "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
226 inspect_network_adapter()
227 }
228 "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
229 let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
230 let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
231 let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
232 let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
233 let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
234 inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
235 }
236 "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
237 let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
238 inspect_app_crashes(process_filter.as_deref(), max_entries)
239 }
240 other => Err(format!(
241 "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, network_profile, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, app_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, docker_filesystems, wsl, wsl_filesystems, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, 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.",
242 other
243 )),
244
245 };
246
247 result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
248}
249
250fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
251 let Some(scope) = admin_sensitive_topic_scope(topic) else {
252 return body;
253 };
254 let lower = body.to_lowercase();
255 let privilege_limited = lower.contains("access denied")
256 || lower.contains("administrator privilege is required")
257 || lower.contains("administrator privileges required")
258 || lower.contains("requires administrator")
259 || lower.contains("requires elevation")
260 || lower.contains("non-admin session")
261 || lower.contains("could not be fully determined from this session");
262 if !privilege_limited || lower.contains("=== elevation note ===") {
263 return body;
264 }
265
266 let mut annotated = body;
267 annotated.push_str("\n=== Elevation note ===\n");
268 annotated.push_str("- Hematite should stay non-admin by default.\n");
269 annotated.push_str(
270 "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
271 );
272 annotated.push_str(&format!(
273 "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
274 ));
275 annotated
276}
277
278fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
279 match topic {
280 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
281 Some("TPM / Secure Boot / firmware")
282 }
283 "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
284 "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
285 "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
286 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
287 "windows_features" | "optional_features" | "installed_features" | "features" => {
288 Some("Windows Features")
289 }
290 "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
291 _ => None,
292 }
293}
294
295#[cfg(test)]
296mod privilege_hint_tests {
297 use super::annotate_privilege_limited_output;
298
299 #[test]
300 fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
301 let body = "Host inspection: network\nError: Access denied.\n".to_string();
302 let annotated = annotate_privilege_limited_output("network", body.clone());
303 assert_eq!(annotated, body);
304 }
305
306 #[test]
307 fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
308 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();
309 let annotated = annotate_privilege_limited_output("tpm", body);
310 assert!(annotated.contains("=== Elevation note ==="));
311 assert!(annotated.contains("stay non-admin by default"));
312 assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
313 }
314}
315
316#[cfg(test)]
317mod event_query_tests {
318 use super::is_event_query_no_results_message;
319
320#[cfg(target_os = "windows")]
321 #[test]
322 fn treats_windows_no_results_message_as_empty_query() {
323 assert!(is_event_query_no_results_message(
324 "No events were found that match the specified selection criteria."
325 ));
326 }
327
328 #[cfg(target_os = "windows")]
329 #[test]
330 fn does_not_treat_real_errors_as_empty_query() {
331 assert!(!is_event_query_no_results_message("Access is denied."));
332 }
333}
334
335fn parse_max_entries(args: &Value) -> usize {
336 args.get("max_entries")
337 .and_then(|v| v.as_u64())
338 .map(|n| n as usize)
339 .unwrap_or(DEFAULT_MAX_ENTRIES)
340 .clamp(1, MAX_ENTRIES_CAP)
341}
342
343fn parse_port_filter(args: &Value) -> Option<u16> {
344 args.get("port")
345 .and_then(|v| v.as_u64())
346 .and_then(|n| u16::try_from(n).ok())
347}
348
349fn parse_name_filter(args: &Value) -> Option<String> {
350 args.get("name")
351 .and_then(|v| v.as_str())
352 .map(str::trim)
353 .filter(|value| !value.is_empty())
354 .map(|value| value.to_string())
355}
356
357fn parse_lookback_hours(args: &Value) -> Option<u32> {
358 args.get("lookback_hours")
359 .and_then(|v| v.as_u64())
360 .map(|n| n as u32)
361}
362
363fn parse_issue_text(args: &Value) -> Option<String> {
364 args.get("issue")
365 .and_then(|v| v.as_str())
366 .map(str::trim)
367 .filter(|value| !value.is_empty())
368 .map(|value| value.to_string())
369}
370
371#[cfg(target_os = "windows")]
372fn is_event_query_no_results_message(message: &str) -> bool {
373 let lower = message.to_ascii_lowercase();
374 lower.contains("no events were found")
375 || lower.contains("no events match the specified selection criteria")
376}
377
378fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
379 match args.get("path").and_then(|v| v.as_str()) {
380 Some(raw_path) => resolve_path(raw_path),
381 None => {
382 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
383 }
384 }
385}
386
387fn inspect_summary(max_entries: usize) -> Result<String, String> {
388 let current_dir =
389 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
390 let workspace_root = crate::tools::file_ops::workspace_root();
391 let workspace_mode = workspace_mode_label(&workspace_root);
392 let path_stats = analyze_path_env();
393 let toolchains = collect_toolchains();
394
395 let mut out = String::from("Host inspection: summary\n\n");
396 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
397 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
398 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
399 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
400 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
401 out.push_str(&format!(
402 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
403 path_stats.total_entries,
404 path_stats.unique_entries,
405 path_stats.duplicate_entries.len(),
406 path_stats.missing_entries.len()
407 ));
408
409 if toolchains.found.is_empty() {
410 out.push_str(
411 "- Toolchains found: none of the common developer tools were detected on PATH\n",
412 );
413 } else {
414 out.push_str("- Toolchains found:\n");
415 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
416 out.push_str(&format!(" - {}: {}\n", label, version));
417 }
418 if toolchains.found.len() > max_entries.min(8) {
419 out.push_str(&format!(
420 " - ... {} more found tools omitted\n",
421 toolchains.found.len() - max_entries.min(8)
422 ));
423 }
424 }
425
426 if !toolchains.missing.is_empty() {
427 out.push_str(&format!(
428 "- Common tools not detected on PATH: {}\n",
429 toolchains.missing.join(", ")
430 ));
431 }
432
433 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
434 match path {
435 Some(path) if path.exists() => match count_top_level_items(&path) {
436 Ok(count) => out.push_str(&format!(
437 "- {}: {} top-level items at {}\n",
438 label,
439 count,
440 path.display()
441 )),
442 Err(e) => out.push_str(&format!(
443 "- {}: exists at {} but could not inspect ({})\n",
444 label,
445 path.display(),
446 e
447 )),
448 },
449 Some(path) => out.push_str(&format!(
450 "- {}: expected at {} but not found\n",
451 label,
452 path.display()
453 )),
454 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
455 }
456 }
457
458 Ok(out.trim_end().to_string())
459}
460
461fn inspect_toolchains() -> Result<String, String> {
462 let report = collect_toolchains();
463 let mut out = String::from("Host inspection: toolchains\n\n");
464
465 if report.found.is_empty() {
466 out.push_str("- No common developer tools were detected on PATH.");
467 } else {
468 out.push_str("Detected developer tools:\n");
469 for (label, version) in report.found {
470 out.push_str(&format!("- {}: {}\n", label, version));
471 }
472 }
473
474 if !report.missing.is_empty() {
475 out.push_str("\nNot detected on PATH:\n");
476 for label in report.missing {
477 out.push_str(&format!("- {}\n", label));
478 }
479 }
480
481 Ok(out.trim_end().to_string())
482}
483
484fn inspect_path(max_entries: usize) -> Result<String, String> {
485 let path_stats = analyze_path_env();
486 let mut out = String::from("Host inspection: PATH\n\n");
487 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
488 out.push_str(&format!(
489 "- Unique entries: {}\n",
490 path_stats.unique_entries
491 ));
492 out.push_str(&format!(
493 "- Duplicate entries: {}\n",
494 path_stats.duplicate_entries.len()
495 ));
496 out.push_str(&format!(
497 "- Missing paths: {}\n",
498 path_stats.missing_entries.len()
499 ));
500
501 out.push_str("\nPATH entries:\n");
502 for entry in path_stats.entries.iter().take(max_entries) {
503 out.push_str(&format!("- {}\n", entry));
504 }
505 if path_stats.entries.len() > max_entries {
506 out.push_str(&format!(
507 "- ... {} more entries omitted\n",
508 path_stats.entries.len() - max_entries
509 ));
510 }
511
512 if !path_stats.duplicate_entries.is_empty() {
513 out.push_str("\nDuplicate entries:\n");
514 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
515 out.push_str(&format!("- {}\n", entry));
516 }
517 if path_stats.duplicate_entries.len() > max_entries {
518 out.push_str(&format!(
519 "- ... {} more duplicates omitted\n",
520 path_stats.duplicate_entries.len() - max_entries
521 ));
522 }
523 }
524
525 if !path_stats.missing_entries.is_empty() {
526 out.push_str("\nMissing directories:\n");
527 for entry in path_stats.missing_entries.iter().take(max_entries) {
528 out.push_str(&format!("- {}\n", entry));
529 }
530 if path_stats.missing_entries.len() > max_entries {
531 out.push_str(&format!(
532 "- ... {} more missing entries omitted\n",
533 path_stats.missing_entries.len() - max_entries
534 ));
535 }
536 }
537
538 Ok(out.trim_end().to_string())
539}
540
541fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
542 let path_stats = analyze_path_env();
543 let toolchains = collect_toolchains();
544 let package_managers = collect_package_managers();
545 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
546
547 let mut out = String::from("Host inspection: env_doctor\n\n");
548 out.push_str(&format!(
549 "- PATH health: {} duplicates, {} missing entries\n",
550 path_stats.duplicate_entries.len(),
551 path_stats.missing_entries.len()
552 ));
553 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
554 out.push_str(&format!(
555 "- Package managers found: {}\n",
556 package_managers.found.len()
557 ));
558
559 if !package_managers.found.is_empty() {
560 out.push_str("\nPackage managers:\n");
561 for (label, version) in package_managers.found.iter().take(max_entries) {
562 out.push_str(&format!("- {}: {}\n", label, version));
563 }
564 if package_managers.found.len() > max_entries {
565 out.push_str(&format!(
566 "- ... {} more package managers omitted\n",
567 package_managers.found.len() - max_entries
568 ));
569 }
570 }
571
572 if !path_stats.duplicate_entries.is_empty() {
573 out.push_str("\nDuplicate PATH entries:\n");
574 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
575 out.push_str(&format!("- {}\n", entry));
576 }
577 if path_stats.duplicate_entries.len() > max_entries.min(5) {
578 out.push_str(&format!(
579 "- ... {} more duplicate entries omitted\n",
580 path_stats.duplicate_entries.len() - max_entries.min(5)
581 ));
582 }
583 }
584
585 if !path_stats.missing_entries.is_empty() {
586 out.push_str("\nMissing PATH entries:\n");
587 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
588 out.push_str(&format!("- {}\n", entry));
589 }
590 if path_stats.missing_entries.len() > max_entries.min(5) {
591 out.push_str(&format!(
592 "- ... {} more missing entries omitted\n",
593 path_stats.missing_entries.len() - max_entries.min(5)
594 ));
595 }
596 }
597
598 if !findings.is_empty() {
599 out.push_str("\nFindings:\n");
600 for finding in findings.iter().take(max_entries.max(5)) {
601 out.push_str(&format!("- {}\n", finding));
602 }
603 if findings.len() > max_entries.max(5) {
604 out.push_str(&format!(
605 "- ... {} more findings omitted\n",
606 findings.len() - max_entries.max(5)
607 ));
608 }
609 } else {
610 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
611 }
612
613 out.push_str(
614 "\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.",
615 );
616
617 Ok(out.trim_end().to_string())
618}
619
620#[derive(Clone, Copy, Debug, Eq, PartialEq)]
621enum FixPlanKind {
622 EnvPath,
623 PortConflict,
624 LmStudio,
625 DriverInstall,
626 GroupPolicy,
627 FirewallRule,
628 SshKey,
629 WslSetup,
630 ServiceConfig,
631 WindowsActivation,
632 RegistryEdit,
633 ScheduledTaskCreate,
634 DiskCleanup,
635 DnsResolution,
636 Generic,
637}
638
639async fn inspect_fix_plan(
640 issue: Option<String>,
641 port_filter: Option<u16>,
642 max_entries: usize,
643) -> Result<String, String> {
644 let issue = issue.unwrap_or_else(|| {
645 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
646 .to_string()
647 });
648 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
649 match plan_kind {
650 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
651 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
652 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
653 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
654 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
655 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
656 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
657 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
658 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
659 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
660 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
661 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
662 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
663 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
664 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
665 }
666}
667
668fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
669 let lower = issue.to_ascii_lowercase();
670 if lower.contains("firewall rule")
673 || lower.contains("inbound rule")
674 || lower.contains("outbound rule")
675 || (lower.contains("firewall")
676 && (lower.contains("allow")
677 || lower.contains("block")
678 || lower.contains("create")
679 || lower.contains("open")))
680 {
681 FixPlanKind::FirewallRule
682 } else if port_filter.is_some()
683 || lower.contains("port ")
684 || lower.contains("address already in use")
685 || lower.contains("already in use")
686 || lower.contains("what owns port")
687 || lower.contains("listening on port")
688 {
689 FixPlanKind::PortConflict
690 } else if lower.contains("lm studio")
691 || lower.contains("localhost:1234")
692 || lower.contains("/v1/models")
693 || lower.contains("no coding model loaded")
694 || lower.contains("embedding model")
695 || lower.contains("server on port 1234")
696 || lower.contains("runtime refresh")
697 {
698 FixPlanKind::LmStudio
699 } else if lower.contains("driver")
700 || lower.contains("gpu driver")
701 || lower.contains("nvidia driver")
702 || lower.contains("amd driver")
703 || lower.contains("install driver")
704 || lower.contains("update driver")
705 {
706 FixPlanKind::DriverInstall
707 } else if lower.contains("group policy")
708 || lower.contains("gpedit")
709 || lower.contains("local policy")
710 || lower.contains("secpol")
711 || lower.contains("administrative template")
712 {
713 FixPlanKind::GroupPolicy
714 } else if lower.contains("ssh key")
715 || lower.contains("ssh-keygen")
716 || lower.contains("generate ssh")
717 || lower.contains("authorized_keys")
718 || lower.contains("id_rsa")
719 || lower.contains("id_ed25519")
720 {
721 FixPlanKind::SshKey
722 } else if lower.contains("wsl")
723 || lower.contains("windows subsystem for linux")
724 || lower.contains("install ubuntu")
725 || lower.contains("install linux on windows")
726 || lower.contains("wsl2")
727 {
728 FixPlanKind::WslSetup
729 } else if lower.contains("service")
730 && (lower.contains("start ")
731 || lower.contains("stop ")
732 || lower.contains("restart ")
733 || lower.contains("enable ")
734 || lower.contains("disable ")
735 || lower.contains("configure service"))
736 {
737 FixPlanKind::ServiceConfig
738 } else if lower.contains("activate windows")
739 || lower.contains("windows activation")
740 || lower.contains("product key")
741 || lower.contains("kms")
742 || lower.contains("not activated")
743 {
744 FixPlanKind::WindowsActivation
745 } else if lower.contains("registry")
746 || lower.contains("regedit")
747 || lower.contains("hklm")
748 || lower.contains("hkcu")
749 || lower.contains("reg add")
750 || lower.contains("reg delete")
751 || lower.contains("registry key")
752 {
753 FixPlanKind::RegistryEdit
754 } else if lower.contains("scheduled task")
755 || lower.contains("task scheduler")
756 || lower.contains("schtasks")
757 || lower.contains("create task")
758 || lower.contains("run on startup")
759 || lower.contains("run on schedule")
760 || lower.contains("cron")
761 {
762 FixPlanKind::ScheduledTaskCreate
763 } else if lower.contains("disk cleanup")
764 || lower.contains("free up disk")
765 || lower.contains("free up space")
766 || lower.contains("clear cache")
767 || lower.contains("disk full")
768 || lower.contains("low disk space")
769 || lower.contains("reclaim space")
770 {
771 FixPlanKind::DiskCleanup
772 } else if lower.contains("cargo")
773 || lower.contains("rustc")
774 || lower.contains("path")
775 || lower.contains("package manager")
776 || lower.contains("package managers")
777 || lower.contains("toolchain")
778 || lower.contains("winget")
779 || lower.contains("choco")
780 || lower.contains("scoop")
781 || lower.contains("python")
782 || lower.contains("node")
783 {
784 FixPlanKind::EnvPath
785 } else if lower.contains("dns ")
786 || lower.contains("nameserver")
787 || lower.contains("cannot resolve")
788 || lower.contains("nslookup")
789 || lower.contains("flushdns")
790 {
791 FixPlanKind::DnsResolution
792 } else {
793 FixPlanKind::Generic
794 }
795}
796
797fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
798 let path_stats = analyze_path_env();
799 let toolchains = collect_toolchains();
800 let package_managers = collect_package_managers();
801 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
802 let found_tools = toolchains
803 .found
804 .iter()
805 .map(|(label, _)| label.as_str())
806 .collect::<HashSet<_>>();
807 let found_managers = package_managers
808 .found
809 .iter()
810 .map(|(label, _)| label.as_str())
811 .collect::<HashSet<_>>();
812
813 let mut out = String::from("Host inspection: fix_plan\n\n");
814 out.push_str(&format!("- Requested issue: {}\n", issue));
815 out.push_str("- Fix-plan type: environment/path\n");
816 out.push_str(&format!(
817 "- PATH health: {} duplicates, {} missing entries\n",
818 path_stats.duplicate_entries.len(),
819 path_stats.missing_entries.len()
820 ));
821 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
822 out.push_str(&format!(
823 "- Package managers found: {}\n",
824 package_managers.found.len()
825 ));
826
827 out.push_str("\nLikely causes:\n");
828 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
829 out.push_str(
830 "- 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",
831 );
832 }
833 if path_stats.duplicate_entries.is_empty()
834 && path_stats.missing_entries.is_empty()
835 && !findings.is_empty()
836 {
837 for finding in findings.iter().take(max_entries.max(4)) {
838 out.push_str(&format!("- {}\n", finding));
839 }
840 } else {
841 if !path_stats.duplicate_entries.is_empty() {
842 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
843 }
844 if !path_stats.missing_entries.is_empty() {
845 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
846 }
847 }
848 if found_tools.contains("node")
849 && !found_managers.contains("npm")
850 && !found_managers.contains("pnpm")
851 {
852 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
853 }
854 if found_tools.contains("python")
855 && !found_managers.contains("pip")
856 && !found_managers.contains("uv")
857 && !found_managers.contains("pipx")
858 {
859 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
860 }
861
862 out.push_str("\nFix plan:\n");
863 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");
864 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
865 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");
866 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
867 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");
868 }
869 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
870 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
871 }
872 if found_tools.contains("node")
873 && !found_managers.contains("npm")
874 && !found_managers.contains("pnpm")
875 {
876 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");
877 }
878 if found_tools.contains("python")
879 && !found_managers.contains("pip")
880 && !found_managers.contains("uv")
881 && !found_managers.contains("pipx")
882 {
883 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");
884 }
885
886 if !path_stats.duplicate_entries.is_empty() {
887 out.push_str("\nExample duplicate PATH rows:\n");
888 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
889 out.push_str(&format!("- {}\n", entry));
890 }
891 }
892 if !path_stats.missing_entries.is_empty() {
893 out.push_str("\nExample missing PATH rows:\n");
894 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
895 out.push_str(&format!("- {}\n", entry));
896 }
897 }
898
899 out.push_str(
900 "\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.",
901 );
902 Ok(out.trim_end().to_string())
903}
904
905fn inspect_port_fix_plan(
906 issue: &str,
907 port_filter: Option<u16>,
908 max_entries: usize,
909) -> Result<String, String> {
910 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
911 let listeners = collect_listening_ports().unwrap_or_default();
912 let mut matching = listeners;
913 if let Some(port) = requested_port {
914 matching.retain(|entry| entry.port == port);
915 }
916 let processes = collect_processes().unwrap_or_default();
917
918 let mut out = String::from("Host inspection: fix_plan\n\n");
919 out.push_str(&format!("- Requested issue: {}\n", issue));
920 out.push_str("- Fix-plan type: port_conflict\n");
921 if let Some(port) = requested_port {
922 out.push_str(&format!("- Requested port: {}\n", port));
923 } else {
924 out.push_str("- Requested port: not parsed from the issue text\n");
925 }
926 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
927
928 if !matching.is_empty() {
929 out.push_str("\nCurrent listeners:\n");
930 for entry in matching.iter().take(max_entries.min(5)) {
931 let process_name = entry
932 .pid
933 .as_deref()
934 .and_then(|pid| pid.parse::<u32>().ok())
935 .and_then(|pid| {
936 processes
937 .iter()
938 .find(|process| process.pid == pid)
939 .map(|process| process.name.as_str())
940 })
941 .unwrap_or("unknown");
942 let pid = entry.pid.as_deref().unwrap_or("unknown");
943 out.push_str(&format!(
944 "- {} {} ({}) pid {} process {}\n",
945 entry.protocol, entry.local, entry.state, pid, process_name
946 ));
947 }
948 }
949
950 out.push_str("\nFix plan:\n");
951 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");
952 if !matching.is_empty() {
953 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");
954 } else {
955 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");
956 }
957 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
958 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");
959 out.push_str(
960 "\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.",
961 );
962 Ok(out.trim_end().to_string())
963}
964
965async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
966 let config = crate::agent::config::load_config();
967 let configured_api = config
968 .api_url
969 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
970 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
971 let reachability = probe_http_endpoint(&models_url).await;
972 let embed_model = detect_loaded_embed_model(&configured_api).await;
973
974 let mut out = String::from("Host inspection: fix_plan\n\n");
975 out.push_str(&format!("- Requested issue: {}\n", issue));
976 out.push_str("- Fix-plan type: lm_studio\n");
977 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
978 out.push_str(&format!("- Probe URL: {}\n", models_url));
979 match &reachability {
980 EndpointProbe::Reachable(status) => {
981 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
982 }
983 EndpointProbe::Unreachable(detail) => {
984 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
985 }
986 }
987 out.push_str(&format!(
988 "- Embedding model loaded: {}\n",
989 embed_model.as_deref().unwrap_or("none detected")
990 ));
991
992 out.push_str("\nFix plan:\n");
993 match reachability {
994 EndpointProbe::Reachable(_) => {
995 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");
996 }
997 EndpointProbe::Unreachable(_) => {
998 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");
999 }
1000 }
1001 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");
1002 out.push_str("- If chat works but semantic search does not, load the embedding model as a second resident model in LM Studio. Hematite expects a `nomic-embed` style model there.\n");
1003 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");
1004 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");
1005 if let Some(model) = embed_model {
1006 out.push_str(&format!(
1007 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1008 model
1009 ));
1010 }
1011 if max_entries > 0 {
1012 out.push_str(
1013 "\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.",
1014 );
1015 }
1016 Ok(out.trim_end().to_string())
1017}
1018
1019fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1020 #[cfg(target_os = "windows")]
1022 let gpu_info = {
1023 let out = Command::new("powershell")
1024 .args([
1025 "-NoProfile",
1026 "-NonInteractive",
1027 "-Command",
1028 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1029 ])
1030 .output()
1031 .ok()
1032 .and_then(|o| String::from_utf8(o.stdout).ok())
1033 .unwrap_or_default();
1034 out.trim().to_string()
1035 };
1036 #[cfg(not(target_os = "windows"))]
1037 let gpu_info = String::from("(GPU detection not available on this platform)");
1038
1039 let mut out = String::from("Host inspection: fix_plan\n\n");
1040 out.push_str(&format!("- Requested issue: {}\n", issue));
1041 out.push_str("- Fix-plan type: driver_install\n");
1042 if !gpu_info.is_empty() {
1043 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1044 }
1045 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1046 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1047 out.push_str(
1048 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1049 );
1050 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1051 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1052 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1053 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
1054 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1055 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");
1056 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1057 out.push_str("\nVerification:\n");
1058 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1059 out.push_str("- The DriverVersion should match what you installed.\n");
1060 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.");
1061 Ok(out.trim_end().to_string())
1062}
1063
1064fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1065 #[cfg(target_os = "windows")]
1067 let edition = {
1068 Command::new("powershell")
1069 .args([
1070 "-NoProfile",
1071 "-NonInteractive",
1072 "-Command",
1073 "(Get-CimInstance Win32_OperatingSystem).Caption",
1074 ])
1075 .output()
1076 .ok()
1077 .and_then(|o| String::from_utf8(o.stdout).ok())
1078 .unwrap_or_default()
1079 .trim()
1080 .to_string()
1081 };
1082 #[cfg(not(target_os = "windows"))]
1083 let edition = String::from("(Windows edition detection not available)");
1084
1085 let is_home = edition.to_lowercase().contains("home");
1086
1087 let mut out = String::from("Host inspection: fix_plan\n\n");
1088 out.push_str(&format!("- Requested issue: {}\n", issue));
1089 out.push_str("- Fix-plan type: group_policy\n");
1090 out.push_str(&format!(
1091 "- Windows edition detected: {}\n",
1092 if edition.is_empty() {
1093 "unknown".to_string()
1094 } else {
1095 edition.clone()
1096 }
1097 ));
1098
1099 if is_home {
1100 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1101 out.push_str("Options on Home edition:\n");
1102 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");
1103 out.push_str(
1104 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1105 );
1106 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1107 } else {
1108 out.push_str("\nFix plan — Editing Local Group Policy:\n");
1109 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1110 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1111 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1112 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1113 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1114 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
1115 }
1116 out.push_str("\nVerification:\n");
1117 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1118 out.push_str(
1119 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1120 );
1121 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.");
1122 Ok(out.trim_end().to_string())
1123}
1124
1125fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1126 #[cfg(target_os = "windows")]
1127 let profile_state = {
1128 Command::new("powershell")
1129 .args([
1130 "-NoProfile",
1131 "-NonInteractive",
1132 "-Command",
1133 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1134 ])
1135 .output()
1136 .ok()
1137 .and_then(|o| String::from_utf8(o.stdout).ok())
1138 .unwrap_or_default()
1139 .trim()
1140 .to_string()
1141 };
1142 #[cfg(not(target_os = "windows"))]
1143 let profile_state = String::new();
1144
1145 let mut out = String::from("Host inspection: fix_plan\n\n");
1146 out.push_str(&format!("- Requested issue: {}\n", issue));
1147 out.push_str("- Fix-plan type: firewall_rule\n");
1148 if !profile_state.is_empty() {
1149 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1150 }
1151 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1152 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1153 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1154 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1155 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1156 out.push_str("\nTo ALLOW an application through the firewall:\n");
1157 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1158 out.push_str("\nTo REMOVE a rule you created:\n");
1159 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1160 out.push_str("\nTo see existing custom rules:\n");
1161 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1162 out.push_str("\nVerification:\n");
1163 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1164 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.");
1165 Ok(out.trim_end().to_string())
1166}
1167
1168fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1169 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1170 let ssh_dir = home.join(".ssh");
1171 let has_ssh_dir = ssh_dir.exists();
1172 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1173 let has_rsa = ssh_dir.join("id_rsa").exists();
1174 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1175
1176 let mut out = String::from("Host inspection: fix_plan\n\n");
1177 out.push_str(&format!("- Requested issue: {}\n", issue));
1178 out.push_str("- Fix-plan type: ssh_key\n");
1179 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1180 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1181 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1182 out.push_str(&format!(
1183 "- authorized_keys found: {}\n",
1184 has_authorized_keys
1185 ));
1186
1187 if has_ed25519 {
1188 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1189 }
1190
1191 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1192 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1193 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1194 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1195 out.push_str(
1196 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1197 );
1198 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1199 out.push_str("3. Start the SSH agent and add your key:\n");
1200 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1201 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1202 out.push_str(" Start-Service ssh-agent\n");
1203 out.push_str(" # Then add the key (normal PowerShell):\n");
1204 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1205 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1206 out.push_str(" # Print your public key:\n");
1207 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1208 out.push_str(" # On the target server, append it:\n");
1209 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1210 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1211 out.push_str("5. Test the connection:\n");
1212 out.push_str(" ssh user@server-address\n");
1213 out.push_str("\nFor GitHub/GitLab:\n");
1214 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1215 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1216 out.push_str("- Test: ssh -T git@github.com\n");
1217 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.");
1218 Ok(out.trim_end().to_string())
1219}
1220
1221fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1222 #[cfg(target_os = "windows")]
1223 let wsl_status = {
1224 let out = Command::new("wsl")
1225 .args(["--status"])
1226 .output()
1227 .ok()
1228 .and_then(|o| {
1229 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1230 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1231 Some(format!("{}{}", stdout, stderr))
1232 })
1233 .unwrap_or_default();
1234 out.trim().to_string()
1235 };
1236 #[cfg(not(target_os = "windows"))]
1237 let wsl_status = String::new();
1238
1239 let wsl_installed =
1240 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1241
1242 let mut out = String::from("Host inspection: fix_plan\n\n");
1243 out.push_str(&format!("- Requested issue: {}\n", issue));
1244 out.push_str("- Fix-plan type: wsl_setup\n");
1245 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1246 if !wsl_status.is_empty() {
1247 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1248 }
1249
1250 if wsl_installed {
1251 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1252 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1253 out.push_str(" Available distros: wsl --list --online\n");
1254 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1255 out.push_str("3. Create your Linux username and password when prompted.\n");
1256 } else {
1257 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1258 out.push_str("1. Open PowerShell as Administrator.\n");
1259 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1260 out.push_str(" wsl --install\n");
1261 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1262 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1263 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1264 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1265 out.push_str(" wsl --set-default-version 2\n");
1266 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1267 out.push_str(" wsl --install -d Debian\n");
1268 out.push_str(" wsl --list --online # to see all available distros\n");
1269 }
1270 out.push_str("\nVerification:\n");
1271 out.push_str("- Run: wsl --list --verbose\n");
1272 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1273 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.");
1274 Ok(out.trim_end().to_string())
1275}
1276
1277fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1278 let lower = issue.to_ascii_lowercase();
1279 let service_hint = if lower.contains("ssh") {
1281 Some("sshd")
1282 } else if lower.contains("mysql") {
1283 Some("MySQL80")
1284 } else if lower.contains("postgres") || lower.contains("postgresql") {
1285 Some("postgresql")
1286 } else if lower.contains("redis") {
1287 Some("Redis")
1288 } else if lower.contains("nginx") {
1289 Some("nginx")
1290 } else if lower.contains("apache") {
1291 Some("Apache2.4")
1292 } else {
1293 None
1294 };
1295
1296 #[cfg(target_os = "windows")]
1297 let service_state = if let Some(svc) = service_hint {
1298 Command::new("powershell")
1299 .args([
1300 "-NoProfile",
1301 "-NonInteractive",
1302 "-Command",
1303 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1304 ])
1305 .output()
1306 .ok()
1307 .and_then(|o| String::from_utf8(o.stdout).ok())
1308 .unwrap_or_default()
1309 .trim()
1310 .to_string()
1311 } else {
1312 String::new()
1313 };
1314 #[cfg(not(target_os = "windows"))]
1315 let service_state = String::new();
1316
1317 let mut out = String::from("Host inspection: fix_plan\n\n");
1318 out.push_str(&format!("- Requested issue: {}\n", issue));
1319 out.push_str("- Fix-plan type: service_config\n");
1320 if let Some(svc) = service_hint {
1321 out.push_str(&format!("- Service detected in request: {}\n", svc));
1322 }
1323 if !service_state.is_empty() {
1324 out.push_str(&format!("- Current state: {}\n", service_state));
1325 }
1326
1327 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1328 out.push_str("\nStart a service:\n");
1329 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1330 out.push_str("\nStop a service:\n");
1331 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1332 out.push_str("\nRestart a service:\n");
1333 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1334 out.push_str("\nEnable a service to start automatically:\n");
1335 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1336 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1337 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1338 out.push_str("\nFind the exact service name:\n");
1339 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1340 out.push_str("\nVerification:\n");
1341 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1342 if let Some(svc) = service_hint {
1343 out.push_str(&format!(
1344 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1345 svc, svc
1346 ));
1347 }
1348 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.");
1349 Ok(out.trim_end().to_string())
1350}
1351
1352fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1353 #[cfg(target_os = "windows")]
1354 let activation_status = {
1355 Command::new("powershell")
1356 .args([
1357 "-NoProfile",
1358 "-NonInteractive",
1359 "-Command",
1360 "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 + ')' })\" }",
1361 ])
1362 .output()
1363 .ok()
1364 .and_then(|o| String::from_utf8(o.stdout).ok())
1365 .unwrap_or_default()
1366 .trim()
1367 .to_string()
1368 };
1369 #[cfg(not(target_os = "windows"))]
1370 let activation_status = String::new();
1371
1372 let is_licensed = activation_status.to_lowercase().contains("licensed")
1373 && !activation_status.to_lowercase().contains("not licensed");
1374
1375 let mut out = String::from("Host inspection: fix_plan\n\n");
1376 out.push_str(&format!("- Requested issue: {}\n", issue));
1377 out.push_str("- Fix-plan type: windows_activation\n");
1378 if !activation_status.is_empty() {
1379 out.push_str(&format!(
1380 "- Current activation state:\n{}\n",
1381 activation_status
1382 ));
1383 }
1384
1385 if is_licensed {
1386 out.push_str(
1387 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1388 );
1389 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1390 out.push_str(" (Forces an online activation attempt)\n");
1391 out.push_str("2. Check activation details: slmgr /dli\n");
1392 } else {
1393 out.push_str("\nFix plan — Activating Windows:\n");
1394 out.push_str("1. Check your current status first:\n");
1395 out.push_str(" slmgr /dli (basic info)\n");
1396 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1397 out.push_str("\n2. If you have a retail product key:\n");
1398 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1399 out.push_str(" slmgr /ato (activate online)\n");
1400 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1401 out.push_str(" - Go to Settings → System → Activation\n");
1402 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1403 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1404 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1405 out.push_str(" - Contact your IT department for the KMS server address\n");
1406 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1407 out.push_str(" - Activate: slmgr /ato\n");
1408 }
1409 out.push_str("\nVerification:\n");
1410 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1411 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1412 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.");
1413 Ok(out.trim_end().to_string())
1414}
1415
1416fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1417 let mut out = String::from("Host inspection: fix_plan\n\n");
1418 out.push_str(&format!("- Requested issue: {}\n", issue));
1419 out.push_str("- Fix-plan type: registry_edit\n");
1420 out.push_str(
1421 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1422 );
1423 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1424 out.push_str("\n1. Back up before you touch anything:\n");
1425 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1426 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1427 out.push_str(" # Or export the whole registry (takes a while):\n");
1428 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1429 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1430 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1431 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1432 out.push_str(
1433 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1434 );
1435 out.push_str("\n4. Create a new key:\n");
1436 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1437 out.push_str("\n5. Delete a value:\n");
1438 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1439 out.push_str("\n6. Restore from backup if something breaks:\n");
1440 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1441 out.push_str("\nCommon registry hives:\n");
1442 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1443 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1444 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1445 out.push_str("\nVerification:\n");
1446 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1447 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.");
1448 Ok(out.trim_end().to_string())
1449}
1450
1451fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1452 let mut out = String::from("Host inspection: fix_plan\n\n");
1453 out.push_str(&format!("- Requested issue: {}\n", issue));
1454 out.push_str("- Fix-plan type: scheduled_task_create\n");
1455 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1456 out.push_str("\nExample: Run a script at 9 AM every day\n");
1457 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1458 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1459 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1460 out.push_str("\nExample: Run at Windows startup\n");
1461 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1462 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1463 out.push_str("\nExample: Run at user logon\n");
1464 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1465 out.push_str(
1466 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1467 );
1468 out.push_str("\nExample: Run every 30 minutes\n");
1469 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1470 out.push_str("\nView all tasks:\n");
1471 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1472 out.push_str("\nDelete a task:\n");
1473 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1474 out.push_str("\nRun a task immediately:\n");
1475 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1476 out.push_str("\nVerification:\n");
1477 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1478 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.");
1479 Ok(out.trim_end().to_string())
1480}
1481
1482fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1483 #[cfg(target_os = "windows")]
1484 let disk_info = {
1485 Command::new("powershell")
1486 .args([
1487 "-NoProfile",
1488 "-NonInteractive",
1489 "-Command",
1490 "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\" }",
1491 ])
1492 .output()
1493 .ok()
1494 .and_then(|o| String::from_utf8(o.stdout).ok())
1495 .unwrap_or_default()
1496 .trim()
1497 .to_string()
1498 };
1499 #[cfg(not(target_os = "windows"))]
1500 let disk_info = String::new();
1501
1502 let mut out = String::from("Host inspection: fix_plan\n\n");
1503 out.push_str(&format!("- Requested issue: {}\n", issue));
1504 out.push_str("- Fix-plan type: disk_cleanup\n");
1505 if !disk_info.is_empty() {
1506 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1507 }
1508 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1509 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1510 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1511 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1512 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1513 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1514 out.push_str(" Stop-Service wuauserv\n");
1515 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1516 out.push_str(" Start-Service wuauserv\n");
1517 out.push_str("\n3. Clear Windows Temp folder:\n");
1518 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1519 out.push_str(
1520 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1521 );
1522 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1523 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1524 out.push_str(" - npm cache: npm cache clean --force\n");
1525 out.push_str(" - pip cache: pip cache purge\n");
1526 out.push_str(
1527 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1528 );
1529 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1530 out.push_str("\n5. Check for large files:\n");
1531 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");
1532 out.push_str("\nVerification:\n");
1533 out.push_str(
1534 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1535 );
1536 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.");
1537 Ok(out.trim_end().to_string())
1538}
1539
1540fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1541 let mut out = String::from("Host inspection: fix_plan\n\n");
1542 out.push_str(&format!("- Requested issue: {}\n", issue));
1543 out.push_str("- Fix-plan type: generic\n");
1544 out.push_str(
1545 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1546 Structured lanes available:\n\
1547 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1548 - Port conflict (address already in use, what owns port)\n\
1549 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1550 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1551 - Group Policy (gpedit, local policy, administrative template)\n\
1552 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1553 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1554 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1555 - Service config (start/stop/restart/enable/disable a service)\n\
1556 - Windows activation (product key, not activated, kms)\n\
1557 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1558 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1559 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1560 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1561 );
1562 Ok(out.trim_end().to_string())
1563}
1564
1565fn inspect_resource_load() -> Result<String, String> {
1566 #[cfg(target_os = "windows")]
1567 {
1568 let output = Command::new("powershell")
1569 .args([
1570 "-NoProfile",
1571 "-Command",
1572 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1573 ])
1574 .output()
1575 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1576
1577 let text = String::from_utf8_lossy(&output.stdout);
1578 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1579
1580 let cpu_load = lines
1581 .next()
1582 .and_then(|l| l.parse::<u32>().ok())
1583 .unwrap_or(0);
1584 let mem_json = lines.collect::<Vec<_>>().join("");
1585 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1586
1587 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1588 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1589 let used_kb = total_kb.saturating_sub(free_kb);
1590 let mem_percent = if total_kb > 0 {
1591 (used_kb * 100) / total_kb
1592 } else {
1593 0
1594 };
1595
1596 let mut out = String::from("Host inspection: resource_load\n\n");
1597 out.push_str("**System Performance Summary:**\n");
1598 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1599 out.push_str(&format!(
1600 "- Memory Usage: {} / {} ({}%)\n",
1601 human_bytes(used_kb * 1024),
1602 human_bytes(total_kb * 1024),
1603 mem_percent
1604 ));
1605
1606 if cpu_load > 85 {
1607 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1608 }
1609 if mem_percent > 90 {
1610 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1611 }
1612
1613 Ok(out)
1614 }
1615 #[cfg(not(target_os = "windows"))]
1616 {
1617 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1618 }
1619}
1620
1621#[derive(Debug)]
1622enum EndpointProbe {
1623 Reachable(u16),
1624 Unreachable(String),
1625}
1626
1627async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1628 let client = match reqwest::Client::builder()
1629 .timeout(std::time::Duration::from_secs(3))
1630 .build()
1631 {
1632 Ok(client) => client,
1633 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1634 };
1635
1636 match client.get(url).send().await {
1637 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1638 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1639 }
1640}
1641
1642async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1643 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1644 let url = format!("{}/api/v0/models", base);
1645 let client = reqwest::Client::builder()
1646 .timeout(std::time::Duration::from_secs(3))
1647 .build()
1648 .ok()?;
1649
1650 #[derive(serde::Deserialize)]
1651 struct ModelList {
1652 data: Vec<ModelEntry>,
1653 }
1654 #[derive(serde::Deserialize)]
1655 struct ModelEntry {
1656 id: String,
1657 #[serde(rename = "type", default)]
1658 model_type: String,
1659 #[serde(default)]
1660 state: String,
1661 }
1662
1663 let response = client.get(url).send().await.ok()?;
1664 let models = response.json::<ModelList>().await.ok()?;
1665 models
1666 .data
1667 .into_iter()
1668 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1669 .map(|model| model.id)
1670}
1671
1672fn first_port_in_text(text: &str) -> Option<u16> {
1673 text.split(|c: char| !c.is_ascii_digit())
1674 .find(|fragment| !fragment.is_empty())
1675 .and_then(|fragment| fragment.parse::<u16>().ok())
1676}
1677
1678fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1679 let mut processes = collect_processes()?;
1680 if let Some(filter) = name_filter.as_deref() {
1681 let lowered = filter.to_ascii_lowercase();
1682 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1683 }
1684 processes.sort_by(|a, b| {
1685 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1686 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1687 b_cpu
1688 .partial_cmp(&a_cpu)
1689 .unwrap_or(std::cmp::Ordering::Equal)
1690 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1691 .then_with(|| a.name.cmp(&b.name))
1692 .then_with(|| a.pid.cmp(&b.pid))
1693 });
1694
1695 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1696
1697 let mut out = String::from("Host inspection: processes\n\n");
1698 if let Some(filter) = name_filter.as_deref() {
1699 out.push_str(&format!("- Filter name: {}\n", filter));
1700 }
1701 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1702 out.push_str(&format!(
1703 "- Total reported working set: {}\n",
1704 human_bytes(total_memory)
1705 ));
1706
1707 if processes.is_empty() {
1708 out.push_str("\nNo running processes matched.");
1709 return Ok(out);
1710 }
1711
1712 out.push_str("\nTop processes by resource usage:\n");
1713 for entry in processes.iter().take(max_entries) {
1714 let cpu_str = entry
1715 .cpu_percent
1716 .map(|p| format!(" [CPU: {:.1}%]", p))
1717 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1718 .unwrap_or_default();
1719 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1720 format!(" [I/O R:{}/W:{}]", r, w)
1721 } else {
1722 " [I/O unknown]".to_string()
1723 };
1724 out.push_str(&format!(
1725 "- {} (pid {}) - {}{}{}{}\n",
1726 entry.name,
1727 entry.pid,
1728 human_bytes(entry.memory_bytes),
1729 cpu_str,
1730 io_str,
1731 entry
1732 .detail
1733 .as_deref()
1734 .map(|detail| format!(" [{}]", detail))
1735 .unwrap_or_default()
1736 ));
1737 }
1738 if processes.len() > max_entries {
1739 out.push_str(&format!(
1740 "- ... {} more processes omitted\n",
1741 processes.len() - max_entries
1742 ));
1743 }
1744
1745 Ok(out.trim_end().to_string())
1746}
1747
1748fn inspect_network(max_entries: usize) -> Result<String, String> {
1749 let adapters = collect_network_adapters()?;
1750 let active_count = adapters
1751 .iter()
1752 .filter(|adapter| adapter.is_active())
1753 .count();
1754 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1755
1756 let mut out = String::from("Host inspection: network\n\n");
1757 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1758 out.push_str(&format!("- Active adapters: {}\n", active_count));
1759 out.push_str(&format!(
1760 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1761 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1762 ));
1763
1764 if adapters.is_empty() {
1765 out.push_str("\nNo adapter details were detected.");
1766 return Ok(out);
1767 }
1768
1769 out.push_str("\nAdapter summary:\n");
1770 for adapter in adapters.iter().take(max_entries) {
1771 let status = if adapter.is_active() {
1772 "active"
1773 } else if adapter.disconnected {
1774 "disconnected"
1775 } else {
1776 "idle"
1777 };
1778 let mut details = vec![status.to_string()];
1779 if !adapter.ipv4.is_empty() {
1780 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1781 }
1782 if !adapter.ipv6.is_empty() {
1783 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1784 }
1785 if !adapter.gateways.is_empty() {
1786 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1787 }
1788 if !adapter.dns_servers.is_empty() {
1789 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1790 }
1791 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1792 }
1793 if adapters.len() > max_entries {
1794 out.push_str(&format!(
1795 "- ... {} more adapters omitted\n",
1796 adapters.len() - max_entries
1797 ));
1798 }
1799
1800 Ok(out.trim_end().to_string())
1801}
1802
1803fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1804 let mut out = String::from("Host inspection: lan_discovery\n\n");
1805
1806 #[cfg(target_os = "windows")]
1807 {
1808 let n = max_entries.clamp(5, 20);
1809 let adapters = collect_network_adapters()?;
1810 let services = collect_services().unwrap_or_default();
1811 let active_adapters: Vec<&NetworkAdapter> = adapters
1812 .iter()
1813 .filter(|adapter| adapter.is_active())
1814 .collect();
1815 let gateways: Vec<String> = active_adapters
1816 .iter()
1817 .flat_map(|adapter| adapter.gateways.clone())
1818 .collect::<HashSet<_>>()
1819 .into_iter()
1820 .collect();
1821
1822 let neighbor_script = r#"
1823$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1824 Where-Object {
1825 $_.IPAddress -notlike '127.*' -and
1826 $_.IPAddress -notlike '169.254*' -and
1827 $_.State -notin @('Unreachable','Invalid')
1828 } |
1829 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1830$neighbors | ConvertTo-Json -Compress
1831"#;
1832 let neighbor_text = Command::new("powershell")
1833 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1834 .output()
1835 .ok()
1836 .and_then(|o| String::from_utf8(o.stdout).ok())
1837 .unwrap_or_default();
1838 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1839 .into_iter()
1840 .take(n)
1841 .collect();
1842
1843 let listener_script = r#"
1844Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1845 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1846 Select-Object LocalAddress, LocalPort, OwningProcess |
1847 ForEach-Object {
1848 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1849 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1850 }
1851"#;
1852 let listener_text = Command::new("powershell")
1853 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1854 .output()
1855 .ok()
1856 .and_then(|o| String::from_utf8(o.stdout).ok())
1857 .unwrap_or_default();
1858 let listeners: Vec<(String, u16, String, String)> = listener_text
1859 .lines()
1860 .filter_map(|line| {
1861 let parts: Vec<&str> = line.trim().split('|').collect();
1862 if parts.len() < 4 {
1863 return None;
1864 }
1865 Some((
1866 parts[0].to_string(),
1867 parts[1].parse::<u16>().ok()?,
1868 parts[2].to_string(),
1869 parts[3].to_string(),
1870 ))
1871 })
1872 .take(n)
1873 .collect();
1874
1875 let smb_mapping_script = r#"
1876Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1877 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1878"#;
1879 let smb_mappings: Vec<String> = Command::new("powershell")
1880 .args([
1881 "-NoProfile",
1882 "-NonInteractive",
1883 "-Command",
1884 smb_mapping_script,
1885 ])
1886 .output()
1887 .ok()
1888 .and_then(|o| String::from_utf8(o.stdout).ok())
1889 .unwrap_or_default()
1890 .lines()
1891 .take(n)
1892 .map(|line| line.trim().to_string())
1893 .filter(|line| !line.is_empty())
1894 .collect();
1895
1896 let smb_connections_script = r#"
1897Get-SmbConnection -ErrorAction SilentlyContinue |
1898 Select-Object ServerName, ShareName, NumOpens |
1899 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1900"#;
1901 let smb_connections: Vec<String> = Command::new("powershell")
1902 .args([
1903 "-NoProfile",
1904 "-NonInteractive",
1905 "-Command",
1906 smb_connections_script,
1907 ])
1908 .output()
1909 .ok()
1910 .and_then(|o| String::from_utf8(o.stdout).ok())
1911 .unwrap_or_default()
1912 .lines()
1913 .take(n)
1914 .map(|line| line.trim().to_string())
1915 .filter(|line| !line.is_empty())
1916 .collect();
1917
1918 let discovery_service_names = [
1919 "FDResPub",
1920 "fdPHost",
1921 "SSDPSRV",
1922 "upnphost",
1923 "LanmanServer",
1924 "LanmanWorkstation",
1925 "lmhosts",
1926 ];
1927 let discovery_services: Vec<&ServiceEntry> = services
1928 .iter()
1929 .filter(|entry| {
1930 discovery_service_names
1931 .iter()
1932 .any(|name| entry.name.eq_ignore_ascii_case(name))
1933 })
1934 .collect();
1935
1936 let mut findings = Vec::new();
1937 if active_adapters.is_empty() {
1938 findings.push(AuditFinding {
1939 finding: "No active LAN adapters were detected.".to_string(),
1940 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1941 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(),
1942 });
1943 }
1944
1945 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
1946 .iter()
1947 .copied()
1948 .filter(|entry| {
1949 !entry.status.eq_ignore_ascii_case("running")
1950 && !entry.status.eq_ignore_ascii_case("active")
1951 })
1952 .collect();
1953 if !stopped_discovery_services.is_empty() {
1954 let names = stopped_discovery_services
1955 .iter()
1956 .map(|entry| entry.name.as_str())
1957 .collect::<Vec<_>>()
1958 .join(", ");
1959 findings.push(AuditFinding {
1960 finding: format!("Discovery-related services are not running: {names}"),
1961 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
1962 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(),
1963 });
1964 }
1965
1966 if listeners.is_empty() {
1967 findings.push(AuditFinding {
1968 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
1969 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
1970 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(),
1971 });
1972 }
1973
1974 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
1975 findings.push(AuditFinding {
1976 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
1977 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
1978 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(),
1979 });
1980 }
1981
1982 out.push_str("=== Findings ===\n");
1983 if findings.is_empty() {
1984 out.push_str(
1985 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
1986 );
1987 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
1988 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");
1989 } else {
1990 for finding in &findings {
1991 out.push_str(&format!("- Finding: {}\n", finding.finding));
1992 out.push_str(&format!(" Impact: {}\n", finding.impact));
1993 out.push_str(&format!(" Fix: {}\n", finding.fix));
1994 }
1995 }
1996
1997 out.push_str("\n=== Active adapter and gateway summary ===\n");
1998 if active_adapters.is_empty() {
1999 out.push_str("- No active adapters detected.\n");
2000 } else {
2001 for adapter in active_adapters.iter().take(n) {
2002 let ipv4 = if adapter.ipv4.is_empty() {
2003 "no IPv4".to_string()
2004 } else {
2005 adapter.ipv4.join(", ")
2006 };
2007 let gateway = if adapter.gateways.is_empty() {
2008 "no gateway".to_string()
2009 } else {
2010 adapter.gateways.join(", ")
2011 };
2012 out.push_str(&format!(
2013 "- {} | IPv4: {} | Gateway: {}\n",
2014 adapter.name, ipv4, gateway
2015 ));
2016 }
2017 }
2018
2019 out.push_str("\n=== Neighborhood evidence ===\n");
2020 out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2021 out.push_str(&format!(
2022 "- Neighbor entries observed: {}\n",
2023 neighbors.len()
2024 ));
2025 if neighbors.is_empty() {
2026 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2027 } else {
2028 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2029 out.push_str(&format!(
2030 "- {} on {} | MAC: {} | State: {}\n",
2031 ip, iface, mac, state
2032 ));
2033 }
2034 }
2035
2036 out.push_str("\n=== Discovery services ===\n");
2037 if discovery_services.is_empty() {
2038 out.push_str("- Discovery service status unavailable.\n");
2039 } else {
2040 for entry in discovery_services.iter().take(n) {
2041 let startup = entry.startup.as_deref().unwrap_or("unknown");
2042 out.push_str(&format!(
2043 "- {} | Status: {} | Startup: {}\n",
2044 entry.name, entry.status, startup
2045 ));
2046 }
2047 }
2048
2049 out.push_str("\n=== Discovery listener surface ===\n");
2050 if listeners.is_empty() {
2051 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2052 } else {
2053 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2054 let label = match *port {
2055 137 => "NetBIOS Name Service",
2056 138 => "NetBIOS Datagram",
2057 1900 => "SSDP/UPnP",
2058 5353 => "mDNS",
2059 5355 => "LLMNR",
2060 _ => "Discovery",
2061 };
2062 let proc_label = if proc_name.is_empty() {
2063 "unknown".to_string()
2064 } else {
2065 proc_name.clone()
2066 };
2067 out.push_str(&format!(
2068 "- {}:{} | {} | PID {} ({})\n",
2069 addr, port, label, pid, proc_label
2070 ));
2071 }
2072 }
2073
2074 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2075 if smb_mappings.is_empty() && smb_connections.is_empty() {
2076 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2077 } else {
2078 if !smb_mappings.is_empty() {
2079 out.push_str("- Mapped drives:\n");
2080 for mapping in smb_mappings.iter().take(n) {
2081 let parts: Vec<&str> = mapping.split('|').collect();
2082 if parts.len() >= 2 {
2083 out.push_str(&format!(" - {} -> {}\n", parts[0], parts[1]));
2084 }
2085 }
2086 }
2087 if !smb_connections.is_empty() {
2088 out.push_str("- Active SMB connections:\n");
2089 for connection in smb_connections.iter().take(n) {
2090 let parts: Vec<&str> = connection.split('|').collect();
2091 if parts.len() >= 3 {
2092 out.push_str(&format!(
2093 " - {}\\{} | Opens: {}\n",
2094 parts[0], parts[1], parts[2]
2095 ));
2096 }
2097 }
2098 }
2099 }
2100 }
2101
2102 #[cfg(not(target_os = "windows"))]
2103 {
2104 let n = max_entries.clamp(5, 20);
2105 let adapters = collect_network_adapters()?;
2106 let arp_output = Command::new("ip")
2107 .args(["neigh"])
2108 .output()
2109 .ok()
2110 .and_then(|o| String::from_utf8(o.stdout).ok())
2111 .unwrap_or_default();
2112 let neighbors: Vec<&str> = arp_output
2113 .lines()
2114 .filter(|line| !line.trim().is_empty())
2115 .take(n)
2116 .collect();
2117
2118 out.push_str("=== Findings ===\n");
2119 if adapters.iter().any(|adapter| adapter.is_active()) {
2120 out.push_str(
2121 "- Finding: LAN discovery support is partially available on this platform.\n",
2122 );
2123 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2124 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2125 } else {
2126 out.push_str("- Finding: No active LAN adapters were detected.\n");
2127 out.push_str(
2128 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2129 );
2130 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2131 }
2132
2133 out.push_str("\n=== Active adapter and gateway summary ===\n");
2134 if adapters.is_empty() {
2135 out.push_str("- No adapters detected.\n");
2136 } else {
2137 for adapter in adapters.iter().take(n) {
2138 let ipv4 = if adapter.ipv4.is_empty() {
2139 "no IPv4".to_string()
2140 } else {
2141 adapter.ipv4.join(", ")
2142 };
2143 let gateway = if adapter.gateways.is_empty() {
2144 "no gateway".to_string()
2145 } else {
2146 adapter.gateways.join(", ")
2147 };
2148 out.push_str(&format!(
2149 "- {} | IPv4: {} | Gateway: {}\n",
2150 adapter.name, ipv4, gateway
2151 ));
2152 }
2153 }
2154
2155 out.push_str("\n=== Neighborhood evidence ===\n");
2156 if neighbors.is_empty() {
2157 out.push_str("- No neighbor entries detected.\n");
2158 } else {
2159 for line in neighbors {
2160 out.push_str(&format!("- {}\n", line.trim()));
2161 }
2162 }
2163 }
2164
2165 Ok(out.trim_end().to_string())
2166}
2167
2168fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2169 let mut services = collect_services()?;
2170 if let Some(filter) = name_filter.as_deref() {
2171 let lowered = filter.to_ascii_lowercase();
2172 services.retain(|entry| {
2173 entry.name.to_ascii_lowercase().contains(&lowered)
2174 || entry
2175 .display_name
2176 .as_deref()
2177 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2178 .unwrap_or(false)
2179 });
2180 }
2181
2182 services.sort_by(|a, b| {
2183 let a_running =
2184 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2185 let b_running =
2186 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2187 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2188 });
2189
2190 let running = services
2191 .iter()
2192 .filter(|entry| {
2193 entry.status.eq_ignore_ascii_case("running")
2194 || entry.status.eq_ignore_ascii_case("active")
2195 })
2196 .count();
2197 let failed = services
2198 .iter()
2199 .filter(|entry| {
2200 entry.status.eq_ignore_ascii_case("failed")
2201 || entry.status.eq_ignore_ascii_case("error")
2202 || entry.status.eq_ignore_ascii_case("stopped")
2203 })
2204 .count();
2205
2206 let mut out = String::from("Host inspection: services\n\n");
2207 if let Some(filter) = name_filter.as_deref() {
2208 out.push_str(&format!("- Filter name: {}\n", filter));
2209 }
2210 out.push_str(&format!("- Services found: {}\n", services.len()));
2211 out.push_str(&format!("- Running/active: {}\n", running));
2212 out.push_str(&format!("- Failed/stopped: {}\n", failed));
2213
2214 if services.is_empty() {
2215 out.push_str("\nNo services matched.");
2216 return Ok(out);
2217 }
2218
2219 let per_section = (max_entries / 2).max(5);
2221
2222 let running_services: Vec<_> = services
2223 .iter()
2224 .filter(|e| {
2225 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2226 })
2227 .collect();
2228 let stopped_services: Vec<_> = services
2229 .iter()
2230 .filter(|e| {
2231 e.status.eq_ignore_ascii_case("stopped")
2232 || e.status.eq_ignore_ascii_case("failed")
2233 || e.status.eq_ignore_ascii_case("error")
2234 })
2235 .collect();
2236
2237 let fmt_entry = |entry: &&ServiceEntry| {
2238 let startup = entry
2239 .startup
2240 .as_deref()
2241 .map(|v| format!(" | startup {}", v))
2242 .unwrap_or_default();
2243 let logon = entry
2244 .start_name
2245 .as_deref()
2246 .map(|v| format!(" | LogOn: {}", v))
2247 .unwrap_or_default();
2248 let display = entry
2249 .display_name
2250 .as_deref()
2251 .filter(|v| *v != &entry.name)
2252 .map(|v| format!(" [{}]", v))
2253 .unwrap_or_default();
2254 format!(
2255 "- {}{} - {}{}{}\n",
2256 entry.name, display, entry.status, startup, logon
2257 )
2258 };
2259
2260 out.push_str(&format!(
2261 "\nRunning services ({} total, showing up to {}):\n",
2262 running_services.len(),
2263 per_section
2264 ));
2265 for entry in running_services.iter().take(per_section) {
2266 out.push_str(&fmt_entry(entry));
2267 }
2268 if running_services.len() > per_section {
2269 out.push_str(&format!(
2270 "- ... {} more running services omitted\n",
2271 running_services.len() - per_section
2272 ));
2273 }
2274
2275 out.push_str(&format!(
2276 "\nStopped/failed services ({} total, showing up to {}):\n",
2277 stopped_services.len(),
2278 per_section
2279 ));
2280 for entry in stopped_services.iter().take(per_section) {
2281 out.push_str(&fmt_entry(entry));
2282 }
2283 if stopped_services.len() > per_section {
2284 out.push_str(&format!(
2285 "- ... {} more stopped services omitted\n",
2286 stopped_services.len() - per_section
2287 ));
2288 }
2289
2290 Ok(out.trim_end().to_string())
2291}
2292
2293async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2294 inspect_directory("Disk", path, max_entries).await
2295}
2296
2297fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2298 let mut listeners = collect_listening_ports()?;
2299 if let Some(port) = port_filter {
2300 listeners.retain(|entry| entry.port == port);
2301 }
2302 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2303
2304 let mut out = String::from("Host inspection: ports\n\n");
2305 if let Some(port) = port_filter {
2306 out.push_str(&format!("- Filter port: {}\n", port));
2307 }
2308 out.push_str(&format!(
2309 "- Listening endpoints found: {}\n",
2310 listeners.len()
2311 ));
2312
2313 if listeners.is_empty() {
2314 out.push_str("\nNo listening endpoints matched.");
2315 return Ok(out);
2316 }
2317
2318 out.push_str("\nListening endpoints:\n");
2319 for entry in listeners.iter().take(max_entries) {
2320 let pid_str = entry
2321 .pid
2322 .as_deref()
2323 .map(|p| format!(" pid {}", p))
2324 .unwrap_or_default();
2325 let name_str = entry
2326 .process_name
2327 .as_deref()
2328 .map(|n| format!(" [{}]", n))
2329 .unwrap_or_default();
2330 out.push_str(&format!(
2331 "- {} {} ({}){}{}\n",
2332 entry.protocol, entry.local, entry.state, pid_str, name_str
2333 ));
2334 }
2335 if listeners.len() > max_entries {
2336 out.push_str(&format!(
2337 "- ... {} more listening endpoints omitted\n",
2338 listeners.len() - max_entries
2339 ));
2340 }
2341
2342 Ok(out.trim_end().to_string())
2343}
2344
2345fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2346 if !path.exists() {
2347 return Err(format!("Path does not exist: {}", path.display()));
2348 }
2349 if !path.is_dir() {
2350 return Err(format!("Path is not a directory: {}", path.display()));
2351 }
2352
2353 let markers = collect_project_markers(&path);
2354 let hematite_state = collect_hematite_state(&path);
2355 let git_state = inspect_git_state(&path);
2356 let release_state = inspect_release_artifacts(&path);
2357
2358 let mut out = String::from("Host inspection: repo_doctor\n\n");
2359 out.push_str(&format!("- Path: {}\n", path.display()));
2360 out.push_str(&format!(
2361 "- Workspace mode: {}\n",
2362 workspace_mode_for_path(&path)
2363 ));
2364
2365 if markers.is_empty() {
2366 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");
2367 } else {
2368 out.push_str("- Project markers:\n");
2369 for marker in markers.iter().take(max_entries) {
2370 out.push_str(&format!(" - {}\n", marker));
2371 }
2372 }
2373
2374 match git_state {
2375 Some(git) => {
2376 out.push_str(&format!("- Git root: {}\n", git.root.display()));
2377 out.push_str(&format!("- Git branch: {}\n", git.branch));
2378 out.push_str(&format!("- Git status: {}\n", git.status_label()));
2379 }
2380 None => out.push_str("- Git: not inside a detected work tree\n"),
2381 }
2382
2383 out.push_str(&format!(
2384 "- Hematite docs/imports/reports: {}/{}/{}\n",
2385 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2386 ));
2387 if hematite_state.workspace_profile {
2388 out.push_str("- Workspace profile: present\n");
2389 } else {
2390 out.push_str("- Workspace profile: absent\n");
2391 }
2392
2393 if let Some(release) = release_state {
2394 out.push_str(&format!("- Cargo version: {}\n", release.version));
2395 out.push_str(&format!(
2396 "- Windows artifacts for current version: {}/{}/{}\n",
2397 bool_label(release.portable_dir),
2398 bool_label(release.portable_zip),
2399 bool_label(release.setup_exe)
2400 ));
2401 }
2402
2403 Ok(out.trim_end().to_string())
2404}
2405
2406async fn inspect_known_directory(
2407 label: &str,
2408 path: Option<PathBuf>,
2409 max_entries: usize,
2410) -> Result<String, String> {
2411 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2412 inspect_directory(label, path, max_entries).await
2413}
2414
2415async fn inspect_directory(
2416 label: &str,
2417 path: PathBuf,
2418 max_entries: usize,
2419) -> Result<String, String> {
2420 let label = label.to_string();
2421 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2422 .await
2423 .map_err(|e| format!("inspect_host task failed: {e}"))?
2424}
2425
2426fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2427 if !path.exists() {
2428 return Err(format!("Path does not exist: {}", path.display()));
2429 }
2430 if !path.is_dir() {
2431 return Err(format!("Path is not a directory: {}", path.display()));
2432 }
2433
2434 let mut top_level_entries = Vec::new();
2435 for entry in fs::read_dir(path)
2436 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2437 {
2438 match entry {
2439 Ok(entry) => top_level_entries.push(entry),
2440 Err(_) => continue,
2441 }
2442 }
2443 top_level_entries.sort_by_key(|entry| entry.file_name());
2444
2445 let top_level_count = top_level_entries.len();
2446 let mut sample_names = Vec::new();
2447 let mut largest_entries = Vec::new();
2448 let mut aggregate = PathAggregate::default();
2449 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2450
2451 for entry in top_level_entries {
2452 let name = entry.file_name().to_string_lossy().to_string();
2453 if sample_names.len() < max_entries {
2454 sample_names.push(name.clone());
2455 }
2456 let kind = match entry.file_type() {
2457 Ok(ft) if ft.is_dir() => "dir",
2458 Ok(ft) if ft.is_symlink() => "symlink",
2459 _ => "file",
2460 };
2461 let stats = measure_path(&entry.path(), &mut budget);
2462 aggregate.merge(&stats);
2463 largest_entries.push(LargestEntry {
2464 name,
2465 kind,
2466 bytes: stats.total_bytes,
2467 });
2468 }
2469
2470 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2471
2472 let mut out = format!("Directory inspection: {}\n\n", label);
2473 out.push_str(&format!("- Path: {}\n", path.display()));
2474 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2475 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2476 out.push_str(&format!(
2477 "- Recursive directories: {}\n",
2478 aggregate.dir_count
2479 ));
2480 out.push_str(&format!(
2481 "- Total size: {}{}\n",
2482 human_bytes(aggregate.total_bytes),
2483 if aggregate.partial {
2484 " (partial scan)"
2485 } else {
2486 ""
2487 }
2488 ));
2489 if aggregate.skipped_entries > 0 {
2490 out.push_str(&format!(
2491 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2492 aggregate.skipped_entries
2493 ));
2494 }
2495
2496 if !largest_entries.is_empty() {
2497 out.push_str("\nLargest top-level entries:\n");
2498 for entry in largest_entries.iter().take(max_entries) {
2499 out.push_str(&format!(
2500 "- {} [{}] - {}\n",
2501 entry.name,
2502 entry.kind,
2503 human_bytes(entry.bytes)
2504 ));
2505 }
2506 }
2507
2508 if !sample_names.is_empty() {
2509 out.push_str("\nSample names:\n");
2510 for name in sample_names {
2511 out.push_str(&format!("- {}\n", name));
2512 }
2513 }
2514
2515 Ok(out.trim_end().to_string())
2516}
2517
2518fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2519 let trimmed = raw.trim();
2520 if trimmed.is_empty() {
2521 return Err("Path must not be empty.".to_string());
2522 }
2523
2524 if let Some(rest) = trimmed
2525 .strip_prefix("~/")
2526 .or_else(|| trimmed.strip_prefix("~\\"))
2527 {
2528 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2529 return Ok(home.join(rest));
2530 }
2531
2532 let path = PathBuf::from(trimmed);
2533 if path.is_absolute() {
2534 Ok(path)
2535 } else {
2536 let cwd =
2537 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2538 let full_path = cwd.join(&path);
2539
2540 if !full_path.exists()
2543 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2544 {
2545 if let Some(home) = home::home_dir() {
2546 let home_path = home.join(trimmed);
2547 if home_path.exists() {
2548 return Ok(home_path);
2549 }
2550 }
2551 }
2552
2553 Ok(full_path)
2554 }
2555}
2556
2557fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2558 workspace_mode_for_path(workspace_root)
2559}
2560
2561fn workspace_mode_for_path(path: &Path) -> &'static str {
2562 if is_project_marker_path(path) {
2563 "project"
2564 } else if path.join(".hematite").join("docs").exists()
2565 || path.join(".hematite").join("imports").exists()
2566 || path.join(".hematite").join("reports").exists()
2567 {
2568 "docs-only"
2569 } else {
2570 "general directory"
2571 }
2572}
2573
2574fn is_project_marker_path(path: &Path) -> bool {
2575 [
2576 "Cargo.toml",
2577 "package.json",
2578 "pyproject.toml",
2579 "go.mod",
2580 "composer.json",
2581 "requirements.txt",
2582 "Makefile",
2583 "justfile",
2584 ]
2585 .iter()
2586 .any(|name| path.join(name).exists())
2587 || path.join(".git").exists()
2588}
2589
2590fn preferred_shell_label() -> &'static str {
2591 #[cfg(target_os = "windows")]
2592 {
2593 "PowerShell"
2594 }
2595 #[cfg(not(target_os = "windows"))]
2596 {
2597 "sh"
2598 }
2599}
2600
2601fn desktop_dir() -> Option<PathBuf> {
2602 home::home_dir().map(|home| home.join("Desktop"))
2603}
2604
2605fn downloads_dir() -> Option<PathBuf> {
2606 home::home_dir().map(|home| home.join("Downloads"))
2607}
2608
2609fn count_top_level_items(path: &Path) -> Result<usize, String> {
2610 let mut count = 0usize;
2611 for entry in
2612 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2613 {
2614 if entry.is_ok() {
2615 count += 1;
2616 }
2617 }
2618 Ok(count)
2619}
2620
2621#[derive(Default)]
2622struct PathAggregate {
2623 total_bytes: u64,
2624 file_count: u64,
2625 dir_count: u64,
2626 skipped_entries: u64,
2627 partial: bool,
2628}
2629
2630impl PathAggregate {
2631 fn merge(&mut self, other: &PathAggregate) {
2632 self.total_bytes += other.total_bytes;
2633 self.file_count += other.file_count;
2634 self.dir_count += other.dir_count;
2635 self.skipped_entries += other.skipped_entries;
2636 self.partial |= other.partial;
2637 }
2638}
2639
2640struct LargestEntry {
2641 name: String,
2642 kind: &'static str,
2643 bytes: u64,
2644}
2645
2646fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2647 if *budget == 0 {
2648 return PathAggregate {
2649 partial: true,
2650 skipped_entries: 1,
2651 ..PathAggregate::default()
2652 };
2653 }
2654 *budget -= 1;
2655
2656 let metadata = match fs::symlink_metadata(path) {
2657 Ok(metadata) => metadata,
2658 Err(_) => {
2659 return PathAggregate {
2660 skipped_entries: 1,
2661 ..PathAggregate::default()
2662 }
2663 }
2664 };
2665
2666 let file_type = metadata.file_type();
2667 if file_type.is_symlink() {
2668 return PathAggregate {
2669 skipped_entries: 1,
2670 ..PathAggregate::default()
2671 };
2672 }
2673
2674 if metadata.is_file() {
2675 return PathAggregate {
2676 total_bytes: metadata.len(),
2677 file_count: 1,
2678 ..PathAggregate::default()
2679 };
2680 }
2681
2682 if !metadata.is_dir() {
2683 return PathAggregate::default();
2684 }
2685
2686 let mut aggregate = PathAggregate {
2687 dir_count: 1,
2688 ..PathAggregate::default()
2689 };
2690
2691 let read_dir = match fs::read_dir(path) {
2692 Ok(read_dir) => read_dir,
2693 Err(_) => {
2694 aggregate.skipped_entries += 1;
2695 return aggregate;
2696 }
2697 };
2698
2699 for child in read_dir {
2700 match child {
2701 Ok(child) => {
2702 let child_stats = measure_path(&child.path(), budget);
2703 aggregate.merge(&child_stats);
2704 }
2705 Err(_) => aggregate.skipped_entries += 1,
2706 }
2707 }
2708
2709 aggregate
2710}
2711
2712struct PathAnalysis {
2713 total_entries: usize,
2714 unique_entries: usize,
2715 entries: Vec<String>,
2716 duplicate_entries: Vec<String>,
2717 missing_entries: Vec<String>,
2718}
2719
2720fn analyze_path_env() -> PathAnalysis {
2721 let mut entries = Vec::new();
2722 let mut duplicate_entries = Vec::new();
2723 let mut missing_entries = Vec::new();
2724 let mut seen = HashSet::new();
2725
2726 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2727 for path in std::env::split_paths(&raw_path) {
2728 let display = path.display().to_string();
2729 if display.trim().is_empty() {
2730 continue;
2731 }
2732
2733 let normalized = normalize_path_entry(&display);
2734 if !seen.insert(normalized) {
2735 duplicate_entries.push(display.clone());
2736 }
2737 if !path.exists() {
2738 missing_entries.push(display.clone());
2739 }
2740 entries.push(display);
2741 }
2742
2743 let total_entries = entries.len();
2744 let unique_entries = seen.len();
2745
2746 PathAnalysis {
2747 total_entries,
2748 unique_entries,
2749 entries,
2750 duplicate_entries,
2751 missing_entries,
2752 }
2753}
2754
2755fn normalize_path_entry(value: &str) -> String {
2756 #[cfg(target_os = "windows")]
2757 {
2758 value
2759 .replace('/', "\\")
2760 .trim_end_matches(['\\', '/'])
2761 .to_ascii_lowercase()
2762 }
2763 #[cfg(not(target_os = "windows"))]
2764 {
2765 value.trim_end_matches('/').to_string()
2766 }
2767}
2768
2769struct ToolchainReport {
2770 found: Vec<(String, String)>,
2771 missing: Vec<String>,
2772}
2773
2774struct PackageManagerReport {
2775 found: Vec<(String, String)>,
2776}
2777
2778#[derive(Debug, Clone)]
2779struct ProcessEntry {
2780 name: String,
2781 pid: u32,
2782 memory_bytes: u64,
2783 cpu_seconds: Option<f64>,
2784 cpu_percent: Option<f64>,
2785 read_ops: Option<u64>,
2786 write_ops: Option<u64>,
2787 detail: Option<String>,
2788}
2789
2790#[derive(Debug, Clone)]
2791struct ServiceEntry {
2792 name: String,
2793 status: String,
2794 startup: Option<String>,
2795 display_name: Option<String>,
2796 start_name: Option<String>,
2797}
2798
2799#[derive(Debug, Clone, Default)]
2800struct NetworkAdapter {
2801 name: String,
2802 ipv4: Vec<String>,
2803 ipv6: Vec<String>,
2804 gateways: Vec<String>,
2805 dns_servers: Vec<String>,
2806 disconnected: bool,
2807}
2808
2809impl NetworkAdapter {
2810 fn is_active(&self) -> bool {
2811 !self.disconnected
2812 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2813 }
2814}
2815
2816#[derive(Debug, Clone, Copy, Default)]
2817struct ListenerExposureSummary {
2818 loopback_only: usize,
2819 wildcard_public: usize,
2820 specific_bind: usize,
2821}
2822
2823#[derive(Debug, Clone)]
2824struct ListeningPort {
2825 protocol: String,
2826 local: String,
2827 port: u16,
2828 state: String,
2829 pid: Option<String>,
2830 process_name: Option<String>,
2831}
2832
2833fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2834 #[cfg(target_os = "windows")]
2835 {
2836 collect_windows_listening_ports()
2837 }
2838 #[cfg(not(target_os = "windows"))]
2839 {
2840 collect_unix_listening_ports()
2841 }
2842}
2843
2844fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2845 #[cfg(target_os = "windows")]
2846 {
2847 collect_windows_network_adapters()
2848 }
2849 #[cfg(not(target_os = "windows"))]
2850 {
2851 collect_unix_network_adapters()
2852 }
2853}
2854
2855fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2856 #[cfg(target_os = "windows")]
2857 {
2858 collect_windows_services()
2859 }
2860 #[cfg(not(target_os = "windows"))]
2861 {
2862 collect_unix_services()
2863 }
2864}
2865
2866#[cfg(target_os = "windows")]
2867fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2868 let output = Command::new("netstat")
2869 .args(["-ano", "-p", "tcp"])
2870 .output()
2871 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2872 if !output.status.success() {
2873 return Err("netstat returned a non-success status.".to_string());
2874 }
2875
2876 let text = String::from_utf8_lossy(&output.stdout);
2877 let mut listeners = Vec::new();
2878 for line in text.lines() {
2879 let trimmed = line.trim();
2880 if !trimmed.starts_with("TCP") {
2881 continue;
2882 }
2883 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2884 if cols.len() < 5 || cols[3] != "LISTENING" {
2885 continue;
2886 }
2887 let Some(port) = extract_port_from_socket(cols[1]) else {
2888 continue;
2889 };
2890 listeners.push(ListeningPort {
2891 protocol: cols[0].to_string(),
2892 local: cols[1].to_string(),
2893 port,
2894 state: cols[3].to_string(),
2895 pid: Some(cols[4].to_string()),
2896 process_name: None,
2897 });
2898 }
2899
2900 let unique_pids: Vec<String> = listeners
2903 .iter()
2904 .filter_map(|l| l.pid.clone())
2905 .collect::<HashSet<_>>()
2906 .into_iter()
2907 .collect();
2908
2909 if !unique_pids.is_empty() {
2910 let pid_list = unique_pids.join(",");
2911 let ps_cmd = format!(
2912 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2913 pid_list
2914 );
2915 if let Ok(ps_out) = Command::new("powershell")
2916 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2917 .output()
2918 {
2919 let mut pid_map = std::collections::HashMap::<String, String>::new();
2920 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2921 for line in ps_text.lines() {
2922 let parts: Vec<&str> = line.split_whitespace().collect();
2923 if parts.len() >= 2 {
2924 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2925 }
2926 }
2927 for listener in &mut listeners {
2928 if let Some(pid) = &listener.pid {
2929 listener.process_name = pid_map.get(pid).cloned();
2930 }
2931 }
2932 }
2933 }
2934
2935 Ok(listeners)
2936}
2937
2938#[cfg(not(target_os = "windows"))]
2939fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2940 let output = Command::new("ss")
2941 .args(["-ltn"])
2942 .output()
2943 .map_err(|e| format!("Failed to run ss: {e}"))?;
2944 if !output.status.success() {
2945 return Err("ss returned a non-success status.".to_string());
2946 }
2947
2948 let text = String::from_utf8_lossy(&output.stdout);
2949 let mut listeners = Vec::new();
2950 for line in text.lines().skip(1) {
2951 let cols: Vec<&str> = line.split_whitespace().collect();
2952 if cols.len() < 4 {
2953 continue;
2954 }
2955 let Some(port) = extract_port_from_socket(cols[3]) else {
2956 continue;
2957 };
2958 listeners.push(ListeningPort {
2959 protocol: "tcp".to_string(),
2960 local: cols[3].to_string(),
2961 port,
2962 state: cols[0].to_string(),
2963 pid: None,
2964 process_name: None,
2965 });
2966 }
2967
2968 Ok(listeners)
2969}
2970
2971fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2972 #[cfg(target_os = "windows")]
2973 {
2974 collect_windows_processes()
2975 }
2976 #[cfg(not(target_os = "windows"))]
2977 {
2978 collect_unix_processes()
2979 }
2980}
2981
2982#[cfg(target_os = "windows")]
2983fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2984 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2985 let output = Command::new("powershell")
2986 .args(["-NoProfile", "-Command", command])
2987 .output()
2988 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2989 if !output.status.success() {
2990 return Err("PowerShell service inspection returned a non-success status.".to_string());
2991 }
2992
2993 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2994}
2995
2996#[cfg(not(target_os = "windows"))]
2997fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2998 let status_output = Command::new("systemctl")
2999 .args([
3000 "list-units",
3001 "--type=service",
3002 "--all",
3003 "--no-pager",
3004 "--no-legend",
3005 "--plain",
3006 ])
3007 .output()
3008 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3009 if !status_output.status.success() {
3010 return Err("systemctl list-units returned a non-success status.".to_string());
3011 }
3012
3013 let startup_output = Command::new("systemctl")
3014 .args([
3015 "list-unit-files",
3016 "--type=service",
3017 "--no-legend",
3018 "--no-pager",
3019 "--plain",
3020 ])
3021 .output()
3022 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3023 if !startup_output.status.success() {
3024 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3025 }
3026
3027 Ok(parse_unix_services(
3028 &String::from_utf8_lossy(&status_output.stdout),
3029 &String::from_utf8_lossy(&startup_output.stdout),
3030 ))
3031}
3032
3033#[cfg(target_os = "windows")]
3034fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3035 let output = Command::new("ipconfig")
3036 .args(["/all"])
3037 .output()
3038 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3039 if !output.status.success() {
3040 return Err("ipconfig returned a non-success status.".to_string());
3041 }
3042
3043 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3044 &output.stdout,
3045 )))
3046}
3047
3048#[cfg(not(target_os = "windows"))]
3049fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3050 let addr_output = Command::new("ip")
3051 .args(["-o", "addr", "show", "up"])
3052 .output()
3053 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3054 if !addr_output.status.success() {
3055 return Err("ip addr returned a non-success status.".to_string());
3056 }
3057
3058 let route_output = Command::new("ip")
3059 .args(["route", "show", "default"])
3060 .output()
3061 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3062 if !route_output.status.success() {
3063 return Err("ip route returned a non-success status.".to_string());
3064 }
3065
3066 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3067 apply_unix_default_routes(
3068 &mut adapters,
3069 &String::from_utf8_lossy(&route_output.stdout),
3070 );
3071 apply_unix_dns_servers(&mut adapters);
3072 Ok(adapters)
3073}
3074
3075#[cfg(target_os = "windows")]
3076fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3077 let script = r#"
3079 $s1 = Get-Process | Select-Object Id, CPU
3080 Start-Sleep -Milliseconds 250
3081 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3082 $s2 | ForEach-Object {
3083 $p2 = $_
3084 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3085 $pct = 0.0
3086 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3087 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3088 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3089 # Standard Task Manager style is (delta / interval) * 100.
3090 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3091 }
3092 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3093 }
3094 "#;
3095
3096 let output = Command::new("powershell")
3097 .args(["-NoProfile", "-Command", script])
3098 .output()
3099 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3100
3101 let text = String::from_utf8_lossy(&output.stdout);
3102 let mut out = Vec::new();
3103 for line in text.lines() {
3104 let parts: Vec<&str> = line.trim().split('|').collect();
3105 if parts.len() < 5 {
3106 continue;
3107 }
3108 let mut entry = ProcessEntry {
3109 name: "unknown".to_string(),
3110 pid: 0,
3111 memory_bytes: 0,
3112 cpu_seconds: None,
3113 cpu_percent: None,
3114 read_ops: None,
3115 write_ops: None,
3116 detail: None,
3117 };
3118 for p in parts {
3119 if let Some((k, v)) = p.split_once(':') {
3120 match k {
3121 "PID" => entry.pid = v.parse().unwrap_or(0),
3122 "NAME" => entry.name = v.to_string(),
3123 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3124 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3125 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3126 "READ" => entry.read_ops = v.parse().ok(),
3127 "WRITE" => entry.write_ops = v.parse().ok(),
3128 _ => {}
3129 }
3130 }
3131 }
3132 out.push(entry);
3133 }
3134 Ok(out)
3135}
3136
3137#[cfg(not(target_os = "windows"))]
3138fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3139 let output = Command::new("ps")
3140 .args(["-eo", "pid=,rss=,comm="])
3141 .output()
3142 .map_err(|e| format!("Failed to run ps: {e}"))?;
3143 if !output.status.success() {
3144 return Err("ps returned a non-success status.".to_string());
3145 }
3146
3147 let text = String::from_utf8_lossy(&output.stdout);
3148 let mut processes = Vec::new();
3149 for line in text.lines() {
3150 let cols: Vec<&str> = line.split_whitespace().collect();
3151 if cols.len() < 3 {
3152 continue;
3153 }
3154 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3155 else {
3156 continue;
3157 };
3158 processes.push(ProcessEntry {
3159 name: cols[2..].join(" "),
3160 pid,
3161 memory_bytes: rss_kib * 1024,
3162 cpu_seconds: None,
3163 cpu_percent: None,
3164 read_ops: None,
3165 write_ops: None,
3166 detail: None,
3167 });
3168 }
3169
3170 Ok(processes)
3171}
3172
3173fn extract_port_from_socket(value: &str) -> Option<u16> {
3174 let cleaned = value.trim().trim_matches(['[', ']']);
3175 let port_str = cleaned.rsplit(':').next()?;
3176 port_str.parse::<u16>().ok()
3177}
3178
3179fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3180 let mut summary = ListenerExposureSummary::default();
3181 for entry in listeners {
3182 let local = entry.local.to_ascii_lowercase();
3183 if is_loopback_listener(&local) {
3184 summary.loopback_only += 1;
3185 } else if is_wildcard_listener(&local) {
3186 summary.wildcard_public += 1;
3187 } else {
3188 summary.specific_bind += 1;
3189 }
3190 }
3191 summary
3192}
3193
3194fn is_loopback_listener(local: &str) -> bool {
3195 local.starts_with("127.")
3196 || local.starts_with("[::1]")
3197 || local.starts_with("::1")
3198 || local.starts_with("localhost:")
3199}
3200
3201fn is_wildcard_listener(local: &str) -> bool {
3202 local.starts_with("0.0.0.0:")
3203 || local.starts_with("[::]:")
3204 || local.starts_with(":::")
3205 || local == "*:*"
3206}
3207
3208struct GitState {
3209 root: PathBuf,
3210 branch: String,
3211 dirty_entries: usize,
3212}
3213
3214impl GitState {
3215 fn status_label(&self) -> String {
3216 if self.dirty_entries == 0 {
3217 "clean".to_string()
3218 } else {
3219 format!("dirty ({} changed path(s))", self.dirty_entries)
3220 }
3221 }
3222}
3223
3224fn inspect_git_state(path: &Path) -> Option<GitState> {
3225 let root = capture_first_line(
3226 "git",
3227 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3228 )?;
3229 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3230 .unwrap_or_else(|| "detached".to_string());
3231 let output = Command::new("git")
3232 .args(["-C", path.to_str()?, "status", "--short"])
3233 .output()
3234 .ok()?;
3235 if !output.status.success() {
3236 return None;
3237 }
3238 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3239 Some(GitState {
3240 root: PathBuf::from(root),
3241 branch,
3242 dirty_entries,
3243 })
3244}
3245
3246struct HematiteState {
3247 docs_count: usize,
3248 import_count: usize,
3249 report_count: usize,
3250 workspace_profile: bool,
3251}
3252
3253fn collect_hematite_state(path: &Path) -> HematiteState {
3254 let root = path.join(".hematite");
3255 HematiteState {
3256 docs_count: count_entries_if_exists(&root.join("docs")),
3257 import_count: count_entries_if_exists(&root.join("imports")),
3258 report_count: count_entries_if_exists(&root.join("reports")),
3259 workspace_profile: root.join("workspace_profile.json").exists(),
3260 }
3261}
3262
3263fn count_entries_if_exists(path: &Path) -> usize {
3264 if !path.exists() || !path.is_dir() {
3265 return 0;
3266 }
3267 fs::read_dir(path)
3268 .ok()
3269 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3270 .unwrap_or(0)
3271}
3272
3273fn collect_project_markers(path: &Path) -> Vec<String> {
3274 [
3275 "Cargo.toml",
3276 "package.json",
3277 "pyproject.toml",
3278 "go.mod",
3279 "justfile",
3280 "Makefile",
3281 ".git",
3282 ]
3283 .iter()
3284 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3285 .collect()
3286}
3287
3288struct ReleaseArtifactState {
3289 version: String,
3290 portable_dir: bool,
3291 portable_zip: bool,
3292 setup_exe: bool,
3293}
3294
3295fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3296 let cargo_toml = path.join("Cargo.toml");
3297 if !cargo_toml.exists() {
3298 return None;
3299 }
3300 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3301 let version = [regex_line_capture(
3302 &cargo_text,
3303 r#"(?m)^version\s*=\s*"([^"]+)""#,
3304 )?]
3305 .concat();
3306 let dist_windows = path.join("dist").join("windows");
3307 let prefix = format!("Hematite-{}", version);
3308 Some(ReleaseArtifactState {
3309 version,
3310 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3311 portable_zip: dist_windows
3312 .join(format!("{}-portable.zip", prefix))
3313 .exists(),
3314 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3315 })
3316}
3317
3318fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3319 let regex = regex::Regex::new(pattern).ok()?;
3320 let captures = regex.captures(text)?;
3321 captures.get(1).map(|m| m.as_str().to_string())
3322}
3323
3324fn bool_label(value: bool) -> &'static str {
3325 if value {
3326 "yes"
3327 } else {
3328 "no"
3329 }
3330}
3331
3332fn collect_toolchains() -> ToolchainReport {
3333 let checks = [
3334 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3335 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3336 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3337 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3338 ToolCheck::new(
3339 "npm",
3340 &[
3341 CommandProbe::new("npm", &["--version"]),
3342 CommandProbe::new("npm.cmd", &["--version"]),
3343 ],
3344 ),
3345 ToolCheck::new(
3346 "pnpm",
3347 &[
3348 CommandProbe::new("pnpm", &["--version"]),
3349 CommandProbe::new("pnpm.cmd", &["--version"]),
3350 ],
3351 ),
3352 ToolCheck::new(
3353 "python",
3354 &[
3355 CommandProbe::new("python", &["--version"]),
3356 CommandProbe::new("python3", &["--version"]),
3357 CommandProbe::new("py", &["-3", "--version"]),
3358 CommandProbe::new("py", &["--version"]),
3359 ],
3360 ),
3361 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3362 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3363 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3364 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3365 ];
3366
3367 let mut found = Vec::new();
3368 let mut missing = Vec::new();
3369
3370 for check in checks {
3371 match check.detect() {
3372 Some(version) => found.push((check.label.to_string(), version)),
3373 None => missing.push(check.label.to_string()),
3374 }
3375 }
3376
3377 ToolchainReport { found, missing }
3378}
3379
3380fn collect_package_managers() -> PackageManagerReport {
3381 let checks = [
3382 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3383 ToolCheck::new(
3384 "npm",
3385 &[
3386 CommandProbe::new("npm", &["--version"]),
3387 CommandProbe::new("npm.cmd", &["--version"]),
3388 ],
3389 ),
3390 ToolCheck::new(
3391 "pnpm",
3392 &[
3393 CommandProbe::new("pnpm", &["--version"]),
3394 CommandProbe::new("pnpm.cmd", &["--version"]),
3395 ],
3396 ),
3397 ToolCheck::new(
3398 "pip",
3399 &[
3400 CommandProbe::new("python", &["-m", "pip", "--version"]),
3401 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3402 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3403 CommandProbe::new("py", &["-m", "pip", "--version"]),
3404 CommandProbe::new("pip", &["--version"]),
3405 ],
3406 ),
3407 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3408 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3409 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3410 ToolCheck::new(
3411 "choco",
3412 &[
3413 CommandProbe::new("choco", &["--version"]),
3414 CommandProbe::new("choco.exe", &["--version"]),
3415 ],
3416 ),
3417 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3418 ];
3419
3420 let mut found = Vec::new();
3421 for check in checks {
3422 match check.detect() {
3423 Some(version) => found.push((check.label.to_string(), version)),
3424 None => {}
3425 }
3426 }
3427
3428 PackageManagerReport { found }
3429}
3430
3431#[derive(Clone)]
3432struct ToolCheck {
3433 label: &'static str,
3434 probes: Vec<CommandProbe>,
3435}
3436
3437impl ToolCheck {
3438 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3439 Self {
3440 label,
3441 probes: probes.to_vec(),
3442 }
3443 }
3444
3445 fn detect(&self) -> Option<String> {
3446 for probe in &self.probes {
3447 if let Some(output) = capture_first_line(probe.program, probe.args) {
3448 return Some(output);
3449 }
3450 }
3451 None
3452 }
3453}
3454
3455#[derive(Clone, Copy)]
3456struct CommandProbe {
3457 program: &'static str,
3458 args: &'static [&'static str],
3459}
3460
3461impl CommandProbe {
3462 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3463 Self { program, args }
3464 }
3465}
3466
3467fn build_env_doctor_findings(
3468 toolchains: &ToolchainReport,
3469 package_managers: &PackageManagerReport,
3470 path_stats: &PathAnalysis,
3471) -> Vec<String> {
3472 let found_tools = toolchains
3473 .found
3474 .iter()
3475 .map(|(label, _)| label.as_str())
3476 .collect::<HashSet<_>>();
3477 let found_managers = package_managers
3478 .found
3479 .iter()
3480 .map(|(label, _)| label.as_str())
3481 .collect::<HashSet<_>>();
3482
3483 let mut findings = Vec::new();
3484
3485 if path_stats.duplicate_entries.len() > 0 {
3486 findings.push(format!(
3487 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3488 path_stats.duplicate_entries.len()
3489 ));
3490 }
3491 if path_stats.missing_entries.len() > 0 {
3492 findings.push(format!(
3493 "PATH contains {} entries that do not exist on disk.",
3494 path_stats.missing_entries.len()
3495 ));
3496 }
3497 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3498 findings.push(
3499 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3500 .to_string(),
3501 );
3502 }
3503 if found_tools.contains("node")
3504 && !found_managers.contains("npm")
3505 && !found_managers.contains("pnpm")
3506 {
3507 findings.push(
3508 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3509 .to_string(),
3510 );
3511 }
3512 if found_tools.contains("python")
3513 && !found_managers.contains("pip")
3514 && !found_managers.contains("uv")
3515 && !found_managers.contains("pipx")
3516 {
3517 findings.push(
3518 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3519 .to_string(),
3520 );
3521 }
3522 let windows_manager_count = ["winget", "choco", "scoop"]
3523 .iter()
3524 .filter(|label| found_managers.contains(**label))
3525 .count();
3526 if windows_manager_count > 1 {
3527 findings.push(
3528 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3529 .to_string(),
3530 );
3531 }
3532 if findings.is_empty() && !found_managers.is_empty() {
3533 findings.push(
3534 "Core package-manager coverage looks healthy for a normal developer workstation."
3535 .to_string(),
3536 );
3537 }
3538
3539 findings
3540}
3541
3542fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3543 let output = std::process::Command::new(program)
3544 .args(args)
3545 .output()
3546 .ok()?;
3547 if !output.status.success() {
3548 return None;
3549 }
3550
3551 let stdout = if output.stdout.is_empty() {
3552 String::from_utf8_lossy(&output.stderr).into_owned()
3553 } else {
3554 String::from_utf8_lossy(&output.stdout).into_owned()
3555 };
3556
3557 stdout
3558 .lines()
3559 .map(str::trim)
3560 .find(|line| !line.is_empty())
3561 .map(|line| line.to_string())
3562}
3563
3564fn human_bytes(bytes: u64) -> String {
3565 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3566 let mut value = bytes as f64;
3567 let mut unit_index = 0usize;
3568
3569 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3570 value /= 1024.0;
3571 unit_index += 1;
3572 }
3573
3574 if unit_index == 0 {
3575 format!("{} {}", bytes, UNITS[unit_index])
3576 } else {
3577 format!("{value:.1} {}", UNITS[unit_index])
3578 }
3579}
3580
3581#[cfg(target_os = "windows")]
3582fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3583 let mut adapters = Vec::new();
3584 let mut current: Option<NetworkAdapter> = None;
3585 let mut pending_dns = false;
3586
3587 for raw_line in text.lines() {
3588 let line = raw_line.trim_end();
3589 let trimmed = line.trim();
3590 if trimmed.is_empty() {
3591 pending_dns = false;
3592 continue;
3593 }
3594
3595 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3596 if let Some(adapter) = current.take() {
3597 adapters.push(adapter);
3598 }
3599 current = Some(NetworkAdapter {
3600 name: trimmed.trim_end_matches(':').to_string(),
3601 ..NetworkAdapter::default()
3602 });
3603 pending_dns = false;
3604 continue;
3605 }
3606
3607 let Some(adapter) = current.as_mut() else {
3608 continue;
3609 };
3610
3611 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3612 adapter.disconnected = true;
3613 }
3614
3615 if let Some(value) = value_after_colon(trimmed) {
3616 let normalized = normalize_ipconfig_value(value);
3617 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3618 adapter.ipv4.push(normalized);
3619 pending_dns = false;
3620 } else if trimmed.starts_with("IPv6 Address")
3621 || trimmed.starts_with("Temporary IPv6 Address")
3622 || trimmed.starts_with("Link-local IPv6 Address")
3623 {
3624 if !normalized.is_empty() {
3625 adapter.ipv6.push(normalized);
3626 }
3627 pending_dns = false;
3628 } else if trimmed.starts_with("Default Gateway") {
3629 if !normalized.is_empty() {
3630 adapter.gateways.push(normalized);
3631 }
3632 pending_dns = false;
3633 } else if trimmed.starts_with("DNS Servers") {
3634 if !normalized.is_empty() {
3635 adapter.dns_servers.push(normalized);
3636 }
3637 pending_dns = true;
3638 } else {
3639 pending_dns = false;
3640 }
3641 } else if pending_dns {
3642 let normalized = normalize_ipconfig_value(trimmed);
3643 if !normalized.is_empty() {
3644 adapter.dns_servers.push(normalized);
3645 }
3646 }
3647 }
3648
3649 if let Some(adapter) = current.take() {
3650 adapters.push(adapter);
3651 }
3652
3653 for adapter in &mut adapters {
3654 dedup_vec(&mut adapter.ipv4);
3655 dedup_vec(&mut adapter.ipv6);
3656 dedup_vec(&mut adapter.gateways);
3657 dedup_vec(&mut adapter.dns_servers);
3658 }
3659
3660 adapters
3661}
3662
3663#[cfg(not(target_os = "windows"))]
3664fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3665 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3666
3667 for line in text.lines() {
3668 let cols: Vec<&str> = line.split_whitespace().collect();
3669 if cols.len() < 4 {
3670 continue;
3671 }
3672 let name = cols[1].trim_end_matches(':').to_string();
3673 let family = cols[2];
3674 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3675 let entry = adapters
3676 .entry(name.clone())
3677 .or_insert_with(|| NetworkAdapter {
3678 name,
3679 ..NetworkAdapter::default()
3680 });
3681 match family {
3682 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3683 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3684 _ => {}
3685 }
3686 }
3687
3688 adapters.into_values().collect()
3689}
3690
3691#[cfg(not(target_os = "windows"))]
3692fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3693 for line in text.lines() {
3694 let cols: Vec<&str> = line.split_whitespace().collect();
3695 if cols.len() < 5 {
3696 continue;
3697 }
3698 let gateway = cols
3699 .windows(2)
3700 .find(|pair| pair[0] == "via")
3701 .map(|pair| pair[1].to_string());
3702 let dev = cols
3703 .windows(2)
3704 .find(|pair| pair[0] == "dev")
3705 .map(|pair| pair[1]);
3706 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3707 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3708 adapter.gateways.push(gateway);
3709 }
3710 }
3711 }
3712
3713 for adapter in adapters {
3714 dedup_vec(&mut adapter.gateways);
3715 }
3716}
3717
3718#[cfg(not(target_os = "windows"))]
3719fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3720 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3721 return;
3722 };
3723 let mut dns_servers = text
3724 .lines()
3725 .filter_map(|line| line.strip_prefix("nameserver "))
3726 .map(str::trim)
3727 .filter(|value| !value.is_empty())
3728 .map(|value| value.to_string())
3729 .collect::<Vec<_>>();
3730 dedup_vec(&mut dns_servers);
3731 if dns_servers.is_empty() {
3732 return;
3733 }
3734 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3735 adapter.dns_servers = dns_servers.clone();
3736 }
3737}
3738
3739#[cfg(target_os = "windows")]
3740fn value_after_colon(line: &str) -> Option<&str> {
3741 line.split_once(':').map(|(_, value)| value.trim())
3742}
3743
3744#[cfg(target_os = "windows")]
3745fn normalize_ipconfig_value(value: &str) -> String {
3746 value
3747 .trim()
3748 .trim_end_matches("(Preferred)")
3749 .trim_end_matches("(Deprecated)")
3750 .trim()
3751 .trim_matches(['(', ')'])
3752 .trim()
3753 .to_string()
3754}
3755
3756#[cfg(target_os = "windows")]
3757fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3758 let mac_upper = mac.to_ascii_uppercase();
3759 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3760 return true;
3761 }
3762
3763 ip == "255.255.255.255"
3764 || ip.starts_with("224.")
3765 || ip.starts_with("225.")
3766 || ip.starts_with("226.")
3767 || ip.starts_with("227.")
3768 || ip.starts_with("228.")
3769 || ip.starts_with("229.")
3770 || ip.starts_with("230.")
3771 || ip.starts_with("231.")
3772 || ip.starts_with("232.")
3773 || ip.starts_with("233.")
3774 || ip.starts_with("234.")
3775 || ip.starts_with("235.")
3776 || ip.starts_with("236.")
3777 || ip.starts_with("237.")
3778 || ip.starts_with("238.")
3779 || ip.starts_with("239.")
3780}
3781
3782fn dedup_vec(values: &mut Vec<String>) {
3783 let mut seen = HashSet::new();
3784 values.retain(|value| seen.insert(value.clone()));
3785}
3786
3787#[cfg(target_os = "windows")]
3788fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3789 let trimmed = text.trim();
3790 if trimmed.is_empty() {
3791 return Vec::new();
3792 }
3793
3794 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3795 return Vec::new();
3796 };
3797 let entries = match value {
3798 Value::Array(items) => items,
3799 other => vec![other],
3800 };
3801
3802 let mut neighbors = Vec::new();
3803 for entry in entries {
3804 let ip = entry
3805 .get("IPAddress")
3806 .and_then(|v| v.as_str())
3807 .unwrap_or("")
3808 .to_string();
3809 if ip.is_empty() {
3810 continue;
3811 }
3812 let mac = entry
3813 .get("LinkLayerAddress")
3814 .and_then(|v| v.as_str())
3815 .unwrap_or("unknown")
3816 .to_string();
3817 let state = entry
3818 .get("State")
3819 .and_then(|v| v.as_str())
3820 .unwrap_or("unknown")
3821 .to_string();
3822 let iface = entry
3823 .get("InterfaceAlias")
3824 .and_then(|v| v.as_str())
3825 .unwrap_or("unknown")
3826 .to_string();
3827 if is_noise_lan_neighbor(&ip, &mac) {
3828 continue;
3829 }
3830 neighbors.push((ip, mac, state, iface));
3831 }
3832
3833 neighbors
3834}
3835
3836#[cfg(target_os = "windows")]
3837fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3838 let trimmed = text.trim();
3839 if trimmed.is_empty() {
3840 return Ok(Vec::new());
3841 }
3842
3843 let value: Value = serde_json::from_str(trimmed)
3844 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3845 let entries = match value {
3846 Value::Array(items) => items,
3847 other => vec![other],
3848 };
3849
3850 let mut services = Vec::new();
3851 for entry in entries {
3852 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3853 continue;
3854 };
3855 services.push(ServiceEntry {
3856 name: name.to_string(),
3857 status: entry
3858 .get("State")
3859 .and_then(|v| v.as_str())
3860 .unwrap_or("unknown")
3861 .to_string(),
3862 startup: entry
3863 .get("StartMode")
3864 .and_then(|v| v.as_str())
3865 .map(|v| v.to_string()),
3866 display_name: entry
3867 .get("DisplayName")
3868 .and_then(|v| v.as_str())
3869 .map(|v| v.to_string()),
3870 start_name: entry
3871 .get("StartName")
3872 .and_then(|v| v.as_str())
3873 .map(|v| v.to_string()),
3874 });
3875 }
3876
3877 Ok(services)
3878}
3879
3880#[cfg(target_os = "windows")]
3881fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3882 match node.cloned() {
3883 Some(Value::Array(items)) => items,
3884 Some(other) => vec![other],
3885 None => Vec::new(),
3886 }
3887}
3888
3889#[cfg(target_os = "windows")]
3890fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3891 windows_json_entries(node)
3892 .into_iter()
3893 .filter_map(|entry| {
3894 let name = entry
3895 .get("FriendlyName")
3896 .and_then(|v| v.as_str())
3897 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3898 .unwrap_or("")
3899 .trim()
3900 .to_string();
3901 if name.is_empty() {
3902 return None;
3903 }
3904 Some(WindowsPnpDevice {
3905 name,
3906 status: entry
3907 .get("Status")
3908 .and_then(|v| v.as_str())
3909 .unwrap_or("Unknown")
3910 .trim()
3911 .to_string(),
3912 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3913 entry
3914 .get("Problem")
3915 .and_then(|v| v.as_i64())
3916 .map(|v| v as u64)
3917 }),
3918 class_name: entry
3919 .get("Class")
3920 .and_then(|v| v.as_str())
3921 .map(|v| v.trim().to_string()),
3922 instance_id: entry
3923 .get("InstanceId")
3924 .and_then(|v| v.as_str())
3925 .map(|v| v.trim().to_string()),
3926 })
3927 })
3928 .collect()
3929}
3930
3931#[cfg(target_os = "windows")]
3932fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3933 windows_json_entries(node)
3934 .into_iter()
3935 .filter_map(|entry| {
3936 let name = entry
3937 .get("Name")
3938 .and_then(|v| v.as_str())
3939 .unwrap_or("")
3940 .trim()
3941 .to_string();
3942 if name.is_empty() {
3943 return None;
3944 }
3945 Some(WindowsSoundDevice {
3946 name,
3947 status: entry
3948 .get("Status")
3949 .and_then(|v| v.as_str())
3950 .unwrap_or("Unknown")
3951 .trim()
3952 .to_string(),
3953 manufacturer: entry
3954 .get("Manufacturer")
3955 .and_then(|v| v.as_str())
3956 .map(|v| v.trim().to_string()),
3957 })
3958 })
3959 .collect()
3960}
3961
3962#[cfg(target_os = "windows")]
3963fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
3964 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3965 || device.problem.unwrap_or(0) != 0
3966}
3967
3968#[cfg(target_os = "windows")]
3969fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
3970 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3971}
3972
3973#[cfg(target_os = "windows")]
3974fn is_microphone_like_name(name: &str) -> bool {
3975 let lower = name.to_ascii_lowercase();
3976 lower.contains("microphone")
3977 || lower.contains("mic")
3978 || lower.contains("input")
3979 || lower.contains("array")
3980 || lower.contains("capture")
3981 || lower.contains("record")
3982}
3983
3984#[cfg(target_os = "windows")]
3985fn is_bluetooth_like_name(name: &str) -> bool {
3986 let lower = name.to_ascii_lowercase();
3987 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
3988}
3989
3990#[cfg(target_os = "windows")]
3991fn service_is_running(service: &ServiceEntry) -> bool {
3992 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
3993}
3994
3995#[cfg(not(target_os = "windows"))]
3996fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3997 let mut startup_modes = std::collections::HashMap::<String, String>::new();
3998 for line in startup_text.lines() {
3999 let cols: Vec<&str> = line.split_whitespace().collect();
4000 if cols.len() < 2 {
4001 continue;
4002 }
4003 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4004 }
4005
4006 let mut services = Vec::new();
4007 for line in status_text.lines() {
4008 let cols: Vec<&str> = line.split_whitespace().collect();
4009 if cols.len() < 4 {
4010 continue;
4011 }
4012 let unit = cols[0];
4013 let load = cols[1];
4014 let active = cols[2];
4015 let sub = cols[3];
4016 let description = if cols.len() > 4 {
4017 Some(cols[4..].join(" "))
4018 } else {
4019 None
4020 };
4021 services.push(ServiceEntry {
4022 name: unit.to_string(),
4023 status: format!("{}/{}", active, sub),
4024 startup: startup_modes
4025 .get(unit)
4026 .cloned()
4027 .or_else(|| Some(load.to_string())),
4028 display_name: description,
4029 start_name: None,
4030 });
4031 }
4032
4033 services
4034}
4035
4036fn inspect_health_report() -> Result<String, String> {
4042 let mut needs_fix: Vec<String> = Vec::new();
4043 let mut watch: Vec<String> = Vec::new();
4044 let mut good: Vec<String> = Vec::new();
4045 let mut tips: Vec<String> = Vec::new();
4046
4047 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4048 health_check_memory(&mut watch, &mut good);
4049 health_check_tools(&mut watch, &mut good, &mut tips);
4050 health_check_recent_errors(&mut watch, &mut tips);
4051
4052 let overall = if !needs_fix.is_empty() {
4053 "ACTION REQUIRED"
4054 } else if !watch.is_empty() {
4055 "WORTH A LOOK"
4056 } else {
4057 "ALL GOOD"
4058 };
4059
4060 let mut out = format!("System Health Report — {overall}\n\n");
4061
4062 if !needs_fix.is_empty() {
4063 out.push_str("Needs fixing:\n");
4064 for item in &needs_fix {
4065 out.push_str(&format!(" [!] {item}\n"));
4066 }
4067 out.push('\n');
4068 }
4069 if !watch.is_empty() {
4070 out.push_str("Worth watching:\n");
4071 for item in &watch {
4072 out.push_str(&format!(" [-] {item}\n"));
4073 }
4074 out.push('\n');
4075 }
4076 if !good.is_empty() {
4077 out.push_str("Looking good:\n");
4078 for item in &good {
4079 out.push_str(&format!(" [+] {item}\n"));
4080 }
4081 out.push('\n');
4082 }
4083 if !tips.is_empty() {
4084 out.push_str("To dig deeper:\n");
4085 for tip in &tips {
4086 out.push_str(&format!(" {tip}\n"));
4087 }
4088 }
4089
4090 Ok(out.trim_end().to_string())
4091}
4092
4093fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4094 #[cfg(target_os = "windows")]
4095 {
4096 let script = r#"try {
4097 $d = Get-PSDrive C -ErrorAction Stop
4098 "$($d.Free)|$($d.Used)"
4099} catch { "ERR" }"#;
4100 if let Ok(out) = Command::new("powershell")
4101 .args(["-NoProfile", "-Command", script])
4102 .output()
4103 {
4104 let text = String::from_utf8_lossy(&out.stdout);
4105 let text = text.trim();
4106 if !text.starts_with("ERR") {
4107 let parts: Vec<&str> = text.split('|').collect();
4108 if parts.len() == 2 {
4109 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4110 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4111 let total = free_bytes + used_bytes;
4112 let free_gb = free_bytes / 1_073_741_824;
4113 let pct_free = if total > 0 {
4114 (free_bytes as f64 / total as f64 * 100.0) as u64
4115 } else {
4116 0
4117 };
4118 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4119 if free_gb < 5 {
4120 needs_fix.push(format!(
4121 "{msg} — very low. Free up space or your system may slow down or stop working."
4122 ));
4123 } else if free_gb < 15 {
4124 watch.push(format!("{msg} — getting low, consider cleaning up."));
4125 } else {
4126 good.push(msg);
4127 }
4128 return;
4129 }
4130 }
4131 }
4132 watch.push("Disk: could not read free space from C: drive.".to_string());
4133 }
4134
4135 #[cfg(not(target_os = "windows"))]
4136 {
4137 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4138 let text = String::from_utf8_lossy(&out.stdout);
4139 for line in text.lines().skip(1) {
4140 let cols: Vec<&str> = line.split_whitespace().collect();
4141 if cols.len() >= 5 {
4142 let avail_str = cols[3].trim_end_matches('G');
4143 let use_pct = cols[4].trim_end_matches('%');
4144 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4145 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4146 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4147 if avail_gb < 5 {
4148 needs_fix.push(format!(
4149 "{msg} — very low. Free up space to prevent system issues."
4150 ));
4151 } else if avail_gb < 15 {
4152 watch.push(format!("{msg} — getting low."));
4153 } else {
4154 good.push(msg);
4155 }
4156 return;
4157 }
4158 }
4159 }
4160 watch.push("Disk: could not determine free space.".to_string());
4161 }
4162}
4163
4164fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4165 #[cfg(target_os = "windows")]
4166 {
4167 let script = r#"try {
4168 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4169 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4170} catch { "ERR" }"#;
4171 if let Ok(out) = Command::new("powershell")
4172 .args(["-NoProfile", "-Command", script])
4173 .output()
4174 {
4175 let text = String::from_utf8_lossy(&out.stdout);
4176 let text = text.trim();
4177 if !text.starts_with("ERR") {
4178 let parts: Vec<&str> = text.split('|').collect();
4179 if parts.len() == 2 {
4180 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4181 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4182 if total_kb > 0 {
4183 let free_gb = free_kb / 1_048_576;
4184 let total_gb = total_kb / 1_048_576;
4185 let free_pct = free_kb * 100 / total_kb;
4186 let msg = format!(
4187 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4188 );
4189 if free_pct < 10 {
4190 watch.push(format!(
4191 "{msg} — very low. Close unused apps to free up memory."
4192 ));
4193 } else if free_pct < 25 {
4194 watch.push(format!("{msg} — running a bit low."));
4195 } else {
4196 good.push(msg);
4197 }
4198 return;
4199 }
4200 }
4201 }
4202 }
4203 }
4204
4205 #[cfg(not(target_os = "windows"))]
4206 {
4207 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4208 let mut total_kb = 0u64;
4209 let mut avail_kb = 0u64;
4210 for line in content.lines() {
4211 if line.starts_with("MemTotal:") {
4212 total_kb = line
4213 .split_whitespace()
4214 .nth(1)
4215 .and_then(|v| v.parse().ok())
4216 .unwrap_or(0);
4217 } else if line.starts_with("MemAvailable:") {
4218 avail_kb = line
4219 .split_whitespace()
4220 .nth(1)
4221 .and_then(|v| v.parse().ok())
4222 .unwrap_or(0);
4223 }
4224 }
4225 if total_kb > 0 {
4226 let free_gb = avail_kb / 1_048_576;
4227 let total_gb = total_kb / 1_048_576;
4228 let free_pct = avail_kb * 100 / total_kb;
4229 let msg =
4230 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4231 if free_pct < 10 {
4232 watch.push(format!("{msg} — very low. Close unused apps."));
4233 } else if free_pct < 25 {
4234 watch.push(format!("{msg} — running a bit low."));
4235 } else {
4236 good.push(msg);
4237 }
4238 }
4239 }
4240 }
4241}
4242
4243fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4244 let tool_checks: &[(&str, &str, &str)] = &[
4245 ("git", "--version", "Git"),
4246 ("cargo", "--version", "Rust / Cargo"),
4247 ("node", "--version", "Node.js"),
4248 ("python", "--version", "Python"),
4249 ("python3", "--version", "Python 3"),
4250 ("npm", "--version", "npm"),
4251 ];
4252
4253 let mut found: Vec<String> = Vec::new();
4254 let mut missing: Vec<String> = Vec::new();
4255 let mut python_found = false;
4256
4257 for (cmd, arg, label) in tool_checks {
4258 if cmd.starts_with("python") && python_found {
4259 continue;
4260 }
4261 let ok = Command::new(cmd)
4262 .arg(arg)
4263 .stdout(std::process::Stdio::null())
4264 .stderr(std::process::Stdio::null())
4265 .status()
4266 .map(|s| s.success())
4267 .unwrap_or(false);
4268 if ok {
4269 found.push((*label).to_string());
4270 if cmd.starts_with("python") {
4271 python_found = true;
4272 }
4273 } else if !cmd.starts_with("python") || !python_found {
4274 missing.push((*label).to_string());
4275 }
4276 }
4277
4278 if !found.is_empty() {
4279 good.push(format!("Dev tools found: {}", found.join(", ")));
4280 }
4281 if !missing.is_empty() {
4282 watch.push(format!(
4283 "Not installed (or not on PATH): {} — only matters if you need them",
4284 missing.join(", ")
4285 ));
4286 tips.push(
4287 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4288 .to_string(),
4289 );
4290 }
4291}
4292
4293fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4294 #[cfg(target_os = "windows")]
4295 {
4296 let script = r#"try {
4297 $cutoff = (Get-Date).AddHours(-24)
4298 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4299 $count
4300} catch { "0" }"#;
4301 if let Ok(out) = Command::new("powershell")
4302 .args(["-NoProfile", "-Command", script])
4303 .output()
4304 {
4305 let text = String::from_utf8_lossy(&out.stdout);
4306 let count: u64 = text.trim().parse().unwrap_or(0);
4307 if count > 0 {
4308 watch.push(format!(
4309 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4310 if count == 1 { "" } else { "s" }
4311 ));
4312 tips.push(
4313 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4314 .to_string(),
4315 );
4316 }
4317 }
4318 }
4319
4320 #[cfg(not(target_os = "windows"))]
4321 {
4322 if let Ok(out) = Command::new("journalctl")
4323 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4324 .output()
4325 {
4326 let text = String::from_utf8_lossy(&out.stdout);
4327 if !text.trim().is_empty() {
4328 watch.push("Critical/error entries found in the system journal.".to_string());
4329 tips.push(
4330 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4331 );
4332 }
4333 }
4334 }
4335}
4336
4337fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4340 let mut out = String::from("Host inspection: log_check\n\n");
4341
4342 #[cfg(target_os = "windows")]
4343 {
4344 let hours = lookback_hours.unwrap_or(24);
4346 out.push_str(&format!(
4347 "Checking System/Application logs from the last {} hours...\n\n",
4348 hours
4349 ));
4350
4351 let n = max_entries.clamp(1, 50);
4352 let script = format!(
4353 r#"try {{
4354 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4355 if (-not $events) {{ "NO_EVENTS"; exit }}
4356 $events | Select-Object -First {n} | ForEach-Object {{
4357 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4358 $line
4359 }}
4360}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4361 hours = hours,
4362 n = n
4363 );
4364 let output = Command::new("powershell")
4365 .args(["-NoProfile", "-Command", &script])
4366 .output()
4367 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4368
4369 let raw = String::from_utf8_lossy(&output.stdout);
4370 let text = raw.trim();
4371
4372 if text.is_empty() || text == "NO_EVENTS" {
4373 out.push_str("No critical or error events found in Application/System logs.\n");
4374 return Ok(out.trim_end().to_string());
4375 }
4376 if text.starts_with("ERROR:") {
4377 out.push_str(&format!("Warning: event log query returned: {text}\n"));
4378 return Ok(out.trim_end().to_string());
4379 }
4380
4381 let mut count = 0usize;
4382 for line in text.lines() {
4383 let parts: Vec<&str> = line.splitn(4, '|').collect();
4384 if parts.len() == 4 {
4385 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4386 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4387 count += 1;
4388 }
4389 }
4390 out.push_str(&format!(
4391 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4392 ));
4393 }
4394
4395 #[cfg(not(target_os = "windows"))]
4396 {
4397 let _ = lookback_hours;
4398 let n = max_entries.clamp(1, 50).to_string();
4400 let output = Command::new("journalctl")
4401 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4402 .output();
4403
4404 match output {
4405 Ok(o) if o.status.success() => {
4406 let text = String::from_utf8_lossy(&o.stdout);
4407 let trimmed = text.trim();
4408 if trimmed.is_empty() || trimmed.contains("No entries") {
4409 out.push_str("No critical or error entries found in the system journal.\n");
4410 } else {
4411 out.push_str(trimmed);
4412 out.push('\n');
4413 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4414 }
4415 }
4416 _ => {
4417 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4419 let mut found = false;
4420 for log_path in &log_paths {
4421 if let Ok(content) = std::fs::read_to_string(log_path) {
4422 let lines: Vec<&str> = content.lines().collect();
4423 let tail: Vec<&str> = lines
4424 .iter()
4425 .rev()
4426 .filter(|l| {
4427 let l_lower = l.to_ascii_lowercase();
4428 l_lower.contains("error") || l_lower.contains("crit")
4429 })
4430 .take(max_entries)
4431 .copied()
4432 .collect::<Vec<_>>()
4433 .into_iter()
4434 .rev()
4435 .collect();
4436 if !tail.is_empty() {
4437 out.push_str(&format!("Source: {log_path}\n"));
4438 for l in &tail {
4439 out.push_str(l);
4440 out.push('\n');
4441 }
4442 found = true;
4443 break;
4444 }
4445 }
4446 }
4447 if !found {
4448 out.push_str(
4449 "journalctl not found and no readable syslog detected on this system.\n",
4450 );
4451 }
4452 }
4453 }
4454 }
4455
4456 Ok(out.trim_end().to_string())
4457}
4458
4459fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4462 let mut out = String::from("Host inspection: startup_items\n\n");
4463
4464 #[cfg(target_os = "windows")]
4465 {
4466 let script = r#"
4468$hives = @(
4469 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4470 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4471 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4472)
4473foreach ($h in $hives) {
4474 try {
4475 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4476 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4477 "$($h.Hive)|$($_.Name)|$($_.Value)"
4478 }
4479 } catch {}
4480}
4481"#;
4482 let output = Command::new("powershell")
4483 .args(["-NoProfile", "-Command", script])
4484 .output()
4485 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4486
4487 let raw = String::from_utf8_lossy(&output.stdout);
4488 let text = raw.trim();
4489
4490 let entries: Vec<(String, String, String)> = text
4491 .lines()
4492 .filter_map(|l| {
4493 let parts: Vec<&str> = l.splitn(3, '|').collect();
4494 if parts.len() == 3 {
4495 Some((
4496 parts[0].to_string(),
4497 parts[1].to_string(),
4498 parts[2].to_string(),
4499 ))
4500 } else {
4501 None
4502 }
4503 })
4504 .take(max_entries)
4505 .collect();
4506
4507 if entries.is_empty() {
4508 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4509 } else {
4510 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4511 let mut last_hive = String::new();
4512 for (hive, name, value) in &entries {
4513 if *hive != last_hive {
4514 out.push_str(&format!("[{}]\n", hive));
4515 last_hive = hive.clone();
4516 }
4517 let display = if value.len() > 100 {
4519 format!("{}…", &value[..100])
4520 } else {
4521 value.clone()
4522 };
4523 out.push_str(&format!(" {name}: {display}\n"));
4524 }
4525 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4526 }
4527
4528 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4530 if let Ok(unified_out) = Command::new("powershell")
4531 .args(["-NoProfile", "-Command", unified_script])
4532 .output()
4533 {
4534 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4535 let trimmed = unified_text.trim();
4536 if !trimmed.is_empty() {
4537 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4538 out.push_str(trimmed);
4539 out.push('\n');
4540 }
4541 }
4542 }
4543
4544 #[cfg(not(target_os = "windows"))]
4545 {
4546 let output = Command::new("systemctl")
4548 .args([
4549 "list-unit-files",
4550 "--type=service",
4551 "--state=enabled",
4552 "--no-legend",
4553 "--no-pager",
4554 "--plain",
4555 ])
4556 .output();
4557
4558 match output {
4559 Ok(o) if o.status.success() => {
4560 let text = String::from_utf8_lossy(&o.stdout);
4561 let services: Vec<&str> = text
4562 .lines()
4563 .filter(|l| !l.trim().is_empty())
4564 .take(max_entries)
4565 .collect();
4566 if services.is_empty() {
4567 out.push_str("No enabled systemd services found.\n");
4568 } else {
4569 out.push_str("Enabled systemd services (run at boot):\n\n");
4570 for s in &services {
4571 out.push_str(&format!(" {s}\n"));
4572 }
4573 out.push_str(&format!(
4574 "\nShowing {} of enabled services.\n",
4575 services.len()
4576 ));
4577 }
4578 }
4579 _ => {
4580 out.push_str(
4581 "systemctl not found on this system. Cannot enumerate startup services.\n",
4582 );
4583 }
4584 }
4585
4586 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4588 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4589 let reboot_entries: Vec<&str> = cron_text
4590 .lines()
4591 .filter(|l| l.trim_start().starts_with("@reboot"))
4592 .collect();
4593 if !reboot_entries.is_empty() {
4594 out.push_str("\nCron @reboot entries:\n");
4595 for e in reboot_entries {
4596 out.push_str(&format!(" {e}\n"));
4597 }
4598 }
4599 }
4600 }
4601
4602 Ok(out.trim_end().to_string())
4603}
4604
4605fn inspect_os_config() -> Result<String, String> {
4606 let mut out = String::from("Host inspection: OS Configuration\n\n");
4607
4608 #[cfg(target_os = "windows")]
4609 {
4610 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4612 let power_str = String::from_utf8_lossy(&power_out.stdout);
4613 out.push_str("=== Power Plan ===\n");
4614 out.push_str(power_str.trim());
4615 out.push_str("\n\n");
4616 }
4617
4618 let fw_script =
4620 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4621 if let Ok(fw_out) = Command::new("powershell")
4622 .args(["-NoProfile", "-Command", fw_script])
4623 .output()
4624 {
4625 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4626 out.push_str("=== Firewall Profiles ===\n");
4627 out.push_str(fw_str.trim());
4628 out.push_str("\n\n");
4629 }
4630
4631 let uptime_script =
4633 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4634 if let Ok(uptime_out) = Command::new("powershell")
4635 .args(["-NoProfile", "-Command", uptime_script])
4636 .output()
4637 {
4638 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4639 out.push_str("=== System Uptime (Last Boot) ===\n");
4640 out.push_str(uptime_str.trim());
4641 out.push_str("\n\n");
4642 }
4643 }
4644
4645 #[cfg(not(target_os = "windows"))]
4646 {
4647 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4649 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4650 out.push_str("=== System Uptime ===\n");
4651 out.push_str(uptime_str.trim());
4652 out.push_str("\n\n");
4653 }
4654
4655 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4657 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4658 if !ufw_str.trim().is_empty() {
4659 out.push_str("=== Firewall (UFW) ===\n");
4660 out.push_str(ufw_str.trim());
4661 out.push_str("\n\n");
4662 }
4663 }
4664 }
4665 Ok(out.trim_end().to_string())
4666}
4667
4668pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4669 let action = args
4670 .get("action")
4671 .and_then(|v| v.as_str())
4672 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4673
4674 let target = args
4675 .get("target")
4676 .and_then(|v| v.as_str())
4677 .unwrap_or("")
4678 .trim();
4679
4680 if target.is_empty() && action != "clear_temp" {
4681 return Err("Missing required argument: 'target' for this action".to_string());
4682 }
4683
4684 match action {
4685 "install_package" => {
4686 #[cfg(target_os = "windows")]
4687 {
4688 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
4689 match Command::new("powershell")
4690 .args(["-NoProfile", "-Command", &cmd])
4691 .output()
4692 {
4693 Ok(out) => Ok(format!(
4694 "Executed remediation (winget install):\n{}",
4695 String::from_utf8_lossy(&out.stdout)
4696 )),
4697 Err(e) => Err(format!("Failed to run winget: {}", e)),
4698 }
4699 }
4700 #[cfg(not(target_os = "windows"))]
4701 {
4702 Err(
4703 "install_package via wrapper is only supported on Windows currently (winget)"
4704 .to_string(),
4705 )
4706 }
4707 }
4708 "restart_service" => {
4709 #[cfg(target_os = "windows")]
4710 {
4711 let cmd = format!("Restart-Service -Name {} -Force", target);
4712 match Command::new("powershell")
4713 .args(["-NoProfile", "-Command", &cmd])
4714 .output()
4715 {
4716 Ok(out) => {
4717 let err_str = String::from_utf8_lossy(&out.stderr);
4718 if !err_str.is_empty() {
4719 return Err(format!("Error restarting service:\n{}", err_str));
4720 }
4721 Ok(format!("Successfully restarted service: {}", target))
4722 }
4723 Err(e) => Err(format!("Failed to restart service: {}", e)),
4724 }
4725 }
4726 #[cfg(not(target_os = "windows"))]
4727 {
4728 Err(
4729 "restart_service via wrapper is only supported on Windows currently"
4730 .to_string(),
4731 )
4732 }
4733 }
4734 "clear_temp" => {
4735 #[cfg(target_os = "windows")]
4736 {
4737 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4738 match Command::new("powershell")
4739 .args(["-NoProfile", "-Command", cmd])
4740 .output()
4741 {
4742 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4743 Err(e) => Err(format!("Failed to clear temp: {}", e)),
4744 }
4745 }
4746 #[cfg(not(target_os = "windows"))]
4747 {
4748 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4749 }
4750 }
4751 other => Err(format!("Unknown remediation action: {}", other)),
4752 }
4753}
4754
4755fn inspect_storage(max_entries: usize) -> Result<String, String> {
4758 let mut out = String::from("Host inspection: storage\n\n");
4759 let _ = max_entries; out.push_str("Drives:\n");
4763
4764 #[cfg(target_os = "windows")]
4765 {
4766 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4767 $free = $_.Free
4768 $used = $_.Used
4769 if ($free -eq $null) { $free = 0 }
4770 if ($used -eq $null) { $used = 0 }
4771 $total = $free + $used
4772 "$($_.Name)|$free|$used|$total"
4773}"#;
4774 match Command::new("powershell")
4775 .args(["-NoProfile", "-Command", script])
4776 .output()
4777 {
4778 Ok(o) => {
4779 let text = String::from_utf8_lossy(&o.stdout);
4780 let mut drive_count = 0usize;
4781 for line in text.lines() {
4782 let parts: Vec<&str> = line.trim().split('|').collect();
4783 if parts.len() == 4 {
4784 let name = parts[0];
4785 let free: u64 = parts[1].parse().unwrap_or(0);
4786 let total: u64 = parts[3].parse().unwrap_or(0);
4787 if total == 0 {
4788 continue;
4789 }
4790 let free_gb = free / 1_073_741_824;
4791 let total_gb = total / 1_073_741_824;
4792 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4793 let bar_len = 20usize;
4794 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4795 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4796 let warn = if free_gb < 5 {
4797 " [!] CRITICALLY LOW"
4798 } else if free_gb < 15 {
4799 " [-] LOW"
4800 } else {
4801 ""
4802 };
4803 out.push_str(&format!(
4804 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4805 ));
4806 drive_count += 1;
4807 }
4808 }
4809 if drive_count == 0 {
4810 out.push_str(" (could not enumerate drives)\n");
4811 }
4812 }
4813 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
4814 }
4815
4816 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4818 match Command::new("powershell")
4819 .args(["-NoProfile", "-Command", latency_script])
4820 .output()
4821 {
4822 Ok(o) => {
4823 out.push_str("\nReal-time Disk Intensity:\n");
4824 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4825 if !text.is_empty() {
4826 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
4827 if let Ok(q) = text.parse::<f64>() {
4828 if q > 2.0 {
4829 out.push_str(
4830 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4831 );
4832 } else {
4833 out.push_str(" [~] Disk latency is within healthy bounds.\n");
4834 }
4835 }
4836 } else {
4837 out.push_str(" Average Disk Queue Length: unavailable\n");
4838 }
4839 }
4840 Err(_) => {
4841 out.push_str("\nReal-time Disk Intensity:\n");
4842 out.push_str(" Average Disk Queue Length: unavailable\n");
4843 }
4844 }
4845 }
4846
4847 #[cfg(not(target_os = "windows"))]
4848 {
4849 match Command::new("df")
4850 .args(["-h", "--output=target,size,avail,pcent"])
4851 .output()
4852 {
4853 Ok(o) => {
4854 let text = String::from_utf8_lossy(&o.stdout);
4855 let mut count = 0usize;
4856 for line in text.lines().skip(1) {
4857 let cols: Vec<&str> = line.split_whitespace().collect();
4858 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4859 out.push_str(&format!(
4860 " {} size: {} avail: {} used: {}\n",
4861 cols[0], cols[1], cols[2], cols[3]
4862 ));
4863 count += 1;
4864 if count >= max_entries {
4865 break;
4866 }
4867 }
4868 }
4869 }
4870 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
4871 }
4872 }
4873
4874 out.push_str("\nLarge developer cache directories (if present):\n");
4876
4877 #[cfg(target_os = "windows")]
4878 {
4879 let home = std::env::var("USERPROFILE").unwrap_or_default();
4880 let check_dirs: &[(&str, &str)] = &[
4881 ("Temp", r"AppData\Local\Temp"),
4882 ("npm cache", r"AppData\Roaming\npm-cache"),
4883 ("Cargo registry", r".cargo\registry"),
4884 ("Cargo git", r".cargo\git"),
4885 ("pip cache", r"AppData\Local\pip\cache"),
4886 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4887 (".rustup toolchains", r".rustup\toolchains"),
4888 ("node_modules (home)", r"node_modules"),
4889 ];
4890
4891 let mut found_any = false;
4892 for (label, rel) in check_dirs {
4893 let full = format!(r"{}\{}", home, rel);
4894 let path = std::path::Path::new(&full);
4895 if path.exists() {
4896 let size_script = format!(
4898 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4899 full.replace('\'', "''")
4900 );
4901 let size_mb = Command::new("powershell")
4902 .args(["-NoProfile", "-Command", &size_script])
4903 .output()
4904 .ok()
4905 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4906 .unwrap_or_else(|| "?".to_string());
4907 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
4908 found_any = true;
4909 }
4910 }
4911 if !found_any {
4912 out.push_str(" (none of the common cache directories found)\n");
4913 }
4914
4915 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4916 }
4917
4918 #[cfg(not(target_os = "windows"))]
4919 {
4920 let home = std::env::var("HOME").unwrap_or_default();
4921 let check_dirs: &[(&str, &str)] = &[
4922 ("npm cache", ".npm"),
4923 ("Cargo registry", ".cargo/registry"),
4924 ("pip cache", ".cache/pip"),
4925 (".rustup toolchains", ".rustup/toolchains"),
4926 ("Yarn cache", ".cache/yarn"),
4927 ];
4928 let mut found_any = false;
4929 for (label, rel) in check_dirs {
4930 let full = format!("{}/{}", home, rel);
4931 if std::path::Path::new(&full).exists() {
4932 let size = Command::new("du")
4933 .args(["-sh", &full])
4934 .output()
4935 .ok()
4936 .map(|o| {
4937 let s = String::from_utf8_lossy(&o.stdout);
4938 s.split_whitespace().next().unwrap_or("?").to_string()
4939 })
4940 .unwrap_or_else(|| "?".to_string());
4941 out.push_str(&format!(" {label}: {size} ({full})\n"));
4942 found_any = true;
4943 }
4944 }
4945 if !found_any {
4946 out.push_str(" (none of the common cache directories found)\n");
4947 }
4948 }
4949
4950 Ok(out.trim_end().to_string())
4951}
4952
4953fn inspect_hardware() -> Result<String, String> {
4956 let mut out = String::from("Host inspection: hardware\n\n");
4957
4958 #[cfg(target_os = "windows")]
4959 {
4960 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4962 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4963} | Select-Object -First 1"#;
4964 if let Ok(o) = Command::new("powershell")
4965 .args(["-NoProfile", "-Command", cpu_script])
4966 .output()
4967 {
4968 let text = String::from_utf8_lossy(&o.stdout);
4969 let text = text.trim();
4970 let parts: Vec<&str> = text.split('|').collect();
4971 if parts.len() == 4 {
4972 out.push_str(&format!(
4973 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
4974 parts[0],
4975 parts[1],
4976 parts[2],
4977 parts[3].parse::<f32>().unwrap_or(0.0)
4978 ));
4979 } else {
4980 out.push_str(&format!("CPU: {text}\n\n"));
4981 }
4982 }
4983
4984 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4986$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4987$speed = ($sticks | Select-Object -First 1).Speed
4988"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4989 if let Ok(o) = Command::new("powershell")
4990 .args(["-NoProfile", "-Command", ram_script])
4991 .output()
4992 {
4993 let text = String::from_utf8_lossy(&o.stdout);
4994 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4995 }
4996
4997 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4999 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5000}"#;
5001 if let Ok(o) = Command::new("powershell")
5002 .args(["-NoProfile", "-Command", gpu_script])
5003 .output()
5004 {
5005 let text = String::from_utf8_lossy(&o.stdout);
5006 let lines: Vec<&str> = text.lines().collect();
5007 if !lines.is_empty() {
5008 out.push_str("GPU(s):\n");
5009 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5010 let parts: Vec<&str> = line.trim().split('|').collect();
5011 if parts.len() == 3 {
5012 let res = if parts[2] == "x" || parts[2].starts_with('0') {
5013 String::new()
5014 } else {
5015 format!(" — {}@display", parts[2])
5016 };
5017 out.push_str(&format!(
5018 " {}\n Driver: {}{}\n",
5019 parts[0], parts[1], res
5020 ));
5021 } else {
5022 out.push_str(&format!(" {}\n", line.trim()));
5023 }
5024 }
5025 out.push('\n');
5026 }
5027 }
5028
5029 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5031$bios = Get-CimInstance Win32_BIOS
5032$cs = Get-CimInstance Win32_ComputerSystem
5033$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5034$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5035"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5036 if let Ok(o) = Command::new("powershell")
5037 .args(["-NoProfile", "-Command", mb_script])
5038 .output()
5039 {
5040 let text = String::from_utf8_lossy(&o.stdout);
5041 let text = text.trim().trim_matches('"');
5042 let parts: Vec<&str> = text.split('|').collect();
5043 if parts.len() == 4 {
5044 out.push_str(&format!(
5045 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5046 parts[0].trim(),
5047 parts[1].trim(),
5048 parts[2].trim(),
5049 parts[3].trim()
5050 ));
5051 }
5052 }
5053
5054 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5056 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5057}"#;
5058 if let Ok(o) = Command::new("powershell")
5059 .args(["-NoProfile", "-Command", disp_script])
5060 .output()
5061 {
5062 let text = String::from_utf8_lossy(&o.stdout);
5063 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5064 if !lines.is_empty() {
5065 out.push_str("Display(s):\n");
5066 for line in &lines {
5067 let parts: Vec<&str> = line.trim().split('|').collect();
5068 if parts.len() == 2 {
5069 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
5070 }
5071 }
5072 }
5073 }
5074 }
5075
5076 #[cfg(not(target_os = "windows"))]
5077 {
5078 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5080 let model = content
5081 .lines()
5082 .find(|l| l.starts_with("model name"))
5083 .and_then(|l| l.split(':').nth(1))
5084 .map(str::trim)
5085 .unwrap_or("unknown");
5086 let cores = content
5087 .lines()
5088 .filter(|l| l.starts_with("processor"))
5089 .count();
5090 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
5091 }
5092
5093 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5095 let total_kb: u64 = content
5096 .lines()
5097 .find(|l| l.starts_with("MemTotal:"))
5098 .and_then(|l| l.split_whitespace().nth(1))
5099 .and_then(|v| v.parse().ok())
5100 .unwrap_or(0);
5101 let total_gb = total_kb / 1_048_576;
5102 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5103 }
5104
5105 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5107 let text = String::from_utf8_lossy(&o.stdout);
5108 let gpu_lines: Vec<&str> = text
5109 .lines()
5110 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5111 .collect();
5112 if !gpu_lines.is_empty() {
5113 out.push_str("GPU(s):\n");
5114 for l in gpu_lines {
5115 out.push_str(&format!(" {l}\n"));
5116 }
5117 out.push('\n');
5118 }
5119 }
5120
5121 if let Ok(o) = Command::new("dmidecode")
5123 .args(["-t", "baseboard", "-t", "bios"])
5124 .output()
5125 {
5126 let text = String::from_utf8_lossy(&o.stdout);
5127 out.push_str("Motherboard/BIOS:\n");
5128 for line in text
5129 .lines()
5130 .filter(|l| {
5131 l.contains("Manufacturer:")
5132 || l.contains("Product Name:")
5133 || l.contains("Version:")
5134 })
5135 .take(6)
5136 {
5137 out.push_str(&format!(" {}\n", line.trim()));
5138 }
5139 }
5140 }
5141
5142 Ok(out.trim_end().to_string())
5143}
5144
5145fn inspect_updates() -> Result<String, String> {
5148 let mut out = String::from("Host inspection: updates\n\n");
5149
5150 #[cfg(target_os = "windows")]
5151 {
5152 let script = r#"
5154try {
5155 $sess = New-Object -ComObject Microsoft.Update.Session
5156 $searcher = $sess.CreateUpdateSearcher()
5157 $count = $searcher.GetTotalHistoryCount()
5158 if ($count -gt 0) {
5159 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5160 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5161 } else { "NONE|LAST_INSTALL" }
5162} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5163"#;
5164 if let Ok(o) = Command::new("powershell")
5165 .args(["-NoProfile", "-Command", script])
5166 .output()
5167 {
5168 let raw = String::from_utf8_lossy(&o.stdout);
5169 let text = raw.trim();
5170 if text.starts_with("ERROR:") {
5171 out.push_str("Last update install: (unable to query)\n");
5172 } else if text.contains("NONE") {
5173 out.push_str("Last update install: No update history found\n");
5174 } else {
5175 let date = text.replace("|LAST_INSTALL", "");
5176 out.push_str(&format!("Last update install: {date}\n"));
5177 }
5178 }
5179
5180 let pending_script = r#"
5182try {
5183 $sess = New-Object -ComObject Microsoft.Update.Session
5184 $searcher = $sess.CreateUpdateSearcher()
5185 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5186 $results.Updates.Count.ToString() + "|PENDING"
5187} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5188"#;
5189 if let Ok(o) = Command::new("powershell")
5190 .args(["-NoProfile", "-Command", pending_script])
5191 .output()
5192 {
5193 let raw = String::from_utf8_lossy(&o.stdout);
5194 let text = raw.trim();
5195 if text.starts_with("ERROR:") {
5196 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5197 } else {
5198 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5199 if count == 0 {
5200 out.push_str("Pending updates: Up to date — no updates waiting\n");
5201 } else if count > 0 {
5202 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5203 out.push_str(
5204 " → Open Windows Update (Settings > Windows Update) to install\n",
5205 );
5206 }
5207 }
5208 }
5209
5210 let svc_script = r#"
5212$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5213if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5214"#;
5215 if let Ok(o) = Command::new("powershell")
5216 .args(["-NoProfile", "-Command", svc_script])
5217 .output()
5218 {
5219 let raw = String::from_utf8_lossy(&o.stdout);
5220 let status = raw.trim();
5221 out.push_str(&format!("Windows Update service: {status}\n"));
5222 }
5223 }
5224
5225 #[cfg(not(target_os = "windows"))]
5226 {
5227 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5228 let mut found = false;
5229 if let Ok(o) = apt_out {
5230 let text = String::from_utf8_lossy(&o.stdout);
5231 let lines: Vec<&str> = text
5232 .lines()
5233 .filter(|l| l.contains('/') && !l.contains("Listing"))
5234 .collect();
5235 if !lines.is_empty() {
5236 out.push_str(&format!(
5237 "{} package(s) can be upgraded (apt)\n",
5238 lines.len()
5239 ));
5240 out.push_str(" → Run: sudo apt upgrade\n");
5241 found = true;
5242 }
5243 }
5244 if !found {
5245 if let Ok(o) = Command::new("dnf")
5246 .args(["check-update", "--quiet"])
5247 .output()
5248 {
5249 let text = String::from_utf8_lossy(&o.stdout);
5250 let count = text
5251 .lines()
5252 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5253 .count();
5254 if count > 0 {
5255 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5256 out.push_str(" → Run: sudo dnf upgrade\n");
5257 } else {
5258 out.push_str("System is up to date.\n");
5259 }
5260 } else {
5261 out.push_str("Could not query package manager for updates.\n");
5262 }
5263 }
5264 }
5265
5266 Ok(out.trim_end().to_string())
5267}
5268
5269fn inspect_security() -> Result<String, String> {
5272 let mut out = String::from("Host inspection: security\n\n");
5273
5274 #[cfg(target_os = "windows")]
5275 {
5276 let defender_script = r#"
5278try {
5279 $status = Get-MpComputerStatus -ErrorAction Stop
5280 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5281} catch { "ERROR:" + $_.Exception.Message }
5282"#;
5283 if let Ok(o) = Command::new("powershell")
5284 .args(["-NoProfile", "-Command", defender_script])
5285 .output()
5286 {
5287 let raw = String::from_utf8_lossy(&o.stdout);
5288 let text = raw.trim();
5289 if text.starts_with("ERROR:") {
5290 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5291 } else {
5292 let get = |key: &str| -> String {
5293 text.split('|')
5294 .find(|s| s.starts_with(key))
5295 .and_then(|s| s.splitn(2, ':').nth(1))
5296 .unwrap_or("unknown")
5297 .to_string()
5298 };
5299 let rtp = get("RTP");
5300 let last_scan = {
5301 text.split('|')
5303 .find(|s| s.starts_with("SCAN:"))
5304 .and_then(|s| s.get(5..))
5305 .unwrap_or("unknown")
5306 .to_string()
5307 };
5308 let def_ver = get("VER");
5309 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5310
5311 let rtp_label = if rtp == "True" {
5312 "ENABLED"
5313 } else {
5314 "DISABLED [!]"
5315 };
5316 out.push_str(&format!(
5317 "Windows Defender real-time protection: {rtp_label}\n"
5318 ));
5319 out.push_str(&format!("Last quick scan: {last_scan}\n"));
5320 out.push_str(&format!("Signature version: {def_ver}\n"));
5321 if age_days >= 0 {
5322 let freshness = if age_days == 0 {
5323 "up to date".to_string()
5324 } else if age_days <= 3 {
5325 format!("{age_days} day(s) old — OK")
5326 } else if age_days <= 7 {
5327 format!("{age_days} day(s) old — consider updating")
5328 } else {
5329 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5330 };
5331 out.push_str(&format!("Signature age: {freshness}\n"));
5332 }
5333 if rtp != "True" {
5334 out.push_str(
5335 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5336 );
5337 out.push_str(
5338 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5339 );
5340 }
5341 }
5342 }
5343
5344 out.push('\n');
5345
5346 let fw_script = r#"
5348try {
5349 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5350} catch { "ERROR:" + $_.Exception.Message }
5351"#;
5352 if let Ok(o) = Command::new("powershell")
5353 .args(["-NoProfile", "-Command", fw_script])
5354 .output()
5355 {
5356 let raw = String::from_utf8_lossy(&o.stdout);
5357 let text = raw.trim();
5358 if !text.starts_with("ERROR:") && !text.is_empty() {
5359 out.push_str("Windows Firewall:\n");
5360 for line in text.lines() {
5361 if let Some((name, enabled)) = line.split_once(':') {
5362 let state = if enabled.trim() == "True" {
5363 "ON"
5364 } else {
5365 "OFF [!]"
5366 };
5367 out.push_str(&format!(" {name}: {state}\n"));
5368 }
5369 }
5370 out.push('\n');
5371 }
5372 }
5373
5374 let act_script = r#"
5376try {
5377 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5378 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5379} catch { "UNKNOWN" }
5380"#;
5381 if let Ok(o) = Command::new("powershell")
5382 .args(["-NoProfile", "-Command", act_script])
5383 .output()
5384 {
5385 let raw = String::from_utf8_lossy(&o.stdout);
5386 match raw.trim() {
5387 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5388 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5389 _ => out.push_str("Windows activation: Unable to determine\n"),
5390 }
5391 }
5392
5393 let uac_script = r#"
5395$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5396if ($val -eq 1) { "ON" } else { "OFF" }
5397"#;
5398 if let Ok(o) = Command::new("powershell")
5399 .args(["-NoProfile", "-Command", uac_script])
5400 .output()
5401 {
5402 let raw = String::from_utf8_lossy(&o.stdout);
5403 let state = raw.trim();
5404 let label = if state == "ON" {
5405 "Enabled"
5406 } else {
5407 "DISABLED [!] — recommended to re-enable via secpol.msc"
5408 };
5409 out.push_str(&format!("UAC (User Account Control): {label}\n"));
5410 }
5411 }
5412
5413 #[cfg(not(target_os = "windows"))]
5414 {
5415 if let Ok(o) = Command::new("ufw").arg("status").output() {
5416 let text = String::from_utf8_lossy(&o.stdout);
5417 out.push_str(&format!(
5418 "UFW: {}\n",
5419 text.lines().next().unwrap_or("unknown")
5420 ));
5421 }
5422 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5423 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5424 out.push_str(&format!("{line}\n"));
5425 }
5426 }
5427 }
5428
5429 Ok(out.trim_end().to_string())
5430}
5431
5432fn inspect_pending_reboot() -> Result<String, String> {
5435 let mut out = String::from("Host inspection: pending_reboot\n\n");
5436
5437 #[cfg(target_os = "windows")]
5438 {
5439 let script = r#"
5440$reasons = @()
5441if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5442 $reasons += "Windows Update requires a restart"
5443}
5444if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5445 $reasons += "Windows component install/update requires a restart"
5446}
5447$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5448if ($pfro -and $pfro.PendingFileRenameOperations) {
5449 $reasons += "Pending file rename operations (driver or system file replacement)"
5450}
5451if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5452"#;
5453 let output = Command::new("powershell")
5454 .args(["-NoProfile", "-Command", script])
5455 .output()
5456 .map_err(|e| format!("pending_reboot: {e}"))?;
5457
5458 let raw = String::from_utf8_lossy(&output.stdout);
5459 let text = raw.trim();
5460
5461 if text == "NO_REBOOT_NEEDED" {
5462 out.push_str("No restart required — system is up to date and stable.\n");
5463 } else if text.is_empty() {
5464 out.push_str("Could not determine reboot status.\n");
5465 } else {
5466 out.push_str("[!] A system restart is pending:\n\n");
5467 for reason in text.split("|REASON|") {
5468 out.push_str(&format!(" • {}\n", reason.trim()));
5469 }
5470 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5471 }
5472 }
5473
5474 #[cfg(not(target_os = "windows"))]
5475 {
5476 if std::path::Path::new("/var/run/reboot-required").exists() {
5477 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5478 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5479 out.push_str("Packages requiring restart:\n");
5480 for p in pkgs.lines().take(10) {
5481 out.push_str(&format!(" • {p}\n"));
5482 }
5483 }
5484 } else {
5485 out.push_str("No restart required.\n");
5486 }
5487 }
5488
5489 Ok(out.trim_end().to_string())
5490}
5491
5492fn inspect_disk_health() -> Result<String, String> {
5495 let mut out = String::from("Host inspection: disk_health\n\n");
5496
5497 #[cfg(target_os = "windows")]
5498 {
5499 let script = r#"
5500try {
5501 $disks = Get-PhysicalDisk -ErrorAction Stop
5502 foreach ($d in $disks) {
5503 $size_gb = [math]::Round($d.Size / 1GB, 0)
5504 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5505 }
5506} catch { "ERROR:" + $_.Exception.Message }
5507"#;
5508 let output = Command::new("powershell")
5509 .args(["-NoProfile", "-Command", script])
5510 .output()
5511 .map_err(|e| format!("disk_health: {e}"))?;
5512
5513 let raw = String::from_utf8_lossy(&output.stdout);
5514 let text = raw.trim();
5515
5516 if text.starts_with("ERROR:") {
5517 out.push_str(&format!("Unable to query disk health: {text}\n"));
5518 out.push_str("This may require running as administrator.\n");
5519 } else if text.is_empty() {
5520 out.push_str("No physical disks found.\n");
5521 } else {
5522 out.push_str("Physical Drive Health:\n\n");
5523 for line in text.lines() {
5524 let parts: Vec<&str> = line.splitn(5, '|').collect();
5525 if parts.len() >= 4 {
5526 let name = parts[0];
5527 let media = parts[1];
5528 let size = parts[2];
5529 let health = parts[3];
5530 let op_status = parts.get(4).unwrap_or(&"");
5531 let health_label = match health.trim() {
5532 "Healthy" => "OK",
5533 "Warning" => "[!] WARNING",
5534 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5535 other => other,
5536 };
5537 out.push_str(&format!(" {name}\n"));
5538 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
5539 out.push_str(&format!(" Health: {health_label}\n"));
5540 if !op_status.is_empty() {
5541 out.push_str(&format!(" Status: {op_status}\n"));
5542 }
5543 out.push('\n');
5544 }
5545 }
5546 }
5547
5548 let smart_script = r#"
5550try {
5551 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5552 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5553} catch { "" }
5554"#;
5555 if let Ok(o) = Command::new("powershell")
5556 .args(["-NoProfile", "-Command", smart_script])
5557 .output()
5558 {
5559 let raw2 = String::from_utf8_lossy(&o.stdout);
5560 let text2 = raw2.trim();
5561 if !text2.is_empty() {
5562 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5563 if failures.is_empty() {
5564 out.push_str("SMART failure prediction: No failures predicted\n");
5565 } else {
5566 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5567 for f in failures {
5568 let name = f.split('|').next().unwrap_or(f);
5569 out.push_str(&format!(" • {name}\n"));
5570 }
5571 out.push_str(
5572 "\nBack up your data immediately and replace the failing drive.\n",
5573 );
5574 }
5575 }
5576 }
5577 }
5578
5579 #[cfg(not(target_os = "windows"))]
5580 {
5581 if let Ok(o) = Command::new("lsblk")
5582 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5583 .output()
5584 {
5585 let text = String::from_utf8_lossy(&o.stdout);
5586 out.push_str("Block devices:\n");
5587 out.push_str(text.trim());
5588 out.push('\n');
5589 }
5590 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5591 let devices = String::from_utf8_lossy(&scan.stdout);
5592 for dev_line in devices.lines().take(4) {
5593 let dev = dev_line.split_whitespace().next().unwrap_or("");
5594 if dev.is_empty() {
5595 continue;
5596 }
5597 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5598 let health = String::from_utf8_lossy(&o.stdout);
5599 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5600 {
5601 out.push_str(&format!("{dev}: {}\n", line.trim()));
5602 }
5603 }
5604 }
5605 } else {
5606 out.push_str("(install smartmontools for SMART health data)\n");
5607 }
5608 }
5609
5610 Ok(out.trim_end().to_string())
5611}
5612
5613fn inspect_battery() -> Result<String, String> {
5616 let mut out = String::from("Host inspection: battery\n\n");
5617
5618 #[cfg(target_os = "windows")]
5619 {
5620 let script = r#"
5621try {
5622 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5623 if (-not $bats) { "NO_BATTERY"; exit }
5624
5625 # Modern Battery Health (Cycle count + Capacity health)
5626 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5627 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
5628 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5629
5630 foreach ($b in $bats) {
5631 $state = switch ($b.BatteryStatus) {
5632 1 { "Discharging" }
5633 2 { "AC Power (Fully Charged)" }
5634 3 { "AC Power (Charging)" }
5635 default { "Status $($b.BatteryStatus)" }
5636 }
5637
5638 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5639 $health = if ($static -and $full) {
5640 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5641 } else { "unknown" }
5642
5643 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5644 }
5645} catch { "ERROR:" + $_.Exception.Message }
5646"#;
5647 let output = Command::new("powershell")
5648 .args(["-NoProfile", "-Command", script])
5649 .output()
5650 .map_err(|e| format!("battery: {e}"))?;
5651
5652 let raw = String::from_utf8_lossy(&output.stdout);
5653 let text = raw.trim();
5654
5655 if text == "NO_BATTERY" {
5656 out.push_str("No battery detected — desktop or AC-only system.\n");
5657 return Ok(out.trim_end().to_string());
5658 }
5659 if text.starts_with("ERROR:") {
5660 out.push_str(&format!("Unable to query battery: {text}\n"));
5661 return Ok(out.trim_end().to_string());
5662 }
5663
5664 for line in text.lines() {
5665 let parts: Vec<&str> = line.split('|').collect();
5666 if parts.len() == 5 {
5667 let name = parts[0];
5668 let charge: i64 = parts[1].parse().unwrap_or(-1);
5669 let state = parts[2];
5670 let cycles = parts[3];
5671 let health = parts[4];
5672
5673 out.push_str(&format!("Battery: {name}\n"));
5674 if charge >= 0 {
5675 let bar_filled = (charge as usize * 20) / 100;
5676 out.push_str(&format!(
5677 " Charge: [{}{}] {}%\n",
5678 "#".repeat(bar_filled),
5679 ".".repeat(20 - bar_filled),
5680 charge
5681 ));
5682 }
5683 out.push_str(&format!(" Status: {state}\n"));
5684 out.push_str(&format!(" Cycles: {cycles}\n"));
5685 out.push_str(&format!(
5686 " Health: {health}% (Actual vs Design Capacity)\n\n"
5687 ));
5688 }
5689 }
5690 }
5691
5692 #[cfg(not(target_os = "windows"))]
5693 {
5694 let power_path = std::path::Path::new("/sys/class/power_supply");
5695 let mut found = false;
5696 if power_path.exists() {
5697 if let Ok(entries) = std::fs::read_dir(power_path) {
5698 for entry in entries.flatten() {
5699 let p = entry.path();
5700 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
5701 if t.trim() == "Battery" {
5702 found = true;
5703 let name = p
5704 .file_name()
5705 .unwrap_or_default()
5706 .to_string_lossy()
5707 .to_string();
5708 out.push_str(&format!("Battery: {name}\n"));
5709 let read = |f: &str| {
5710 std::fs::read_to_string(p.join(f))
5711 .ok()
5712 .map(|s| s.trim().to_string())
5713 };
5714 if let Some(cap) = read("capacity") {
5715 out.push_str(&format!(" Charge: {cap}%\n"));
5716 }
5717 if let Some(status) = read("status") {
5718 out.push_str(&format!(" Status: {status}\n"));
5719 }
5720 if let (Some(full), Some(design)) =
5721 (read("energy_full"), read("energy_full_design"))
5722 {
5723 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
5724 {
5725 if d > 0.0 {
5726 out.push_str(&format!(
5727 " Wear level: {:.1}% of design capacity\n",
5728 (f / d) * 100.0
5729 ));
5730 }
5731 }
5732 }
5733 }
5734 }
5735 }
5736 }
5737 }
5738 if !found {
5739 out.push_str("No battery found.\n");
5740 }
5741 }
5742
5743 Ok(out.trim_end().to_string())
5744}
5745
5746fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5749 let mut out = String::from("Host inspection: recent_crashes\n\n");
5750 let n = max_entries.clamp(1, 30);
5751
5752 #[cfg(target_os = "windows")]
5753 {
5754 let bsod_script = format!(
5756 r#"
5757try {{
5758 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5759 if ($events) {{
5760 $events | ForEach-Object {{
5761 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5762 }}
5763 }} else {{ "NO_BSOD" }}
5764}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5765 );
5766
5767 if let Ok(o) = Command::new("powershell")
5768 .args(["-NoProfile", "-Command", &bsod_script])
5769 .output()
5770 {
5771 let raw = String::from_utf8_lossy(&o.stdout);
5772 let text = raw.trim();
5773 if text == "NO_BSOD" {
5774 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5775 } else if text.starts_with("ERROR:") {
5776 out.push_str("System crashes: unable to query\n");
5777 } else {
5778 out.push_str("System crashes / unexpected shutdowns:\n");
5779 for line in text.lines() {
5780 let parts: Vec<&str> = line.splitn(3, '|').collect();
5781 if parts.len() >= 3 {
5782 let time = parts[0];
5783 let id = parts[1];
5784 let msg = parts[2];
5785 let label = if id == "41" {
5786 "Unexpected shutdown"
5787 } else {
5788 "BSOD (BugCheck)"
5789 };
5790 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
5791 }
5792 }
5793 out.push('\n');
5794 }
5795 }
5796
5797 let app_script = format!(
5799 r#"
5800try {{
5801 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5802 if ($crashes) {{
5803 $crashes | ForEach-Object {{
5804 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5805 }}
5806 }} else {{ "NO_CRASHES" }}
5807}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5808 );
5809
5810 if let Ok(o) = Command::new("powershell")
5811 .args(["-NoProfile", "-Command", &app_script])
5812 .output()
5813 {
5814 let raw = String::from_utf8_lossy(&o.stdout);
5815 let text = raw.trim();
5816 if text == "NO_CRASHES" {
5817 out.push_str("Application crashes: None in recent history\n");
5818 } else if text.starts_with("ERROR_APP:") {
5819 out.push_str("Application crashes: unable to query\n");
5820 } else {
5821 out.push_str("Application crashes:\n");
5822 for line in text.lines().take(n) {
5823 let parts: Vec<&str> = line.splitn(2, '|').collect();
5824 if parts.len() >= 2 {
5825 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
5826 }
5827 }
5828 }
5829 }
5830 }
5831
5832 #[cfg(not(target_os = "windows"))]
5833 {
5834 let n_str = n.to_string();
5835 if let Ok(o) = Command::new("journalctl")
5836 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5837 .output()
5838 {
5839 let text = String::from_utf8_lossy(&o.stdout);
5840 let trimmed = text.trim();
5841 if trimmed.is_empty() || trimmed.contains("No entries") {
5842 out.push_str("No kernel panics or critical crashes found.\n");
5843 } else {
5844 out.push_str("Kernel critical events:\n");
5845 out.push_str(trimmed);
5846 out.push('\n');
5847 }
5848 }
5849 if let Ok(o) = Command::new("coredumpctl")
5850 .args(["list", "--no-pager"])
5851 .output()
5852 {
5853 let text = String::from_utf8_lossy(&o.stdout);
5854 let count = text
5855 .lines()
5856 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5857 .count();
5858 if count > 0 {
5859 out.push_str(&format!(
5860 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
5861 ));
5862 }
5863 }
5864 }
5865
5866 Ok(out.trim_end().to_string())
5867}
5868
5869fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5872 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5873 let n = max_entries.clamp(1, 30);
5874
5875 #[cfg(target_os = "windows")]
5876 {
5877 let script = format!(
5878 r#"
5879try {{
5880 $tasks = Get-ScheduledTask -ErrorAction Stop |
5881 Where-Object {{ $_.State -ne 'Disabled' }} |
5882 ForEach-Object {{
5883 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5884 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5885 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5886 }} else {{ "never" }}
5887 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5888 $exec = ($_.Actions | Select-Object -First 1).Execute
5889 if (-not $exec) {{ $exec = "(no exec)" }}
5890 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5891 }}
5892 $tasks | Select-Object -First {n}
5893}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5894 );
5895
5896 let output = Command::new("powershell")
5897 .args(["-NoProfile", "-Command", &script])
5898 .output()
5899 .map_err(|e| format!("scheduled_tasks: {e}"))?;
5900
5901 let raw = String::from_utf8_lossy(&output.stdout);
5902 let text = raw.trim();
5903
5904 if text.starts_with("ERROR:") {
5905 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5906 } else if text.is_empty() {
5907 out.push_str("No active scheduled tasks found.\n");
5908 } else {
5909 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5910 for line in text.lines() {
5911 let parts: Vec<&str> = line.splitn(6, '|').collect();
5912 if parts.len() >= 5 {
5913 let name = parts[0];
5914 let path = parts[1];
5915 let state = parts[2];
5916 let last = parts[3];
5917 let res = parts[4];
5918 let exec = parts.get(5).unwrap_or(&"").trim();
5919 let display_path = path.trim_matches('\\');
5920 let display_path = if display_path.is_empty() {
5921 "Root"
5922 } else {
5923 display_path
5924 };
5925 out.push_str(&format!(" {name} [{display_path}]\n"));
5926 out.push_str(&format!(
5927 " State: {state} | Last run: {last} | Result: {res}\n"
5928 ));
5929 if !exec.is_empty() && exec != "(no exec)" {
5930 let short = if exec.len() > 80 { &exec[..80] } else { exec };
5931 out.push_str(&format!(" Runs: {short}\n"));
5932 }
5933 }
5934 }
5935 }
5936 }
5937
5938 #[cfg(not(target_os = "windows"))]
5939 {
5940 if let Ok(o) = Command::new("systemctl")
5941 .args(["list-timers", "--no-pager", "--all"])
5942 .output()
5943 {
5944 let text = String::from_utf8_lossy(&o.stdout);
5945 out.push_str("Systemd timers:\n");
5946 for l in text
5947 .lines()
5948 .filter(|l| {
5949 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5950 })
5951 .take(n)
5952 {
5953 out.push_str(&format!(" {l}\n"));
5954 }
5955 out.push('\n');
5956 }
5957 if let Ok(o) = Command::new("crontab").arg("-l").output() {
5958 let text = String::from_utf8_lossy(&o.stdout);
5959 let jobs: Vec<&str> = text
5960 .lines()
5961 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5962 .collect();
5963 if !jobs.is_empty() {
5964 out.push_str("User crontab:\n");
5965 for j in jobs.iter().take(n) {
5966 out.push_str(&format!(" {j}\n"));
5967 }
5968 }
5969 }
5970 }
5971
5972 Ok(out.trim_end().to_string())
5973}
5974
5975fn inspect_dev_conflicts() -> Result<String, String> {
5978 let mut out = String::from("Host inspection: dev_conflicts\n\n");
5979 let mut conflicts: Vec<String> = Vec::new();
5980 let mut notes: Vec<String> = Vec::new();
5981
5982 {
5984 let node_ver = Command::new("node")
5985 .arg("--version")
5986 .output()
5987 .ok()
5988 .and_then(|o| String::from_utf8(o.stdout).ok())
5989 .map(|s| s.trim().to_string());
5990 let nvm_active = Command::new("nvm")
5991 .arg("current")
5992 .output()
5993 .ok()
5994 .and_then(|o| String::from_utf8(o.stdout).ok())
5995 .map(|s| s.trim().to_string())
5996 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5997 let fnm_active = Command::new("fnm")
5998 .arg("current")
5999 .output()
6000 .ok()
6001 .and_then(|o| String::from_utf8(o.stdout).ok())
6002 .map(|s| s.trim().to_string())
6003 .filter(|s| !s.is_empty() && !s.contains("none"));
6004 let volta_active = Command::new("volta")
6005 .args(["which", "node"])
6006 .output()
6007 .ok()
6008 .and_then(|o| String::from_utf8(o.stdout).ok())
6009 .map(|s| s.trim().to_string())
6010 .filter(|s| !s.is_empty());
6011
6012 out.push_str("Node.js:\n");
6013 if let Some(ref v) = node_ver {
6014 out.push_str(&format!(" Active: {v}\n"));
6015 } else {
6016 out.push_str(" Not installed\n");
6017 }
6018 let managers: Vec<&str> = [
6019 nvm_active.as_deref(),
6020 fnm_active.as_deref(),
6021 volta_active.as_deref(),
6022 ]
6023 .iter()
6024 .filter_map(|x| *x)
6025 .collect();
6026 if managers.len() > 1 {
6027 conflicts.push(format!(
6028 "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
6029 ));
6030 } else if !managers.is_empty() {
6031 out.push_str(&format!(" Version manager: {}\n", managers[0]));
6032 }
6033 out.push('\n');
6034 }
6035
6036 {
6038 let py3 = Command::new("python3")
6039 .arg("--version")
6040 .output()
6041 .ok()
6042 .and_then(|o| {
6043 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6044 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6045 let v = if stdout.is_empty() { stderr } else { stdout };
6046 if v.is_empty() {
6047 None
6048 } else {
6049 Some(v)
6050 }
6051 });
6052 let py = Command::new("python")
6053 .arg("--version")
6054 .output()
6055 .ok()
6056 .and_then(|o| {
6057 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6058 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6059 let v = if stdout.is_empty() { stderr } else { stdout };
6060 if v.is_empty() {
6061 None
6062 } else {
6063 Some(v)
6064 }
6065 });
6066 let pyenv = Command::new("pyenv")
6067 .arg("version")
6068 .output()
6069 .ok()
6070 .and_then(|o| String::from_utf8(o.stdout).ok())
6071 .map(|s| s.trim().to_string())
6072 .filter(|s| !s.is_empty());
6073 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6074
6075 out.push_str("Python:\n");
6076 match (&py3, &py) {
6077 (Some(v3), Some(v)) if v3 != v => {
6078 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
6079 if v.contains("2.") {
6080 conflicts.push(
6081 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6082 );
6083 } else {
6084 notes.push(
6085 "python and python3 resolve to different minor versions.".to_string(),
6086 );
6087 }
6088 }
6089 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
6090 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
6091 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
6092 (None, None) => out.push_str(" Not installed\n"),
6093 }
6094 if let Some(ref pe) = pyenv {
6095 out.push_str(&format!(" pyenv: {pe}\n"));
6096 }
6097 if let Some(env) = conda_env {
6098 if env == "base" {
6099 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6100 } else {
6101 out.push_str(&format!(" conda env: {env}\n"));
6102 }
6103 }
6104 out.push('\n');
6105 }
6106
6107 {
6109 let toolchain = Command::new("rustup")
6110 .args(["show", "active-toolchain"])
6111 .output()
6112 .ok()
6113 .and_then(|o| String::from_utf8(o.stdout).ok())
6114 .map(|s| s.trim().to_string())
6115 .filter(|s| !s.is_empty());
6116 let cargo_ver = Command::new("cargo")
6117 .arg("--version")
6118 .output()
6119 .ok()
6120 .and_then(|o| String::from_utf8(o.stdout).ok())
6121 .map(|s| s.trim().to_string());
6122 let rustc_ver = Command::new("rustc")
6123 .arg("--version")
6124 .output()
6125 .ok()
6126 .and_then(|o| String::from_utf8(o.stdout).ok())
6127 .map(|s| s.trim().to_string());
6128
6129 out.push_str("Rust:\n");
6130 if let Some(ref t) = toolchain {
6131 out.push_str(&format!(" Active toolchain: {t}\n"));
6132 }
6133 if let Some(ref c) = cargo_ver {
6134 out.push_str(&format!(" {c}\n"));
6135 }
6136 if let Some(ref r) = rustc_ver {
6137 out.push_str(&format!(" {r}\n"));
6138 }
6139 if cargo_ver.is_none() && rustc_ver.is_none() {
6140 out.push_str(" Not installed\n");
6141 }
6142
6143 #[cfg(not(target_os = "windows"))]
6145 if let Ok(o) = Command::new("which").arg("rustc").output() {
6146 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6147 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6148 conflicts.push(format!(
6149 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6150 ));
6151 }
6152 }
6153 out.push('\n');
6154 }
6155
6156 {
6158 let git_ver = Command::new("git")
6159 .arg("--version")
6160 .output()
6161 .ok()
6162 .and_then(|o| String::from_utf8(o.stdout).ok())
6163 .map(|s| s.trim().to_string());
6164 out.push_str("Git:\n");
6165 if let Some(ref v) = git_ver {
6166 out.push_str(&format!(" {v}\n"));
6167 let email = Command::new("git")
6168 .args(["config", "--global", "user.email"])
6169 .output()
6170 .ok()
6171 .and_then(|o| String::from_utf8(o.stdout).ok())
6172 .map(|s| s.trim().to_string());
6173 if let Some(ref e) = email {
6174 if e.is_empty() {
6175 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6176 } else {
6177 out.push_str(&format!(" user.email: {e}\n"));
6178 }
6179 }
6180 let gpg_sign = Command::new("git")
6181 .args(["config", "--global", "commit.gpgsign"])
6182 .output()
6183 .ok()
6184 .and_then(|o| String::from_utf8(o.stdout).ok())
6185 .map(|s| s.trim().to_string());
6186 if gpg_sign.as_deref() == Some("true") {
6187 let key = Command::new("git")
6188 .args(["config", "--global", "user.signingkey"])
6189 .output()
6190 .ok()
6191 .and_then(|o| String::from_utf8(o.stdout).ok())
6192 .map(|s| s.trim().to_string());
6193 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6194 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6195 }
6196 }
6197 } else {
6198 out.push_str(" Not installed\n");
6199 }
6200 out.push('\n');
6201 }
6202
6203 {
6205 let path_env = std::env::var("PATH").unwrap_or_default();
6206 let sep = if cfg!(windows) { ';' } else { ':' };
6207 let mut seen = HashSet::new();
6208 let mut dupes: Vec<String> = Vec::new();
6209 for p in path_env.split(sep) {
6210 let norm = p.trim().to_lowercase();
6211 if !norm.is_empty() && !seen.insert(norm) {
6212 dupes.push(p.to_string());
6213 }
6214 }
6215 if !dupes.is_empty() {
6216 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6217 notes.push(format!(
6218 "Duplicate PATH entries: {} {}",
6219 shown.join(", "),
6220 if dupes.len() > 3 {
6221 format!("+{} more", dupes.len() - 3)
6222 } else {
6223 String::new()
6224 }
6225 ));
6226 }
6227 }
6228
6229 if conflicts.is_empty() && notes.is_empty() {
6231 out.push_str("No conflicts detected — dev environment looks clean.\n");
6232 } else {
6233 if !conflicts.is_empty() {
6234 out.push_str("CONFLICTS:\n");
6235 for c in &conflicts {
6236 out.push_str(&format!(" [!] {c}\n"));
6237 }
6238 out.push('\n');
6239 }
6240 if !notes.is_empty() {
6241 out.push_str("NOTES:\n");
6242 for n in ¬es {
6243 out.push_str(&format!(" [-] {n}\n"));
6244 }
6245 }
6246 }
6247
6248 Ok(out.trim_end().to_string())
6249}
6250
6251fn inspect_connectivity() -> Result<String, String> {
6254 let mut out = String::from("Host inspection: connectivity\n\n");
6255
6256 #[cfg(target_os = "windows")]
6257 {
6258 let inet_script = r#"
6259try {
6260 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6261 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6262} catch { "ERROR:" + $_.Exception.Message }
6263"#;
6264 if let Ok(o) = Command::new("powershell")
6265 .args(["-NoProfile", "-Command", inet_script])
6266 .output()
6267 {
6268 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6269 match text.as_str() {
6270 "REACHABLE" => out.push_str("Internet: reachable\n"),
6271 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6272 _ => out.push_str(&format!(
6273 "Internet: {}\n",
6274 text.trim_start_matches("ERROR:").trim()
6275 )),
6276 }
6277 }
6278
6279 let dns_script = r#"
6280try {
6281 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6282 "DNS:ok"
6283} catch { "DNS:fail:" + $_.Exception.Message }
6284"#;
6285 if let Ok(o) = Command::new("powershell")
6286 .args(["-NoProfile", "-Command", dns_script])
6287 .output()
6288 {
6289 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6290 if text == "DNS:ok" {
6291 out.push_str("DNS: resolving correctly\n");
6292 } else {
6293 let detail = text.trim_start_matches("DNS:fail:").trim();
6294 out.push_str(&format!("DNS: failed — {}\n", detail));
6295 }
6296 }
6297
6298 let gw_script = r#"
6299(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6300"#;
6301 if let Ok(o) = Command::new("powershell")
6302 .args(["-NoProfile", "-Command", gw_script])
6303 .output()
6304 {
6305 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6306 if !gw.is_empty() && gw != "0.0.0.0" {
6307 out.push_str(&format!("Default gateway: {}\n", gw));
6308 }
6309 }
6310 }
6311
6312 #[cfg(not(target_os = "windows"))]
6313 {
6314 let reachable = Command::new("ping")
6315 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6316 .output()
6317 .map(|o| o.status.success())
6318 .unwrap_or(false);
6319 out.push_str(if reachable {
6320 "Internet: reachable\n"
6321 } else {
6322 "Internet: unreachable\n"
6323 });
6324 let dns_ok = Command::new("getent")
6325 .args(["hosts", "dns.google"])
6326 .output()
6327 .map(|o| o.status.success())
6328 .unwrap_or(false);
6329 out.push_str(if dns_ok {
6330 "DNS: resolving correctly\n"
6331 } else {
6332 "DNS: failed\n"
6333 });
6334 if let Ok(o) = Command::new("ip")
6335 .args(["route", "show", "default"])
6336 .output()
6337 {
6338 let text = String::from_utf8_lossy(&o.stdout);
6339 if let Some(line) = text.lines().next() {
6340 out.push_str(&format!("Default gateway: {}\n", line.trim()));
6341 }
6342 }
6343 }
6344
6345 Ok(out.trim_end().to_string())
6346}
6347
6348fn inspect_wifi() -> Result<String, String> {
6351 let mut out = String::from("Host inspection: wifi\n\n");
6352
6353 #[cfg(target_os = "windows")]
6354 {
6355 let output = Command::new("netsh")
6356 .args(["wlan", "show", "interfaces"])
6357 .output()
6358 .map_err(|e| format!("wifi: {e}"))?;
6359 let text = String::from_utf8_lossy(&output.stdout).to_string();
6360
6361 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6362 out.push_str("No wireless interface detected on this machine.\n");
6363 return Ok(out.trim_end().to_string());
6364 }
6365
6366 let fields = [
6367 ("SSID", "SSID"),
6368 ("State", "State"),
6369 ("Signal", "Signal"),
6370 ("Radio type", "Radio type"),
6371 ("Channel", "Channel"),
6372 ("Receive rate (Mbps)", "Download speed (Mbps)"),
6373 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6374 ("Authentication", "Authentication"),
6375 ("Network type", "Network type"),
6376 ];
6377
6378 let mut any = false;
6379 for line in text.lines() {
6380 let trimmed = line.trim();
6381 for (key, label) in &fields {
6382 if trimmed.starts_with(key) && trimmed.contains(':') {
6383 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6384 if !val.is_empty() {
6385 out.push_str(&format!(" {label}: {val}\n"));
6386 any = true;
6387 }
6388 }
6389 }
6390 }
6391 if !any {
6392 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
6393 }
6394 }
6395
6396 #[cfg(not(target_os = "windows"))]
6397 {
6398 if let Ok(o) = Command::new("nmcli")
6399 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6400 .output()
6401 {
6402 let text = String::from_utf8_lossy(&o.stdout).to_string();
6403 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6404 if lines.is_empty() {
6405 out.push_str("No Wi-Fi devices found.\n");
6406 } else {
6407 for l in lines {
6408 out.push_str(&format!(" {l}\n"));
6409 }
6410 }
6411 } else if let Ok(o) = Command::new("iwconfig").output() {
6412 let text = String::from_utf8_lossy(&o.stdout).to_string();
6413 if !text.trim().is_empty() {
6414 out.push_str(text.trim());
6415 out.push('\n');
6416 }
6417 } else {
6418 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6419 }
6420 }
6421
6422 Ok(out.trim_end().to_string())
6423}
6424
6425fn inspect_connections(max_entries: usize) -> Result<String, String> {
6428 let mut out = String::from("Host inspection: connections\n\n");
6429 let n = max_entries.clamp(1, 25);
6430
6431 #[cfg(target_os = "windows")]
6432 {
6433 let script = format!(
6434 r#"
6435try {{
6436 $procs = @{{}}
6437 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6438 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6439 Sort-Object OwningProcess
6440 "TOTAL:" + $all.Count
6441 $all | Select-Object -First {n} | ForEach-Object {{
6442 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6443 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6444 }}
6445}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6446 );
6447
6448 let output = Command::new("powershell")
6449 .args(["-NoProfile", "-Command", &script])
6450 .output()
6451 .map_err(|e| format!("connections: {e}"))?;
6452
6453 let raw = String::from_utf8_lossy(&output.stdout);
6454 let text = raw.trim();
6455
6456 if text.starts_with("ERROR:") {
6457 out.push_str(&format!("Unable to query connections: {text}\n"));
6458 } else {
6459 let mut total = 0usize;
6460 let mut rows = Vec::new();
6461 for line in text.lines() {
6462 if let Some(rest) = line.strip_prefix("TOTAL:") {
6463 total = rest.trim().parse().unwrap_or(0);
6464 } else {
6465 rows.push(line);
6466 }
6467 }
6468 out.push_str(&format!("Established TCP connections: {total}\n\n"));
6469 for row in &rows {
6470 let parts: Vec<&str> = row.splitn(4, '|').collect();
6471 if parts.len() == 4 {
6472 out.push_str(&format!(
6473 " {:<15} (pid {:<5}) | {} → {}\n",
6474 parts[0], parts[1], parts[2], parts[3]
6475 ));
6476 }
6477 }
6478 if total > n {
6479 out.push_str(&format!(
6480 "\n ... {} more connections not shown\n",
6481 total.saturating_sub(n)
6482 ));
6483 }
6484 }
6485 }
6486
6487 #[cfg(not(target_os = "windows"))]
6488 {
6489 if let Ok(o) = Command::new("ss")
6490 .args(["-tnp", "state", "established"])
6491 .output()
6492 {
6493 let text = String::from_utf8_lossy(&o.stdout);
6494 let lines: Vec<&str> = text
6495 .lines()
6496 .skip(1)
6497 .filter(|l| !l.trim().is_empty())
6498 .collect();
6499 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6500 for line in lines.iter().take(n) {
6501 out.push_str(&format!(" {}\n", line.trim()));
6502 }
6503 if lines.len() > n {
6504 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
6505 }
6506 } else {
6507 out.push_str("ss not available — install iproute2\n");
6508 }
6509 }
6510
6511 Ok(out.trim_end().to_string())
6512}
6513
6514fn inspect_vpn() -> Result<String, String> {
6517 let mut out = String::from("Host inspection: vpn\n\n");
6518
6519 #[cfg(target_os = "windows")]
6520 {
6521 let script = r#"
6522try {
6523 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6524 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6525 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6526 }
6527 if ($vpn) {
6528 foreach ($a in $vpn) {
6529 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6530 }
6531 } else { "NONE" }
6532} catch { "ERROR:" + $_.Exception.Message }
6533"#;
6534 let output = Command::new("powershell")
6535 .args(["-NoProfile", "-Command", script])
6536 .output()
6537 .map_err(|e| format!("vpn: {e}"))?;
6538
6539 let raw = String::from_utf8_lossy(&output.stdout);
6540 let text = raw.trim();
6541
6542 if text == "NONE" {
6543 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6544 } else if text.starts_with("ERROR:") {
6545 out.push_str(&format!("Unable to query adapters: {text}\n"));
6546 } else {
6547 out.push_str("VPN adapters:\n\n");
6548 for line in text.lines() {
6549 let parts: Vec<&str> = line.splitn(4, '|').collect();
6550 if parts.len() >= 3 {
6551 let name = parts[0];
6552 let desc = parts[1];
6553 let status = parts[2];
6554 let media = parts.get(3).unwrap_or(&"unknown");
6555 let label = if status.trim() == "Up" {
6556 "CONNECTED"
6557 } else {
6558 "disconnected"
6559 };
6560 out.push_str(&format!(
6561 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
6562 ));
6563 }
6564 }
6565 }
6566
6567 let ras_script = r#"
6569try {
6570 $c = Get-VpnConnection -ErrorAction Stop
6571 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6572 else { "NO_RAS" }
6573} catch { "NO_RAS" }
6574"#;
6575 if let Ok(o) = Command::new("powershell")
6576 .args(["-NoProfile", "-Command", ras_script])
6577 .output()
6578 {
6579 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6580 if t != "NO_RAS" && !t.is_empty() {
6581 out.push_str("Windows VPN connections:\n");
6582 for line in t.lines() {
6583 let parts: Vec<&str> = line.splitn(3, '|').collect();
6584 if parts.len() >= 2 {
6585 let name = parts[0];
6586 let status = parts[1];
6587 let server = parts.get(2).unwrap_or(&"");
6588 out.push_str(&format!(" {name} → {server} [{status}]\n"));
6589 }
6590 }
6591 }
6592 }
6593 }
6594
6595 #[cfg(not(target_os = "windows"))]
6596 {
6597 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6598 let text = String::from_utf8_lossy(&o.stdout);
6599 let vpn_ifaces: Vec<&str> = text
6600 .lines()
6601 .filter(|l| {
6602 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6603 })
6604 .collect();
6605 if vpn_ifaces.is_empty() {
6606 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6607 } else {
6608 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6609 for l in vpn_ifaces {
6610 out.push_str(&format!(" {}\n", l.trim()));
6611 }
6612 }
6613 }
6614 }
6615
6616 Ok(out.trim_end().to_string())
6617}
6618
6619fn inspect_proxy() -> Result<String, String> {
6622 let mut out = String::from("Host inspection: proxy\n\n");
6623
6624 #[cfg(target_os = "windows")]
6625 {
6626 let script = r#"
6627$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6628if ($ie) {
6629 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6630} else { "NONE" }
6631"#;
6632 if let Ok(o) = Command::new("powershell")
6633 .args(["-NoProfile", "-Command", script])
6634 .output()
6635 {
6636 let raw = String::from_utf8_lossy(&o.stdout);
6637 let text = raw.trim();
6638 if text != "NONE" && !text.is_empty() {
6639 let get = |key: &str| -> &str {
6640 text.split('|')
6641 .find(|s| s.starts_with(key))
6642 .and_then(|s| s.splitn(2, ':').nth(1))
6643 .unwrap_or("")
6644 };
6645 let enabled = get("ENABLE");
6646 let server = get("SERVER");
6647 let overrides = get("OVERRIDE");
6648 out.push_str("WinINET / IE proxy:\n");
6649 out.push_str(&format!(
6650 " Enabled: {}\n",
6651 if enabled == "1" { "yes" } else { "no" }
6652 ));
6653 if !server.is_empty() && server != "None" {
6654 out.push_str(&format!(" Proxy server: {server}\n"));
6655 }
6656 if !overrides.is_empty() && overrides != "None" {
6657 out.push_str(&format!(" Bypass list: {overrides}\n"));
6658 }
6659 out.push('\n');
6660 }
6661 }
6662
6663 if let Ok(o) = Command::new("netsh")
6664 .args(["winhttp", "show", "proxy"])
6665 .output()
6666 {
6667 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6668 out.push_str("WinHTTP proxy:\n");
6669 for line in text.lines() {
6670 let l = line.trim();
6671 if !l.is_empty() {
6672 out.push_str(&format!(" {l}\n"));
6673 }
6674 }
6675 out.push('\n');
6676 }
6677
6678 let mut env_found = false;
6679 for var in &[
6680 "http_proxy",
6681 "https_proxy",
6682 "HTTP_PROXY",
6683 "HTTPS_PROXY",
6684 "no_proxy",
6685 "NO_PROXY",
6686 ] {
6687 if let Ok(val) = std::env::var(var) {
6688 if !env_found {
6689 out.push_str("Environment proxy variables:\n");
6690 env_found = true;
6691 }
6692 out.push_str(&format!(" {var}: {val}\n"));
6693 }
6694 }
6695 if !env_found {
6696 out.push_str("No proxy environment variables set.\n");
6697 }
6698 }
6699
6700 #[cfg(not(target_os = "windows"))]
6701 {
6702 let mut found = false;
6703 for var in &[
6704 "http_proxy",
6705 "https_proxy",
6706 "HTTP_PROXY",
6707 "HTTPS_PROXY",
6708 "no_proxy",
6709 "NO_PROXY",
6710 "ALL_PROXY",
6711 "all_proxy",
6712 ] {
6713 if let Ok(val) = std::env::var(var) {
6714 if !found {
6715 out.push_str("Proxy environment variables:\n");
6716 found = true;
6717 }
6718 out.push_str(&format!(" {var}: {val}\n"));
6719 }
6720 }
6721 if !found {
6722 out.push_str("No proxy environment variables set.\n");
6723 }
6724 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
6725 let proxy_lines: Vec<&str> = content
6726 .lines()
6727 .filter(|l| l.to_lowercase().contains("proxy"))
6728 .collect();
6729 if !proxy_lines.is_empty() {
6730 out.push_str("\nSystem proxy (/etc/environment):\n");
6731 for l in proxy_lines {
6732 out.push_str(&format!(" {l}\n"));
6733 }
6734 }
6735 }
6736 }
6737
6738 Ok(out.trim_end().to_string())
6739}
6740
6741fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6744 let mut out = String::from("Host inspection: firewall_rules\n\n");
6745 let n = max_entries.clamp(1, 20);
6746
6747 #[cfg(target_os = "windows")]
6748 {
6749 let script = format!(
6750 r#"
6751try {{
6752 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6753 Where-Object {{
6754 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6755 $_.Owner -eq $null
6756 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6757 "TOTAL:" + $rules.Count
6758 $rules | ForEach-Object {{
6759 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6760 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6761 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6762 }}
6763}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6764 );
6765
6766 let output = Command::new("powershell")
6767 .args(["-NoProfile", "-Command", &script])
6768 .output()
6769 .map_err(|e| format!("firewall_rules: {e}"))?;
6770
6771 let raw = String::from_utf8_lossy(&output.stdout);
6772 let text = raw.trim();
6773
6774 if text.starts_with("ERROR:") {
6775 out.push_str(&format!(
6776 "Unable to query firewall rules: {}\n",
6777 text.trim_start_matches("ERROR:").trim()
6778 ));
6779 out.push_str("This query may require running as administrator.\n");
6780 } else if text.is_empty() {
6781 out.push_str("No non-default enabled firewall rules found.\n");
6782 } else {
6783 let mut total = 0usize;
6784 for line in text.lines() {
6785 if let Some(rest) = line.strip_prefix("TOTAL:") {
6786 total = rest.trim().parse().unwrap_or(0);
6787 out.push_str(&format!(
6788 "Non-default enabled rules (showing up to {n}):\n\n"
6789 ));
6790 } else {
6791 let parts: Vec<&str> = line.splitn(4, '|').collect();
6792 if parts.len() >= 3 {
6793 let name = parts[0];
6794 let dir = parts[1];
6795 let action = parts[2];
6796 let profile = parts.get(3).unwrap_or(&"Any");
6797 let icon = if action == "Block" { "[!]" } else { " " };
6798 out.push_str(&format!(
6799 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6800 ));
6801 }
6802 }
6803 }
6804 if total == 0 {
6805 out.push_str("No non-default enabled rules found.\n");
6806 }
6807 }
6808 }
6809
6810 #[cfg(not(target_os = "windows"))]
6811 {
6812 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6813 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6814 if !text.is_empty() {
6815 out.push_str(&text);
6816 out.push('\n');
6817 }
6818 } else if let Ok(o) = Command::new("iptables")
6819 .args(["-L", "-n", "--line-numbers"])
6820 .output()
6821 {
6822 let text = String::from_utf8_lossy(&o.stdout);
6823 for l in text.lines().take(n * 2) {
6824 out.push_str(&format!(" {l}\n"));
6825 }
6826 } else {
6827 out.push_str("ufw and iptables not available or insufficient permissions.\n");
6828 }
6829 }
6830
6831 Ok(out.trim_end().to_string())
6832}
6833
6834fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6837 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6838 let hops = max_entries.clamp(5, 30);
6839
6840 #[cfg(target_os = "windows")]
6841 {
6842 let output = Command::new("tracert")
6843 .args(["-d", "-h", &hops.to_string(), host])
6844 .output()
6845 .map_err(|e| format!("tracert: {e}"))?;
6846 let raw = String::from_utf8_lossy(&output.stdout);
6847 let mut hop_count = 0usize;
6848 for line in raw.lines() {
6849 let trimmed = line.trim();
6850 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6851 hop_count += 1;
6852 out.push_str(&format!(" {trimmed}\n"));
6853 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6854 out.push_str(&format!("{trimmed}\n"));
6855 }
6856 }
6857 if hop_count == 0 {
6858 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6859 }
6860 }
6861
6862 #[cfg(not(target_os = "windows"))]
6863 {
6864 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6865 || std::path::Path::new("/usr/sbin/traceroute").exists()
6866 {
6867 "traceroute"
6868 } else {
6869 "tracepath"
6870 };
6871 let output = Command::new(cmd)
6872 .args(["-m", &hops.to_string(), "-n", host])
6873 .output()
6874 .map_err(|e| format!("{cmd}: {e}"))?;
6875 let raw = String::from_utf8_lossy(&output.stdout);
6876 let mut hop_count = 0usize;
6877 for line in raw.lines().take(hops + 2) {
6878 let trimmed = line.trim();
6879 if !trimmed.is_empty() {
6880 hop_count += 1;
6881 out.push_str(&format!(" {trimmed}\n"));
6882 }
6883 }
6884 if hop_count == 0 {
6885 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6886 }
6887 }
6888
6889 Ok(out.trim_end().to_string())
6890}
6891
6892fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6895 let mut out = String::from("Host inspection: dns_cache\n\n");
6896 let n = max_entries.clamp(10, 100);
6897
6898 #[cfg(target_os = "windows")]
6899 {
6900 let output = Command::new("powershell")
6901 .args([
6902 "-NoProfile",
6903 "-Command",
6904 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6905 ])
6906 .output()
6907 .map_err(|e| format!("dns_cache: {e}"))?;
6908
6909 let raw = String::from_utf8_lossy(&output.stdout);
6910 let lines: Vec<&str> = raw.lines().skip(1).collect();
6911 let total = lines.len();
6912
6913 if total == 0 {
6914 out.push_str("DNS cache is empty or could not be read.\n");
6915 } else {
6916 out.push_str(&format!(
6917 "DNS cache entries (showing up to {n} of {total}):\n\n"
6918 ));
6919 let mut shown = 0usize;
6920 for line in lines.iter().take(n) {
6921 let cols: Vec<&str> = line.splitn(4, ',').collect();
6922 if cols.len() >= 3 {
6923 let entry = cols[0].trim_matches('"');
6924 let rtype = cols[1].trim_matches('"');
6925 let data = cols[2].trim_matches('"');
6926 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6927 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
6928 shown += 1;
6929 }
6930 }
6931 if total > shown {
6932 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
6933 }
6934 }
6935 }
6936
6937 #[cfg(not(target_os = "windows"))]
6938 {
6939 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6940 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6941 if !text.is_empty() {
6942 out.push_str("systemd-resolved statistics:\n");
6943 for line in text.lines().take(n) {
6944 out.push_str(&format!(" {line}\n"));
6945 }
6946 out.push('\n');
6947 }
6948 }
6949 if let Ok(o) = Command::new("dscacheutil")
6950 .args(["-cachedump", "-entries", "Host"])
6951 .output()
6952 {
6953 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6954 if !text.is_empty() {
6955 out.push_str("DNS cache (macOS dscacheutil):\n");
6956 for line in text.lines().take(n) {
6957 out.push_str(&format!(" {line}\n"));
6958 }
6959 } else {
6960 out.push_str("DNS cache is empty or not accessible on this platform.\n");
6961 }
6962 } else {
6963 out.push_str(
6964 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6965 );
6966 }
6967 }
6968
6969 Ok(out.trim_end().to_string())
6970}
6971
6972fn inspect_arp() -> Result<String, String> {
6975 let mut out = String::from("Host inspection: arp\n\n");
6976
6977 #[cfg(target_os = "windows")]
6978 {
6979 let output = Command::new("arp")
6980 .args(["-a"])
6981 .output()
6982 .map_err(|e| format!("arp: {e}"))?;
6983 let raw = String::from_utf8_lossy(&output.stdout);
6984 let mut count = 0usize;
6985 for line in raw.lines() {
6986 let t = line.trim();
6987 if t.is_empty() {
6988 continue;
6989 }
6990 out.push_str(&format!(" {t}\n"));
6991 if t.contains("dynamic") || t.contains("static") {
6992 count += 1;
6993 }
6994 }
6995 out.push_str(&format!("\nTotal entries: {count}\n"));
6996 }
6997
6998 #[cfg(not(target_os = "windows"))]
6999 {
7000 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7001 let raw = String::from_utf8_lossy(&o.stdout);
7002 let mut count = 0usize;
7003 for line in raw.lines() {
7004 let t = line.trim();
7005 if !t.is_empty() {
7006 out.push_str(&format!(" {t}\n"));
7007 count += 1;
7008 }
7009 }
7010 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7011 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7012 let raw = String::from_utf8_lossy(&o.stdout);
7013 let mut count = 0usize;
7014 for line in raw.lines() {
7015 let t = line.trim();
7016 if !t.is_empty() {
7017 out.push_str(&format!(" {t}\n"));
7018 count += 1;
7019 }
7020 }
7021 out.push_str(&format!("\nTotal entries: {count}\n"));
7022 } else {
7023 out.push_str("arp and ip neigh not available.\n");
7024 }
7025 }
7026
7027 Ok(out.trim_end().to_string())
7028}
7029
7030fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7033 let mut out = String::from("Host inspection: route_table\n\n");
7034 let n = max_entries.clamp(10, 50);
7035
7036 #[cfg(target_os = "windows")]
7037 {
7038 let script = r#"
7039try {
7040 $routes = Get-NetRoute -ErrorAction Stop |
7041 Where-Object { $_.RouteMetric -lt 9000 } |
7042 Sort-Object RouteMetric |
7043 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7044 "TOTAL:" + $routes.Count
7045 $routes | ForEach-Object {
7046 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7047 }
7048} catch { "ERROR:" + $_.Exception.Message }
7049"#;
7050 let output = Command::new("powershell")
7051 .args(["-NoProfile", "-Command", script])
7052 .output()
7053 .map_err(|e| format!("route_table: {e}"))?;
7054 let raw = String::from_utf8_lossy(&output.stdout);
7055 let text = raw.trim();
7056
7057 if text.starts_with("ERROR:") {
7058 out.push_str(&format!(
7059 "Unable to read route table: {}\n",
7060 text.trim_start_matches("ERROR:").trim()
7061 ));
7062 } else {
7063 let mut shown = 0usize;
7064 for line in text.lines() {
7065 if let Some(rest) = line.strip_prefix("TOTAL:") {
7066 let total: usize = rest.trim().parse().unwrap_or(0);
7067 out.push_str(&format!(
7068 "Routing table (showing up to {n} of {total} routes):\n\n"
7069 ));
7070 out.push_str(&format!(
7071 " {:<22} {:<18} {:>8} Interface\n",
7072 "Destination", "Next Hop", "Metric"
7073 ));
7074 out.push_str(&format!(" {}\n", "-".repeat(70)));
7075 } else if shown < n {
7076 let parts: Vec<&str> = line.splitn(4, '|').collect();
7077 if parts.len() == 4 {
7078 let dest = parts[0];
7079 let hop =
7080 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7081 "on-link"
7082 } else {
7083 parts[1]
7084 };
7085 let metric = parts[2];
7086 let iface = parts[3];
7087 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
7088 shown += 1;
7089 }
7090 }
7091 }
7092 }
7093 }
7094
7095 #[cfg(not(target_os = "windows"))]
7096 {
7097 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7098 let raw = String::from_utf8_lossy(&o.stdout);
7099 let lines: Vec<&str> = raw.lines().collect();
7100 let total = lines.len();
7101 out.push_str(&format!(
7102 "Routing table (showing up to {n} of {total} routes):\n\n"
7103 ));
7104 for line in lines.iter().take(n) {
7105 out.push_str(&format!(" {line}\n"));
7106 }
7107 if total > n {
7108 out.push_str(&format!("\n ... and {} more routes\n", total - n));
7109 }
7110 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7111 let raw = String::from_utf8_lossy(&o.stdout);
7112 for line in raw.lines().take(n) {
7113 out.push_str(&format!(" {line}\n"));
7114 }
7115 } else {
7116 out.push_str("ip route and netstat not available.\n");
7117 }
7118 }
7119
7120 Ok(out.trim_end().to_string())
7121}
7122
7123fn inspect_env(max_entries: usize) -> Result<String, String> {
7126 let mut out = String::from("Host inspection: env\n\n");
7127 let n = max_entries.clamp(10, 50);
7128
7129 fn looks_like_secret(name: &str) -> bool {
7130 let n = name.to_uppercase();
7131 n.contains("KEY")
7132 || n.contains("SECRET")
7133 || n.contains("TOKEN")
7134 || n.contains("PASSWORD")
7135 || n.contains("PASSWD")
7136 || n.contains("CREDENTIAL")
7137 || n.contains("AUTH")
7138 || n.contains("CERT")
7139 || n.contains("PRIVATE")
7140 }
7141
7142 let known_dev_vars: &[&str] = &[
7143 "CARGO_HOME",
7144 "RUSTUP_HOME",
7145 "GOPATH",
7146 "GOROOT",
7147 "GOBIN",
7148 "JAVA_HOME",
7149 "ANDROID_HOME",
7150 "ANDROID_SDK_ROOT",
7151 "PYTHONPATH",
7152 "PYTHONHOME",
7153 "VIRTUAL_ENV",
7154 "CONDA_DEFAULT_ENV",
7155 "CONDA_PREFIX",
7156 "NODE_PATH",
7157 "NVM_DIR",
7158 "NVM_BIN",
7159 "PNPM_HOME",
7160 "DENO_INSTALL",
7161 "DENO_DIR",
7162 "DOTNET_ROOT",
7163 "NUGET_PACKAGES",
7164 "CMAKE_HOME",
7165 "VCPKG_ROOT",
7166 "AWS_PROFILE",
7167 "AWS_REGION",
7168 "AWS_DEFAULT_REGION",
7169 "GCP_PROJECT",
7170 "GOOGLE_CLOUD_PROJECT",
7171 "GOOGLE_APPLICATION_CREDENTIALS",
7172 "AZURE_SUBSCRIPTION_ID",
7173 "DATABASE_URL",
7174 "REDIS_URL",
7175 "MONGO_URI",
7176 "EDITOR",
7177 "VISUAL",
7178 "SHELL",
7179 "TERM",
7180 "XDG_CONFIG_HOME",
7181 "XDG_DATA_HOME",
7182 "XDG_CACHE_HOME",
7183 "HOME",
7184 "USERPROFILE",
7185 "APPDATA",
7186 "LOCALAPPDATA",
7187 "TEMP",
7188 "TMP",
7189 "COMPUTERNAME",
7190 "USERNAME",
7191 "USERDOMAIN",
7192 "PROCESSOR_ARCHITECTURE",
7193 "NUMBER_OF_PROCESSORS",
7194 "OS",
7195 "HOMEDRIVE",
7196 "HOMEPATH",
7197 "HTTP_PROXY",
7198 "HTTPS_PROXY",
7199 "NO_PROXY",
7200 "ALL_PROXY",
7201 "http_proxy",
7202 "https_proxy",
7203 "no_proxy",
7204 "DOCKER_HOST",
7205 "DOCKER_BUILDKIT",
7206 "COMPOSE_PROJECT_NAME",
7207 "KUBECONFIG",
7208 "KUBE_CONTEXT",
7209 "CI",
7210 "GITHUB_ACTIONS",
7211 "GITLAB_CI",
7212 "LMSTUDIO_HOME",
7213 "HEMATITE_URL",
7214 ];
7215
7216 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7217 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7218 let total = all_vars.len();
7219
7220 let mut dev_found: Vec<String> = Vec::new();
7221 let mut secret_found: Vec<String> = Vec::new();
7222
7223 for (k, v) in &all_vars {
7224 if k == "PATH" {
7225 continue;
7226 }
7227 if looks_like_secret(k) {
7228 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7229 } else {
7230 let k_upper = k.to_uppercase();
7231 let is_known = known_dev_vars
7232 .iter()
7233 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7234 if is_known {
7235 let display = if v.len() > 120 {
7236 format!("{k} = {}…", &v[..117])
7237 } else {
7238 format!("{k} = {v}")
7239 };
7240 dev_found.push(display);
7241 }
7242 }
7243 }
7244
7245 out.push_str(&format!("Total environment variables: {total}\n\n"));
7246
7247 if let Ok(p) = std::env::var("PATH") {
7248 let sep = if cfg!(target_os = "windows") {
7249 ';'
7250 } else {
7251 ':'
7252 };
7253 let count = p.split(sep).count();
7254 out.push_str(&format!(
7255 "PATH: {count} entries (use topic=path for full audit)\n\n"
7256 ));
7257 }
7258
7259 if !secret_found.is_empty() {
7260 out.push_str(&format!(
7261 "=== Secret/credential variables ({} detected, values hidden) ===\n",
7262 secret_found.len()
7263 ));
7264 for s in secret_found.iter().take(n) {
7265 out.push_str(&format!(" {s}\n"));
7266 }
7267 out.push('\n');
7268 }
7269
7270 if !dev_found.is_empty() {
7271 out.push_str(&format!(
7272 "=== Developer & tool variables ({}) ===\n",
7273 dev_found.len()
7274 ));
7275 for d in dev_found.iter().take(n) {
7276 out.push_str(&format!(" {d}\n"));
7277 }
7278 out.push('\n');
7279 }
7280
7281 let other_count = all_vars
7282 .iter()
7283 .filter(|(k, _)| {
7284 k != "PATH"
7285 && !looks_like_secret(k)
7286 && !known_dev_vars
7287 .iter()
7288 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7289 })
7290 .count();
7291 if other_count > 0 {
7292 out.push_str(&format!(
7293 "Other variables: {other_count} (use 'env' in shell to see all)\n"
7294 ));
7295 }
7296
7297 Ok(out.trim_end().to_string())
7298}
7299
7300fn inspect_hosts_file() -> Result<String, String> {
7303 let mut out = String::from("Host inspection: hosts_file\n\n");
7304
7305 let hosts_path = if cfg!(target_os = "windows") {
7306 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7307 } else {
7308 std::path::PathBuf::from("/etc/hosts")
7309 };
7310
7311 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7312
7313 match fs::read_to_string(&hosts_path) {
7314 Ok(content) => {
7315 let mut active_entries: Vec<String> = Vec::new();
7316 let mut comment_lines = 0usize;
7317 let mut blank_lines = 0usize;
7318
7319 for line in content.lines() {
7320 let t = line.trim();
7321 if t.is_empty() {
7322 blank_lines += 1;
7323 } else if t.starts_with('#') {
7324 comment_lines += 1;
7325 } else {
7326 active_entries.push(line.to_string());
7327 }
7328 }
7329
7330 out.push_str(&format!(
7331 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7332 active_entries.len(),
7333 comment_lines,
7334 blank_lines
7335 ));
7336
7337 if active_entries.is_empty() {
7338 out.push_str(
7339 "No active host entries (file contains only comments/blanks — standard default state).\n",
7340 );
7341 } else {
7342 out.push_str("=== Active entries ===\n");
7343 for entry in &active_entries {
7344 out.push_str(&format!(" {entry}\n"));
7345 }
7346 out.push('\n');
7347
7348 let custom: Vec<&String> = active_entries
7349 .iter()
7350 .filter(|e| {
7351 let t = e.trim_start();
7352 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7353 })
7354 .collect();
7355 if !custom.is_empty() {
7356 out.push_str(&format!(
7357 "[!] Custom (non-loopback) entries: {}\n",
7358 custom.len()
7359 ));
7360 for e in &custom {
7361 out.push_str(&format!(" {e}\n"));
7362 }
7363 } else {
7364 out.push_str("All active entries are standard loopback or block entries.\n");
7365 }
7366 }
7367
7368 out.push_str("\n=== Full file ===\n");
7369 for line in content.lines() {
7370 out.push_str(&format!(" {line}\n"));
7371 }
7372 }
7373 Err(e) => {
7374 out.push_str(&format!("Could not read hosts file: {e}\n"));
7375 if cfg!(target_os = "windows") {
7376 out.push_str(
7377 "On Windows, run Hematite as Administrator if permission is denied.\n",
7378 );
7379 }
7380 }
7381 }
7382
7383 Ok(out.trim_end().to_string())
7384}
7385
7386struct AuditFinding {
7389 finding: String,
7390 impact: String,
7391 fix: String,
7392}
7393
7394#[cfg(target_os = "windows")]
7395#[derive(Debug, Clone)]
7396struct WindowsPnpDevice {
7397 name: String,
7398 status: String,
7399 problem: Option<u64>,
7400 class_name: Option<String>,
7401 instance_id: Option<String>,
7402}
7403
7404#[cfg(target_os = "windows")]
7405#[derive(Debug, Clone)]
7406struct WindowsSoundDevice {
7407 name: String,
7408 status: String,
7409 manufacturer: Option<String>,
7410}
7411
7412struct DockerMountAudit {
7413 mount_type: String,
7414 source: Option<String>,
7415 destination: String,
7416 name: Option<String>,
7417 read_write: Option<bool>,
7418 driver: Option<String>,
7419 exists_on_host: Option<bool>,
7420}
7421
7422struct DockerContainerAudit {
7423 name: String,
7424 image: String,
7425 status: String,
7426 mounts: Vec<DockerMountAudit>,
7427}
7428
7429struct DockerVolumeAudit {
7430 name: String,
7431 driver: String,
7432 mountpoint: Option<String>,
7433 scope: Option<String>,
7434}
7435
7436#[cfg(target_os = "windows")]
7437struct WslDistroAudit {
7438 name: String,
7439 state: String,
7440 version: String,
7441}
7442
7443#[cfg(target_os = "windows")]
7444struct WslRootUsage {
7445 total_kb: u64,
7446 used_kb: u64,
7447 avail_kb: u64,
7448 use_percent: String,
7449 mnt_c_present: Option<bool>,
7450}
7451
7452fn docker_engine_version() -> Result<String, String> {
7453 let version_output = Command::new("docker")
7454 .args(["version", "--format", "{{.Server.Version}}"])
7455 .output();
7456
7457 match version_output {
7458 Err(_) => Err(
7459 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7460 ),
7461 Ok(o) if !o.status.success() => {
7462 let stderr = String::from_utf8_lossy(&o.stderr);
7463 if stderr.contains("cannot connect")
7464 || stderr.contains("Is the docker daemon running")
7465 || stderr.contains("pipe")
7466 || stderr.contains("socket")
7467 {
7468 Err(
7469 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7470 )
7471 } else {
7472 Err(format!("Docker: error - {}", stderr.trim()))
7473 }
7474 }
7475 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7476 }
7477}
7478
7479fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7480 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7481 return Vec::new();
7482 };
7483 let Value::Array(entries) = value else {
7484 return Vec::new();
7485 };
7486
7487 let mut mounts = Vec::new();
7488 for entry in entries {
7489 let mount_type = entry
7490 .get("Type")
7491 .and_then(|v| v.as_str())
7492 .unwrap_or("unknown")
7493 .to_string();
7494 let source = entry
7495 .get("Source")
7496 .and_then(|v| v.as_str())
7497 .map(|v| v.to_string());
7498 let destination = entry
7499 .get("Destination")
7500 .and_then(|v| v.as_str())
7501 .unwrap_or("?")
7502 .to_string();
7503 let name = entry
7504 .get("Name")
7505 .and_then(|v| v.as_str())
7506 .map(|v| v.to_string());
7507 let read_write = entry.get("RW").and_then(|v| v.as_bool());
7508 let driver = entry
7509 .get("Driver")
7510 .and_then(|v| v.as_str())
7511 .map(|v| v.to_string());
7512 let exists_on_host = if mount_type == "bind" {
7513 source.as_deref().map(|path| Path::new(path).exists())
7514 } else {
7515 None
7516 };
7517 mounts.push(DockerMountAudit {
7518 mount_type,
7519 source,
7520 destination,
7521 name,
7522 read_write,
7523 driver,
7524 exists_on_host,
7525 });
7526 }
7527
7528 mounts
7529}
7530
7531fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7532 let mut audit = DockerVolumeAudit {
7533 name: name.to_string(),
7534 driver: "unknown".to_string(),
7535 mountpoint: None,
7536 scope: None,
7537 };
7538
7539 if let Ok(output) = Command::new("docker")
7540 .args(["volume", "inspect", name, "--format", "{{json .}}"])
7541 .output()
7542 {
7543 if output.status.success() {
7544 if let Ok(value) =
7545 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7546 {
7547 audit.driver = value
7548 .get("Driver")
7549 .and_then(|v| v.as_str())
7550 .unwrap_or("unknown")
7551 .to_string();
7552 audit.mountpoint = value
7553 .get("Mountpoint")
7554 .and_then(|v| v.as_str())
7555 .map(|v| v.to_string());
7556 audit.scope = value
7557 .get("Scope")
7558 .and_then(|v| v.as_str())
7559 .map(|v| v.to_string());
7560 }
7561 }
7562 }
7563
7564 audit
7565}
7566
7567#[cfg(target_os = "windows")]
7568fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7569 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7570 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7571 let path = local_app_data
7572 .join("Docker")
7573 .join("wsl")
7574 .join("disk")
7575 .join(file_name);
7576 if let Ok(metadata) = fs::metadata(&path) {
7577 return Some((path, metadata.len()));
7578 }
7579 }
7580 None
7581}
7582
7583#[cfg(target_os = "windows")]
7584fn clean_wsl_text(raw: &[u8]) -> String {
7585 String::from_utf8_lossy(raw)
7586 .chars()
7587 .filter(|c| *c != '\0')
7588 .collect()
7589}
7590
7591#[cfg(target_os = "windows")]
7592fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7593 let mut distros = Vec::new();
7594 for line in raw.lines() {
7595 let trimmed = line.trim();
7596 if trimmed.is_empty()
7597 || trimmed.to_uppercase().starts_with("NAME")
7598 || trimmed.starts_with("---")
7599 {
7600 continue;
7601 }
7602 let normalized = trimmed.trim_start_matches('*').trim();
7603 let cols: Vec<&str> = normalized.split_whitespace().collect();
7604 if cols.len() < 3 {
7605 continue;
7606 }
7607 let version = cols[cols.len() - 1].to_string();
7608 let state = cols[cols.len() - 2].to_string();
7609 let name = cols[..cols.len() - 2].join(" ");
7610 if !name.is_empty() {
7611 distros.push(WslDistroAudit {
7612 name,
7613 state,
7614 version,
7615 });
7616 }
7617 }
7618 distros
7619}
7620
7621#[cfg(target_os = "windows")]
7622fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7623 let output = Command::new("wsl")
7624 .args([
7625 "-d",
7626 distro_name,
7627 "--",
7628 "sh",
7629 "-lc",
7630 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7631 ])
7632 .output()
7633 .ok()?;
7634 if !output.status.success() {
7635 return None;
7636 }
7637
7638 let text = clean_wsl_text(&output.stdout);
7639 let mut total_kb = 0;
7640 let mut used_kb = 0;
7641 let mut avail_kb = 0;
7642 let mut use_percent = String::from("unknown");
7643 let mut mnt_c_present = None;
7644
7645 for line in text.lines() {
7646 let trimmed = line.trim();
7647 if trimmed.starts_with("__MNTC__:") {
7648 mnt_c_present = Some(trimmed.ends_with("ok"));
7649 continue;
7650 }
7651 let cols: Vec<&str> = trimmed.split_whitespace().collect();
7652 if cols.len() >= 6 {
7653 total_kb = cols[1].parse::<u64>().unwrap_or(0);
7654 used_kb = cols[2].parse::<u64>().unwrap_or(0);
7655 avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7656 use_percent = cols[4].to_string();
7657 }
7658 }
7659
7660 Some(WslRootUsage {
7661 total_kb,
7662 used_kb,
7663 avail_kb,
7664 use_percent,
7665 mnt_c_present,
7666 })
7667}
7668
7669#[cfg(target_os = "windows")]
7670fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7671 let mut vhds = Vec::new();
7672 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7673 return vhds;
7674 };
7675 let packages_dir = local_app_data.join("Packages");
7676 let Ok(entries) = fs::read_dir(packages_dir) else {
7677 return vhds;
7678 };
7679
7680 for entry in entries.flatten() {
7681 let path = entry.path().join("LocalState").join("ext4.vhdx");
7682 if let Ok(metadata) = fs::metadata(&path) {
7683 vhds.push((path, metadata.len()));
7684 }
7685 }
7686 vhds.sort_by(|a, b| b.1.cmp(&a.1));
7687 vhds
7688}
7689
7690fn inspect_docker(max_entries: usize) -> Result<String, String> {
7691 let mut out = String::from("Host inspection: docker\n\n");
7692 let n = max_entries.clamp(5, 25);
7693
7694 let version_output = Command::new("docker")
7695 .args(["version", "--format", "{{.Server.Version}}"])
7696 .output();
7697
7698 match version_output {
7699 Err(_) => {
7700 out.push_str("Docker: not found on PATH.\n");
7701 out.push_str(
7702 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
7703 );
7704 return Ok(out.trim_end().to_string());
7705 }
7706 Ok(o) if !o.status.success() => {
7707 let stderr = String::from_utf8_lossy(&o.stderr);
7708 if stderr.contains("cannot connect")
7709 || stderr.contains("Is the docker daemon running")
7710 || stderr.contains("pipe")
7711 || stderr.contains("socket")
7712 {
7713 out.push_str("Docker: installed but daemon is NOT running.\n");
7714 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
7715 } else {
7716 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
7717 }
7718 return Ok(out.trim_end().to_string());
7719 }
7720 Ok(o) => {
7721 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
7722 out.push_str(&format!("Docker Engine: {version}\n"));
7723 }
7724 }
7725
7726 if let Ok(o) = Command::new("docker")
7727 .args([
7728 "info",
7729 "--format",
7730 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
7731 ])
7732 .output()
7733 {
7734 let info = String::from_utf8_lossy(&o.stdout);
7735 for line in info.lines() {
7736 let t = line.trim();
7737 if !t.is_empty() {
7738 out.push_str(&format!(" {t}\n"));
7739 }
7740 }
7741 out.push('\n');
7742 }
7743
7744 if let Ok(o) = Command::new("docker")
7745 .args([
7746 "ps",
7747 "--format",
7748 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
7749 ])
7750 .output()
7751 {
7752 let raw = String::from_utf8_lossy(&o.stdout);
7753 let lines: Vec<&str> = raw.lines().collect();
7754 if lines.len() <= 1 {
7755 out.push_str("Running containers: none\n\n");
7756 } else {
7757 out.push_str(&format!(
7758 "=== Running containers ({}) ===\n",
7759 lines.len().saturating_sub(1)
7760 ));
7761 for line in lines.iter().take(n + 1) {
7762 out.push_str(&format!(" {line}\n"));
7763 }
7764 if lines.len() > n + 1 {
7765 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
7766 }
7767 out.push('\n');
7768 }
7769 }
7770
7771 if let Ok(o) = Command::new("docker")
7772 .args([
7773 "images",
7774 "--format",
7775 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
7776 ])
7777 .output()
7778 {
7779 let raw = String::from_utf8_lossy(&o.stdout);
7780 let lines: Vec<&str> = raw.lines().collect();
7781 if lines.len() > 1 {
7782 out.push_str(&format!(
7783 "=== Local images ({}) ===\n",
7784 lines.len().saturating_sub(1)
7785 ));
7786 for line in lines.iter().take(n + 1) {
7787 out.push_str(&format!(" {line}\n"));
7788 }
7789 if lines.len() > n + 1 {
7790 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
7791 }
7792 out.push('\n');
7793 }
7794 }
7795
7796 if let Ok(o) = Command::new("docker")
7797 .args([
7798 "compose",
7799 "ls",
7800 "--format",
7801 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
7802 ])
7803 .output()
7804 {
7805 let raw = String::from_utf8_lossy(&o.stdout);
7806 let lines: Vec<&str> = raw.lines().collect();
7807 if lines.len() > 1 {
7808 out.push_str(&format!(
7809 "=== Compose projects ({}) ===\n",
7810 lines.len().saturating_sub(1)
7811 ));
7812 for line in lines.iter().take(n + 1) {
7813 out.push_str(&format!(" {line}\n"));
7814 }
7815 out.push('\n');
7816 }
7817 }
7818
7819 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7820 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7821 if !ctx.is_empty() {
7822 out.push_str(&format!("Active context: {ctx}\n"));
7823 }
7824 }
7825
7826 Ok(out.trim_end().to_string())
7827}
7828
7829fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
7832 let mut out = String::from("Host inspection: docker_filesystems\n\n");
7833 let n = max_entries.clamp(3, 12);
7834
7835 match docker_engine_version() {
7836 Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
7837 Err(message) => {
7838 out.push_str(&message);
7839 return Ok(out.trim_end().to_string());
7840 }
7841 }
7842
7843 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7844 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7845 if !ctx.is_empty() {
7846 out.push_str(&format!("Active context: {ctx}\n"));
7847 }
7848 }
7849 out.push('\n');
7850
7851 let mut containers = Vec::new();
7852 if let Ok(o) = Command::new("docker")
7853 .args([
7854 "ps",
7855 "-a",
7856 "--format",
7857 "{{.Names}}\t{{.Image}}\t{{.Status}}",
7858 ])
7859 .output()
7860 {
7861 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7862 let cols: Vec<&str> = line.split('\t').collect();
7863 if cols.len() < 3 {
7864 continue;
7865 }
7866 let name = cols[0].trim().to_string();
7867 if name.is_empty() {
7868 continue;
7869 }
7870 let inspect_output = Command::new("docker")
7871 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
7872 .output();
7873 let mounts = match inspect_output {
7874 Ok(result) if result.status.success() => {
7875 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
7876 }
7877 _ => Vec::new(),
7878 };
7879 containers.push(DockerContainerAudit {
7880 name,
7881 image: cols[1].trim().to_string(),
7882 status: cols[2].trim().to_string(),
7883 mounts,
7884 });
7885 }
7886 }
7887
7888 let mut volumes = Vec::new();
7889 if let Ok(o) = Command::new("docker")
7890 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
7891 .output()
7892 {
7893 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7894 let cols: Vec<&str> = line.split('\t').collect();
7895 let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
7896 continue;
7897 };
7898 let mut audit = inspect_docker_volume(name);
7899 if audit.driver == "unknown" {
7900 audit.driver = cols
7901 .get(1)
7902 .map(|v| v.trim())
7903 .filter(|v| !v.is_empty())
7904 .unwrap_or("unknown")
7905 .to_string();
7906 }
7907 volumes.push(audit);
7908 }
7909 }
7910
7911 let mut findings = Vec::new();
7912 for container in &containers {
7913 for mount in &container.mounts {
7914 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7915 let source = mount.source.as_deref().unwrap_or("<unknown>");
7916 findings.push(AuditFinding {
7917 finding: format!(
7918 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
7919 container.name, source, mount.destination
7920 ),
7921 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
7922 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
7923 });
7924 }
7925 }
7926 }
7927
7928 #[cfg(target_os = "windows")]
7929 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7930 if size_bytes >= 20 * 1024 * 1024 * 1024 {
7931 findings.push(AuditFinding {
7932 finding: format!(
7933 "Docker Desktop disk image is large: {} at {}",
7934 human_bytes(size_bytes),
7935 path.display()
7936 ),
7937 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
7938 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(),
7939 });
7940 }
7941 }
7942
7943 out.push_str("=== Findings ===\n");
7944 if findings.is_empty() {
7945 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
7946 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
7947 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
7948 } else {
7949 for finding in &findings {
7950 out.push_str(&format!("- Finding: {}\n", finding.finding));
7951 out.push_str(&format!(" Impact: {}\n", finding.impact));
7952 out.push_str(&format!(" Fix: {}\n", finding.fix));
7953 }
7954 }
7955
7956 out.push_str("\n=== Container mount summary ===\n");
7957 if containers.is_empty() {
7958 out.push_str("- No containers found.\n");
7959 } else {
7960 for container in &containers {
7961 out.push_str(&format!(
7962 "- {} ({}) [{}]\n",
7963 container.name, container.image, container.status
7964 ));
7965 if container.mounts.is_empty() {
7966 out.push_str(" - no mounts reported\n");
7967 continue;
7968 }
7969 for mount in &container.mounts {
7970 let mut source = mount
7971 .name
7972 .clone()
7973 .or_else(|| mount.source.clone())
7974 .unwrap_or_else(|| "<unknown>".to_string());
7975 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7976 source.push_str(" [missing]");
7977 }
7978 let mut extras = Vec::new();
7979 if let Some(rw) = mount.read_write {
7980 extras.push(if rw { "rw" } else { "ro" }.to_string());
7981 }
7982 if let Some(driver) = &mount.driver {
7983 extras.push(format!("driver={driver}"));
7984 }
7985 let extra_suffix = if extras.is_empty() {
7986 String::new()
7987 } else {
7988 format!(" ({})", extras.join(", "))
7989 };
7990 out.push_str(&format!(
7991 " - {}: {} -> {}{}\n",
7992 mount.mount_type, source, mount.destination, extra_suffix
7993 ));
7994 }
7995 }
7996 }
7997
7998 out.push_str("\n=== Named volumes ===\n");
7999 if volumes.is_empty() {
8000 out.push_str("- No named volumes found.\n");
8001 } else {
8002 for volume in &volumes {
8003 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8004 if let Some(scope) = &volume.scope {
8005 detail.push_str(&format!(", scope: {scope}"));
8006 }
8007 if let Some(mountpoint) = &volume.mountpoint {
8008 detail.push_str(&format!(", mountpoint: {mountpoint}"));
8009 }
8010 out.push_str(&format!("{detail}\n"));
8011 }
8012 }
8013
8014 #[cfg(target_os = "windows")]
8015 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8016 out.push_str("\n=== Docker Desktop disk ===\n");
8017 out.push_str(&format!(
8018 "- {} at {}\n",
8019 human_bytes(size_bytes),
8020 path.display()
8021 ));
8022 }
8023
8024 Ok(out.trim_end().to_string())
8025}
8026
8027fn inspect_wsl() -> Result<String, String> {
8028 let mut out = String::from("Host inspection: wsl\n\n");
8029
8030 #[cfg(target_os = "windows")]
8031 {
8032 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8033 let raw = String::from_utf8_lossy(&o.stdout);
8034 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8035 for line in cleaned.lines().take(4) {
8036 let t = line.trim();
8037 if !t.is_empty() {
8038 out.push_str(&format!(" {t}\n"));
8039 }
8040 }
8041 out.push('\n');
8042 }
8043
8044 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8045 match list_output {
8046 Err(e) => {
8047 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8048 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8049 }
8050 Ok(o) if !o.status.success() => {
8051 let stderr = String::from_utf8_lossy(&o.stderr);
8052 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8053 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8054 out.push_str("Run: wsl --install\n");
8055 }
8056 Ok(o) => {
8057 let raw = String::from_utf8_lossy(&o.stdout);
8058 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8059 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8060 let distro_lines: Vec<&str> = lines
8061 .iter()
8062 .filter(|l| {
8063 let t = l.trim();
8064 !t.is_empty()
8065 && !t.to_uppercase().starts_with("NAME")
8066 && !t.starts_with("---")
8067 })
8068 .copied()
8069 .collect();
8070
8071 if distro_lines.is_empty() {
8072 out.push_str("WSL: installed but no distributions found.\n");
8073 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8074 } else {
8075 out.push_str("=== WSL Distributions ===\n");
8076 for line in &lines {
8077 out.push_str(&format!(" {}\n", line.trim()));
8078 }
8079 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8080 }
8081 }
8082 }
8083
8084 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8085 let raw = String::from_utf8_lossy(&o.stdout);
8086 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8087 let status_lines: Vec<&str> = cleaned
8088 .lines()
8089 .filter(|l| !l.trim().is_empty())
8090 .take(8)
8091 .collect();
8092 if !status_lines.is_empty() {
8093 out.push_str("\n=== WSL status ===\n");
8094 for line in status_lines {
8095 out.push_str(&format!(" {}\n", line.trim()));
8096 }
8097 }
8098 }
8099 }
8100
8101 #[cfg(not(target_os = "windows"))]
8102 {
8103 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8104 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8105 }
8106
8107 Ok(out.trim_end().to_string())
8108}
8109
8110fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8113 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8114
8115 #[cfg(target_os = "windows")]
8116 {
8117 let n = max_entries.clamp(3, 12);
8118 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8119 let distros = match list_output {
8120 Err(e) => {
8121 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8122 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8123 return Ok(out.trim_end().to_string());
8124 }
8125 Ok(o) if !o.status.success() => {
8126 let cleaned = clean_wsl_text(&o.stderr);
8127 out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8128 out.push_str("Run: wsl --install\n");
8129 return Ok(out.trim_end().to_string());
8130 }
8131 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8132 };
8133
8134 out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8135
8136 let vhdx_files = collect_wsl_vhdx_files();
8137 let mut findings = Vec::new();
8138 let mut live_usage = Vec::new();
8139
8140 for distro in distros.iter().take(n) {
8141 if distro.state.eq_ignore_ascii_case("Running") {
8142 if let Some(usage) = wsl_root_usage(&distro.name) {
8143 if let Some(false) = usage.mnt_c_present {
8144 findings.push(AuditFinding {
8145 finding: format!(
8146 "Distro '{}' is running without /mnt/c available",
8147 distro.name
8148 ),
8149 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8150 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8151 });
8152 }
8153
8154 let percent_num = usage
8155 .use_percent
8156 .trim_end_matches('%')
8157 .parse::<u32>()
8158 .unwrap_or(0);
8159 if percent_num >= 85 {
8160 findings.push(AuditFinding {
8161 finding: format!(
8162 "Distro '{}' root filesystem is {} full",
8163 distro.name, usage.use_percent
8164 ),
8165 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8166 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8167 });
8168 }
8169 live_usage.push((distro.name.clone(), usage));
8170 }
8171 }
8172 }
8173
8174 for (path, size_bytes) in vhdx_files.iter().take(n) {
8175 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8176 findings.push(AuditFinding {
8177 finding: format!(
8178 "Host-side WSL disk image is large: {} at {}",
8179 human_bytes(*size_bytes),
8180 path.display()
8181 ),
8182 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8183 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8184 });
8185 }
8186 }
8187
8188 out.push_str("=== Findings ===\n");
8189 if findings.is_empty() {
8190 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8191 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8192 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8193 } else {
8194 for finding in &findings {
8195 out.push_str(&format!("- Finding: {}\n", finding.finding));
8196 out.push_str(&format!(" Impact: {}\n", finding.impact));
8197 out.push_str(&format!(" Fix: {}\n", finding.fix));
8198 }
8199 }
8200
8201 out.push_str("\n=== Distro bridge and root usage ===\n");
8202 if distros.is_empty() {
8203 out.push_str("- No WSL distributions found.\n");
8204 } else {
8205 for distro in distros.iter().take(n) {
8206 out.push_str(&format!(
8207 "- {} [state: {}, version: {}]\n",
8208 distro.name, distro.state, distro.version
8209 ));
8210 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8211 out.push_str(&format!(
8212 " - rootfs: {} used / {} total ({}), free: {}\n",
8213 human_bytes(usage.used_kb * 1024),
8214 human_bytes(usage.total_kb * 1024),
8215 usage.use_percent,
8216 human_bytes(usage.avail_kb * 1024)
8217 ));
8218 match usage.mnt_c_present {
8219 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8220 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8221 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8222 }
8223 } else if distro.state.eq_ignore_ascii_case("Running") {
8224 out.push_str(" - live rootfs check: unavailable\n");
8225 } else {
8226 out.push_str(
8227 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8228 );
8229 }
8230 }
8231 }
8232
8233 out.push_str("\n=== Host-side VHDX files ===\n");
8234 if vhdx_files.is_empty() {
8235 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8236 } else {
8237 for (path, size_bytes) in vhdx_files.iter().take(n) {
8238 out.push_str(&format!(
8239 "- {} at {}\n",
8240 human_bytes(*size_bytes),
8241 path.display()
8242 ));
8243 }
8244 }
8245 }
8246
8247 #[cfg(not(target_os = "windows"))]
8248 {
8249 let _ = max_entries;
8250 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8251 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8252 }
8253
8254 Ok(out.trim_end().to_string())
8255}
8256
8257fn dirs_home() -> Option<PathBuf> {
8258 std::env::var("HOME")
8259 .ok()
8260 .map(PathBuf::from)
8261 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8262}
8263
8264fn inspect_ssh() -> Result<String, String> {
8265 let mut out = String::from("Host inspection: ssh\n\n");
8266
8267 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8268 let ver = if o.stdout.is_empty() {
8269 String::from_utf8_lossy(&o.stderr).trim().to_string()
8270 } else {
8271 String::from_utf8_lossy(&o.stdout).trim().to_string()
8272 };
8273 if !ver.is_empty() {
8274 out.push_str(&format!("SSH client: {ver}\n"));
8275 }
8276 } else {
8277 out.push_str("SSH client: not found on PATH.\n");
8278 }
8279
8280 #[cfg(target_os = "windows")]
8281 {
8282 let script = r#"
8283$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8284if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8285else { "SSHD:not_installed" }
8286"#;
8287 if let Ok(o) = Command::new("powershell")
8288 .args(["-NoProfile", "-Command", script])
8289 .output()
8290 {
8291 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8292 if text.contains("not_installed") {
8293 out.push_str("SSH server (sshd): not installed\n");
8294 } else {
8295 out.push_str(&format!(
8296 "SSH server (sshd): {}\n",
8297 text.trim_start_matches("SSHD:")
8298 ));
8299 }
8300 }
8301 }
8302
8303 #[cfg(not(target_os = "windows"))]
8304 {
8305 if let Ok(o) = Command::new("systemctl")
8306 .args(["is-active", "sshd"])
8307 .output()
8308 {
8309 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8310 out.push_str(&format!("SSH server (sshd): {status}\n"));
8311 } else if let Ok(o) = Command::new("systemctl")
8312 .args(["is-active", "ssh"])
8313 .output()
8314 {
8315 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8316 out.push_str(&format!("SSH server (ssh): {status}\n"));
8317 }
8318 }
8319
8320 out.push('\n');
8321
8322 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8323 if ssh_dir.exists() {
8324 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8325
8326 let kh = ssh_dir.join("known_hosts");
8327 if kh.exists() {
8328 let count = fs::read_to_string(&kh)
8329 .map(|c| {
8330 c.lines()
8331 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8332 .count()
8333 })
8334 .unwrap_or(0);
8335 out.push_str(&format!(" known_hosts: {count} entries\n"));
8336 } else {
8337 out.push_str(" known_hosts: not present\n");
8338 }
8339
8340 let ak = ssh_dir.join("authorized_keys");
8341 if ak.exists() {
8342 let count = fs::read_to_string(&ak)
8343 .map(|c| {
8344 c.lines()
8345 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8346 .count()
8347 })
8348 .unwrap_or(0);
8349 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
8350 } else {
8351 out.push_str(" authorized_keys: not present\n");
8352 }
8353
8354 let key_names = [
8355 "id_rsa",
8356 "id_ed25519",
8357 "id_ecdsa",
8358 "id_dsa",
8359 "id_ecdsa_sk",
8360 "id_ed25519_sk",
8361 ];
8362 let found_keys: Vec<&str> = key_names
8363 .iter()
8364 .filter(|k| ssh_dir.join(k).exists())
8365 .copied()
8366 .collect();
8367 if !found_keys.is_empty() {
8368 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
8369 } else {
8370 out.push_str(" Private keys: none found\n");
8371 }
8372
8373 let config_path = ssh_dir.join("config");
8374 if config_path.exists() {
8375 out.push_str("\n=== SSH config hosts ===\n");
8376 match fs::read_to_string(&config_path) {
8377 Ok(content) => {
8378 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8379 let mut current: Option<(String, Vec<String>)> = None;
8380 for line in content.lines() {
8381 let t = line.trim();
8382 if t.is_empty() || t.starts_with('#') {
8383 continue;
8384 }
8385 if let Some(host) = t.strip_prefix("Host ") {
8386 if let Some(prev) = current.take() {
8387 hosts.push(prev);
8388 }
8389 current = Some((host.trim().to_string(), Vec::new()));
8390 } else if let Some((_, ref mut details)) = current {
8391 let tu = t.to_uppercase();
8392 if tu.starts_with("HOSTNAME ")
8393 || tu.starts_with("USER ")
8394 || tu.starts_with("PORT ")
8395 || tu.starts_with("IDENTITYFILE ")
8396 {
8397 details.push(t.to_string());
8398 }
8399 }
8400 }
8401 if let Some(prev) = current {
8402 hosts.push(prev);
8403 }
8404
8405 if hosts.is_empty() {
8406 out.push_str(" No Host entries found.\n");
8407 } else {
8408 for (h, details) in &hosts {
8409 if details.is_empty() {
8410 out.push_str(&format!(" Host {h}\n"));
8411 } else {
8412 out.push_str(&format!(
8413 " Host {h} [{}]\n",
8414 details.join(", ")
8415 ));
8416 }
8417 }
8418 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
8419 }
8420 }
8421 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
8422 }
8423 } else {
8424 out.push_str(" SSH config: not present\n");
8425 }
8426 } else {
8427 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8428 }
8429 }
8430
8431 Ok(out.trim_end().to_string())
8432}
8433
8434fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8437 let mut out = String::from("Host inspection: installed_software\n\n");
8438 let n = max_entries.clamp(10, 50);
8439
8440 #[cfg(target_os = "windows")]
8441 {
8442 let winget_out = Command::new("winget")
8443 .args(["list", "--accept-source-agreements"])
8444 .output();
8445
8446 if let Ok(o) = winget_out {
8447 if o.status.success() {
8448 let raw = String::from_utf8_lossy(&o.stdout);
8449 let mut header_done = false;
8450 let mut packages: Vec<&str> = Vec::new();
8451 for line in raw.lines() {
8452 let t = line.trim();
8453 if t.starts_with("---") {
8454 header_done = true;
8455 continue;
8456 }
8457 if header_done && !t.is_empty() {
8458 packages.push(line);
8459 }
8460 }
8461 let total = packages.len();
8462 out.push_str(&format!(
8463 "=== Installed software via winget ({total} packages) ===\n\n"
8464 ));
8465 for line in packages.iter().take(n) {
8466 out.push_str(&format!(" {line}\n"));
8467 }
8468 if total > n {
8469 out.push_str(&format!("\n ... and {} more packages\n", total - n));
8470 }
8471 out.push_str("\nFor full list: winget list\n");
8472 return Ok(out.trim_end().to_string());
8473 }
8474 }
8475
8476 let script = format!(
8478 r#"
8479$apps = @()
8480$reg_paths = @(
8481 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8482 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8483 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8484)
8485foreach ($p in $reg_paths) {{
8486 try {{
8487 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8488 Where-Object {{ $_.DisplayName }} |
8489 Select-Object DisplayName, DisplayVersion, Publisher
8490 }} catch {{}}
8491}}
8492$sorted = $apps | Sort-Object DisplayName -Unique
8493"TOTAL:" + $sorted.Count
8494$sorted | Select-Object -First {n} | ForEach-Object {{
8495 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8496}}
8497"#
8498 );
8499 if let Ok(o) = Command::new("powershell")
8500 .args(["-NoProfile", "-Command", &script])
8501 .output()
8502 {
8503 let raw = String::from_utf8_lossy(&o.stdout);
8504 out.push_str("=== Installed software (registry scan) ===\n");
8505 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
8506 out.push_str(&format!(" {}\n", "-".repeat(90)));
8507 for line in raw.lines() {
8508 if let Some(rest) = line.strip_prefix("TOTAL:") {
8509 let total: usize = rest.trim().parse().unwrap_or(0);
8510 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
8511 } else if !line.trim().is_empty() {
8512 let parts: Vec<&str> = line.splitn(3, '|').collect();
8513 let name = parts.first().map(|s| s.trim()).unwrap_or("");
8514 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8515 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8516 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
8517 }
8518 }
8519 } else {
8520 out.push_str(
8521 "Could not query installed software (winget and registry scan both failed).\n",
8522 );
8523 }
8524 }
8525
8526 #[cfg(target_os = "linux")]
8527 {
8528 let mut found = false;
8529 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8530 if o.status.success() {
8531 let raw = String::from_utf8_lossy(&o.stdout);
8532 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8533 let total = installed.len();
8534 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8535 for line in installed.iter().take(n) {
8536 out.push_str(&format!(" {}\n", line.trim()));
8537 }
8538 if total > n {
8539 out.push_str(&format!(" ... and {} more\n", total - n));
8540 }
8541 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8542 found = true;
8543 }
8544 }
8545 if !found {
8546 if let Ok(o) = Command::new("rpm")
8547 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8548 .output()
8549 {
8550 if o.status.success() {
8551 let raw = String::from_utf8_lossy(&o.stdout);
8552 let lines: Vec<&str> = raw.lines().collect();
8553 let total = lines.len();
8554 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8555 for line in lines.iter().take(n) {
8556 out.push_str(&format!(" {line}\n"));
8557 }
8558 if total > n {
8559 out.push_str(&format!(" ... and {} more\n", total - n));
8560 }
8561 found = true;
8562 }
8563 }
8564 }
8565 if !found {
8566 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8567 if o.status.success() {
8568 let raw = String::from_utf8_lossy(&o.stdout);
8569 let lines: Vec<&str> = raw.lines().collect();
8570 let total = lines.len();
8571 out.push_str(&format!(
8572 "=== Installed packages via pacman ({total}) ===\n"
8573 ));
8574 for line in lines.iter().take(n) {
8575 out.push_str(&format!(" {line}\n"));
8576 }
8577 if total > n {
8578 out.push_str(&format!(" ... and {} more\n", total - n));
8579 }
8580 found = true;
8581 }
8582 }
8583 }
8584 if !found {
8585 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8586 }
8587 }
8588
8589 #[cfg(target_os = "macos")]
8590 {
8591 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8592 if o.status.success() {
8593 let raw = String::from_utf8_lossy(&o.stdout);
8594 let lines: Vec<&str> = raw.lines().collect();
8595 let total = lines.len();
8596 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8597 for line in lines.iter().take(n) {
8598 out.push_str(&format!(" {line}\n"));
8599 }
8600 if total > n {
8601 out.push_str(&format!(" ... and {} more\n", total - n));
8602 }
8603 out.push_str("\nFor full list: brew list --versions\n");
8604 }
8605 } else {
8606 out.push_str("Homebrew not found.\n");
8607 }
8608 if let Ok(o) = Command::new("mas").args(["list"]).output() {
8609 if o.status.success() {
8610 let raw = String::from_utf8_lossy(&o.stdout);
8611 let lines: Vec<&str> = raw.lines().collect();
8612 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8613 for line in lines.iter().take(n) {
8614 out.push_str(&format!(" {line}\n"));
8615 }
8616 }
8617 }
8618 }
8619
8620 Ok(out.trim_end().to_string())
8621}
8622
8623fn inspect_git_config() -> Result<String, String> {
8626 let mut out = String::from("Host inspection: git_config\n\n");
8627
8628 if let Ok(o) = Command::new("git").args(["--version"]).output() {
8629 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8630 out.push_str(&format!("Git: {ver}\n\n"));
8631 } else {
8632 out.push_str("Git: not found on PATH.\n");
8633 return Ok(out.trim_end().to_string());
8634 }
8635
8636 if let Ok(o) = Command::new("git")
8637 .args(["config", "--global", "--list"])
8638 .output()
8639 {
8640 if o.status.success() {
8641 let raw = String::from_utf8_lossy(&o.stdout);
8642 let mut pairs: Vec<(String, String)> = raw
8643 .lines()
8644 .filter_map(|l| {
8645 let mut parts = l.splitn(2, '=');
8646 let k = parts.next()?.trim().to_string();
8647 let v = parts.next().unwrap_or("").trim().to_string();
8648 Some((k, v))
8649 })
8650 .collect();
8651 pairs.sort_by(|a, b| a.0.cmp(&b.0));
8652
8653 out.push_str("=== Global git config ===\n");
8654
8655 let sections: &[(&str, &[&str])] = &[
8656 ("Identity", &["user.name", "user.email", "user.signingkey"]),
8657 (
8658 "Core",
8659 &[
8660 "core.editor",
8661 "core.autocrlf",
8662 "core.eol",
8663 "core.ignorecase",
8664 "core.filemode",
8665 ],
8666 ),
8667 (
8668 "Commit/Signing",
8669 &[
8670 "commit.gpgsign",
8671 "tag.gpgsign",
8672 "gpg.format",
8673 "gpg.ssh.allowedsignersfile",
8674 ],
8675 ),
8676 (
8677 "Push/Pull",
8678 &[
8679 "push.default",
8680 "push.autosetupremote",
8681 "pull.rebase",
8682 "pull.ff",
8683 ],
8684 ),
8685 ("Credential", &["credential.helper"]),
8686 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
8687 ];
8688
8689 let mut shown_keys: HashSet<String> = HashSet::new();
8690 for (section, keys) in sections {
8691 let mut section_lines: Vec<String> = Vec::new();
8692 for key in *keys {
8693 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
8694 section_lines.push(format!(" {k} = {v}"));
8695 shown_keys.insert(k.clone());
8696 }
8697 }
8698 if !section_lines.is_empty() {
8699 out.push_str(&format!("\n[{section}]\n"));
8700 for line in section_lines {
8701 out.push_str(&format!("{line}\n"));
8702 }
8703 }
8704 }
8705
8706 let other: Vec<&(String, String)> = pairs
8707 .iter()
8708 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
8709 .collect();
8710 if !other.is_empty() {
8711 out.push_str("\n[Other]\n");
8712 for (k, v) in other.iter().take(20) {
8713 out.push_str(&format!(" {k} = {v}\n"));
8714 }
8715 if other.len() > 20 {
8716 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
8717 }
8718 }
8719
8720 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
8721 } else {
8722 out.push_str("No global git config found.\n");
8723 out.push_str("Set up with:\n");
8724 out.push_str(" git config --global user.name \"Your Name\"\n");
8725 out.push_str(" git config --global user.email \"you@example.com\"\n");
8726 }
8727 }
8728
8729 if let Ok(o) = Command::new("git")
8730 .args(["config", "--local", "--list"])
8731 .output()
8732 {
8733 if o.status.success() {
8734 let raw = String::from_utf8_lossy(&o.stdout);
8735 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8736 if !lines.is_empty() {
8737 out.push_str(&format!(
8738 "\n=== Local repo config ({} keys) ===\n",
8739 lines.len()
8740 ));
8741 for line in lines.iter().take(15) {
8742 out.push_str(&format!(" {line}\n"));
8743 }
8744 if lines.len() > 15 {
8745 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
8746 }
8747 }
8748 }
8749 }
8750
8751 if let Ok(o) = Command::new("git")
8752 .args(["config", "--global", "--get-regexp", r"alias\."])
8753 .output()
8754 {
8755 if o.status.success() {
8756 let raw = String::from_utf8_lossy(&o.stdout);
8757 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8758 if !aliases.is_empty() {
8759 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
8760 for a in aliases.iter().take(20) {
8761 out.push_str(&format!(" {a}\n"));
8762 }
8763 if aliases.len() > 20 {
8764 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
8765 }
8766 }
8767 }
8768 }
8769
8770 Ok(out.trim_end().to_string())
8771}
8772
8773fn inspect_databases() -> Result<String, String> {
8776 let mut out = String::from("Host inspection: databases\n\n");
8777 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
8778
8779 struct DbEngine {
8780 name: &'static str,
8781 service_names: &'static [&'static str],
8782 default_port: u16,
8783 cli_name: &'static str,
8784 cli_version_args: &'static [&'static str],
8785 }
8786
8787 let engines: &[DbEngine] = &[
8788 DbEngine {
8789 name: "PostgreSQL",
8790 service_names: &[
8791 "postgresql",
8792 "postgresql-x64-14",
8793 "postgresql-x64-15",
8794 "postgresql-x64-16",
8795 "postgresql-x64-17",
8796 ],
8797
8798 default_port: 5432,
8799 cli_name: "psql",
8800 cli_version_args: &["--version"],
8801 },
8802 DbEngine {
8803 name: "MySQL",
8804 service_names: &["mysql", "mysql80", "mysql57"],
8805
8806 default_port: 3306,
8807 cli_name: "mysql",
8808 cli_version_args: &["--version"],
8809 },
8810 DbEngine {
8811 name: "MariaDB",
8812 service_names: &["mariadb", "mariadb.exe"],
8813
8814 default_port: 3306,
8815 cli_name: "mariadb",
8816 cli_version_args: &["--version"],
8817 },
8818 DbEngine {
8819 name: "MongoDB",
8820 service_names: &["mongodb", "mongod"],
8821
8822 default_port: 27017,
8823 cli_name: "mongod",
8824 cli_version_args: &["--version"],
8825 },
8826 DbEngine {
8827 name: "Redis",
8828 service_names: &["redis", "redis-server"],
8829
8830 default_port: 6379,
8831 cli_name: "redis-server",
8832 cli_version_args: &["--version"],
8833 },
8834 DbEngine {
8835 name: "SQL Server",
8836 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
8837
8838 default_port: 1433,
8839 cli_name: "sqlcmd",
8840 cli_version_args: &["-?"],
8841 },
8842 DbEngine {
8843 name: "SQLite",
8844 service_names: &[], default_port: 0, cli_name: "sqlite3",
8848 cli_version_args: &["--version"],
8849 },
8850 DbEngine {
8851 name: "CouchDB",
8852 service_names: &["couchdb", "apache-couchdb"],
8853
8854 default_port: 5984,
8855 cli_name: "couchdb",
8856 cli_version_args: &["--version"],
8857 },
8858 DbEngine {
8859 name: "Cassandra",
8860 service_names: &["cassandra"],
8861
8862 default_port: 9042,
8863 cli_name: "cqlsh",
8864 cli_version_args: &["--version"],
8865 },
8866 DbEngine {
8867 name: "Elasticsearch",
8868 service_names: &["elasticsearch-service-x64", "elasticsearch"],
8869
8870 default_port: 9200,
8871 cli_name: "elasticsearch",
8872 cli_version_args: &["--version"],
8873 },
8874 ];
8875
8876 fn port_listening(port: u16) -> bool {
8878 if port == 0 {
8879 return false;
8880 }
8881 std::net::TcpStream::connect_timeout(
8883 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8884 std::time::Duration::from_millis(150),
8885 )
8886 .is_ok()
8887 }
8888
8889 let mut found_any = false;
8890
8891 for engine in engines {
8892 let mut status_parts: Vec<String> = Vec::new();
8893 let mut detected = false;
8894
8895 let version = Command::new(engine.cli_name)
8897 .args(engine.cli_version_args)
8898 .output()
8899 .ok()
8900 .and_then(|o| {
8901 let combined = if o.stdout.is_empty() {
8902 String::from_utf8_lossy(&o.stderr).trim().to_string()
8903 } else {
8904 String::from_utf8_lossy(&o.stdout).trim().to_string()
8905 };
8906 combined.lines().next().map(|l| l.trim().to_string())
8908 });
8909
8910 if let Some(ref ver) = version {
8911 if !ver.is_empty() {
8912 status_parts.push(format!("version: {ver}"));
8913 detected = true;
8914 }
8915 }
8916
8917 if engine.default_port > 0 && port_listening(engine.default_port) {
8919 status_parts.push(format!("listening on :{}", engine.default_port));
8920 detected = true;
8921 } else if engine.default_port > 0 && detected {
8922 status_parts.push(format!("not listening on :{}", engine.default_port));
8923 }
8924
8925 #[cfg(target_os = "windows")]
8927 {
8928 if !engine.service_names.is_empty() {
8929 let service_list = engine.service_names.join("','");
8930 let script = format!(
8931 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
8932 service_list
8933 );
8934 if let Ok(o) = Command::new("powershell")
8935 .args(["-NoProfile", "-Command", &script])
8936 .output()
8937 {
8938 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8939 if !text.is_empty() {
8940 let parts: Vec<&str> = text.splitn(2, ':').collect();
8941 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
8942 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
8943 status_parts.push(format!("service '{svc_name}': {svc_state}"));
8944 detected = true;
8945 }
8946 }
8947 }
8948 }
8949
8950 #[cfg(not(target_os = "windows"))]
8952 {
8953 for svc in engine.service_names {
8954 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
8955 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
8956 if !state.is_empty() && state != "inactive" {
8957 status_parts.push(format!("systemd '{svc}': {state}"));
8958 detected = true;
8959 break;
8960 }
8961 }
8962 }
8963 }
8964
8965 if detected {
8966 found_any = true;
8967 let label = if engine.default_port > 0 {
8968 format!("{} (default port: {})", engine.name, engine.default_port)
8969 } else {
8970 format!("{} (file-based, no port)", engine.name)
8971 };
8972 out.push_str(&format!("[FOUND] {label}\n"));
8973 for part in &status_parts {
8974 out.push_str(&format!(" {part}\n"));
8975 }
8976 out.push('\n');
8977 }
8978 }
8979
8980 if !found_any {
8981 out.push_str("No local database engines detected.\n");
8982 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
8983 out.push_str(
8984 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8985 );
8986 } else {
8987 out.push_str("---\n");
8988 out.push_str(
8989 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8990 );
8991 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
8992 }
8993
8994 Ok(out.trim_end().to_string())
8995}
8996
8997fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9000 let mut out = String::from("Host inspection: user_accounts\n\n");
9001
9002 #[cfg(target_os = "windows")]
9003 {
9004 let users_out = Command::new("powershell")
9005 .args([
9006 "-NoProfile", "-NonInteractive", "-Command",
9007 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9008 ])
9009 .output()
9010 .ok()
9011 .and_then(|o| String::from_utf8(o.stdout).ok())
9012 .unwrap_or_default();
9013
9014 out.push_str("=== Local User Accounts ===\n");
9015 if users_out.trim().is_empty() {
9016 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
9017 } else {
9018 for line in users_out.lines().take(max_entries) {
9019 if !line.trim().is_empty() {
9020 out.push_str(line);
9021 out.push('\n');
9022 }
9023 }
9024 }
9025
9026 let admins_out = Command::new("powershell")
9027 .args([
9028 "-NoProfile", "-NonInteractive", "-Command",
9029 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
9030 ])
9031 .output()
9032 .ok()
9033 .and_then(|o| String::from_utf8(o.stdout).ok())
9034 .unwrap_or_default();
9035
9036 out.push_str("\n=== Administrators Group Members ===\n");
9037 if admins_out.trim().is_empty() {
9038 out.push_str(" (unable to retrieve)\n");
9039 } else {
9040 out.push_str(admins_out.trim());
9041 out.push('\n');
9042 }
9043
9044 let sessions_out = Command::new("powershell")
9045 .args([
9046 "-NoProfile",
9047 "-NonInteractive",
9048 "-Command",
9049 "query user 2>$null",
9050 ])
9051 .output()
9052 .ok()
9053 .and_then(|o| String::from_utf8(o.stdout).ok())
9054 .unwrap_or_default();
9055
9056 out.push_str("\n=== Active Logon Sessions ===\n");
9057 if sessions_out.trim().is_empty() {
9058 out.push_str(" (none or requires elevation)\n");
9059 } else {
9060 for line in sessions_out.lines().take(max_entries) {
9061 if !line.trim().is_empty() {
9062 out.push_str(&format!(" {}\n", line));
9063 }
9064 }
9065 }
9066
9067 let is_admin = Command::new("powershell")
9068 .args([
9069 "-NoProfile", "-NonInteractive", "-Command",
9070 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9071 ])
9072 .output()
9073 .ok()
9074 .and_then(|o| String::from_utf8(o.stdout).ok())
9075 .map(|s| s.trim().to_lowercase())
9076 .unwrap_or_default();
9077
9078 out.push_str("\n=== Current Session Elevation ===\n");
9079 out.push_str(&format!(
9080 " Running as Administrator: {}\n",
9081 if is_admin.contains("true") {
9082 "YES"
9083 } else {
9084 "no"
9085 }
9086 ));
9087 }
9088
9089 #[cfg(not(target_os = "windows"))]
9090 {
9091 let who_out = Command::new("who")
9092 .output()
9093 .ok()
9094 .and_then(|o| String::from_utf8(o.stdout).ok())
9095 .unwrap_or_default();
9096 out.push_str("=== Active Sessions ===\n");
9097 if who_out.trim().is_empty() {
9098 out.push_str(" (none)\n");
9099 } else {
9100 for line in who_out.lines().take(max_entries) {
9101 out.push_str(&format!(" {}\n", line));
9102 }
9103 }
9104 let id_out = Command::new("id")
9105 .output()
9106 .ok()
9107 .and_then(|o| String::from_utf8(o.stdout).ok())
9108 .unwrap_or_default();
9109 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
9110 }
9111
9112 Ok(out.trim_end().to_string())
9113}
9114
9115fn inspect_audit_policy() -> Result<String, String> {
9118 let mut out = String::from("Host inspection: audit_policy\n\n");
9119
9120 #[cfg(target_os = "windows")]
9121 {
9122 let auditpol_out = Command::new("auditpol")
9123 .args(["/get", "/category:*"])
9124 .output()
9125 .ok()
9126 .and_then(|o| String::from_utf8(o.stdout).ok())
9127 .unwrap_or_default();
9128
9129 if auditpol_out.trim().is_empty()
9130 || auditpol_out.to_lowercase().contains("access is denied")
9131 {
9132 out.push_str("Audit policy requires Administrator elevation to read.\n");
9133 out.push_str(
9134 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9135 );
9136 } else {
9137 out.push_str("=== Windows Audit Policy ===\n");
9138 let mut any_enabled = false;
9139 for line in auditpol_out.lines() {
9140 let trimmed = line.trim();
9141 if trimmed.is_empty() {
9142 continue;
9143 }
9144 if trimmed.contains("Success") || trimmed.contains("Failure") {
9145 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
9146 any_enabled = true;
9147 } else {
9148 out.push_str(&format!(" {}\n", trimmed));
9149 }
9150 }
9151 if !any_enabled {
9152 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9153 out.push_str(
9154 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9155 );
9156 }
9157 }
9158
9159 let evtlog = Command::new("powershell")
9160 .args([
9161 "-NoProfile", "-NonInteractive", "-Command",
9162 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9163 ])
9164 .output()
9165 .ok()
9166 .and_then(|o| String::from_utf8(o.stdout).ok())
9167 .map(|s| s.trim().to_string())
9168 .unwrap_or_default();
9169
9170 out.push_str(&format!(
9171 "\n=== Windows Event Log Service ===\n Status: {}\n",
9172 if evtlog.is_empty() {
9173 "unknown".to_string()
9174 } else {
9175 evtlog
9176 }
9177 ));
9178 }
9179
9180 #[cfg(not(target_os = "windows"))]
9181 {
9182 let auditd_status = Command::new("systemctl")
9183 .args(["is-active", "auditd"])
9184 .output()
9185 .ok()
9186 .and_then(|o| String::from_utf8(o.stdout).ok())
9187 .map(|s| s.trim().to_string())
9188 .unwrap_or_else(|| "not found".to_string());
9189
9190 out.push_str(&format!(
9191 "=== auditd service ===\n Status: {}\n",
9192 auditd_status
9193 ));
9194
9195 if auditd_status == "active" {
9196 let rules = Command::new("auditctl")
9197 .args(["-l"])
9198 .output()
9199 .ok()
9200 .and_then(|o| String::from_utf8(o.stdout).ok())
9201 .unwrap_or_default();
9202 out.push_str("\n=== Active Audit Rules ===\n");
9203 if rules.trim().is_empty() || rules.contains("No rules") {
9204 out.push_str(" No rules configured.\n");
9205 } else {
9206 for line in rules.lines() {
9207 out.push_str(&format!(" {}\n", line));
9208 }
9209 }
9210 }
9211 }
9212
9213 Ok(out.trim_end().to_string())
9214}
9215
9216fn inspect_shares(max_entries: usize) -> Result<String, String> {
9219 let mut out = String::from("Host inspection: shares\n\n");
9220
9221 #[cfg(target_os = "windows")]
9222 {
9223 let smb_out = Command::new("powershell")
9224 .args([
9225 "-NoProfile", "-NonInteractive", "-Command",
9226 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9227 ])
9228 .output()
9229 .ok()
9230 .and_then(|o| String::from_utf8(o.stdout).ok())
9231 .unwrap_or_default();
9232
9233 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9234 let smb_lines: Vec<&str> = smb_out
9235 .lines()
9236 .filter(|l| !l.trim().is_empty())
9237 .take(max_entries)
9238 .collect();
9239 if smb_lines.is_empty() {
9240 out.push_str(" No SMB shares or unable to retrieve.\n");
9241 } else {
9242 for line in &smb_lines {
9243 let name = line.trim().split('|').next().unwrap_or("").trim();
9244 if name.ends_with('$') {
9245 out.push_str(&format!(" {}\n", line.trim()));
9246 } else {
9247 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
9248 }
9249 }
9250 }
9251
9252 let smb_security = Command::new("powershell")
9253 .args([
9254 "-NoProfile", "-NonInteractive", "-Command",
9255 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9256 ])
9257 .output()
9258 .ok()
9259 .and_then(|o| String::from_utf8(o.stdout).ok())
9260 .unwrap_or_default();
9261
9262 out.push_str("\n=== SMB Server Security Settings ===\n");
9263 if smb_security.trim().is_empty() {
9264 out.push_str(" (unable to retrieve)\n");
9265 } else {
9266 out.push_str(smb_security.trim());
9267 out.push('\n');
9268 if smb_security.to_lowercase().contains("smb1: true") {
9269 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9270 }
9271 }
9272
9273 let drives_out = Command::new("powershell")
9274 .args([
9275 "-NoProfile", "-NonInteractive", "-Command",
9276 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9277 ])
9278 .output()
9279 .ok()
9280 .and_then(|o| String::from_utf8(o.stdout).ok())
9281 .unwrap_or_default();
9282
9283 out.push_str("\n=== Mapped Network Drives ===\n");
9284 if drives_out.trim().is_empty() {
9285 out.push_str(" None.\n");
9286 } else {
9287 for line in drives_out.lines().take(max_entries) {
9288 if !line.trim().is_empty() {
9289 out.push_str(line);
9290 out.push('\n');
9291 }
9292 }
9293 }
9294 }
9295
9296 #[cfg(not(target_os = "windows"))]
9297 {
9298 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9299 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9300 if smb_conf.is_empty() {
9301 out.push_str(" Not found or Samba not installed.\n");
9302 } else {
9303 for line in smb_conf.lines().take(max_entries) {
9304 out.push_str(&format!(" {}\n", line));
9305 }
9306 }
9307 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9308 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9309 if nfs_exports.is_empty() {
9310 out.push_str(" Not configured.\n");
9311 } else {
9312 for line in nfs_exports.lines().take(max_entries) {
9313 out.push_str(&format!(" {}\n", line));
9314 }
9315 }
9316 }
9317
9318 Ok(out.trim_end().to_string())
9319}
9320
9321fn inspect_dns_servers() -> Result<String, String> {
9324 let mut out = String::from("Host inspection: dns_servers\n\n");
9325
9326 #[cfg(target_os = "windows")]
9327 {
9328 let dns_out = Command::new("powershell")
9329 .args([
9330 "-NoProfile", "-NonInteractive", "-Command",
9331 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9332 ])
9333 .output()
9334 .ok()
9335 .and_then(|o| String::from_utf8(o.stdout).ok())
9336 .unwrap_or_default();
9337
9338 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9339 if dns_out.trim().is_empty() {
9340 out.push_str(" (unable to retrieve)\n");
9341 } else {
9342 for line in dns_out.lines() {
9343 if line.trim().is_empty() {
9344 continue;
9345 }
9346 let mut annotation = "";
9347 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9348 annotation = " <- Google Public DNS";
9349 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9350 annotation = " <- Cloudflare DNS";
9351 } else if line.contains("9.9.9.9") {
9352 annotation = " <- Quad9";
9353 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9354 annotation = " <- OpenDNS";
9355 }
9356 out.push_str(line);
9357 out.push_str(annotation);
9358 out.push('\n');
9359 }
9360 }
9361
9362 let doh_out = Command::new("powershell")
9363 .args([
9364 "-NoProfile", "-NonInteractive", "-Command",
9365 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
9366 ])
9367 .output()
9368 .ok()
9369 .and_then(|o| String::from_utf8(o.stdout).ok())
9370 .unwrap_or_default();
9371
9372 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9373 if doh_out.trim().is_empty() {
9374 out.push_str(" Not configured (plain DNS).\n");
9375 } else {
9376 out.push_str(doh_out.trim());
9377 out.push('\n');
9378 }
9379
9380 let suffixes = Command::new("powershell")
9381 .args([
9382 "-NoProfile", "-NonInteractive", "-Command",
9383 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
9384 ])
9385 .output()
9386 .ok()
9387 .and_then(|o| String::from_utf8(o.stdout).ok())
9388 .unwrap_or_default();
9389
9390 if !suffixes.trim().is_empty() {
9391 out.push_str("\n=== DNS Search Suffix List ===\n");
9392 out.push_str(suffixes.trim());
9393 out.push('\n');
9394 }
9395 }
9396
9397 #[cfg(not(target_os = "windows"))]
9398 {
9399 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9400 out.push_str("=== /etc/resolv.conf ===\n");
9401 if resolv.is_empty() {
9402 out.push_str(" Not found.\n");
9403 } else {
9404 for line in resolv.lines() {
9405 if !line.trim().is_empty() && !line.starts_with('#') {
9406 out.push_str(&format!(" {}\n", line));
9407 }
9408 }
9409 }
9410 let resolved_out = Command::new("resolvectl")
9411 .args(["status", "--no-pager"])
9412 .output()
9413 .ok()
9414 .and_then(|o| String::from_utf8(o.stdout).ok())
9415 .unwrap_or_default();
9416 if !resolved_out.is_empty() {
9417 out.push_str("\n=== systemd-resolved ===\n");
9418 for line in resolved_out.lines().take(30) {
9419 out.push_str(&format!(" {}\n", line));
9420 }
9421 }
9422 }
9423
9424 Ok(out.trim_end().to_string())
9425}
9426
9427fn inspect_bitlocker() -> Result<String, String> {
9428 let mut out = String::from("Host inspection: bitlocker\n\n");
9429
9430 #[cfg(target_os = "windows")]
9431 {
9432 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9433 let output = Command::new("powershell")
9434 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9435 .output()
9436 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9437
9438 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9439 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9440
9441 if !stdout.trim().is_empty() {
9442 out.push_str("=== BitLocker Volumes ===\n");
9443 for line in stdout.lines() {
9444 out.push_str(&format!(" {}\n", line));
9445 }
9446 } else if !stderr.trim().is_empty() {
9447 if stderr.contains("Access is denied") {
9448 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9449 } else {
9450 out.push_str(&format!(
9451 "Error retrieving BitLocker info: {}\n",
9452 stderr.trim()
9453 ));
9454 }
9455 } else {
9456 out.push_str("No BitLocker volumes detected or access denied.\n");
9457 }
9458 }
9459
9460 #[cfg(not(target_os = "windows"))]
9461 {
9462 out.push_str(
9463 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9464 );
9465 let lsblk = Command::new("lsblk")
9466 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9467 .output()
9468 .ok()
9469 .and_then(|o| String::from_utf8(o.stdout).ok())
9470 .unwrap_or_default();
9471 if lsblk.contains("crypto_LUKS") {
9472 out.push_str("=== LUKS Encrypted Volumes ===\n");
9473 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9474 out.push_str(&format!(" {}\n", line));
9475 }
9476 } else {
9477 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9478 }
9479 }
9480
9481 Ok(out.trim_end().to_string())
9482}
9483
9484fn inspect_rdp() -> Result<String, String> {
9485 let mut out = String::from("Host inspection: rdp\n\n");
9486
9487 #[cfg(target_os = "windows")]
9488 {
9489 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9490 let f_deny = Command::new("powershell")
9491 .args([
9492 "-NoProfile",
9493 "-Command",
9494 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9495 ])
9496 .output()
9497 .ok()
9498 .and_then(|o| String::from_utf8(o.stdout).ok())
9499 .unwrap_or_default()
9500 .trim()
9501 .to_string();
9502
9503 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9504 out.push_str(&format!("=== RDP Status: {} ===\n", status));
9505
9506 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"])
9507 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9508 out.push_str(&format!(
9509 " Port: {}\n",
9510 if port.is_empty() {
9511 "3389 (default)"
9512 } else {
9513 &port
9514 }
9515 ));
9516
9517 let nla = Command::new("powershell")
9518 .args([
9519 "-NoProfile",
9520 "-Command",
9521 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9522 ])
9523 .output()
9524 .ok()
9525 .and_then(|o| String::from_utf8(o.stdout).ok())
9526 .unwrap_or_default()
9527 .trim()
9528 .to_string();
9529 out.push_str(&format!(
9530 " NLA Required: {}\n",
9531 if nla == "1" { "Yes" } else { "No" }
9532 ));
9533
9534 out.push_str("\n=== Active Sessions ===\n");
9535 let qwinsta = Command::new("qwinsta")
9536 .output()
9537 .ok()
9538 .and_then(|o| String::from_utf8(o.stdout).ok())
9539 .unwrap_or_default();
9540 if qwinsta.trim().is_empty() {
9541 out.push_str(" No active sessions listed.\n");
9542 } else {
9543 for line in qwinsta.lines() {
9544 out.push_str(&format!(" {}\n", line));
9545 }
9546 }
9547
9548 out.push_str("\n=== Firewall Rule Check ===\n");
9549 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))\" }"])
9550 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9551 if fw.trim().is_empty() {
9552 out.push_str(" No enabled RDP firewall rules found.\n");
9553 } else {
9554 out.push_str(fw.trim_end());
9555 out.push('\n');
9556 }
9557 }
9558
9559 #[cfg(not(target_os = "windows"))]
9560 {
9561 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9562 let ss = Command::new("ss")
9563 .args(["-tlnp"])
9564 .output()
9565 .ok()
9566 .and_then(|o| String::from_utf8(o.stdout).ok())
9567 .unwrap_or_default();
9568 let matches: Vec<&str> = ss
9569 .lines()
9570 .filter(|l| l.contains(":3389") || l.contains(":590"))
9571 .collect();
9572 if matches.is_empty() {
9573 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
9574 } else {
9575 for m in matches {
9576 out.push_str(&format!(" {}\n", m));
9577 }
9578 }
9579 }
9580
9581 Ok(out.trim_end().to_string())
9582}
9583
9584fn inspect_shadow_copies() -> Result<String, String> {
9585 let mut out = String::from("Host inspection: shadow_copies\n\n");
9586
9587 #[cfg(target_os = "windows")]
9588 {
9589 let output = Command::new("vssadmin")
9590 .args(["list", "shadows"])
9591 .output()
9592 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9593 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9594
9595 if stdout.contains("No items found") || stdout.trim().is_empty() {
9596 out.push_str("No Volume Shadow Copies found.\n");
9597 } else {
9598 out.push_str("=== Volume Shadow Copies ===\n");
9599 for line in stdout.lines().take(50) {
9600 if line.contains("Creation Time:")
9601 || line.contains("Contents:")
9602 || line.contains("Volume Name:")
9603 {
9604 out.push_str(&format!(" {}\n", line.trim()));
9605 }
9606 }
9607 }
9608
9609 out.push_str("\n=== Shadow Copy Storage ===\n");
9610 let storage_out = Command::new("vssadmin")
9611 .args(["list", "shadowstorage"])
9612 .output()
9613 .ok();
9614 if let Some(o) = storage_out {
9615 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9616 for line in stdout.lines() {
9617 if line.contains("Used Shadow Copy Storage space:")
9618 || line.contains("Max Shadow Copy Storage space:")
9619 {
9620 out.push_str(&format!(" {}\n", line.trim()));
9621 }
9622 }
9623 }
9624 }
9625
9626 #[cfg(not(target_os = "windows"))]
9627 {
9628 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
9629 let lvs = Command::new("lvs")
9630 .output()
9631 .ok()
9632 .and_then(|o| String::from_utf8(o.stdout).ok())
9633 .unwrap_or_default();
9634 if !lvs.is_empty() {
9635 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
9636 out.push_str(&lvs);
9637 } else {
9638 out.push_str("No LVM volumes detected.\n");
9639 }
9640 }
9641
9642 Ok(out.trim_end().to_string())
9643}
9644
9645fn inspect_pagefile() -> Result<String, String> {
9646 let mut out = String::from("Host inspection: pagefile\n\n");
9647
9648 #[cfg(target_os = "windows")]
9649 {
9650 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)\" }";
9651 let output = Command::new("powershell")
9652 .args(["-NoProfile", "-Command", ps_cmd])
9653 .output()
9654 .ok()
9655 .and_then(|o| String::from_utf8(o.stdout).ok())
9656 .unwrap_or_default();
9657
9658 if output.trim().is_empty() {
9659 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
9660 let managed = Command::new("powershell")
9661 .args([
9662 "-NoProfile",
9663 "-Command",
9664 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
9665 ])
9666 .output()
9667 .ok()
9668 .and_then(|o| String::from_utf8(o.stdout).ok())
9669 .unwrap_or_default()
9670 .trim()
9671 .to_string();
9672 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
9673 } else {
9674 out.push_str("=== Page File Usage ===\n");
9675 out.push_str(&output);
9676 }
9677 }
9678
9679 #[cfg(not(target_os = "windows"))]
9680 {
9681 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
9682 let swap = Command::new("swapon")
9683 .args(["--show"])
9684 .output()
9685 .ok()
9686 .and_then(|o| String::from_utf8(o.stdout).ok())
9687 .unwrap_or_default();
9688 if swap.is_empty() {
9689 let free = Command::new("free")
9690 .args(["-h"])
9691 .output()
9692 .ok()
9693 .and_then(|o| String::from_utf8(o.stdout).ok())
9694 .unwrap_or_default();
9695 out.push_str(&free);
9696 } else {
9697 out.push_str(&swap);
9698 }
9699 }
9700
9701 Ok(out.trim_end().to_string())
9702}
9703
9704fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
9705 let mut out = String::from("Host inspection: windows_features\n\n");
9706
9707 #[cfg(target_os = "windows")]
9708 {
9709 out.push_str("=== Quick Check: Notable Features ===\n");
9710 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
9711 let output = Command::new("powershell")
9712 .args(["-NoProfile", "-Command", quick_ps])
9713 .output()
9714 .ok();
9715
9716 if let Some(o) = output {
9717 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9718 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
9719
9720 if !stdout.trim().is_empty() {
9721 for f in stdout.lines() {
9722 out.push_str(&format!(" [ENABLED] {}\n", f));
9723 }
9724 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
9725 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
9726 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
9727 out.push_str(
9728 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
9729 );
9730 }
9731 }
9732
9733 out.push_str(&format!(
9734 "\n=== All Enabled Features (capped at {}) ===\n",
9735 max_entries
9736 ));
9737 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
9738 let all_out = Command::new("powershell")
9739 .args(["-NoProfile", "-Command", &all_ps])
9740 .output()
9741 .ok();
9742 if let Some(o) = all_out {
9743 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9744 if !stdout.trim().is_empty() {
9745 out.push_str(&stdout);
9746 }
9747 }
9748 }
9749
9750 #[cfg(not(target_os = "windows"))]
9751 {
9752 let _ = max_entries;
9753 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
9754 }
9755
9756 Ok(out.trim_end().to_string())
9757}
9758
9759fn inspect_audio(max_entries: usize) -> Result<String, String> {
9760 let mut out = String::from("Host inspection: audio\n\n");
9761
9762 #[cfg(target_os = "windows")]
9763 {
9764 let n = max_entries.clamp(5, 20);
9765 let services = collect_services().unwrap_or_default();
9766 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
9767 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
9768
9769 let core_services: Vec<&ServiceEntry> = services
9770 .iter()
9771 .filter(|entry| {
9772 core_service_names
9773 .iter()
9774 .any(|name| entry.name.eq_ignore_ascii_case(name))
9775 })
9776 .collect();
9777 let bluetooth_audio_services: Vec<&ServiceEntry> = services
9778 .iter()
9779 .filter(|entry| {
9780 bluetooth_audio_service_names
9781 .iter()
9782 .any(|name| entry.name.eq_ignore_ascii_case(name))
9783 })
9784 .collect();
9785
9786 let probe_script = r#"
9787$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
9788 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9789$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9790 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9791$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
9792 Select-Object Name, Status, Manufacturer, PNPDeviceID)
9793[pscustomobject]@{
9794 Media = $media
9795 Endpoints = $endpoints
9796 SoundDevices = $sound
9797} | ConvertTo-Json -Compress -Depth 4
9798"#;
9799 let probe_raw = Command::new("powershell")
9800 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9801 .output()
9802 .ok()
9803 .and_then(|o| String::from_utf8(o.stdout).ok())
9804 .unwrap_or_default();
9805 let probe_loaded = !probe_raw.trim().is_empty();
9806 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9807
9808 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
9809 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
9810 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
9811
9812 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
9813 .iter()
9814 .filter(|device| !is_microphone_like_name(&device.name))
9815 .collect();
9816 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
9817 .iter()
9818 .filter(|device| is_microphone_like_name(&device.name))
9819 .collect();
9820 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
9821 .iter()
9822 .filter(|device| is_bluetooth_like_name(&device.name))
9823 .collect();
9824 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
9825 .iter()
9826 .filter(|device| windows_device_has_issue(device))
9827 .collect();
9828 let media_problems: Vec<&WindowsPnpDevice> = media_devices
9829 .iter()
9830 .filter(|device| windows_device_has_issue(device))
9831 .collect();
9832 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
9833 .iter()
9834 .filter(|device| windows_sound_device_has_issue(device))
9835 .collect();
9836
9837 let mut findings = Vec::new();
9838
9839 let stopped_core_services: Vec<&ServiceEntry> = core_services
9840 .iter()
9841 .copied()
9842 .filter(|service| !service_is_running(service))
9843 .collect();
9844 if !stopped_core_services.is_empty() {
9845 let names = stopped_core_services
9846 .iter()
9847 .map(|service| service.name.as_str())
9848 .collect::<Vec<_>>()
9849 .join(", ");
9850 findings.push(AuditFinding {
9851 finding: format!("Core audio services are not running: {names}"),
9852 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
9853 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
9854 });
9855 }
9856
9857 if probe_loaded
9858 && endpoints.is_empty()
9859 && media_devices.is_empty()
9860 && sound_devices.is_empty()
9861 {
9862 findings.push(AuditFinding {
9863 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
9864 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(),
9865 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(),
9866 });
9867 }
9868
9869 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
9870 {
9871 let mut problem_labels = Vec::new();
9872 problem_labels.extend(
9873 endpoint_problems
9874 .iter()
9875 .take(3)
9876 .map(|device| device.name.clone()),
9877 );
9878 problem_labels.extend(
9879 media_problems
9880 .iter()
9881 .take(3)
9882 .map(|device| device.name.clone()),
9883 );
9884 problem_labels.extend(
9885 sound_problems
9886 .iter()
9887 .take(3)
9888 .map(|device| device.name.clone()),
9889 );
9890 findings.push(AuditFinding {
9891 finding: format!(
9892 "Windows reports audio device issues for: {}",
9893 problem_labels.join(", ")
9894 ),
9895 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
9896 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(),
9897 });
9898 }
9899
9900 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
9901 .iter()
9902 .copied()
9903 .filter(|service| !service_is_running(service))
9904 .collect();
9905 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
9906 let names = stopped_bt_audio_services
9907 .iter()
9908 .map(|service| service.name.as_str())
9909 .collect::<Vec<_>>()
9910 .join(", ");
9911 findings.push(AuditFinding {
9912 finding: format!(
9913 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
9914 ),
9915 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
9916 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
9917 });
9918 }
9919
9920 out.push_str("=== Findings ===\n");
9921 if findings.is_empty() {
9922 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
9923 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
9924 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");
9925 } else {
9926 for finding in &findings {
9927 out.push_str(&format!("- Finding: {}\n", finding.finding));
9928 out.push_str(&format!(" Impact: {}\n", finding.impact));
9929 out.push_str(&format!(" Fix: {}\n", finding.fix));
9930 }
9931 }
9932
9933 out.push_str("\n=== Audio services ===\n");
9934 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
9935 out.push_str(
9936 "- No Windows audio services were retrieved from the service inventory.\n",
9937 );
9938 } else {
9939 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
9940 out.push_str(&format!(
9941 "- {} | Status: {} | Startup: {}\n",
9942 service.name,
9943 service.status,
9944 service.startup.as_deref().unwrap_or("Unknown")
9945 ));
9946 }
9947 }
9948
9949 out.push_str("\n=== Playback and recording endpoints ===\n");
9950 if !probe_loaded {
9951 out.push_str("- Windows endpoint inventory probe returned no data.\n");
9952 } else if endpoints.is_empty() {
9953 out.push_str("- No audio endpoints detected.\n");
9954 } else {
9955 out.push_str(&format!(
9956 "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
9957 playback_endpoints.len(),
9958 recording_endpoints.len()
9959 ));
9960 for device in playback_endpoints.iter().take(n) {
9961 out.push_str(&format!(
9962 "- [PLAYBACK] {} | Status: {}{}\n",
9963 device.name,
9964 device.status,
9965 device
9966 .problem
9967 .filter(|problem| *problem != 0)
9968 .map(|problem| format!(" | ProblemCode: {problem}"))
9969 .unwrap_or_default()
9970 ));
9971 }
9972 for device in recording_endpoints.iter().take(n) {
9973 out.push_str(&format!(
9974 "- [MIC] {} | Status: {}{}\n",
9975 device.name,
9976 device.status,
9977 device
9978 .problem
9979 .filter(|problem| *problem != 0)
9980 .map(|problem| format!(" | ProblemCode: {problem}"))
9981 .unwrap_or_default()
9982 ));
9983 }
9984 }
9985
9986 out.push_str("\n=== Sound hardware devices ===\n");
9987 if sound_devices.is_empty() {
9988 out.push_str("- No Win32_SoundDevice entries were returned.\n");
9989 } else {
9990 for device in sound_devices.iter().take(n) {
9991 out.push_str(&format!(
9992 "- {} | Status: {}{}\n",
9993 device.name,
9994 device.status,
9995 device
9996 .manufacturer
9997 .as_deref()
9998 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
9999 .unwrap_or_default()
10000 ));
10001 }
10002 }
10003
10004 out.push_str("\n=== Media-class device inventory ===\n");
10005 if media_devices.is_empty() {
10006 out.push_str("- No media-class PnP devices were returned.\n");
10007 } else {
10008 for device in media_devices.iter().take(n) {
10009 out.push_str(&format!(
10010 "- {} | Status: {}{}\n",
10011 device.name,
10012 device.status,
10013 device
10014 .class_name
10015 .as_deref()
10016 .map(|class_name| format!(" | Class: {class_name}"))
10017 .unwrap_or_default()
10018 ));
10019 }
10020 }
10021 }
10022
10023 #[cfg(not(target_os = "windows"))]
10024 {
10025 let _ = max_entries;
10026 out.push_str(
10027 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10028 );
10029 out.push_str(
10030 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10031 );
10032 }
10033
10034 Ok(out.trim_end().to_string())
10035}
10036
10037fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10038 let mut out = String::from("Host inspection: bluetooth\n\n");
10039
10040 #[cfg(target_os = "windows")]
10041 {
10042 let n = max_entries.clamp(5, 20);
10043 let services = collect_services().unwrap_or_default();
10044 let bluetooth_services: Vec<&ServiceEntry> = services
10045 .iter()
10046 .filter(|entry| {
10047 entry.name.eq_ignore_ascii_case("bthserv")
10048 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10049 || entry.name.eq_ignore_ascii_case("BTAGService")
10050 || entry.name.starts_with("BluetoothUserService")
10051 || entry
10052 .display_name
10053 .as_deref()
10054 .unwrap_or("")
10055 .to_ascii_lowercase()
10056 .contains("bluetooth")
10057 })
10058 .collect();
10059
10060 let probe_script = r#"
10061$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10062 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10063$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10064 Where-Object {
10065 $_.Class -eq 'Bluetooth' -or
10066 $_.FriendlyName -match 'Bluetooth' -or
10067 $_.InstanceId -like 'BTH*'
10068 } |
10069 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10070$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10071 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10072 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10073[pscustomobject]@{
10074 Radios = $radios
10075 Devices = $devices
10076 AudioEndpoints = $audio
10077} | ConvertTo-Json -Compress -Depth 4
10078"#;
10079 let probe_raw = Command::new("powershell")
10080 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10081 .output()
10082 .ok()
10083 .and_then(|o| String::from_utf8(o.stdout).ok())
10084 .unwrap_or_default();
10085 let probe_loaded = !probe_raw.trim().is_empty();
10086 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10087
10088 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10089 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10090 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10091 let radio_problems: Vec<&WindowsPnpDevice> = radios
10092 .iter()
10093 .filter(|device| windows_device_has_issue(device))
10094 .collect();
10095 let device_problems: Vec<&WindowsPnpDevice> = devices
10096 .iter()
10097 .filter(|device| windows_device_has_issue(device))
10098 .collect();
10099
10100 let mut findings = Vec::new();
10101
10102 if probe_loaded && radios.is_empty() {
10103 findings.push(AuditFinding {
10104 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10105 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10106 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10107 });
10108 }
10109
10110 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10111 .iter()
10112 .copied()
10113 .filter(|service| !service_is_running(service))
10114 .collect();
10115 if !stopped_bluetooth_services.is_empty() {
10116 let names = stopped_bluetooth_services
10117 .iter()
10118 .map(|service| service.name.as_str())
10119 .collect::<Vec<_>>()
10120 .join(", ");
10121 findings.push(AuditFinding {
10122 finding: format!("Bluetooth-related services are not fully running: {names}"),
10123 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10124 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10125 });
10126 }
10127
10128 if !radio_problems.is_empty() || !device_problems.is_empty() {
10129 let problem_labels = radio_problems
10130 .iter()
10131 .chain(device_problems.iter())
10132 .take(5)
10133 .map(|device| device.name.as_str())
10134 .collect::<Vec<_>>()
10135 .join(", ");
10136 findings.push(AuditFinding {
10137 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10138 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10139 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(),
10140 });
10141 }
10142
10143 if !audio_endpoints.is_empty()
10144 && bluetooth_services
10145 .iter()
10146 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10147 && bluetooth_services
10148 .iter()
10149 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10150 .any(|service| !service_is_running(service))
10151 {
10152 findings.push(AuditFinding {
10153 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10154 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10155 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10156 });
10157 }
10158
10159 out.push_str("=== Findings ===\n");
10160 if findings.is_empty() {
10161 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10162 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10163 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");
10164 } else {
10165 for finding in &findings {
10166 out.push_str(&format!("- Finding: {}\n", finding.finding));
10167 out.push_str(&format!(" Impact: {}\n", finding.impact));
10168 out.push_str(&format!(" Fix: {}\n", finding.fix));
10169 }
10170 }
10171
10172 out.push_str("\n=== Bluetooth services ===\n");
10173 if bluetooth_services.is_empty() {
10174 out.push_str(
10175 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10176 );
10177 } else {
10178 for service in bluetooth_services.iter().take(n) {
10179 out.push_str(&format!(
10180 "- {} | Status: {} | Startup: {}\n",
10181 service.name,
10182 service.status,
10183 service.startup.as_deref().unwrap_or("Unknown")
10184 ));
10185 }
10186 }
10187
10188 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10189 if !probe_loaded {
10190 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10191 } else if radios.is_empty() {
10192 out.push_str("- No Bluetooth radios detected.\n");
10193 } else {
10194 for device in radios.iter().take(n) {
10195 out.push_str(&format!(
10196 "- {} | Status: {}{}\n",
10197 device.name,
10198 device.status,
10199 device
10200 .problem
10201 .filter(|problem| *problem != 0)
10202 .map(|problem| format!(" | ProblemCode: {problem}"))
10203 .unwrap_or_default()
10204 ));
10205 }
10206 }
10207
10208 out.push_str("\n=== Bluetooth-associated devices ===\n");
10209 if devices.is_empty() {
10210 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10211 } else {
10212 for device in devices.iter().take(n) {
10213 out.push_str(&format!(
10214 "- {} | Status: {}{}\n",
10215 device.name,
10216 device.status,
10217 device
10218 .class_name
10219 .as_deref()
10220 .map(|class_name| format!(" | Class: {class_name}"))
10221 .unwrap_or_default()
10222 ));
10223 }
10224 }
10225
10226 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10227 if audio_endpoints.is_empty() {
10228 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10229 } else {
10230 for device in audio_endpoints.iter().take(n) {
10231 out.push_str(&format!(
10232 "- {} | Status: {}{}\n",
10233 device.name,
10234 device.status,
10235 device
10236 .instance_id
10237 .as_deref()
10238 .map(|instance_id| format!(" | Instance: {instance_id}"))
10239 .unwrap_or_default()
10240 ));
10241 }
10242 }
10243 }
10244
10245 #[cfg(not(target_os = "windows"))]
10246 {
10247 let _ = max_entries;
10248 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10249 out.push_str(
10250 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10251 );
10252 }
10253
10254 Ok(out.trim_end().to_string())
10255}
10256
10257fn inspect_printers(max_entries: usize) -> Result<String, String> {
10258 let mut out = String::from("Host inspection: printers\n\n");
10259
10260 #[cfg(target_os = "windows")]
10261 {
10262 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)])
10263 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10264 if list.trim().is_empty() {
10265 out.push_str("No printers detected.\n");
10266 } else {
10267 out.push_str("=== Installed Printers ===\n");
10268 out.push_str(&list);
10269 }
10270
10271 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10272 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10273 if !jobs.trim().is_empty() {
10274 out.push_str("\n=== Active Print Jobs ===\n");
10275 out.push_str(&jobs);
10276 }
10277 }
10278
10279 #[cfg(not(target_os = "windows"))]
10280 {
10281 let _ = max_entries;
10282 out.push_str("Checking LPSTAT for printers...\n");
10283 let lpstat = Command::new("lpstat")
10284 .args(["-p", "-d"])
10285 .output()
10286 .ok()
10287 .and_then(|o| String::from_utf8(o.stdout).ok())
10288 .unwrap_or_default();
10289 if lpstat.is_empty() {
10290 out.push_str(" No CUPS/LP printers found.\n");
10291 } else {
10292 out.push_str(&lpstat);
10293 }
10294 }
10295
10296 Ok(out.trim_end().to_string())
10297}
10298
10299fn inspect_winrm() -> Result<String, String> {
10300 let mut out = String::from("Host inspection: winrm\n\n");
10301
10302 #[cfg(target_os = "windows")]
10303 {
10304 let svc = Command::new("powershell")
10305 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10306 .output()
10307 .ok()
10308 .and_then(|o| String::from_utf8(o.stdout).ok())
10309 .unwrap_or_default()
10310 .trim()
10311 .to_string();
10312 out.push_str(&format!(
10313 "WinRM Service Status: {}\n\n",
10314 if svc.is_empty() { "NOT_FOUND" } else { &svc }
10315 ));
10316
10317 out.push_str("=== WinRM Listeners ===\n");
10318 let output = Command::new("powershell")
10319 .args([
10320 "-NoProfile",
10321 "-Command",
10322 "winrm enumerate winrm/config/listener 2>$null",
10323 ])
10324 .output()
10325 .ok();
10326 if let Some(o) = output {
10327 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10328 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10329
10330 if !stdout.trim().is_empty() {
10331 for line in stdout.lines() {
10332 if line.contains("Address =")
10333 || line.contains("Transport =")
10334 || line.contains("Port =")
10335 {
10336 out.push_str(&format!(" {}\n", line.trim()));
10337 }
10338 }
10339 } else if stderr.contains("Access is denied") {
10340 out.push_str(" Error: Access denied to WinRM configuration.\n");
10341 } else {
10342 out.push_str(" No listeners configured.\n");
10343 }
10344 }
10345
10346 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10347 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))\" }"])
10348 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10349 if test_out.trim().is_empty() {
10350 out.push_str(" WinRM not responding to local WS-Man requests.\n");
10351 } else {
10352 out.push_str(&test_out);
10353 }
10354 }
10355
10356 #[cfg(not(target_os = "windows"))]
10357 {
10358 out.push_str(
10359 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10360 );
10361 let ss = Command::new("ss")
10362 .args(["-tln"])
10363 .output()
10364 .ok()
10365 .and_then(|o| String::from_utf8(o.stdout).ok())
10366 .unwrap_or_default();
10367 if ss.contains(":5985") || ss.contains(":5986") {
10368 out.push_str(" WinRM ports (5985/5986) are listening.\n");
10369 } else {
10370 out.push_str(" WinRM ports not detected.\n");
10371 }
10372 }
10373
10374 Ok(out.trim_end().to_string())
10375}
10376
10377fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10378 let mut out = String::from("Host inspection: network_stats\n\n");
10379
10380 #[cfg(target_os = "windows")]
10381 {
10382 let ps_cmd = format!(
10383 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10384 Start-Sleep -Milliseconds 250; \
10385 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10386 $s2 | ForEach-Object {{ \
10387 $name = $_.Name; \
10388 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10389 if ($prev) {{ \
10390 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10391 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10392 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10393 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10394 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10395 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10396 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10397 }} \
10398 }}",
10399 max_entries
10400 );
10401 let output = Command::new("powershell")
10402 .args(["-NoProfile", "-Command", &ps_cmd])
10403 .output()
10404 .ok()
10405 .and_then(|o| String::from_utf8(o.stdout).ok())
10406 .unwrap_or_default();
10407 if output.trim().is_empty() {
10408 out.push_str("No network adapter statistics available.\n");
10409 } else {
10410 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10411 out.push_str(&output);
10412 }
10413
10414 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)\" } }"])
10415 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10416 if !discards.trim().is_empty() {
10417 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10418 out.push_str(&discards);
10419 }
10420 }
10421
10422 #[cfg(not(target_os = "windows"))]
10423 {
10424 let _ = max_entries;
10425 out.push_str("=== Network Stats (ip -s link) ===\n");
10426 let ip_s = Command::new("ip")
10427 .args(["-s", "link"])
10428 .output()
10429 .ok()
10430 .and_then(|o| String::from_utf8(o.stdout).ok())
10431 .unwrap_or_default();
10432 if ip_s.is_empty() {
10433 let netstat = Command::new("netstat")
10434 .args(["-i"])
10435 .output()
10436 .ok()
10437 .and_then(|o| String::from_utf8(o.stdout).ok())
10438 .unwrap_or_default();
10439 out.push_str(&netstat);
10440 } else {
10441 out.push_str(&ip_s);
10442 }
10443 }
10444
10445 Ok(out.trim_end().to_string())
10446}
10447
10448fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10449 let mut out = String::from("Host inspection: udp_ports\n\n");
10450
10451 #[cfg(target_os = "windows")]
10452 {
10453 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);
10454 let output = Command::new("powershell")
10455 .args(["-NoProfile", "-Command", &ps_cmd])
10456 .output()
10457 .ok();
10458
10459 if let Some(o) = output {
10460 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10461 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10462
10463 if !stdout.trim().is_empty() {
10464 out.push_str("=== UDP Listeners (Local:Port) ===\n");
10465 for line in stdout.lines() {
10466 let mut note = "";
10467 if line.contains(":53 ") {
10468 note = " [DNS]";
10469 } else if line.contains(":67 ") || line.contains(":68 ") {
10470 note = " [DHCP]";
10471 } else if line.contains(":123 ") {
10472 note = " [NTP]";
10473 } else if line.contains(":161 ") {
10474 note = " [SNMP]";
10475 } else if line.contains(":1900 ") {
10476 note = " [SSDP/UPnP]";
10477 } else if line.contains(":5353 ") {
10478 note = " [mDNS]";
10479 }
10480
10481 out.push_str(&format!("{}{}\n", line, note));
10482 }
10483 } else if stderr.contains("Access is denied") {
10484 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10485 } else {
10486 out.push_str("No UDP listeners detected.\n");
10487 }
10488 }
10489 }
10490
10491 #[cfg(not(target_os = "windows"))]
10492 {
10493 let ss_out = Command::new("ss")
10494 .args(["-ulnp"])
10495 .output()
10496 .ok()
10497 .and_then(|o| String::from_utf8(o.stdout).ok())
10498 .unwrap_or_default();
10499 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10500 if ss_out.is_empty() {
10501 let netstat_out = Command::new("netstat")
10502 .args(["-ulnp"])
10503 .output()
10504 .ok()
10505 .and_then(|o| String::from_utf8(o.stdout).ok())
10506 .unwrap_or_default();
10507 if netstat_out.is_empty() {
10508 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
10509 } else {
10510 for line in netstat_out.lines().take(max_entries) {
10511 out.push_str(&format!(" {}\n", line));
10512 }
10513 }
10514 } else {
10515 for line in ss_out.lines().take(max_entries) {
10516 out.push_str(&format!(" {}\n", line));
10517 }
10518 }
10519 }
10520
10521 Ok(out.trim_end().to_string())
10522}
10523
10524fn inspect_gpo() -> Result<String, String> {
10525 let mut out = String::from("Host inspection: gpo\n\n");
10526
10527 #[cfg(target_os = "windows")]
10528 {
10529 let output = Command::new("gpresult")
10530 .args(["/r", "/scope", "computer"])
10531 .output()
10532 .ok();
10533
10534 if let Some(o) = output {
10535 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10536 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10537
10538 if stdout.contains("Applied Group Policy Objects") {
10539 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10540 let mut capture = false;
10541 for line in stdout.lines() {
10542 if line.contains("Applied Group Policy Objects") {
10543 capture = true;
10544 } else if capture && line.contains("The following GPOs were not applied") {
10545 break;
10546 }
10547 if capture && !line.trim().is_empty() {
10548 out.push_str(&format!(" {}\n", line.trim()));
10549 }
10550 }
10551 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10552 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10553 } else {
10554 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10555 }
10556 }
10557 }
10558
10559 #[cfg(not(target_os = "windows"))]
10560 {
10561 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10562 }
10563
10564 Ok(out.trim_end().to_string())
10565}
10566
10567fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10568 let mut out = String::from("Host inspection: certificates\n\n");
10569
10570 #[cfg(target_os = "windows")]
10571 {
10572 let ps_cmd = format!(
10573 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10574 $days = ($_.NotAfter - (Get-Date)).Days; \
10575 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10576 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10577 }}",
10578 max_entries
10579 );
10580 let output = Command::new("powershell")
10581 .args(["-NoProfile", "-Command", &ps_cmd])
10582 .output()
10583 .ok();
10584
10585 if let Some(o) = output {
10586 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10587 if !stdout.trim().is_empty() {
10588 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10589 out.push_str(&stdout);
10590 } else {
10591 out.push_str("No certificates found in the Local Machine Personal store.\n");
10592 }
10593 }
10594 }
10595
10596 #[cfg(not(target_os = "windows"))]
10597 {
10598 let _ = max_entries;
10599 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10600 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10602 if Path::new(path).exists() {
10603 out.push_str(&format!(" Cert directory found: {}\n", path));
10604 }
10605 }
10606 }
10607
10608 Ok(out.trim_end().to_string())
10609}
10610
10611fn inspect_integrity() -> Result<String, String> {
10612 let mut out = String::from("Host inspection: integrity\n\n");
10613
10614 #[cfg(target_os = "windows")]
10615 {
10616 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
10617 let output = Command::new("powershell")
10618 .args(["-NoProfile", "-Command", &ps_cmd])
10619 .output()
10620 .ok();
10621
10622 if let Some(o) = output {
10623 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10624 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10625 out.push_str("=== Windows Component Store Health (CBS) ===\n");
10626 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
10627 let repair = val
10628 .get("AutoRepairNeeded")
10629 .and_then(|v| v.as_u64())
10630 .unwrap_or(0);
10631
10632 out.push_str(&format!(
10633 " Corruption Detected: {}\n",
10634 if corrupt != 0 {
10635 "YES (SFC/DISM recommended)"
10636 } else {
10637 "No"
10638 }
10639 ));
10640 out.push_str(&format!(
10641 " Auto-Repair Needed: {}\n",
10642 if repair != 0 { "YES" } else { "No" }
10643 ));
10644
10645 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
10646 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
10647 }
10648 } else {
10649 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
10650 }
10651 }
10652
10653 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
10654 out.push_str(
10655 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
10656 );
10657 }
10658 }
10659
10660 #[cfg(not(target_os = "windows"))]
10661 {
10662 out.push_str("System integrity check (Linux)\n\n");
10663 let pkg_check = Command::new("rpm")
10664 .args(["-Va"])
10665 .output()
10666 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
10667 .ok();
10668 if let Some(o) = pkg_check {
10669 out.push_str(" Package verification system active.\n");
10670 if o.status.success() {
10671 out.push_str(" No major package integrity issues detected.\n");
10672 }
10673 }
10674 }
10675
10676 Ok(out.trim_end().to_string())
10677}
10678
10679fn inspect_domain() -> Result<String, String> {
10680 let mut out = String::from("Host inspection: domain\n\n");
10681
10682 #[cfg(target_os = "windows")]
10683 {
10684 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
10685 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
10686 let output = Command::new("powershell")
10687 .args(["-NoProfile", "-Command", &ps_cmd])
10688 .output()
10689 .ok();
10690
10691 if let Some(o) = output {
10692 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10693 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10694 let part_of_domain = val
10695 .get("PartOfDomain")
10696 .and_then(|v| v.as_bool())
10697 .unwrap_or(false);
10698 let domain = val
10699 .get("Domain")
10700 .and_then(|v| v.as_str())
10701 .unwrap_or("Unknown");
10702 let workgroup = val
10703 .get("Workgroup")
10704 .and_then(|v| v.as_str())
10705 .unwrap_or("Unknown");
10706
10707 out.push_str(&format!(
10708 " Join Status: {}\n",
10709 if part_of_domain {
10710 "DOMAIN JOINED"
10711 } else {
10712 "WORKGROUP"
10713 }
10714 ));
10715 if part_of_domain {
10716 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
10717 } else {
10718 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
10719 }
10720
10721 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
10722 out.push_str(&format!(" NetBIOS Name: {}\n", name));
10723 }
10724 } else {
10725 out.push_str(" Domain identity data unavailable from WMI.\n");
10726 }
10727 } else {
10728 out.push_str(" Domain identity data unavailable from WMI.\n");
10729 }
10730 }
10731
10732 #[cfg(not(target_os = "windows"))]
10733 {
10734 let domainname = Command::new("domainname")
10735 .output()
10736 .ok()
10737 .and_then(|o| String::from_utf8(o.stdout).ok())
10738 .unwrap_or_default();
10739 out.push_str("=== Linux Domain Identity ===\n");
10740 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
10741 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
10742 } else {
10743 out.push_str(" No NIS domain configured.\n");
10744 }
10745 }
10746
10747 Ok(out.trim_end().to_string())
10748}
10749
10750fn inspect_device_health() -> Result<String, String> {
10751 let mut out = String::from("Host inspection: device_health\n\n");
10752
10753 #[cfg(target_os = "windows")]
10754 {
10755 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)\" }";
10756 let output = Command::new("powershell")
10757 .args(["-NoProfile", "-Command", ps_cmd])
10758 .output()
10759 .ok()
10760 .and_then(|o| String::from_utf8(o.stdout).ok())
10761 .unwrap_or_default();
10762
10763 if output.trim().is_empty() {
10764 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
10765 } else {
10766 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
10767 out.push_str(&output);
10768 out.push_str(
10769 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
10770 );
10771 }
10772 }
10773
10774 #[cfg(not(target_os = "windows"))]
10775 {
10776 out.push_str("Checking dmesg for hardware errors...\n");
10777 let dmesg = Command::new("dmesg")
10778 .args(["--level=err,crit,alert"])
10779 .output()
10780 .ok()
10781 .and_then(|o| String::from_utf8(o.stdout).ok())
10782 .unwrap_or_default();
10783 if dmesg.is_empty() {
10784 out.push_str(" No critical hardware errors found in dmesg.\n");
10785 } else {
10786 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
10787 }
10788 }
10789
10790 Ok(out.trim_end().to_string())
10791}
10792
10793fn inspect_drivers(max_entries: usize) -> Result<String, String> {
10794 let mut out = String::from("Host inspection: drivers\n\n");
10795
10796 #[cfg(target_os = "windows")]
10797 {
10798 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
10799 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);
10800 let output = Command::new("powershell")
10801 .args(["-NoProfile", "-Command", &ps_cmd])
10802 .output()
10803 .ok()
10804 .and_then(|o| String::from_utf8(o.stdout).ok())
10805 .unwrap_or_default();
10806
10807 if output.trim().is_empty() {
10808 out.push_str(" No drivers retrieved via WMI.\n");
10809 } else {
10810 out.push_str(&output);
10811 }
10812 }
10813
10814 #[cfg(not(target_os = "windows"))]
10815 {
10816 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
10817 let lsmod = Command::new("lsmod")
10818 .output()
10819 .ok()
10820 .and_then(|o| String::from_utf8(o.stdout).ok())
10821 .unwrap_or_default();
10822 out.push_str(
10823 &lsmod
10824 .lines()
10825 .take(max_entries)
10826 .collect::<Vec<_>>()
10827 .join("\n"),
10828 );
10829 }
10830
10831 Ok(out.trim_end().to_string())
10832}
10833
10834fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
10835 let mut out = String::from("Host inspection: peripherals\n\n");
10836
10837 #[cfg(target_os = "windows")]
10838 {
10839 let _ = max_entries;
10840 out.push_str("=== USB Controllers & Hubs ===\n");
10841 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
10842 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10843 out.push_str(if usb.is_empty() {
10844 " None detected.\n"
10845 } else {
10846 &usb
10847 });
10848
10849 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
10850 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
10851 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10852 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
10853 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10854 out.push_str(&kb);
10855 out.push_str(&mouse);
10856
10857 out.push_str("\n=== Connected Monitors (WMI) ===\n");
10858 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
10859 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10860 out.push_str(if mon.is_empty() {
10861 " No active monitors identified via WMI.\n"
10862 } else {
10863 &mon
10864 });
10865 }
10866
10867 #[cfg(not(target_os = "windows"))]
10868 {
10869 out.push_str("=== Connected USB Devices (lsusb) ===\n");
10870 let lsusb = Command::new("lsusb")
10871 .output()
10872 .ok()
10873 .and_then(|o| String::from_utf8(o.stdout).ok())
10874 .unwrap_or_default();
10875 out.push_str(
10876 &lsusb
10877 .lines()
10878 .take(max_entries)
10879 .collect::<Vec<_>>()
10880 .join("\n"),
10881 );
10882 }
10883
10884 Ok(out.trim_end().to_string())
10885}
10886
10887fn inspect_sessions(max_entries: usize) -> Result<String, String> {
10888 let mut out = String::from("Host inspection: sessions\n\n");
10889
10890 #[cfg(target_os = "windows")]
10891 {
10892 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
10893 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
10894 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
10895}"#;
10896 if let Ok(o) = Command::new("powershell")
10897 .args(["-NoProfile", "-Command", script])
10898 .output()
10899 {
10900 let text = String::from_utf8_lossy(&o.stdout);
10901 let lines: Vec<&str> = text.lines().collect();
10902 if lines.is_empty() {
10903 out.push_str(" No active logon sessions enumerated via WMI.\n");
10904 } else {
10905 for line in lines
10906 .iter()
10907 .take(max_entries)
10908 .filter(|l| !l.trim().is_empty())
10909 {
10910 let parts: Vec<&str> = line.trim().split('|').collect();
10911 if parts.len() == 4 {
10912 let logon_type = match parts[2] {
10913 "2" => "Interactive",
10914 "3" => "Network",
10915 "4" => "Batch",
10916 "5" => "Service",
10917 "7" => "Unlock",
10918 "8" => "NetworkCleartext",
10919 "9" => "NewCredentials",
10920 "10" => "RemoteInteractive",
10921 "11" => "CachedInteractive",
10922 _ => "Other",
10923 };
10924 out.push_str(&format!(
10925 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
10926 parts[0], logon_type, parts[1], parts[3]
10927 ));
10928 }
10929 }
10930 }
10931 } else {
10932 out.push_str(" Active logon session data unavailable from WMI.\n");
10933 }
10934 }
10935
10936 #[cfg(not(target_os = "windows"))]
10937 {
10938 out.push_str("=== Logged-in Users (who) ===\n");
10939 let who = Command::new("who")
10940 .output()
10941 .ok()
10942 .and_then(|o| String::from_utf8(o.stdout).ok())
10943 .unwrap_or_default();
10944 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
10945 }
10946
10947 Ok(out.trim_end().to_string())
10948}
10949
10950async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
10951 let mut out = String::from("Host inspection: disk_benchmark\n\n");
10952 let mut final_path = path;
10953
10954 if !final_path.exists() {
10955 if let Ok(current_exe) = std::env::current_exe() {
10956 out.push_str(&format!(
10957 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
10958 final_path.display()
10959 ));
10960 final_path = current_exe;
10961 } else {
10962 return Err(format!("Target not found: {}", final_path.display()));
10963 }
10964 }
10965
10966 let target = if final_path.is_dir() {
10967 let mut target_file = final_path.join("Cargo.toml");
10969 if !target_file.exists() {
10970 target_file = final_path.join("README.md");
10971 }
10972 if !target_file.exists() {
10973 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
10974 }
10975 target_file
10976 } else {
10977 final_path
10978 };
10979
10980 out.push_str(&format!("Target: {}\n", target.display()));
10981 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
10982
10983 #[cfg(target_os = "windows")]
10984 {
10985 let script = format!(
10986 r#"
10987$target = "{}"
10988if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
10989
10990$diskQueue = @()
10991$readStats = @()
10992$startTime = Get-Date
10993$duration = 5
10994
10995# Background reader job
10996$job = Start-Job -ScriptBlock {{
10997 param($t, $d)
10998 $stop = (Get-Date).AddSeconds($d)
10999 while ((Get-Date) -lt $stop) {{
11000 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11001 }}
11002}} -ArgumentList $target, $duration
11003
11004# Metrics collector loop
11005$stopTime = (Get-Date).AddSeconds($duration)
11006while ((Get-Date) -lt $stopTime) {{
11007 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11008 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11009
11010 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11011 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11012
11013 Start-Sleep -Milliseconds 250
11014}}
11015
11016Stop-Job $job
11017Receive-Job $job | Out-Null
11018Remove-Job $job
11019
11020$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11021$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11022$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11023
11024"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11025"#,
11026 target.display()
11027 );
11028
11029 let output = Command::new("powershell")
11030 .args(["-NoProfile", "-Command", &script])
11031 .output()
11032 .map_err(|e| format!("Benchmark failed: {e}"))?;
11033
11034 let raw = String::from_utf8_lossy(&output.stdout);
11035 let text = raw.trim();
11036
11037 if text.starts_with("ERROR") {
11038 return Err(text.to_string());
11039 }
11040
11041 let mut lines = text.lines();
11042 if let Some(metrics_line) = lines.next() {
11043 let parts: Vec<&str> = metrics_line.split('|').collect();
11044 let mut avg_q = "unknown".to_string();
11045 let mut max_q = "unknown".to_string();
11046 let mut avg_r = "unknown".to_string();
11047
11048 for p in parts {
11049 if let Some((k, v)) = p.split_once(':') {
11050 match k {
11051 "AVG_Q" => avg_q = v.to_string(),
11052 "MAX_Q" => max_q = v.to_string(),
11053 "AVG_R" => avg_r = v.to_string(),
11054 _ => {}
11055 }
11056 }
11057 }
11058
11059 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11060 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11061 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11062 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
11063 out.push_str("\nVerdict: ");
11064 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11065 if q_num > 1.0 {
11066 out.push_str(
11067 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11068 );
11069 } else if q_num > 0.1 {
11070 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11071 } else {
11072 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11073 }
11074 }
11075 }
11076
11077 #[cfg(not(target_os = "windows"))]
11078 {
11079 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11080 out.push_str("Generic disk load simulated.\n");
11081 }
11082
11083 Ok(out)
11084}
11085
11086fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11087 let mut out = String::from("Host inspection: permissions\n\n");
11088 out.push_str(&format!(
11089 "Auditing access control for: {}\n\n",
11090 path.display()
11091 ));
11092
11093 #[cfg(target_os = "windows")]
11094 {
11095 let script = format!(
11096 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11097 path.display()
11098 );
11099 let output = Command::new("powershell")
11100 .args(["-NoProfile", "-Command", &script])
11101 .output()
11102 .map_err(|e| format!("ACL check failed: {e}"))?;
11103
11104 let text = String::from_utf8_lossy(&output.stdout);
11105 if text.trim().is_empty() {
11106 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11107 } else {
11108 out.push_str("=== Windows NTFS Permissions ===\n");
11109 out.push_str(&text);
11110 }
11111 }
11112
11113 #[cfg(not(target_os = "windows"))]
11114 {
11115 let output = Command::new("ls")
11116 .args(["-ld", &path.to_string_lossy()])
11117 .output()
11118 .map_err(|e| format!("ls check failed: {e}"))?;
11119 out.push_str("=== Unix File Permissions ===\n");
11120 out.push_str(&String::from_utf8_lossy(&output.stdout));
11121 }
11122
11123 Ok(out.trim_end().to_string())
11124}
11125
11126fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11127 let mut out = String::from("Host inspection: login_history\n\n");
11128
11129 #[cfg(target_os = "windows")]
11130 {
11131 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11132 out.push_str("Note: This typically requires Administrator elevation.\n\n");
11133
11134 let n = max_entries.clamp(1, 50);
11135 let script = format!(
11136 r#"try {{
11137 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11138 $events | ForEach-Object {{
11139 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11140 # Extract target user name from the XML/Properties if possible
11141 $user = $_.Properties[5].Value
11142 $type = $_.Properties[8].Value
11143 "[$time] User: $user | Type: $type"
11144 }}
11145}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11146 );
11147
11148 let output = Command::new("powershell")
11149 .args(["-NoProfile", "-Command", &script])
11150 .output()
11151 .map_err(|e| format!("Login history query failed: {e}"))?;
11152
11153 let text = String::from_utf8_lossy(&output.stdout);
11154 if text.starts_with("ERROR:") {
11155 out.push_str(&format!("Unable to query Security Log: {}\n", text));
11156 } else if text.trim().is_empty() {
11157 out.push_str("No recent logon events found or access denied.\n");
11158 } else {
11159 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11160 out.push_str(&text);
11161 }
11162 }
11163
11164 #[cfg(not(target_os = "windows"))]
11165 {
11166 let output = Command::new("last")
11167 .args(["-n", &max_entries.to_string()])
11168 .output()
11169 .map_err(|e| format!("last command failed: {e}"))?;
11170 out.push_str("=== Unix Login History (last) ===\n");
11171 out.push_str(&String::from_utf8_lossy(&output.stdout));
11172 }
11173
11174 Ok(out.trim_end().to_string())
11175}
11176
11177fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11178 let mut out = String::from("Host inspection: share_access\n\n");
11179 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11180
11181 #[cfg(target_os = "windows")]
11182 {
11183 let script = format!(
11184 r#"
11185$p = '{}'
11186$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11187if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11188 $res.Reachable = $true
11189 try {{
11190 $null = Get-ChildItem -Path $p -ErrorAction Stop
11191 $res.Readable = $true
11192 }} catch {{
11193 $res.Error = $_.Exception.Message
11194 }}
11195}} else {{
11196 $res.Error = "Server unreachable (Ping failed)"
11197}}
11198"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11199 path.display()
11200 );
11201
11202 let output = Command::new("powershell")
11203 .args(["-NoProfile", "-Command", &script])
11204 .output()
11205 .map_err(|e| format!("Share test failed: {e}"))?;
11206
11207 let text = String::from_utf8_lossy(&output.stdout);
11208 out.push_str("=== Share Triage Results ===\n");
11209 out.push_str(&text);
11210 }
11211
11212 #[cfg(not(target_os = "windows"))]
11213 {
11214 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11215 }
11216
11217 Ok(out.trim_end().to_string())
11218}
11219
11220fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11221 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11222 out.push_str(&format!("Issue: {}\n\n", issue));
11223 out.push_str("Proposed Remediation Steps:\n");
11224 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11225 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11226 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11227 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11228 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11229 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11230 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11231 out.push_str(
11232 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11233 );
11234
11235 Ok(out)
11236}
11237
11238fn inspect_registry_audit() -> Result<String, String> {
11239 let mut out = String::from("Host inspection: registry_audit\n\n");
11240 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11241
11242 #[cfg(target_os = "windows")]
11243 {
11244 let script = r#"
11245$findings = @()
11246
11247# 1. Image File Execution Options (Debugger Hijacking)
11248$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11249if (Test-Path $ifeo) {
11250 Get-ChildItem $ifeo | ForEach-Object {
11251 $p = Get-ItemProperty $_.PSPath
11252 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11253 }
11254}
11255
11256# 2. Winlogon Shell Integrity
11257$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11258$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11259if ($shell -and $shell -ne "explorer.exe") {
11260 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11261}
11262
11263# 3. Session Manager BootExecute
11264$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11265$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11266if ($boot -and $boot -notcontains "autocheck autochk *") {
11267 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11268}
11269
11270if ($findings.Count -eq 0) {
11271 "PASS: No common registry hijacking or shell overrides detected."
11272} else {
11273 $findings -join "`n"
11274}
11275"#;
11276 let output = Command::new("powershell")
11277 .args(["-NoProfile", "-Command", &script])
11278 .output()
11279 .map_err(|e| format!("Registry audit failed: {e}"))?;
11280
11281 let text = String::from_utf8_lossy(&output.stdout);
11282 out.push_str("=== Persistence & Integrity Check ===\n");
11283 out.push_str(&text);
11284 }
11285
11286 #[cfg(not(target_os = "windows"))]
11287 {
11288 out.push_str("Registry auditing is specific to Windows environments.\n");
11289 }
11290
11291 Ok(out.trim_end().to_string())
11292}
11293
11294fn inspect_thermal() -> Result<String, String> {
11295 let mut out = String::from("Host inspection: thermal\n\n");
11296 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11297
11298 #[cfg(target_os = "windows")]
11299 {
11300 let script = r#"
11301$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11302if ($thermal) {
11303 $thermal | ForEach-Object {
11304 $temp = [math]::Round(($_.Temperature - 273.15), 1)
11305 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11306 }
11307} else {
11308 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11309 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11310 "Current CPU Load: $throttling%"
11311}
11312"#;
11313 let output = Command::new("powershell")
11314 .args(["-NoProfile", "-Command", script])
11315 .output()
11316 .map_err(|e| format!("Thermal check failed: {e}"))?;
11317 out.push_str("=== Windows Thermal State ===\n");
11318 out.push_str(&String::from_utf8_lossy(&output.stdout));
11319 }
11320
11321 #[cfg(not(target_os = "windows"))]
11322 {
11323 out.push_str(
11324 "Thermal inspection is currently optimized for Windows performance counters.\n",
11325 );
11326 }
11327
11328 Ok(out.trim_end().to_string())
11329}
11330
11331fn inspect_activation() -> Result<String, String> {
11332 let mut out = String::from("Host inspection: activation\n\n");
11333 out.push_str("Auditing Windows activation and license state...\n\n");
11334
11335 #[cfg(target_os = "windows")]
11336 {
11337 let script = r#"
11338$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11339$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11340"Status: $($xpr.Trim())"
11341"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11342"#;
11343 let output = Command::new("powershell")
11344 .args(["-NoProfile", "-Command", script])
11345 .output()
11346 .map_err(|e| format!("Activation check failed: {e}"))?;
11347 out.push_str("=== Windows License Report ===\n");
11348 out.push_str(&String::from_utf8_lossy(&output.stdout));
11349 }
11350
11351 #[cfg(not(target_os = "windows"))]
11352 {
11353 out.push_str("Windows activation check is specific to the Windows platform.\n");
11354 }
11355
11356 Ok(out.trim_end().to_string())
11357}
11358
11359fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11360 let mut out = String::from("Host inspection: patch_history\n\n");
11361 out.push_str(&format!(
11362 "Listing the last {} installed Windows updates (KBs)...\n\n",
11363 max_entries
11364 ));
11365
11366 #[cfg(target_os = "windows")]
11367 {
11368 let n = max_entries.clamp(1, 50);
11369 let script = format!(
11370 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11371 n
11372 );
11373 let output = Command::new("powershell")
11374 .args(["-NoProfile", "-Command", &script])
11375 .output()
11376 .map_err(|e| format!("Patch history query failed: {e}"))?;
11377 out.push_str("=== Recent HotFixes (KBs) ===\n");
11378 out.push_str(&String::from_utf8_lossy(&output.stdout));
11379 }
11380
11381 #[cfg(not(target_os = "windows"))]
11382 {
11383 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11384 }
11385
11386 Ok(out.trim_end().to_string())
11387}
11388
11389fn inspect_ad_user(identity: &str) -> Result<String, String> {
11392 let mut out = String::from("Host inspection: ad_user\n\n");
11393 let ident = identity.trim();
11394 if ident.is_empty() {
11395 out.push_str("Status: No identity specified. Performing self-discovery...\n");
11396 #[cfg(target_os = "windows")]
11397 {
11398 let script = r#"
11399$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11400"USER: " + $u.Name
11401"SID: " + $u.User.Value
11402"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11403"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11404"#;
11405 let output = Command::new("powershell")
11406 .args(["-NoProfile", "-Command", script])
11407 .output()
11408 .ok();
11409 if let Some(o) = output {
11410 out.push_str(&String::from_utf8_lossy(&o.stdout));
11411 }
11412 }
11413 return Ok(out);
11414 }
11415
11416 #[cfg(target_os = "windows")]
11417 {
11418 let script = format!(
11419 r#"
11420try {{
11421 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11422 "NAME: " + $u.Name
11423 "SID: " + $u.SID
11424 "ENABLED: " + $u.Enabled
11425 "EXPIRED: " + $u.PasswordExpired
11426 "LOGON: " + $u.LastLogonDate
11427 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11428}} catch {{
11429 # Fallback to net user if AD module is missing or fails
11430 $net = net user "{ident}" /domain 2>&1
11431 if ($LASTEXITCODE -eq 0) {{
11432 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11433 }} else {{
11434 "ERROR: " + $_.Exception.Message
11435 }}
11436}}"#
11437 );
11438
11439 let output = Command::new("powershell")
11440 .args(["-NoProfile", "-Command", &script])
11441 .output()
11442 .ok();
11443
11444 if let Some(o) = output {
11445 let stdout = String::from_utf8_lossy(&o.stdout);
11446 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11447 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11448 }
11449 out.push_str(&stdout);
11450 }
11451 }
11452
11453 #[cfg(not(target_os = "windows"))]
11454 {
11455 let _ = ident;
11456 out.push_str("(AD User lookup only available on Windows nodes)\n");
11457 }
11458
11459 Ok(out.trim_end().to_string())
11460}
11461
11462fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11465 let mut out = String::from("Host inspection: dns_lookup\n\n");
11466 let target = name.trim();
11467 if target.is_empty() {
11468 return Err("Missing required target name for dns_lookup.".to_string());
11469 }
11470
11471 #[cfg(target_os = "windows")]
11472 {
11473 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11474 let output = Command::new("powershell")
11475 .args(["-NoProfile", "-Command", &script])
11476 .output()
11477 .ok();
11478 if let Some(o) = output {
11479 let stdout = String::from_utf8_lossy(&o.stdout);
11480 if stdout.trim().is_empty() {
11481 out.push_str(&format!("No {record_type} records found for {target}.\n"));
11482 } else {
11483 out.push_str(&stdout);
11484 }
11485 }
11486 }
11487
11488 #[cfg(not(target_os = "windows"))]
11489 {
11490 let output = Command::new("dig")
11491 .args([target, record_type, "+short"])
11492 .output()
11493 .ok();
11494 if let Some(o) = output {
11495 out.push_str(&String::from_utf8_lossy(&o.stdout));
11496 }
11497 }
11498
11499 Ok(out.trim_end().to_string())
11500}
11501
11502#[cfg(target_os = "windows")]
11505fn ps_exec(script: &str) -> String {
11506 Command::new("powershell")
11507 .args(["-NoProfile", "-NonInteractive", "-Command", script])
11508 .output()
11509 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
11510 .unwrap_or_default()
11511}
11512
11513fn inspect_hyperv() -> Result<String, String> {
11514 #[cfg(target_os = "windows")]
11515 {
11516 let mut findings: Vec<String> = Vec::new();
11517 let mut out = String::new();
11518
11519 let ps_role = r#"
11521$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
11522$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
11523$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
11524$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
11525"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
11526 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
11527 $(if ($feature) { $feature.State } else { "Unknown" }),
11528 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
11529 $(if ($ram) { $ram } else { "0" })
11530"#;
11531 let role_out = ps_exec(ps_role);
11532 out.push_str("=== Hyper-V role state ===\n");
11533
11534 let mut vmms_running = false;
11535 let mut host_ram_bytes: u64 = 0;
11536
11537 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
11538 let kv: std::collections::HashMap<&str, &str> = line
11539 .split('|')
11540 .filter_map(|p| {
11541 let mut it = p.splitn(2, ':');
11542 Some((it.next()?, it.next()?))
11543 })
11544 .collect();
11545 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
11546 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
11547 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
11548 host_ram_bytes = kv
11549 .get("HostRAMBytes")
11550 .and_then(|v| v.parse().ok())
11551 .unwrap_or(0);
11552
11553 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
11554 vmms_running = vmms_status.starts_with("Running");
11555
11556 out.push_str(&format!("- Host: {host_name}\n"));
11557 out.push_str(&format!(
11558 "- Hyper-V feature: {}\n",
11559 if hyperv_installed {
11560 "Enabled"
11561 } else {
11562 "Not installed"
11563 }
11564 ));
11565 out.push_str(&format!("- VMMS service: {vmms_status}\n"));
11566 if host_ram_bytes > 0 {
11567 out.push_str(&format!(
11568 "- Host physical RAM: {} GB\n",
11569 host_ram_bytes / 1_073_741_824
11570 ));
11571 }
11572
11573 if !hyperv_installed {
11574 findings.push(
11575 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
11576 );
11577 } else if !vmms_running {
11578 findings.push(
11579 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
11580 );
11581 }
11582 } else {
11583 out.push_str("- Could not determine Hyper-V role state\n");
11584 findings.push("Hyper-V does not appear to be installed on this machine.".into());
11585 }
11586
11587 out.push_str("\n=== Virtual machines ===\n");
11589 if vmms_running {
11590 let ps_vms = r#"
11591Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
11592 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
11593 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
11594 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
11595 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
11596 $_.Status, $_.Generation
11597}
11598"#;
11599 let vms_out = ps_exec(ps_vms);
11600 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
11601
11602 if vm_lines.is_empty() {
11603 out.push_str("- No virtual machines found on this host\n");
11604 } else {
11605 let mut total_ram_bytes: u64 = 0;
11606 let mut saved_vms: Vec<String> = Vec::new();
11607 for line in &vm_lines {
11608 let kv: std::collections::HashMap<&str, &str> = line
11609 .split('|')
11610 .filter_map(|p| {
11611 let mut it = p.splitn(2, ':');
11612 Some((it.next()?, it.next()?))
11613 })
11614 .collect();
11615 let name = kv.get("VM").copied().unwrap_or("Unknown");
11616 let state = kv.get("State").copied().unwrap_or("Unknown");
11617 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
11618 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
11619 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
11620 let status = kv.get("Status").copied().unwrap_or("");
11621 let gen = kv.get("Generation").copied().unwrap_or("?");
11622
11623 if let Ok(r) = ram.parse::<f64>() {
11624 total_ram_bytes += (r * 1_073_741_824.0) as u64;
11625 }
11626 if state.eq_ignore_ascii_case("Saved") {
11627 saved_vms.push(name.to_string());
11628 }
11629
11630 out.push_str(&format!(
11631 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
11632 ));
11633 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
11634 out.push_str(&format!(" Status: {status}\n"));
11635 }
11636 }
11637
11638 out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
11639 if total_ram_bytes > 0 && host_ram_bytes > 0 {
11640 let pct = (total_ram_bytes * 100) / host_ram_bytes;
11641 out.push_str(&format!(
11642 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
11643 total_ram_bytes / 1_073_741_824
11644 ));
11645 if pct > 90 {
11646 findings.push(format!(
11647 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
11648 ));
11649 }
11650 }
11651 if !saved_vms.is_empty() {
11652 findings.push(format!(
11653 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
11654 saved_vms.join(", ")
11655 ));
11656 }
11657 }
11658 } else {
11659 out.push_str("- VMMS not running — cannot enumerate VMs\n");
11660 }
11661
11662 out.push_str("\n=== VM network switches ===\n");
11664 if vmms_running {
11665 let ps_switches = r#"
11666Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
11667 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
11668 $_.Name, $_.SwitchType,
11669 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
11670}
11671"#;
11672 let sw_out = ps_exec(ps_switches);
11673 let switch_lines: Vec<&str> = sw_out
11674 .lines()
11675 .filter(|l| l.starts_with("Switch:"))
11676 .collect();
11677
11678 if switch_lines.is_empty() {
11679 out.push_str("- No VM switches configured\n");
11680 } else {
11681 for line in &switch_lines {
11682 let kv: std::collections::HashMap<&str, &str> = line
11683 .split('|')
11684 .filter_map(|p| {
11685 let mut it = p.splitn(2, ':');
11686 Some((it.next()?, it.next()?))
11687 })
11688 .collect();
11689 let name = kv.get("Switch").copied().unwrap_or("Unknown");
11690 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
11691 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
11692 out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
11693 }
11694 }
11695 } else {
11696 out.push_str("- VMMS not running — cannot enumerate switches\n");
11697 }
11698
11699 out.push_str("\n=== VM checkpoints ===\n");
11701 if vmms_running {
11702 let ps_checkpoints = r#"
11703$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
11704if ($all) {
11705 $all | ForEach-Object {
11706 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
11707 $_.Name, $_.VMName,
11708 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
11709 $_.SnapshotType
11710 }
11711} else {
11712 "NONE"
11713}
11714"#;
11715 let cp_out = ps_exec(ps_checkpoints);
11716 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
11717 out.push_str("- No checkpoints found\n");
11718 } else {
11719 let cp_lines: Vec<&str> = cp_out
11720 .lines()
11721 .filter(|l| l.starts_with("Checkpoint:"))
11722 .collect();
11723 let mut per_vm: std::collections::HashMap<&str, usize> =
11724 std::collections::HashMap::new();
11725 for line in &cp_lines {
11726 let kv: std::collections::HashMap<&str, &str> = line
11727 .split('|')
11728 .filter_map(|p| {
11729 let mut it = p.splitn(2, ':');
11730 Some((it.next()?, it.next()?))
11731 })
11732 .collect();
11733 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
11734 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
11735 let created = kv.get("Created").copied().unwrap_or("");
11736 let cp_type = kv.get("Type").copied().unwrap_or("");
11737 out.push_str(&format!(
11738 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
11739 ));
11740 *per_vm.entry(vm_name).or_insert(0) += 1;
11741 }
11742 for (vm, count) in &per_vm {
11743 if *count >= 3 {
11744 findings.push(format!(
11745 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
11746 ));
11747 }
11748 }
11749 }
11750 } else {
11751 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
11752 }
11753
11754 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
11755 if findings.is_empty() {
11756 result.push_str("- No Hyper-V health issues detected.\n");
11757 } else {
11758 for f in &findings {
11759 result.push_str(&format!("- Finding: {f}\n"));
11760 }
11761 }
11762 result.push('\n');
11763 result.push_str(&out);
11764 return Ok(result.trim_end().to_string());
11765 }
11766
11767 #[cfg(not(target_os = "windows"))]
11768 Ok(
11769 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
11770 .into(),
11771 )
11772}
11773
11774fn inspect_ip_config() -> Result<String, String> {
11777 let mut out = String::from("Host inspection: ip_config\n\n");
11778
11779 #[cfg(target_os = "windows")]
11780 {
11781 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
11782 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
11783 '\\n Status: ' + $_.NetAdapter.Status + \
11784 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
11785 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
11786 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11787 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11788 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
11789 }";
11790 let output = Command::new("powershell")
11791 .args(["-NoProfile", "-Command", script])
11792 .output()
11793 .ok();
11794 if let Some(o) = output {
11795 out.push_str(&String::from_utf8_lossy(&o.stdout));
11796 }
11797 }
11798
11799 #[cfg(not(target_os = "windows"))]
11800 {
11801 let output = Command::new("ip").args(["addr", "show"]).output().ok();
11802 if let Some(o) = output {
11803 out.push_str(&String::from_utf8_lossy(&o.stdout));
11804 }
11805 }
11806
11807 Ok(out.trim_end().to_string())
11808}
11809
11810fn inspect_event_query(
11813 event_id: Option<u32>,
11814 log_name: Option<&str>,
11815 source: Option<&str>,
11816 hours: u32,
11817 level: Option<&str>,
11818 max_entries: usize,
11819) -> Result<String, String> {
11820 #[cfg(target_os = "windows")]
11821 {
11822 let mut findings: Vec<String> = Vec::new();
11823
11824 let log = log_name.unwrap_or("*");
11826 let cap = max_entries.min(50);
11827
11828 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
11830 Some("error") | Some("errors") => Some(2u8),
11831 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
11832 Some("information") | Some("info") => Some(4u8),
11833 _ => None,
11834 };
11835
11836 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
11838 if log != "*" {
11839 filter_parts.push(format!("LogName = '{log}'"));
11840 }
11841 if let Some(id) = event_id {
11842 filter_parts.push(format!("Id = {id}"));
11843 }
11844 if let Some(src) = source {
11845 filter_parts.push(format!("ProviderName = '{src}'"));
11846 }
11847 if let Some(lvl) = level_filter {
11848 filter_parts.push(format!("Level = {lvl}"));
11849 }
11850
11851 let filter_ht = filter_parts.join("; ");
11852
11853 let ps = format!(
11854 r#"
11855$filter = @{{ {filter_ht} }}
11856try {{
11857 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
11858 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
11859 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
11860 if ($events) {{
11861 $events | ForEach-Object {{
11862 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
11863 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
11864 $_.Id, $_.LevelDisplayName, $_.ProviderName,
11865 ($_.Msg -replace '\|','/')
11866 }}
11867 }} else {{
11868 "NONE"
11869 }}
11870}} catch {{
11871 "ERROR:$($_.Exception.Message)"
11872}}
11873"#
11874 );
11875
11876 let raw = ps_exec(&ps);
11877 let lines: Vec<&str> = raw.lines().collect();
11878
11879 let mut query_desc = format!("last {hours}h");
11881 if let Some(id) = event_id {
11882 query_desc.push_str(&format!(", Event ID {id}"));
11883 }
11884 if let Some(src) = source {
11885 query_desc.push_str(&format!(", source '{src}'"));
11886 }
11887 if log != "*" {
11888 query_desc.push_str(&format!(", log '{log}'"));
11889 }
11890 if let Some(l) = level {
11891 query_desc.push_str(&format!(", level '{l}'"));
11892 }
11893
11894 let mut out = format!("=== Event query: {query_desc} ===\n");
11895
11896 if lines
11897 .iter()
11898 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
11899 {
11900 out.push_str("- No matching events found.\n");
11901 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
11902 let msg = err_line.trim_start_matches("ERROR:").trim();
11903 if is_event_query_no_results_message(msg) {
11904 out.push_str("- No matching events found.\n");
11905 } else {
11906 out.push_str(&format!("- Query error: {msg}\n"));
11907 findings.push(format!("Event query failed: {msg}"));
11908 }
11909 } else {
11910 let event_lines: Vec<&str> = lines
11911 .iter()
11912 .filter(|l| l.starts_with("TIME:"))
11913 .copied()
11914 .collect();
11915 if event_lines.is_empty() {
11916 out.push_str("- No matching events found.\n");
11917 } else {
11918 let mut error_count = 0usize;
11920 let mut warning_count = 0usize;
11921
11922 for line in &event_lines {
11923 let kv: std::collections::HashMap<&str, &str> = line
11924 .split('|')
11925 .filter_map(|p| {
11926 let mut it = p.splitn(2, ':');
11927 Some((it.next()?, it.next()?))
11928 })
11929 .collect();
11930 let time = kv.get("TIME").copied().unwrap_or("?");
11931 let id = kv.get("ID").copied().unwrap_or("?");
11932 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
11933 let src = kv.get("SOURCE").copied().unwrap_or("?");
11934 let msg = kv.get("MSG").copied().unwrap_or("").trim();
11935
11936 let msg_display = if msg.len() > 120 {
11938 format!("{}…", &msg[..120])
11939 } else {
11940 msg.to_string()
11941 };
11942
11943 out.push_str(&format!(
11944 "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n"
11945 ));
11946
11947 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
11948 error_count += 1;
11949 } else if lvl.eq_ignore_ascii_case("warning") {
11950 warning_count += 1;
11951 }
11952 }
11953
11954 out.push_str(&format!(
11955 "\n- Total shown: {} event(s)\n",
11956 event_lines.len()
11957 ));
11958
11959 if error_count > 0 {
11960 findings.push(format!(
11961 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
11962 ));
11963 }
11964 if warning_count > 5 {
11965 findings.push(format!(
11966 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
11967 ));
11968 }
11969 }
11970 }
11971
11972 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
11973 if findings.is_empty() {
11974 result.push_str("- No actionable findings from this event query.\n");
11975 } else {
11976 for f in &findings {
11977 result.push_str(&format!("- Finding: {f}\n"));
11978 }
11979 }
11980 result.push('\n');
11981 result.push_str(&out);
11982 return Ok(result.trim_end().to_string());
11983 }
11984
11985 #[cfg(not(target_os = "windows"))]
11986 {
11987 let _ = (event_id, log_name, source, hours, level, max_entries);
11988 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
11989 }
11990}
11991
11992fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
11995 let n = max_entries.clamp(5, 50);
11996 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
11997 let mut findings: Vec<String> = Vec::new();
11998 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
11999 let mut sections = String::new();
12000
12001 #[cfg(target_os = "windows")]
12002 {
12003 let proc_filter_ps = match process_filter {
12004 Some(proc) => format!(
12005 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12006 proc.replace('\'', "''")
12007 ),
12008 None => String::new(),
12009 };
12010
12011 let ps = format!(
12012 r#"
12013$results = @()
12014try {{
12015 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12016 if ($events) {{
12017 foreach ($e in $events) {{
12018 $msg = $e.Message
12019 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12020 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12021 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12022 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12023 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12024 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12025 }}
12026 $results
12027 }} else {{ 'NONE' }}
12028}} catch {{ 'ERROR:' + $_.Exception.Message }}
12029"#
12030 );
12031
12032 let raw = ps_exec(&ps);
12033 let text = raw.trim();
12034
12035 let wer_ps = r#"
12037$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12038$count = 0
12039if (Test-Path $wer) {
12040 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12041}
12042$count
12043"#;
12044 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12045
12046 if text == "NONE" {
12047 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12048 } else if text.starts_with("ERROR:") {
12049 let msg = text.trim_start_matches("ERROR:").trim();
12050 sections.push_str(&format!(
12051 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12052 ));
12053 } else {
12054 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12055 let crash_count = events
12056 .iter()
12057 .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12058 .count();
12059 let hang_count = events
12060 .iter()
12061 .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12062 .count();
12063
12064 let mut app_counts: std::collections::HashMap<String, usize> =
12066 std::collections::HashMap::new();
12067 for line in &events {
12068 let parts: Vec<&str> = line.splitn(6, '|').collect();
12069 if parts.len() >= 3 {
12070 *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12071 }
12072 }
12073
12074 if crash_count > 0 {
12075 findings.push(format!(
12076 "{crash_count} application crash event(s) — review below for faulting app and exception code."
12077 ));
12078 }
12079 if hang_count > 0 {
12080 findings.push(format!(
12081 "{hang_count} application hang event(s) — process stopped responding."
12082 ));
12083 }
12084 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12085 if count > 1 {
12086 findings.push(format!(
12087 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12088 ));
12089 }
12090 }
12091 if wer_count > 10 {
12092 findings.push(format!(
12093 "{wer_count} WER reports archived — elevated crash history on this machine."
12094 ));
12095 }
12096
12097 let filter_note = match process_filter {
12098 Some(p) => format!(" (filtered: {p})"),
12099 None => String::new(),
12100 };
12101 sections.push_str(&format!(
12102 "=== Application crashes and hangs{filter_note} ===\n"
12103 ));
12104
12105 for line in &events {
12106 let parts: Vec<&str> = line.splitn(6, '|').collect();
12107 if parts.len() >= 6 {
12108 let time = parts[0];
12109 let kind = parts[1];
12110 let app = parts[2];
12111 let ver = parts[3];
12112 let module = parts[4];
12113 let exc = parts[5];
12114 let ver_note = if !ver.is_empty() {
12115 format!(" v{ver}")
12116 } else {
12117 String::new()
12118 };
12119 sections.push_str(&format!(" [{time}] {kind}: {app}{ver_note}\n"));
12120 if !module.is_empty() && module != "?" {
12121 let exc_note = if !exc.is_empty() {
12122 format!(" (exc {exc})")
12123 } else {
12124 String::new()
12125 };
12126 sections.push_str(&format!(" faulting module: {module}{exc_note}\n"));
12127 } else if !exc.is_empty() {
12128 sections.push_str(&format!(" exception: {exc}\n"));
12129 }
12130 }
12131 }
12132 sections.push_str(&format!(
12133 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12134 ));
12135
12136 if wer_count > 0 {
12137 sections.push_str(&format!(
12138 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12139 ));
12140 }
12141 }
12142 }
12143
12144 #[cfg(not(target_os = "windows"))]
12145 {
12146 let _ = (process_filter, n);
12147 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12148 }
12149
12150 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12151 if findings.is_empty() {
12152 result.push_str("- No actionable findings.\n");
12153 } else {
12154 for f in &findings {
12155 result.push_str(&format!("- Finding: {f}\n"));
12156 }
12157 }
12158 result.push('\n');
12159 result.push_str(§ions);
12160 Ok(result.trim_end().to_string())
12161}
12162
12163#[cfg(target_os = "windows")]
12164fn gpu_voltage_telemetry_note() -> String {
12165 let output = Command::new("nvidia-smi")
12166 .args(["--help-query-gpu"])
12167 .output();
12168
12169 match output {
12170 Ok(o) => {
12171 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12172 if text.contains("\"voltage\"") || text.contains("voltage.") {
12173 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12174 } else {
12175 "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()
12176 }
12177 }
12178 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12179 }
12180}
12181
12182#[cfg(target_os = "windows")]
12183fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
12184 if raw == 0 {
12185 return None;
12186 }
12187 if raw & 0x80 != 0 {
12188 let tenths = raw & 0x7f;
12189 return Some(format!(
12190 "{:.1} V (firmware-reported WMI current voltage)",
12191 tenths as f64 / 10.0
12192 ));
12193 }
12194
12195 let legacy = match raw {
12196 1 => Some("5.0 V"),
12197 2 => Some("3.3 V"),
12198 4 => Some("2.9 V"),
12199 _ => None,
12200 }?;
12201 Some(format!(
12202 "{} (legacy WMI voltage capability flag, not live telemetry)",
12203 legacy
12204 ))
12205}
12206
12207async fn inspect_overclocker() -> Result<String, String> {
12208 let mut out = String::from("Host inspection: overclocker\n\n");
12209
12210 #[cfg(target_os = "windows")]
12211 {
12212 out.push_str(
12213 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
12214 );
12215
12216 let nvidia = Command::new("nvidia-smi")
12218 .args([
12219 "--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",
12220 "--format=csv,noheader,nounits",
12221 ])
12222 .output();
12223
12224 if let Ok(o) = nvidia {
12225 let stdout = String::from_utf8_lossy(&o.stdout);
12226 if !stdout.trim().is_empty() {
12227 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
12228 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
12229 if parts.len() >= 10 {
12230 out.push_str(&format!("- Model: {}\n", parts[0]));
12231 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
12232 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
12233 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
12234 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
12235 if !parts[6].eq_ignore_ascii_case("[N/A]") {
12236 out.push_str(&format!("- Power Avg: {} W\n", parts[6]));
12237 }
12238 if !parts[7].eq_ignore_ascii_case("[N/A]") {
12239 out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
12240 }
12241 if !parts[8].eq_ignore_ascii_case("[N/A]") {
12242 out.push_str(&format!("- Power Cap: {} W requested\n", parts[8]));
12243 }
12244 if !parts[9].eq_ignore_ascii_case("[N/A]") {
12245 out.push_str(&format!("- Power Enf: {} W enforced\n", parts[9]));
12246 }
12247 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
12248
12249 if parts.len() > 10 {
12250 let throttle_hex = parts[10];
12251 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
12252 if !reasons.is_empty() {
12253 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
12254 } else {
12255 out.push_str("- Throttling: None (Performance State: Max)\n");
12256 }
12257 }
12258 }
12259 out.push_str("\n");
12260 }
12261 }
12262
12263 out.push_str("=== VOLTAGE TELEMETRY ===\n");
12264 out.push_str(&format!(
12265 "- GPU Voltage: {}\n\n",
12266 gpu_voltage_telemetry_note()
12267 ));
12268
12269 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
12271 let history = gpu_state.history.lock().unwrap();
12272 if history.len() >= 2 {
12273 out.push_str("=== SILICON TRENDS (Session) ===\n");
12274 let first = history.front().unwrap();
12275 let last = history.back().unwrap();
12276
12277 let temp_diff = last.temperature as i32 - first.temperature as i32;
12278 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
12279
12280 let temp_trend = if temp_diff > 1 {
12281 "Rising"
12282 } else if temp_diff < -1 {
12283 "Falling"
12284 } else {
12285 "Stable"
12286 };
12287 let clock_trend = if clock_diff > 10 {
12288 "Increasing"
12289 } else if clock_diff < -10 {
12290 "Decreasing"
12291 } else {
12292 "Stable"
12293 };
12294
12295 out.push_str(&format!(
12296 "- Temperature: {} ({}°C anomaly)\n",
12297 temp_trend, temp_diff
12298 ));
12299 out.push_str(&format!(
12300 "- Core Clock: {} ({} MHz delta)\n",
12301 clock_trend, clock_diff
12302 ));
12303 out.push_str("\n");
12304 }
12305
12306 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))\" }";
12308 let cpu_stats = Command::new("powershell")
12309 .args(["-NoProfile", "-Command", ps_cmd])
12310 .output();
12311
12312 if let Ok(o) = cpu_stats {
12313 let stdout = String::from_utf8_lossy(&o.stdout);
12314 if !stdout.trim().is_empty() {
12315 out.push_str("=== SILICON CORE (CPU) ===\n");
12316 for line in stdout.lines() {
12317 if let Some((path, val)) = line.split_once(':') {
12318 if path.to_lowercase().contains("processor frequency") {
12319 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
12320 } else if path.to_lowercase().contains("% of maximum frequency") {
12321 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
12322 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
12323 if throttle_num < 95.0 {
12324 out.push_str(
12325 " [WARNING] Active downclocking or power-saving detected.\n",
12326 );
12327 }
12328 }
12329 }
12330 }
12331 }
12332 }
12333
12334 let thermal = Command::new("powershell")
12336 .args([
12337 "-NoProfile",
12338 "-Command",
12339 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
12340 ])
12341 .output();
12342 if let Ok(o) = thermal {
12343 let stdout = String::from_utf8_lossy(&o.stdout);
12344 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12345 let temp = if v.is_array() {
12346 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12347 } else {
12348 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12349 };
12350 if temp > 1.0 {
12351 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
12352 }
12353 }
12354 }
12355
12356 let wmi = Command::new("powershell")
12358 .args([
12359 "-NoProfile",
12360 "-Command",
12361 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
12362 ])
12363 .output();
12364
12365 if let Ok(o) = wmi {
12366 let stdout = String::from_utf8_lossy(&o.stdout);
12367 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12368 out.push_str("\n=== HARDWARE DNA ===\n");
12369 out.push_str(&format!(
12370 "- Rated Max: {} MHz\n",
12371 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
12372 ));
12373 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
12374 Some(raw) => {
12375 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
12376 out.push_str(&format!("- CPU Voltage: {}\n", decoded));
12377 } else {
12378 out.push_str(
12379 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
12380 );
12381 }
12382 }
12383 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
12384 }
12385 }
12386 }
12387 }
12388
12389 #[cfg(not(target_os = "windows"))]
12390 {
12391 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
12392 }
12393
12394 Ok(out.trim_end().to_string())
12395}
12396
12397#[cfg(target_os = "windows")]
12399fn decode_nvidia_throttle_reasons(hex: &str) -> String {
12400 let hex = hex.trim().trim_start_matches("0x");
12401 let val = match u64::from_str_radix(hex, 16) {
12402 Ok(v) => v,
12403 Err(_) => return String::new(),
12404 };
12405
12406 if val == 0 {
12407 return String::new();
12408 }
12409
12410 let mut reasons = Vec::new();
12411 if val & 0x01 != 0 {
12412 reasons.push("GPU Idle");
12413 }
12414 if val & 0x02 != 0 {
12415 reasons.push("Applications Clocks Setting");
12416 }
12417 if val & 0x04 != 0 {
12418 reasons.push("SW Power Cap (PL1/PL2)");
12419 }
12420 if val & 0x08 != 0 {
12421 reasons.push("HW Slowdown (Thermal/Power)");
12422 }
12423 if val & 0x10 != 0 {
12424 reasons.push("Sync Boost");
12425 }
12426 if val & 0x20 != 0 {
12427 reasons.push("SW Thermal Slowdown");
12428 }
12429 if val & 0x40 != 0 {
12430 reasons.push("HW Thermal Slowdown");
12431 }
12432 if val & 0x80 != 0 {
12433 reasons.push("HW Power Brake Slowdown");
12434 }
12435 if val & 0x100 != 0 {
12436 reasons.push("Display Clock Setting");
12437 }
12438
12439 reasons.join(", ")
12440}
12441
12442#[cfg(windows)]
12445fn run_powershell(script: &str) -> Result<String, String> {
12446 use std::process::Command;
12447 let out = Command::new("powershell")
12448 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12449 .output()
12450 .map_err(|e| format!("powershell launch failed: {e}"))?;
12451 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
12452}
12453
12454#[cfg(windows)]
12457fn inspect_camera(max_entries: usize) -> Result<String, String> {
12458 let mut out = String::from("=== Camera devices ===\n");
12459
12460 let ps_devices = r#"
12462Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
12463ForEach-Object {
12464 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
12465 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
12466}
12467"#;
12468 match run_powershell(ps_devices) {
12469 Ok(o) if !o.trim().is_empty() => {
12470 for line in o.lines().take(max_entries) {
12471 let l = line.trim();
12472 if !l.is_empty() {
12473 out.push_str(&format!("- {l}\n"));
12474 }
12475 }
12476 }
12477 _ => out.push_str("- No camera devices found via PnP\n"),
12478 }
12479
12480 out.push_str("\n=== Windows camera privacy ===\n");
12482 let ps_privacy = r#"
12483$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
12484$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
12485"Global: $global"
12486$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
12487 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
12488 ForEach-Object {
12489 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
12490 if ($v) { " $($_.PSChildName): $v" }
12491 }
12492$apps
12493"#;
12494 match run_powershell(ps_privacy) {
12495 Ok(o) if !o.trim().is_empty() => {
12496 for line in o.lines().take(max_entries) {
12497 let l = line.trim_end();
12498 if !l.is_empty() {
12499 out.push_str(&format!("{l}\n"));
12500 }
12501 }
12502 }
12503 _ => out.push_str("- Could not read camera privacy registry\n"),
12504 }
12505
12506 out.push_str("\n=== Biometric / Hello camera ===\n");
12508 let ps_bio = r#"
12509Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
12510ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
12511"#;
12512 match run_powershell(ps_bio) {
12513 Ok(o) if !o.trim().is_empty() => {
12514 for line in o.lines().take(max_entries) {
12515 let l = line.trim();
12516 if !l.is_empty() {
12517 out.push_str(&format!("- {l}\n"));
12518 }
12519 }
12520 }
12521 _ => out.push_str("- No biometric devices found\n"),
12522 }
12523
12524 let mut findings: Vec<String> = Vec::new();
12526 if out.contains("Status: Error") || out.contains("Status: Unknown") {
12527 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
12528 }
12529 if out.contains("Global: Deny") {
12530 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());
12531 }
12532
12533 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
12534 if findings.is_empty() {
12535 result.push_str("- No obvious camera or privacy gate issue detected.\n");
12536 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
12537 } else {
12538 for f in &findings {
12539 result.push_str(&format!("- Finding: {f}\n"));
12540 }
12541 }
12542 result.push('\n');
12543 result.push_str(&out);
12544 Ok(result)
12545}
12546
12547#[cfg(not(windows))]
12548fn inspect_camera(_max_entries: usize) -> Result<String, String> {
12549 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
12550}
12551
12552#[cfg(windows)]
12555fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
12556 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
12557
12558 let ps_hello = r#"
12560$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
12561$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
12562$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
12563"PIN-style logon path: $helloKey"
12564"WbioSrvc start type: $faceConfigured"
12565"FingerPrint key present: $pinConfigured"
12566"#;
12567 match run_powershell(ps_hello) {
12568 Ok(o) => {
12569 for line in o.lines().take(max_entries) {
12570 let l = line.trim();
12571 if !l.is_empty() {
12572 out.push_str(&format!("- {l}\n"));
12573 }
12574 }
12575 }
12576 Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
12577 }
12578
12579 out.push_str("\n=== Biometric service ===\n");
12581 let ps_bio_svc = r#"
12582$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
12583if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
12584else { "WbioSrvc not found" }
12585"#;
12586 match run_powershell(ps_bio_svc) {
12587 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
12588 Err(_) => out.push_str("- Could not query biometric service\n"),
12589 }
12590
12591 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
12593 let ps_events = r#"
12594$cutoff = (Get-Date).AddHours(-24)
12595Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
12596ForEach-Object {
12597 $xml = [xml]$_.ToXml()
12598 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
12599 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
12600 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
12601} | Select-Object -First 10
12602"#;
12603 match run_powershell(ps_events) {
12604 Ok(o) if !o.trim().is_empty() => {
12605 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
12606 out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
12607 for line in o.lines().take(max_entries) {
12608 let l = line.trim();
12609 if !l.is_empty() {
12610 out.push_str(&format!(" {l}\n"));
12611 }
12612 }
12613 }
12614 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
12615 }
12616
12617 out.push_str("\n=== Active credential providers ===\n");
12619 let ps_cp = r#"
12620Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
12621ForEach-Object {
12622 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
12623 if ($name) { $name }
12624} | Select-Object -First 15
12625"#;
12626 match run_powershell(ps_cp) {
12627 Ok(o) if !o.trim().is_empty() => {
12628 for line in o.lines().take(max_entries) {
12629 let l = line.trim();
12630 if !l.is_empty() {
12631 out.push_str(&format!("- {l}\n"));
12632 }
12633 }
12634 }
12635 _ => out.push_str("- Could not enumerate credential providers\n"),
12636 }
12637
12638 let mut findings: Vec<String> = Vec::new();
12639 if out.contains("WbioSrvc | Status: Stopped") {
12640 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
12641 }
12642 if out.contains("recent logon failure") && !out.contains("0 recent") {
12643 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
12644 }
12645
12646 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
12647 if findings.is_empty() {
12648 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
12649 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
12650 } else {
12651 for f in &findings {
12652 result.push_str(&format!("- Finding: {f}\n"));
12653 }
12654 }
12655 result.push('\n');
12656 result.push_str(&out);
12657 Ok(result)
12658}
12659
12660#[cfg(not(windows))]
12661fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
12662 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
12663}
12664
12665#[cfg(windows)]
12668fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
12669 let mut out = String::from("=== Installer engines ===\n");
12670
12671 let ps_engines = r#"
12672$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
12673foreach ($name in $services) {
12674 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
12675 if ($svc) {
12676 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
12677 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
12678 "$name | Status: $($svc.Status) | StartType: $startType"
12679 } else {
12680 "$name | Not present"
12681 }
12682}
12683if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
12684 "msiexec.exe | Present: Yes"
12685} else {
12686 "msiexec.exe | Present: No"
12687}
12688"#;
12689 match run_powershell(ps_engines) {
12690 Ok(o) if !o.trim().is_empty() => {
12691 for line in o.lines().take(max_entries + 6) {
12692 let l = line.trim();
12693 if !l.is_empty() {
12694 out.push_str(&format!("- {l}\n"));
12695 }
12696 }
12697 }
12698 _ => out.push_str("- Could not inspect installer engine services\n"),
12699 }
12700
12701 out.push_str("\n=== winget and App Installer ===\n");
12702 let ps_winget = r#"
12703$cmd = Get-Command winget -ErrorAction SilentlyContinue
12704if ($cmd) {
12705 try {
12706 $v = & winget --version 2>$null
12707 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
12708 } catch { "winget | Present but invocation failed" }
12709} else {
12710 "winget | Missing"
12711}
12712$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
12713if ($appInstaller) {
12714 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
12715} else {
12716 "DesktopAppInstaller | Status: Missing"
12717}
12718"#;
12719 match run_powershell(ps_winget) {
12720 Ok(o) if !o.trim().is_empty() => {
12721 for line in o.lines().take(max_entries) {
12722 let l = line.trim();
12723 if !l.is_empty() {
12724 out.push_str(&format!("- {l}\n"));
12725 }
12726 }
12727 }
12728 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
12729 }
12730
12731 out.push_str("\n=== Microsoft Store packages ===\n");
12732 let ps_store = r#"
12733$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
12734if ($store) {
12735 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
12736} else {
12737 "Microsoft.WindowsStore | Status: Missing"
12738}
12739"#;
12740 match run_powershell(ps_store) {
12741 Ok(o) if !o.trim().is_empty() => {
12742 for line in o.lines().take(max_entries) {
12743 let l = line.trim();
12744 if !l.is_empty() {
12745 out.push_str(&format!("- {l}\n"));
12746 }
12747 }
12748 }
12749 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
12750 }
12751
12752 out.push_str("\n=== Reboot and transaction blockers ===\n");
12753 let ps_blockers = r#"
12754$pending = $false
12755if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
12756 "RebootPending: CBS"
12757 $pending = $true
12758}
12759if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
12760 "RebootPending: WindowsUpdate"
12761 $pending = $true
12762}
12763$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
12764if ($rename) {
12765 "PendingFileRenameOperations: Yes"
12766 $pending = $true
12767}
12768if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
12769 "InstallerInProgress: Yes"
12770 $pending = $true
12771}
12772if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
12773"#;
12774 match run_powershell(ps_blockers) {
12775 Ok(o) if !o.trim().is_empty() => {
12776 for line in o.lines().take(max_entries) {
12777 let l = line.trim();
12778 if !l.is_empty() {
12779 out.push_str(&format!("- {l}\n"));
12780 }
12781 }
12782 }
12783 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
12784 }
12785
12786 out.push_str("\n=== Recent installer failures (7d) ===\n");
12787 let ps_failures = r#"
12788$cutoff = (Get-Date).AddDays(-7)
12789$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
12790 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12791$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
12792 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
12793 Select-Object -First 6 |
12794 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12795$all = @($msi) + @($appx)
12796if ($all.Count -eq 0) {
12797 "No recent MSI/AppX installer errors detected"
12798} else {
12799 $all | Select-Object -First 8
12800}
12801"#;
12802 match run_powershell(ps_failures) {
12803 Ok(o) if !o.trim().is_empty() => {
12804 for line in o.lines().take(max_entries + 2) {
12805 let l = line.trim();
12806 if !l.is_empty() {
12807 out.push_str(&format!("- {l}\n"));
12808 }
12809 }
12810 }
12811 _ => out.push_str("- Could not inspect recent installer failure events\n"),
12812 }
12813
12814 let mut findings: Vec<String> = Vec::new();
12815 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
12816 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
12817 }
12818 if out.contains("msiexec.exe | Present: No") {
12819 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
12820 }
12821 if out.contains("winget | Missing") {
12822 findings.push(
12823 "winget is missing - App Installer may not be installed or registered for this user."
12824 .into(),
12825 );
12826 }
12827 if out.contains("DesktopAppInstaller | Status: Missing") {
12828 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
12829 }
12830 if out.contains("Microsoft.WindowsStore | Status: Missing") {
12831 findings.push(
12832 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
12833 .into(),
12834 );
12835 }
12836 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
12837 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
12838 }
12839 if out.contains("InstallerInProgress: Yes") {
12840 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
12841 }
12842 if out.contains("MSI | ") || out.contains("AppX | ") {
12843 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());
12844 }
12845
12846 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
12847 if findings.is_empty() {
12848 result.push_str("- No obvious installer-platform blocker detected.\n");
12849 } else {
12850 for finding in &findings {
12851 result.push_str(&format!("- Finding: {finding}\n"));
12852 }
12853 }
12854 result.push('\n');
12855 result.push_str(&out);
12856 Ok(result)
12857}
12858
12859#[cfg(not(windows))]
12860fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
12861 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())
12862}
12863
12864#[cfg(windows)]
12867fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
12868 let mut out = String::from("=== OneDrive client ===\n");
12869
12870 let ps_client = r#"
12871$candidatePaths = @(
12872 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
12873 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
12874 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
12875) | Where-Object { $_ -and (Test-Path $_) }
12876$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
12877$exe = $candidatePaths | Select-Object -First 1
12878if (-not $exe -and $proc) {
12879 try { $exe = $proc.Path } catch {}
12880}
12881if ($exe) {
12882 "Installed: Yes"
12883 "Executable: $exe"
12884 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
12885} else {
12886 "Installed: Unknown"
12887}
12888if ($proc) {
12889 "Process: Running | PID: $($proc.Id)"
12890} else {
12891 "Process: Not running"
12892}
12893"#;
12894 match run_powershell(ps_client) {
12895 Ok(o) if !o.trim().is_empty() => {
12896 for line in o.lines().take(max_entries) {
12897 let l = line.trim();
12898 if !l.is_empty() {
12899 out.push_str(&format!("- {l}\n"));
12900 }
12901 }
12902 }
12903 _ => out.push_str("- Could not inspect OneDrive client state\n"),
12904 }
12905
12906 out.push_str("\n=== OneDrive accounts ===\n");
12907 let ps_accounts = r#"
12908function MaskEmail([string]$Email) {
12909 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
12910 $parts = $Email.Split('@', 2)
12911 $local = $parts[0]
12912 $domain = $parts[1]
12913 if ($local.Length -le 1) { return "*@$domain" }
12914 return ($local.Substring(0,1) + "***@" + $domain)
12915}
12916$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
12917if (Test-Path $base) {
12918 Get-ChildItem $base -ErrorAction SilentlyContinue |
12919 Sort-Object PSChildName |
12920 Select-Object -First 12 |
12921 ForEach-Object {
12922 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
12923 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
12924 $mail = MaskEmail ([string]$p.UserEmail)
12925 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
12926 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
12927 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
12928 }
12929} else {
12930 "No OneDrive accounts configured"
12931}
12932"#;
12933 match run_powershell(ps_accounts) {
12934 Ok(o) if !o.trim().is_empty() => {
12935 for line in o.lines().take(max_entries) {
12936 let l = line.trim();
12937 if !l.is_empty() {
12938 out.push_str(&format!("- {l}\n"));
12939 }
12940 }
12941 }
12942 _ => out.push_str("- Could not read OneDrive account registry state\n"),
12943 }
12944
12945 out.push_str("\n=== OneDrive policy overrides ===\n");
12946 let ps_policy = r#"
12947$paths = @(
12948 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
12949 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
12950)
12951$names = @(
12952 'DisableFileSyncNGSC',
12953 'DisableLibrariesDefaultSaveToOneDrive',
12954 'KFMSilentOptIn',
12955 'KFMBlockOptIn',
12956 'SilentAccountConfig'
12957)
12958$found = $false
12959foreach ($path in $paths) {
12960 if (Test-Path $path) {
12961 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
12962 foreach ($name in $names) {
12963 $value = $p.$name
12964 if ($null -ne $value -and [string]$value -ne '') {
12965 "$path | $name=$value"
12966 $found = $true
12967 }
12968 }
12969 }
12970}
12971if (-not $found) { "No OneDrive policy overrides detected" }
12972"#;
12973 match run_powershell(ps_policy) {
12974 Ok(o) if !o.trim().is_empty() => {
12975 for line in o.lines().take(max_entries) {
12976 let l = line.trim();
12977 if !l.is_empty() {
12978 out.push_str(&format!("- {l}\n"));
12979 }
12980 }
12981 }
12982 _ => out.push_str("- Could not read OneDrive policy state\n"),
12983 }
12984
12985 out.push_str("\n=== Known Folder Backup ===\n");
12986 let ps_kfm = r#"
12987$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
12988$roots = @()
12989if (Test-Path $base) {
12990 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
12991 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
12992 if ($p.UserFolder) {
12993 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
12994 }
12995 }
12996}
12997$roots = $roots | Select-Object -Unique
12998$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
12999if (Test-Path $shell) {
13000 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13001 $folders = @(
13002 @{ Name='Desktop'; Value=$props.Desktop },
13003 @{ Name='Documents'; Value=$props.Personal },
13004 @{ Name='Pictures'; Value=$props.'My Pictures' }
13005 )
13006 foreach ($folder in $folders) {
13007 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13008 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13009 $protected = $false
13010 foreach ($root in $roots) {
13011 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13012 $protected = $true
13013 break
13014 }
13015 }
13016 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13017 }
13018} else {
13019 "Explorer shell folders unavailable"
13020}
13021"#;
13022 match run_powershell(ps_kfm) {
13023 Ok(o) if !o.trim().is_empty() => {
13024 for line in o.lines().take(max_entries) {
13025 let l = line.trim();
13026 if !l.is_empty() {
13027 out.push_str(&format!("- {l}\n"));
13028 }
13029 }
13030 }
13031 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13032 }
13033
13034 let mut findings: Vec<String> = Vec::new();
13035 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13036 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13037 }
13038 if out.contains("No OneDrive accounts configured") {
13039 findings.push(
13040 "No OneDrive accounts are configured - sync cannot start until the user signs in."
13041 .into(),
13042 );
13043 }
13044 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13045 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13046 }
13047 if out.contains("Exists: No") {
13048 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13049 }
13050 if out.contains("DisableFileSyncNGSC=1") {
13051 findings
13052 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13053 }
13054 if out.contains("KFMBlockOptIn=1") {
13055 findings
13056 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13057 }
13058 if out.contains("SyncRoot: C:\\") {
13059 let mut missing_kfm: Vec<&str> = Vec::new();
13060 for folder in ["Desktop", "Documents", "Pictures"] {
13061 if out.lines().any(|line| {
13062 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13063 }) {
13064 missing_kfm.push(folder);
13065 }
13066 }
13067 if !missing_kfm.is_empty() {
13068 findings.push(format!(
13069 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13070 missing_kfm.join(", ")
13071 ));
13072 }
13073 }
13074
13075 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13076 if findings.is_empty() {
13077 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13078 } else {
13079 for finding in &findings {
13080 result.push_str(&format!("- Finding: {finding}\n"));
13081 }
13082 }
13083 result.push('\n');
13084 result.push_str(&out);
13085 Ok(result)
13086}
13087
13088#[cfg(not(windows))]
13089fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13090 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13091}
13092
13093#[cfg(windows)]
13094fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13095 let mut out = String::from("=== Browser inventory ===\n");
13096
13097 let ps_inventory = r#"
13098$browsers = @(
13099 @{ Name='Edge'; Paths=@(
13100 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13101 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13102 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13103 @{ Name='Chrome'; Paths=@(
13104 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13105 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13106 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13107 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13108 @{ Name='Firefox'; Paths=@(
13109 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13110 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13111 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13112)
13113foreach ($browser in $browsers) {
13114 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13115 if ($exe) {
13116 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13117 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13118 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13119 } else {
13120 "$($browser.Name) | Installed: No"
13121 }
13122}
13123$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13124$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13125$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13126"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13127"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13128"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13129"#;
13130 match run_powershell(ps_inventory) {
13131 Ok(o) if !o.trim().is_empty() => {
13132 for line in o.lines().take(max_entries + 6) {
13133 let l = line.trim();
13134 if !l.is_empty() {
13135 out.push_str(&format!("- {l}\n"));
13136 }
13137 }
13138 }
13139 _ => out.push_str("- Could not inspect installed browser inventory\n"),
13140 }
13141
13142 out.push_str("\n=== Runtime state ===\n");
13143 let ps_runtime = r#"
13144$targets = 'msedge','chrome','firefox','msedgewebview2'
13145foreach ($name in $targets) {
13146 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13147 if ($procs) {
13148 $count = @($procs).Count
13149 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13150 "$name | Processes: $count | WorkingSetMB: $wsMb"
13151 } else {
13152 "$name | Processes: 0 | WorkingSetMB: 0"
13153 }
13154}
13155"#;
13156 match run_powershell(ps_runtime) {
13157 Ok(o) if !o.trim().is_empty() => {
13158 for line in o.lines().take(max_entries + 4) {
13159 let l = line.trim();
13160 if !l.is_empty() {
13161 out.push_str(&format!("- {l}\n"));
13162 }
13163 }
13164 }
13165 _ => out.push_str("- Could not inspect browser runtime state\n"),
13166 }
13167
13168 out.push_str("\n=== WebView2 runtime ===\n");
13169 let ps_webview = r#"
13170$paths = @(
13171 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13172 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13173) | Where-Object { $_ -and (Test-Path $_) }
13174$runtimeDir = $paths | ForEach-Object {
13175 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13176 Where-Object { $_.Name -match '^\d+\.' } |
13177 Sort-Object Name -Descending |
13178 Select-Object -First 1
13179} | Select-Object -First 1
13180if ($runtimeDir) {
13181 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13182 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13183 "Installed: Yes"
13184 "Version: $version"
13185 "Executable: $exe"
13186} else {
13187 "Installed: No"
13188}
13189$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
13190"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
13191"#;
13192 match run_powershell(ps_webview) {
13193 Ok(o) if !o.trim().is_empty() => {
13194 for line in o.lines().take(max_entries) {
13195 let l = line.trim();
13196 if !l.is_empty() {
13197 out.push_str(&format!("- {l}\n"));
13198 }
13199 }
13200 }
13201 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13202 }
13203
13204 out.push_str("\n=== Policy and proxy surface ===\n");
13205 let ps_policy = r#"
13206$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
13207$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
13208$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
13209$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
13210$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
13211"UserProxyEnabled: $proxyEnabled"
13212"UserProxyServer: $proxyServer"
13213"UserAutoConfigURL: $autoConfig"
13214"UserAutoDetect: $autoDetect"
13215$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
13216if ($winhttp) {
13217 $normalized = ($winhttp -replace '\s+', ' ').Trim()
13218 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
13219 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
13220 "WinHTTP: $normalized"
13221}
13222$policyTargets = @(
13223 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
13224 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
13225)
13226foreach ($policy in $policyTargets) {
13227 if (Test-Path $policy.Path) {
13228 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
13229 foreach ($key in $policy.Keys) {
13230 $value = $item.$key
13231 if ($null -ne $value -and [string]$value -ne '') {
13232 if ($value -is [array]) {
13233 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
13234 } else {
13235 "$($policy.Name)Policy | $key=$value"
13236 }
13237 }
13238 }
13239 }
13240}
13241"#;
13242 match run_powershell(ps_policy) {
13243 Ok(o) if !o.trim().is_empty() => {
13244 for line in o.lines().take(max_entries + 8) {
13245 let l = line.trim();
13246 if !l.is_empty() {
13247 out.push_str(&format!("- {l}\n"));
13248 }
13249 }
13250 }
13251 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
13252 }
13253
13254 out.push_str("\n=== Profile and cache pressure ===\n");
13255 let ps_profiles = r#"
13256$profiles = @(
13257 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
13258 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
13259 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
13260)
13261foreach ($profile in $profiles) {
13262 if (Test-Path $profile.Root) {
13263 if ($profile.Name -eq 'Firefox') {
13264 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
13265 } else {
13266 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
13267 Where-Object {
13268 $_.Name -eq 'Default' -or
13269 $_.Name -eq 'Guest Profile' -or
13270 $_.Name -eq 'System Profile' -or
13271 $_.Name -like 'Profile *'
13272 }
13273 }
13274 $profileCount = @($dirs).Count
13275 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
13276 if (-not $sizeBytes) { $sizeBytes = 0 }
13277 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
13278 $extCount = 'Unknown'
13279 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
13280 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
13281 }
13282 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
13283 } else {
13284 "$($profile.Name) | ProfileRoot: Missing"
13285 }
13286}
13287"#;
13288 match run_powershell(ps_profiles) {
13289 Ok(o) if !o.trim().is_empty() => {
13290 for line in o.lines().take(max_entries + 4) {
13291 let l = line.trim();
13292 if !l.is_empty() {
13293 out.push_str(&format!("- {l}\n"));
13294 }
13295 }
13296 }
13297 _ => out.push_str("- Could not inspect browser profile pressure\n"),
13298 }
13299
13300 out.push_str("\n=== Recent browser failures (7d) ===\n");
13301 let ps_failures = r#"
13302$cutoff = (Get-Date).AddDays(-7)
13303$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
13304$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
13305 Where-Object {
13306 $msg = [string]$_.Message
13307 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
13308 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
13309 } |
13310 Select-Object -First 6
13311if ($events) {
13312 foreach ($event in $events) {
13313 $msg = ($event.Message -replace '\s+', ' ')
13314 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13315 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13316 }
13317} else {
13318 "No recent browser crash or WER events detected"
13319}
13320"#;
13321 match run_powershell(ps_failures) {
13322 Ok(o) if !o.trim().is_empty() => {
13323 for line in o.lines().take(max_entries + 2) {
13324 let l = line.trim();
13325 if !l.is_empty() {
13326 out.push_str(&format!("- {l}\n"));
13327 }
13328 }
13329 }
13330 _ => out.push_str("- Could not inspect recent browser failure events\n"),
13331 }
13332
13333 let mut findings: Vec<String> = Vec::new();
13334 if out.contains("Edge | Installed: No")
13335 && out.contains("Chrome | Installed: No")
13336 && out.contains("Firefox | Installed: No")
13337 {
13338 findings.push(
13339 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
13340 .into(),
13341 );
13342 }
13343 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
13344 findings.push(
13345 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
13346 .into(),
13347 );
13348 }
13349 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
13350 findings.push(
13351 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
13352 .into(),
13353 );
13354 }
13355 if out.contains("EdgePolicy | Proxy")
13356 || out.contains("ChromePolicy | Proxy")
13357 || out.contains("ExtensionInstallForcelist=")
13358 {
13359 findings.push(
13360 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
13361 .into(),
13362 );
13363 }
13364 for browser in ["msedge", "chrome", "firefox"] {
13365 let process_marker = format!("{browser} | Processes: ");
13366 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
13367 let count = line
13368 .split("| Processes: ")
13369 .nth(1)
13370 .and_then(|rest| rest.split(" |").next())
13371 .and_then(|value| value.trim().parse::<usize>().ok())
13372 .unwrap_or(0);
13373 let ws_mb = line
13374 .split("| WorkingSetMB: ")
13375 .nth(1)
13376 .and_then(|value| value.trim().parse::<f64>().ok())
13377 .unwrap_or(0.0);
13378 if count >= 25 {
13379 findings.push(format!(
13380 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
13381 ));
13382 } else if ws_mb >= 2500.0 {
13383 findings.push(format!(
13384 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
13385 ));
13386 }
13387 }
13388 }
13389 if out.contains("=== WebView2 runtime ===\n- Installed: No")
13390 || (out.contains("=== WebView2 runtime ===")
13391 && out.contains("- Installed: No")
13392 && out.contains("- ProcessCount: 0"))
13393 {
13394 findings.push(
13395 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
13396 .into(),
13397 );
13398 }
13399 for browser in ["Edge", "Chrome", "Firefox"] {
13400 let prefix = format!("{browser} | ProfileRoot:");
13401 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
13402 let size_gb = line
13403 .split("| SizeGB: ")
13404 .nth(1)
13405 .and_then(|rest| rest.split(" |").next())
13406 .and_then(|value| value.trim().parse::<f64>().ok())
13407 .unwrap_or(0.0);
13408 let ext_count = line
13409 .split("| Extensions: ")
13410 .nth(1)
13411 .and_then(|value| value.trim().parse::<usize>().ok())
13412 .unwrap_or(0);
13413 if size_gb >= 2.5 {
13414 findings.push(format!(
13415 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
13416 ));
13417 }
13418 if ext_count >= 20 {
13419 findings.push(format!(
13420 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
13421 ));
13422 }
13423 }
13424 }
13425 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13426 findings.push(
13427 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
13428 .into(),
13429 );
13430 }
13431
13432 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
13433 if findings.is_empty() {
13434 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
13435 } else {
13436 for finding in &findings {
13437 result.push_str(&format!("- Finding: {finding}\n"));
13438 }
13439 }
13440 result.push('\n');
13441 result.push_str(&out);
13442 Ok(result)
13443}
13444
13445#[cfg(not(windows))]
13446fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
13447 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())
13448}
13449
13450#[cfg(windows)]
13451fn inspect_outlook(max_entries: usize) -> Result<String, String> {
13452 let mut out = String::from("=== Outlook install inventory ===\n");
13453
13454 let ps_install = r#"
13455$installPaths = @(
13456 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13457 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13458 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
13459 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
13460 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
13461 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
13462)
13463$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13464if ($exe) {
13465 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13466 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
13467 "Installed: Yes"
13468 "Executable: $exe"
13469 "Version: $version"
13470 "Product: $productName"
13471} else {
13472 "Installed: No"
13473}
13474$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
13475if ($newOutlook) {
13476 "NewOutlook: Installed | Version: $($newOutlook.Version)"
13477} else {
13478 "NewOutlook: Not installed"
13479}
13480"#;
13481 match run_powershell(ps_install) {
13482 Ok(o) if !o.trim().is_empty() => {
13483 for line in o.lines().take(max_entries + 4) {
13484 let l = line.trim();
13485 if !l.is_empty() {
13486 out.push_str(&format!("- {l}\n"));
13487 }
13488 }
13489 }
13490 _ => out.push_str("- Could not inspect Outlook install paths\n"),
13491 }
13492
13493 out.push_str("\n=== Runtime state ===\n");
13494 let ps_runtime = r#"
13495$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
13496if ($proc) {
13497 $count = @($proc).Count
13498 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13499 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
13500 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
13501} else {
13502 "Running: No"
13503}
13504"#;
13505 match run_powershell(ps_runtime) {
13506 Ok(o) if !o.trim().is_empty() => {
13507 for line in o.lines().take(4) {
13508 let l = line.trim();
13509 if !l.is_empty() {
13510 out.push_str(&format!("- {l}\n"));
13511 }
13512 }
13513 }
13514 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
13515 }
13516
13517 out.push_str("\n=== Mail profiles ===\n");
13518 let ps_profiles = r#"
13519$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
13520if (-not (Test-Path $profileKey)) {
13521 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
13522}
13523if (Test-Path $profileKey) {
13524 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
13525 $count = @($profiles).Count
13526 "ProfileCount: $count"
13527 foreach ($p in $profiles | Select-Object -First 10) {
13528 "Profile: $($p.PSChildName)"
13529 }
13530} else {
13531 "ProfileCount: 0"
13532 "No Outlook profiles found in registry"
13533}
13534"#;
13535 match run_powershell(ps_profiles) {
13536 Ok(o) if !o.trim().is_empty() => {
13537 for line in o.lines().take(max_entries + 2) {
13538 let l = line.trim();
13539 if !l.is_empty() {
13540 out.push_str(&format!("- {l}\n"));
13541 }
13542 }
13543 }
13544 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
13545 }
13546
13547 out.push_str("\n=== OST and PST data files ===\n");
13548 let ps_datafiles = r#"
13549$searchRoots = @(
13550 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
13551 (Join-Path $env:USERPROFILE 'Documents'),
13552 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
13553) | Where-Object { $_ -and (Test-Path $_) }
13554$files = foreach ($root in $searchRoots) {
13555 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
13556 Select-Object FullName,
13557 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
13558 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
13559 LastWriteTime
13560}
13561if ($files) {
13562 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
13563 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
13564 }
13565} else {
13566 "No OST or PST files found in standard locations"
13567}
13568"#;
13569 match run_powershell(ps_datafiles) {
13570 Ok(o) if !o.trim().is_empty() => {
13571 for line in o.lines().take(max_entries + 4) {
13572 let l = line.trim();
13573 if !l.is_empty() {
13574 out.push_str(&format!("- {l}\n"));
13575 }
13576 }
13577 }
13578 _ => out.push_str("- Could not inspect OST/PST data files\n"),
13579 }
13580
13581 out.push_str("\n=== Add-in pressure ===\n");
13582 let ps_addins = r#"
13583$addinPaths = @(
13584 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13585 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13586 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
13587)
13588$addins = foreach ($path in $addinPaths) {
13589 if (Test-Path $path) {
13590 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
13591 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13592 $loadBehavior = $item.LoadBehavior
13593 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
13594 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
13595 }
13596 }
13597}
13598$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
13599$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
13600"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
13601foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
13602 $state = switch ($a.LoadBehavior) {
13603 0 { 'Disabled' }
13604 2 { 'LoadOnStart(inactive)' }
13605 3 { 'ActiveOnStart' }
13606 8 { 'DemandLoad' }
13607 9 { 'ActiveDemand' }
13608 16 { 'ConnectedFirst' }
13609 default { "LoadBehavior=$($a.LoadBehavior)" }
13610 }
13611 "$($a.Name) | $state"
13612}
13613$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
13614$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
13615if (Test-Path $disabledByResiliency) {
13616 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
13617 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
13618 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
13619}
13620"#;
13621 match run_powershell(ps_addins) {
13622 Ok(o) if !o.trim().is_empty() => {
13623 for line in o.lines().take(max_entries + 8) {
13624 let l = line.trim();
13625 if !l.is_empty() {
13626 out.push_str(&format!("- {l}\n"));
13627 }
13628 }
13629 }
13630 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
13631 }
13632
13633 out.push_str("\n=== Authentication and cache friction ===\n");
13634 let ps_auth = r#"
13635$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
13636$tokenCount = if (Test-Path $tokenCache) {
13637 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
13638} else { 0 }
13639"TokenBrokerCacheFiles: $tokenCount"
13640$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
13641$credsCount = @($credentialManager).Count
13642"OfficeCredentialsInVault: $credsCount"
13643$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
13644if (Test-Path $samlKey) {
13645 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
13646 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
13647 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
13648 "WAMOverride: $connected"
13649 "SignedInUserId: $signedIn"
13650}
13651$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
13652if (Test-Path $outlookReg) {
13653 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
13654 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
13655}
13656"#;
13657 match run_powershell(ps_auth) {
13658 Ok(o) if !o.trim().is_empty() => {
13659 for line in o.lines().take(max_entries + 4) {
13660 let l = line.trim();
13661 if !l.is_empty() {
13662 out.push_str(&format!("- {l}\n"));
13663 }
13664 }
13665 }
13666 _ => out.push_str("- Could not inspect Outlook auth state\n"),
13667 }
13668
13669 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
13670 let ps_events = r#"
13671$cutoff = (Get-Date).AddDays(-7)
13672$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
13673 Where-Object {
13674 $msg = [string]$_.Message
13675 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
13676 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
13677 } |
13678 Select-Object -First 8
13679if ($events) {
13680 foreach ($event in $events) {
13681 $msg = ($event.Message -replace '\s+', ' ')
13682 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13683 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13684 }
13685} else {
13686 "No recent Outlook crash or error events detected in Application log"
13687}
13688"#;
13689 match run_powershell(ps_events) {
13690 Ok(o) if !o.trim().is_empty() => {
13691 for line in o.lines().take(max_entries + 4) {
13692 let l = line.trim();
13693 if !l.is_empty() {
13694 out.push_str(&format!("- {l}\n"));
13695 }
13696 }
13697 }
13698 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
13699 }
13700
13701 let mut findings: Vec<String> = Vec::new();
13702
13703 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
13704 findings.push(
13705 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
13706 .into(),
13707 );
13708 }
13709
13710 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
13711 let ws_mb = line
13712 .split("WorkingSetMB: ")
13713 .nth(1)
13714 .and_then(|r| r.split(" |").next())
13715 .and_then(|v| v.trim().parse::<f64>().ok())
13716 .unwrap_or(0.0);
13717 if ws_mb >= 1500.0 {
13718 findings.push(format!(
13719 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
13720 ));
13721 }
13722 }
13723
13724 let large_ost: Vec<String> = out
13725 .lines()
13726 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
13727 .filter_map(|l| {
13728 let mb = l
13729 .split("SizeMB: ")
13730 .nth(1)
13731 .and_then(|r| r.split(" |").next())
13732 .and_then(|v| v.trim().parse::<f64>().ok())
13733 .unwrap_or(0.0);
13734 if mb >= 10_000.0 {
13735 Some(format!("{mb:.0} MB OST file detected"))
13736 } else {
13737 None
13738 }
13739 })
13740 .collect();
13741 for msg in large_ost {
13742 findings.push(format!(
13743 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
13744 ));
13745 }
13746
13747 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
13748 let active_count = line
13749 .split("Active: ")
13750 .nth(1)
13751 .and_then(|r| r.split(" |").next())
13752 .and_then(|v| v.trim().parse::<usize>().ok())
13753 .unwrap_or(0);
13754 if active_count >= 8 {
13755 findings.push(format!(
13756 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
13757 ));
13758 }
13759 }
13760
13761 if out.contains("ResiliencyDisabledItems:") {
13762 findings.push(
13763 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
13764 .into(),
13765 );
13766 }
13767
13768 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
13769 findings.push(
13770 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
13771 .into(),
13772 );
13773 }
13774
13775 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13776 findings.push(
13777 "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)."
13778 .into(),
13779 );
13780 }
13781
13782 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
13783 if findings.is_empty() {
13784 result.push_str("- No obvious Outlook health blocker detected.\n");
13785 } else {
13786 for finding in &findings {
13787 result.push_str(&format!("- Finding: {finding}\n"));
13788 }
13789 }
13790 result.push('\n');
13791 result.push_str(&out);
13792 Ok(result)
13793}
13794
13795#[cfg(not(windows))]
13796fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
13797 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
13798}
13799
13800#[cfg(windows)]
13801fn inspect_teams(max_entries: usize) -> Result<String, String> {
13802 let mut out = String::from("=== Teams install inventory ===\n");
13803
13804 let ps_install = r#"
13805# Classic Teams (Teams 1.0)
13806$classicExe = @(
13807 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
13808 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
13809) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13810
13811if ($classicExe) {
13812 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
13813 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
13814} else {
13815 "ClassicTeams: Not installed"
13816}
13817
13818# New Teams (Teams 2.0 / ms-teams.exe)
13819$newTeamsExe = @(
13820 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
13821 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
13822) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13823
13824$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
13825if ($newTeamsPkg) {
13826 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
13827} elseif ($newTeamsExe) {
13828 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
13829 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
13830} else {
13831 "NewTeams: Not installed"
13832}
13833
13834# Teams Machine-Wide Installer (MSI/per-machine)
13835$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
13836 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
13837 Select-Object -First 1
13838if ($mwi) {
13839 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
13840} else {
13841 "MachineWideInstaller: Not found"
13842}
13843"#;
13844 match run_powershell(ps_install) {
13845 Ok(o) if !o.trim().is_empty() => {
13846 for line in o.lines().take(max_entries + 4) {
13847 let l = line.trim();
13848 if !l.is_empty() {
13849 out.push_str(&format!("- {l}\n"));
13850 }
13851 }
13852 }
13853 _ => out.push_str("- Could not inspect Teams install paths\n"),
13854 }
13855
13856 out.push_str("\n=== Runtime state ===\n");
13857 let ps_runtime = r#"
13858$targets = @('Teams','ms-teams')
13859foreach ($name in $targets) {
13860 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13861 if ($procs) {
13862 $count = @($procs).Count
13863 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13864 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
13865 } else {
13866 "$name | Running: No"
13867 }
13868}
13869"#;
13870 match run_powershell(ps_runtime) {
13871 Ok(o) if !o.trim().is_empty() => {
13872 for line in o.lines().take(6) {
13873 let l = line.trim();
13874 if !l.is_empty() {
13875 out.push_str(&format!("- {l}\n"));
13876 }
13877 }
13878 }
13879 _ => out.push_str("- Could not inspect Teams runtime state\n"),
13880 }
13881
13882 out.push_str("\n=== Cache directory sizing ===\n");
13883 let ps_cache = r#"
13884$cachePaths = @(
13885 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
13886 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
13887 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
13888 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
13889)
13890foreach ($entry in $cachePaths) {
13891 if (Test-Path $entry.Path) {
13892 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
13893 if (-not $sizeBytes) { $sizeBytes = 0 }
13894 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
13895 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
13896 } else {
13897 "$($entry.Name) | Path: $($entry.Path) | Not found"
13898 }
13899}
13900"#;
13901 match run_powershell(ps_cache) {
13902 Ok(o) if !o.trim().is_empty() => {
13903 for line in o.lines().take(max_entries + 4) {
13904 let l = line.trim();
13905 if !l.is_empty() {
13906 out.push_str(&format!("- {l}\n"));
13907 }
13908 }
13909 }
13910 _ => out.push_str("- Could not inspect Teams cache directories\n"),
13911 }
13912
13913 out.push_str("\n=== WebView2 runtime ===\n");
13914 let ps_webview = r#"
13915$paths = @(
13916 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13917 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13918) | Where-Object { $_ -and (Test-Path $_) }
13919$runtimeDir = $paths | ForEach-Object {
13920 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13921 Where-Object { $_.Name -match '^\d+\.' } |
13922 Sort-Object Name -Descending |
13923 Select-Object -First 1
13924} | Select-Object -First 1
13925if ($runtimeDir) {
13926 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13927 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13928 "Installed: Yes | Version: $version"
13929} else {
13930 "Installed: No -- New Teams and some Office features require WebView2"
13931}
13932"#;
13933 match run_powershell(ps_webview) {
13934 Ok(o) if !o.trim().is_empty() => {
13935 for line in o.lines().take(4) {
13936 let l = line.trim();
13937 if !l.is_empty() {
13938 out.push_str(&format!("- {l}\n"));
13939 }
13940 }
13941 }
13942 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13943 }
13944
13945 out.push_str("\n=== Account and sign-in state ===\n");
13946 let ps_auth = r#"
13947# Classic Teams account registry
13948$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
13949if (Test-Path $classicAcct) {
13950 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
13951 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
13952 "ClassicTeamsAccount: $email"
13953} else {
13954 "ClassicTeamsAccount: Not configured"
13955}
13956# WAM / token broker state for Teams
13957$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
13958$tokenCount = if (Test-Path $tokenCache) {
13959 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
13960} else { 0 }
13961"TokenBrokerCacheFiles: $tokenCount"
13962# Office identity
13963$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
13964if (Test-Path $officeId) {
13965 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
13966 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
13967 "OfficeSignedInUserId: $signedIn"
13968}
13969# Check if Teams is in startup
13970$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
13971$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
13972"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
13973"#;
13974 match run_powershell(ps_auth) {
13975 Ok(o) if !o.trim().is_empty() => {
13976 for line in o.lines().take(max_entries + 4) {
13977 let l = line.trim();
13978 if !l.is_empty() {
13979 out.push_str(&format!("- {l}\n"));
13980 }
13981 }
13982 }
13983 _ => out.push_str("- Could not inspect Teams account state\n"),
13984 }
13985
13986 out.push_str("\n=== Audio and video device binding ===\n");
13987 let ps_devices = r#"
13988# Teams stores device prefs in the settings file
13989$settingsPaths = @(
13990 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
13991 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
13992)
13993$found = $false
13994foreach ($sp in $settingsPaths) {
13995 if (Test-Path $sp) {
13996 $found = $true
13997 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
13998 if ($raw) {
13999 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14000 if ($json) {
14001 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14002 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14003 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14004 "ConfigFile: $sp"
14005 "Microphone: $mic"
14006 "Speaker: $spk"
14007 "Camera: $cam"
14008 } else {
14009 "ConfigFile: $sp (not parseable as JSON)"
14010 }
14011 } else {
14012 "ConfigFile: $sp (empty)"
14013 }
14014 break
14015 }
14016}
14017if (-not $found) {
14018 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14019}
14020"#;
14021 match run_powershell(ps_devices) {
14022 Ok(o) if !o.trim().is_empty() => {
14023 for line in o.lines().take(max_entries + 4) {
14024 let l = line.trim();
14025 if !l.is_empty() {
14026 out.push_str(&format!("- {l}\n"));
14027 }
14028 }
14029 }
14030 _ => out.push_str("- Could not inspect Teams device binding\n"),
14031 }
14032
14033 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14034 let ps_events = r#"
14035$cutoff = (Get-Date).AddDays(-7)
14036$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14037 Where-Object {
14038 $msg = [string]$_.Message
14039 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14040 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14041 } |
14042 Select-Object -First 8
14043if ($events) {
14044 foreach ($event in $events) {
14045 $msg = ($event.Message -replace '\s+', ' ')
14046 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14047 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14048 }
14049} else {
14050 "No recent Teams crash or error events detected in Application log"
14051}
14052"#;
14053 match run_powershell(ps_events) {
14054 Ok(o) if !o.trim().is_empty() => {
14055 for line in o.lines().take(max_entries + 4) {
14056 let l = line.trim();
14057 if !l.is_empty() {
14058 out.push_str(&format!("- {l}\n"));
14059 }
14060 }
14061 }
14062 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14063 }
14064
14065 let mut findings: Vec<String> = Vec::new();
14066
14067 let classic_installed = out.contains("- ClassicTeams: Installed");
14068 let new_installed = out.contains("- NewTeams: Installed");
14069 if !classic_installed && !new_installed {
14070 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14071 }
14072
14073 for name in ["Teams", "ms-teams"] {
14074 let marker = format!("{name} | Running: Yes | Processes:");
14075 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14076 let ws_mb = line
14077 .split("WorkingSetMB: ")
14078 .nth(1)
14079 .and_then(|v| v.trim().parse::<f64>().ok())
14080 .unwrap_or(0.0);
14081 if ws_mb >= 1000.0 {
14082 findings.push(format!(
14083 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14084 ));
14085 }
14086 }
14087 }
14088
14089 for (label, threshold_mb) in [
14090 ("ClassicTeamsCache", 500.0_f64),
14091 ("ClassicTeamsSquirrel", 2000.0),
14092 ("NewTeamsCache", 500.0),
14093 ("NewTeamsAppData", 3000.0),
14094 ] {
14095 let marker = format!("{label} |");
14096 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14097 let mb = line
14098 .split("SizeMB: ")
14099 .nth(1)
14100 .and_then(|v| v.trim().parse::<f64>().ok())
14101 .unwrap_or(0.0);
14102 if mb >= threshold_mb {
14103 findings.push(format!(
14104 "{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."
14105 ));
14106 }
14107 }
14108 }
14109
14110 if out.contains("- Installed: No -- New Teams") {
14111 findings.push(
14112 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14113 .into(),
14114 );
14115 }
14116
14117 if out.contains("- ClassicTeamsAccount: Not configured")
14118 && out.contains("- OfficeSignedInUserId: None")
14119 {
14120 findings.push(
14121 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14122 .into(),
14123 );
14124 }
14125
14126 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14127 findings.push(
14128 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14129 .into(),
14130 );
14131 }
14132
14133 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14134 if findings.is_empty() {
14135 result.push_str("- No obvious Teams health blocker detected.\n");
14136 } else {
14137 for finding in &findings {
14138 result.push_str(&format!("- Finding: {finding}\n"));
14139 }
14140 }
14141 result.push('\n');
14142 result.push_str(&out);
14143 Ok(result)
14144}
14145
14146#[cfg(not(windows))]
14147fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14148 Ok(
14149 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14150 .into(),
14151 )
14152}
14153
14154#[cfg(windows)]
14155fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14156 let mut out = String::from("=== Identity broker services ===\n");
14157
14158 let ps_services = r#"
14159$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14160foreach ($name in $serviceNames) {
14161 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14162 if ($svc) {
14163 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14164 } else {
14165 "$name | Not found"
14166 }
14167}
14168"#;
14169 match run_powershell(ps_services) {
14170 Ok(o) if !o.trim().is_empty() => {
14171 for line in o.lines().take(max_entries) {
14172 let l = line.trim();
14173 if !l.is_empty() {
14174 out.push_str(&format!("- {l}\n"));
14175 }
14176 }
14177 }
14178 _ => out.push_str("- Could not inspect identity broker services\n"),
14179 }
14180
14181 out.push_str("\n=== Device registration ===\n");
14182 let ps_device = r#"
14183$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
14184if ($dsreg) {
14185 try {
14186 $raw = & $dsreg.Source /status 2>$null
14187 $text = ($raw -join "`n")
14188 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
14189 $seen = $false
14190 foreach ($key in $keys) {
14191 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
14192 if ($match.Success) {
14193 "${key}: $($match.Groups[1].Value.Trim())"
14194 $seen = $true
14195 }
14196 }
14197 if (-not $seen) {
14198 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
14199 }
14200 } catch {
14201 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
14202 }
14203} else {
14204 "DeviceRegistration: dsregcmd unavailable"
14205}
14206"#;
14207 match run_powershell(ps_device) {
14208 Ok(o) if !o.trim().is_empty() => {
14209 for line in o.lines().take(max_entries + 4) {
14210 let l = line.trim();
14211 if !l.is_empty() {
14212 out.push_str(&format!("- {l}\n"));
14213 }
14214 }
14215 }
14216 _ => out.push_str(
14217 "- DeviceRegistration: Could not inspect device registration state in this session\n",
14218 ),
14219 }
14220
14221 out.push_str("\n=== Broker packages and caches ===\n");
14222 let ps_broker = r#"
14223$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
14224if ($pkg) {
14225 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
14226} else {
14227 "AADBrokerPlugin: Not installed"
14228}
14229$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14230$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14231"TokenBrokerCacheFiles: $tokenCount"
14232$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
14233$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14234"IdentityCacheFiles: $identityCount"
14235$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
14236$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14237"OneAuthFiles: $oneAuthCount"
14238"#;
14239 match run_powershell(ps_broker) {
14240 Ok(o) if !o.trim().is_empty() => {
14241 for line in o.lines().take(max_entries + 4) {
14242 let l = line.trim();
14243 if !l.is_empty() {
14244 out.push_str(&format!("- {l}\n"));
14245 }
14246 }
14247 }
14248 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
14249 }
14250
14251 out.push_str("\n=== Microsoft app account signals ===\n");
14252 let ps_accounts = r#"
14253function MaskEmail([string]$Email) {
14254 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14255 $parts = $Email.Split('@', 2)
14256 $local = $parts[0]
14257 $domain = $parts[1]
14258 if ($local.Length -le 1) { return "*@$domain" }
14259 return ($local.Substring(0,1) + "***@" + $domain)
14260}
14261$allAccounts = @()
14262$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14263if (Test-Path $officeId) {
14264 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14265 if ($id.SignedInUserId) {
14266 $allAccounts += [string]$id.SignedInUserId
14267 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
14268 } else {
14269 "OfficeSignedInUserId: None"
14270 }
14271} else {
14272 "OfficeSignedInUserId: Not configured"
14273}
14274$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14275if (Test-Path $teamsAcct) {
14276 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
14277 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
14278 if (-not [string]::IsNullOrWhiteSpace($email)) {
14279 $allAccounts += $email
14280 "TeamsAccount: $(MaskEmail $email)"
14281 } else {
14282 "TeamsAccount: Unknown"
14283 }
14284} else {
14285 "TeamsAccount: Not configured"
14286}
14287$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14288$oneDriveEmails = @()
14289if (Test-Path $oneDriveBase) {
14290 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
14291 ForEach-Object {
14292 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14293 if ($p.UserEmail) { [string]$p.UserEmail }
14294 } |
14295 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
14296 Sort-Object -Unique
14297}
14298$allAccounts += $oneDriveEmails
14299"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
14300if (@($oneDriveEmails).Count -gt 0) {
14301 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14302}
14303$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
14304"DistinctIdentityCount: $($distinct.Count)"
14305if ($distinct.Count -gt 0) {
14306 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14307}
14308"#;
14309 match run_powershell(ps_accounts) {
14310 Ok(o) if !o.trim().is_empty() => {
14311 for line in o.lines().take(max_entries + 6) {
14312 let l = line.trim();
14313 if !l.is_empty() {
14314 out.push_str(&format!("- {l}\n"));
14315 }
14316 }
14317 }
14318 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
14319 }
14320
14321 out.push_str("\n=== WebView2 auth dependency ===\n");
14322 let ps_webview = r#"
14323$paths = @(
14324 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14325 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14326) | Where-Object { $_ -and (Test-Path $_) }
14327$runtimeDir = $paths | ForEach-Object {
14328 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14329 Where-Object { $_.Name -match '^\d+\.' } |
14330 Sort-Object Name -Descending |
14331 Select-Object -First 1
14332} | Select-Object -First 1
14333if ($runtimeDir) {
14334 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14335 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14336 "WebView2: Installed | Version: $version"
14337} else {
14338 "WebView2: Not installed"
14339}
14340"#;
14341 match run_powershell(ps_webview) {
14342 Ok(o) if !o.trim().is_empty() => {
14343 for line in o.lines().take(4) {
14344 let l = line.trim();
14345 if !l.is_empty() {
14346 out.push_str(&format!("- {l}\n"));
14347 }
14348 }
14349 }
14350 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14351 }
14352
14353 out.push_str("\n=== Recent auth-related events (24h) ===\n");
14354 let ps_events = r#"
14355try {
14356 $cutoff = (Get-Date).AddHours(-24)
14357 $events = @()
14358 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
14359 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
14360 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
14361 Select-Object -First 4
14362 }
14363 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
14364 Where-Object {
14365 ($_.LevelDisplayName -in @('Error','Warning')) -and (
14366 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
14367 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
14368 )
14369 } |
14370 Select-Object -First 6
14371 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
14372 "AuthEventCount: $(@($events).Count)"
14373 if ($events) {
14374 foreach ($e in $events) {
14375 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
14376 'No message'
14377 } else {
14378 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
14379 }
14380 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
14381 }
14382 } else {
14383 "No auth-related warning/error events detected"
14384 }
14385} catch {
14386 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
14387}
14388"#;
14389 match run_powershell(ps_events) {
14390 Ok(o) if !o.trim().is_empty() => {
14391 for line in o.lines().take(max_entries + 8) {
14392 let l = line.trim();
14393 if !l.is_empty() {
14394 out.push_str(&format!("- {l}\n"));
14395 }
14396 }
14397 }
14398 _ => out
14399 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
14400 }
14401
14402 let parse_count = |prefix: &str| -> Option<u64> {
14403 out.lines().find_map(|line| {
14404 line.trim()
14405 .strip_prefix(prefix)
14406 .and_then(|value| value.trim().parse::<u64>().ok())
14407 })
14408 };
14409
14410 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
14411 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
14412
14413 let mut findings: Vec<String> = Vec::new();
14414 if out.contains("TokenBroker | Status: Stopped")
14415 || out.contains("wlidsvc | Status: Stopped")
14416 || out.contains("OneAuth | Status: Stopped")
14417 {
14418 findings.push(
14419 "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."
14420 .into(),
14421 );
14422 }
14423 if out.contains("AADBrokerPlugin: Not installed") {
14424 findings.push(
14425 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
14426 .into(),
14427 );
14428 }
14429 if out.contains("WebView2: Not installed") {
14430 findings.push(
14431 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
14432 .into(),
14433 );
14434 }
14435 if distinct_identity_count > 1 {
14436 findings.push(format!(
14437 "{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."
14438 ));
14439 }
14440 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
14441 && distinct_identity_count > 0
14442 {
14443 findings.push(
14444 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
14445 .into(),
14446 );
14447 }
14448 if out.contains("DeviceRegistration: dsregcmd")
14449 || out.contains("DeviceRegistration: Could not inspect device registration state")
14450 {
14451 findings.push(
14452 "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."
14453 .into(),
14454 );
14455 }
14456 if auth_event_count > 0 {
14457 findings.push(format!(
14458 "{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."
14459 ));
14460 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
14461 findings.push(
14462 "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."
14463 .into(),
14464 );
14465 }
14466
14467 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
14468 if findings.is_empty() {
14469 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
14470 } else {
14471 for finding in &findings {
14472 result.push_str(&format!("- Finding: {finding}\n"));
14473 }
14474 }
14475 result.push('\n');
14476 result.push_str(&out);
14477 Ok(result)
14478}
14479
14480#[cfg(not(windows))]
14481fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
14482 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())
14483}
14484
14485#[cfg(windows)]
14486fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14487 let mut out = String::from("=== File History ===\n");
14488
14489 let ps_fh = r#"
14490$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
14491if ($svc) {
14492 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
14493} else {
14494 "FileHistoryService: Not found"
14495}
14496# File History config in registry
14497$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
14498$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
14499if (Test-Path $fhUser) {
14500 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
14501 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
14502 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
14503 $lastBackup = if ($fh.ProtectedUpToTime) {
14504 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
14505 } else { 'Never' }
14506 "Enabled: $enabled"
14507 "BackupDrive: $target"
14508 "LastBackup: $lastBackup"
14509} else {
14510 "Enabled: Not configured"
14511 "BackupDrive: Not configured"
14512 "LastBackup: Never"
14513}
14514"#;
14515 match run_powershell(ps_fh) {
14516 Ok(o) if !o.trim().is_empty() => {
14517 for line in o.lines().take(6) {
14518 let l = line.trim();
14519 if !l.is_empty() {
14520 out.push_str(&format!("- {l}\n"));
14521 }
14522 }
14523 }
14524 _ => out.push_str("- Could not inspect File History state\n"),
14525 }
14526
14527 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
14528 let ps_wbadmin = r#"
14529$svc = Get-Service wbengine -ErrorAction SilentlyContinue
14530"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
14531# Last backup from wbadmin
14532$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
14533if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
14534 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
14535 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
14536 if ($lastDate) { $lastDate.Trim() }
14537 if ($lastTarget) { $lastTarget.Trim() }
14538} else {
14539 "LastWbadminBackup: No backup versions found"
14540}
14541# Task-based backup
14542$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
14543foreach ($t in $task) {
14544 "BackupTask: $($t.TaskName) | State: $($t.State)"
14545}
14546"#;
14547 match run_powershell(ps_wbadmin) {
14548 Ok(o) if !o.trim().is_empty() => {
14549 for line in o.lines().take(8) {
14550 let l = line.trim();
14551 if !l.is_empty() {
14552 out.push_str(&format!("- {l}\n"));
14553 }
14554 }
14555 }
14556 _ => out.push_str("- Could not inspect Windows Backup state\n"),
14557 }
14558
14559 out.push_str("\n=== System Restore ===\n");
14560 let ps_sr = r#"
14561$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
14562 Select-Object -ExpandProperty DeviceID
14563foreach ($drive in $drives) {
14564 $protection = try {
14565 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
14566 } catch { $null }
14567 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
14568 $rpConf = try {
14569 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
14570 } catch { $null }
14571 # Check if SR is disabled for this drive
14572 $disabled = $false
14573 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
14574 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
14575}
14576# Most recent restore point
14577$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
14578if ($points) {
14579 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
14580 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
14581 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
14582} else {
14583 "MostRecentRestorePoint: None found"
14584}
14585$srEnabled = try {
14586 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
14587 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
14588} catch { 'Unknown' }
14589"SystemRestoreState: $srEnabled"
14590"#;
14591 match run_powershell(ps_sr) {
14592 Ok(o) if !o.trim().is_empty() => {
14593 for line in o.lines().take(8) {
14594 let l = line.trim();
14595 if !l.is_empty() {
14596 out.push_str(&format!("- {l}\n"));
14597 }
14598 }
14599 }
14600 _ => out.push_str("- Could not inspect System Restore state\n"),
14601 }
14602
14603 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
14604 let ps_kfm = r#"
14605$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
14606if (Test-Path $kfmKey) {
14607 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
14608 foreach ($acct in $accounts | Select-Object -First 3) {
14609 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
14610 $email = $props.UserEmail
14611 $kfmDesktop = $props.'KFMSilentOptInDesktop'
14612 $kfmDocs = $props.'KFMSilentOptInDocuments'
14613 $kfmPics = $props.'KFMSilentOptInPictures'
14614 "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' })"
14615 }
14616} else {
14617 "OneDriveKFM: No OneDrive accounts found"
14618}
14619"#;
14620 match run_powershell(ps_kfm) {
14621 Ok(o) if !o.trim().is_empty() => {
14622 for line in o.lines().take(6) {
14623 let l = line.trim();
14624 if !l.is_empty() {
14625 out.push_str(&format!("- {l}\n"));
14626 }
14627 }
14628 }
14629 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
14630 }
14631
14632 out.push_str("\n=== Recent backup failure events (7d) ===\n");
14633 let ps_events = r#"
14634$cutoff = (Get-Date).AddDays(-7)
14635$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14636 Where-Object {
14637 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
14638 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
14639 } |
14640 Where-Object { $_.Level -le 3 } |
14641 Select-Object -First 6
14642if ($events) {
14643 foreach ($event in $events) {
14644 $msg = ($event.Message -replace '\s+', ' ')
14645 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14646 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14647 }
14648} else {
14649 "No recent backup failure events detected"
14650}
14651"#;
14652 match run_powershell(ps_events) {
14653 Ok(o) if !o.trim().is_empty() => {
14654 for line in o.lines().take(8) {
14655 let l = line.trim();
14656 if !l.is_empty() {
14657 out.push_str(&format!("- {l}\n"));
14658 }
14659 }
14660 }
14661 _ => out.push_str("- Could not inspect backup failure events\n"),
14662 }
14663
14664 let mut findings: Vec<String> = Vec::new();
14665
14666 let fh_enabled = out.contains("- Enabled: Enabled");
14667 let fh_never =
14668 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
14669 let no_wbadmin = out.contains("No backup versions found");
14670 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
14671
14672 if !fh_enabled && no_wbadmin {
14673 findings.push(
14674 "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(),
14675 );
14676 } else if fh_enabled && fh_never {
14677 findings.push(
14678 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
14679 );
14680 }
14681
14682 if no_restore_point {
14683 findings.push(
14684 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
14685 );
14686 }
14687
14688 if out.contains("- FileHistoryService: Stopped")
14689 || out.contains("- FileHistoryService: Not found")
14690 {
14691 findings.push(
14692 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
14693 );
14694 }
14695
14696 if out.contains("Application Error |")
14697 || out.contains("Microsoft-Windows-Backup |")
14698 || out.contains("wbengine |")
14699 {
14700 findings.push(
14701 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
14702 );
14703 }
14704
14705 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
14706 if findings.is_empty() {
14707 result.push_str("- No obvious backup health blocker detected.\n");
14708 } else {
14709 for finding in &findings {
14710 result.push_str(&format!("- Finding: {finding}\n"));
14711 }
14712 }
14713 result.push('\n');
14714 result.push_str(&out);
14715 Ok(result)
14716}
14717
14718#[cfg(not(windows))]
14719fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14720 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
14721}
14722
14723#[cfg(windows)]
14724fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
14725 let mut out = String::from("=== Windows Search service ===\n");
14726
14727 let ps_svc = r#"
14729$svc = Get-Service WSearch -ErrorAction SilentlyContinue
14730if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
14731else { "WSearch service not found" }
14732"#;
14733 match run_powershell(ps_svc) {
14734 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
14735 Err(_) => out.push_str("- Could not query WSearch service\n"),
14736 }
14737
14738 out.push_str("\n=== Indexer state ===\n");
14740 let ps_idx = r#"
14741$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
14742$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
14743if ($props) {
14744 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
14745 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
14746 "DataDirectory: $($props.DataDirectory)"
14747} else { "Registry key not found" }
14748"#;
14749 match run_powershell(ps_idx) {
14750 Ok(o) => {
14751 for line in o.lines() {
14752 let l = line.trim();
14753 if !l.is_empty() {
14754 out.push_str(&format!("- {l}\n"));
14755 }
14756 }
14757 }
14758 Err(_) => out.push_str("- Could not read indexer registry\n"),
14759 }
14760
14761 out.push_str("\n=== Indexed locations ===\n");
14763 let ps_locs = r#"
14764$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
14765if ($comObj) {
14766 $catalog = $comObj.GetCatalog('SystemIndex')
14767 $manager = $catalog.GetCrawlScopeManager()
14768 $rules = $manager.EnumerateRoots()
14769 while ($true) {
14770 try {
14771 $root = $rules.Next(1)
14772 if ($root.Count -eq 0) { break }
14773 $r = $root[0]
14774 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
14775 } catch { break }
14776 }
14777} else { " COM admin interface not available (normal on non-admin sessions)" }
14778"#;
14779 match run_powershell(ps_locs) {
14780 Ok(o) if !o.trim().is_empty() => {
14781 for line in o.lines() {
14782 let l = line.trim_end();
14783 if !l.is_empty() {
14784 out.push_str(&format!("{l}\n"));
14785 }
14786 }
14787 }
14788 _ => {
14789 let ps_reg = r#"
14791Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
14792ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
14793"#;
14794 match run_powershell(ps_reg) {
14795 Ok(o) if !o.trim().is_empty() => {
14796 for line in o.lines() {
14797 let l = line.trim_end();
14798 if !l.is_empty() {
14799 out.push_str(&format!("{l}\n"));
14800 }
14801 }
14802 }
14803 _ => out.push_str(" - Could not enumerate indexed locations\n"),
14804 }
14805 }
14806 }
14807
14808 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
14810 let ps_evts = r#"
14811Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
14812Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
14813ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
14814"#;
14815 match run_powershell(ps_evts) {
14816 Ok(o) if !o.trim().is_empty() => {
14817 for line in o.lines() {
14818 let l = line.trim();
14819 if !l.is_empty() {
14820 out.push_str(&format!("- {l}\n"));
14821 }
14822 }
14823 }
14824 _ => out.push_str("- No recent indexer errors found\n"),
14825 }
14826
14827 let mut findings: Vec<String> = Vec::new();
14828 if out.contains("Status: Stopped") {
14829 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
14830 }
14831 if out.contains("IsContentIndexingEnabled: 0")
14832 || out.contains("IsContentIndexingEnabled: False")
14833 {
14834 findings.push(
14835 "Content indexing is disabled — file content won't be searchable, only filenames."
14836 .into(),
14837 );
14838 }
14839 if out.contains("SetupCompletedSuccessfully: 0")
14840 || out.contains("SetupCompletedSuccessfully: False")
14841 {
14842 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
14843 }
14844
14845 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
14846 if findings.is_empty() {
14847 result.push_str("- Windows Search service and indexer appear healthy.\n");
14848 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
14849 } else {
14850 for f in &findings {
14851 result.push_str(&format!("- Finding: {f}\n"));
14852 }
14853 }
14854 result.push('\n');
14855 result.push_str(&out);
14856 Ok(result)
14857}
14858
14859#[cfg(not(windows))]
14860fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
14861 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
14862}
14863
14864#[cfg(windows)]
14867fn inspect_display_config(max_entries: usize) -> Result<String, String> {
14868 let mut out = String::new();
14869
14870 out.push_str("=== Active displays ===\n");
14872 let ps_displays = r#"
14873Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
14874Select-Object -First 20 |
14875ForEach-Object {
14876 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
14877}
14878"#;
14879 match run_powershell(ps_displays) {
14880 Ok(o) if !o.trim().is_empty() => {
14881 for line in o.lines().take(max_entries) {
14882 let l = line.trim();
14883 if !l.is_empty() {
14884 out.push_str(&format!("- {l}\n"));
14885 }
14886 }
14887 }
14888 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
14889 }
14890
14891 out.push_str("\n=== Video adapters ===\n");
14893 let ps_gpu = r#"
14894Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
14895ForEach-Object {
14896 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
14897 $hz = "$($_.CurrentRefreshRate) Hz"
14898 $bits = "$($_.CurrentBitsPerPixel) bpp"
14899 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
14900}
14901"#;
14902 match run_powershell(ps_gpu) {
14903 Ok(o) if !o.trim().is_empty() => {
14904 for line in o.lines().take(max_entries) {
14905 let l = line.trim();
14906 if !l.is_empty() {
14907 out.push_str(&format!("- {l}\n"));
14908 }
14909 }
14910 }
14911 _ => out.push_str("- Could not query video adapter info\n"),
14912 }
14913
14914 out.push_str("\n=== Connected monitors ===\n");
14916 let ps_monitors = r#"
14917Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
14918ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
14919"#;
14920 match run_powershell(ps_monitors) {
14921 Ok(o) if !o.trim().is_empty() => {
14922 for line in o.lines().take(max_entries) {
14923 let l = line.trim();
14924 if !l.is_empty() {
14925 out.push_str(&format!("- {l}\n"));
14926 }
14927 }
14928 }
14929 _ => out.push_str("- No monitor info available via WMI\n"),
14930 }
14931
14932 out.push_str("\n=== DPI / scaling ===\n");
14934 let ps_dpi = r#"
14935Add-Type -TypeDefinition @'
14936using System; using System.Runtime.InteropServices;
14937public class DPI {
14938 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
14939 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
14940 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
14941}
14942'@ -ErrorAction SilentlyContinue
14943try {
14944 $hdc = [DPI]::GetDC([IntPtr]::Zero)
14945 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
14946 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
14947 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
14948 $scale = [Math]::Round($dpiX / 96.0 * 100)
14949 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
14950} catch { "DPI query unavailable" }
14951"#;
14952 match run_powershell(ps_dpi) {
14953 Ok(o) if !o.trim().is_empty() => {
14954 out.push_str(&format!("- {}\n", o.trim()));
14955 }
14956 _ => out.push_str("- DPI info unavailable\n"),
14957 }
14958
14959 let mut findings: Vec<String> = Vec::new();
14960 if out.contains("0x0") || out.contains("@ 0 Hz") {
14961 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
14962 }
14963
14964 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
14965 if findings.is_empty() {
14966 result.push_str("- Display configuration appears normal.\n");
14967 } else {
14968 for f in &findings {
14969 result.push_str(&format!("- Finding: {f}\n"));
14970 }
14971 }
14972 result.push('\n');
14973 result.push_str(&out);
14974 Ok(result)
14975}
14976
14977#[cfg(not(windows))]
14978fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
14979 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
14980}
14981
14982#[cfg(windows)]
14985fn inspect_ntp() -> Result<String, String> {
14986 let mut out = String::new();
14987
14988 out.push_str("=== Windows Time service ===\n");
14990 let ps_svc = r#"
14991$svc = Get-Service W32Time -ErrorAction SilentlyContinue
14992if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
14993else { "W32Time service not found" }
14994"#;
14995 match run_powershell(ps_svc) {
14996 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
14997 Err(_) => out.push_str("- Could not query W32Time service\n"),
14998 }
14999
15000 out.push_str("\n=== NTP source and sync status ===\n");
15002 let ps_sync = r#"
15003$q = w32tm /query /status 2>$null
15004if ($q) { $q } else { "w32tm query unavailable" }
15005"#;
15006 match run_powershell(ps_sync) {
15007 Ok(o) if !o.trim().is_empty() => {
15008 for line in o.lines() {
15009 let l = line.trim();
15010 if !l.is_empty() {
15011 out.push_str(&format!(" {l}\n"));
15012 }
15013 }
15014 }
15015 _ => out.push_str(" - Could not query w32tm status\n"),
15016 }
15017
15018 out.push_str("\n=== Configured NTP servers ===\n");
15020 let ps_peers = r#"
15021w32tm /query /peers 2>$null | Select-Object -First 10
15022"#;
15023 match run_powershell(ps_peers) {
15024 Ok(o) if !o.trim().is_empty() => {
15025 for line in o.lines() {
15026 let l = line.trim();
15027 if !l.is_empty() {
15028 out.push_str(&format!(" {l}\n"));
15029 }
15030 }
15031 }
15032 _ => {
15033 let ps_reg = r#"
15035(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15036"#;
15037 match run_powershell(ps_reg) {
15038 Ok(o) if !o.trim().is_empty() => {
15039 out.push_str(&format!(" NtpServer (registry): {}\n", o.trim()));
15040 }
15041 _ => out.push_str(" - Could not enumerate NTP peers\n"),
15042 }
15043 }
15044 }
15045
15046 let mut findings: Vec<String> = Vec::new();
15047 if out.contains("W32Time | Status: Stopped") {
15048 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15049 }
15050 if out.contains("The computer did not resync") || out.contains("Error") {
15051 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15052 }
15053
15054 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15055 if findings.is_empty() {
15056 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15057 } else {
15058 for f in &findings {
15059 result.push_str(&format!("- Finding: {f}\n"));
15060 }
15061 }
15062 result.push('\n');
15063 result.push_str(&out);
15064 Ok(result)
15065}
15066
15067#[cfg(not(windows))]
15068fn inspect_ntp() -> Result<String, String> {
15069 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15071
15072 let timedatectl = std::process::Command::new("timedatectl")
15073 .arg("status")
15074 .output();
15075
15076 if let Ok(o) = timedatectl {
15077 let text = String::from_utf8_lossy(&o.stdout);
15078 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15079 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15080 } else {
15081 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15082 }
15083 for line in text.lines() {
15084 let l = line.trim();
15085 if !l.is_empty() {
15086 out.push_str(&format!(" {l}\n"));
15087 }
15088 }
15089 return Ok(out);
15090 }
15091
15092 let sntp = std::process::Command::new("sntp")
15094 .args(["-d", "time.apple.com"])
15095 .output();
15096 if let Ok(o) = sntp {
15097 out.push_str("- NTP check via sntp:\n");
15098 out.push_str(&String::from_utf8_lossy(&o.stdout));
15099 return Ok(out);
15100 }
15101
15102 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15103 Ok(out)
15104}
15105
15106#[cfg(windows)]
15109fn inspect_cpu_power() -> Result<String, String> {
15110 let mut out = String::new();
15111
15112 out.push_str("=== Active power plan ===\n");
15114 let ps_plan = r#"
15115$plan = powercfg /getactivescheme 2>$null
15116if ($plan) { $plan } else { "Could not query power scheme" }
15117"#;
15118 match run_powershell(ps_plan) {
15119 Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15120 _ => out.push_str("- Could not read active power plan\n"),
15121 }
15122
15123 out.push_str("\n=== Processor performance policy ===\n");
15125 let ps_proc = r#"
15126$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15127$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15128$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15129$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15130if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15131if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15132if ($boost) {
15133 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15134 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15135 "Turbo boost mode: $bname"
15136}
15137"#;
15138 match run_powershell(ps_proc) {
15139 Ok(o) if !o.trim().is_empty() => {
15140 for line in o.lines() {
15141 let l = line.trim();
15142 if !l.is_empty() {
15143 out.push_str(&format!("- {l}\n"));
15144 }
15145 }
15146 }
15147 _ => out.push_str("- Could not query processor performance settings\n"),
15148 }
15149
15150 out.push_str("\n=== CPU frequency ===\n");
15152 let ps_freq = r#"
15153Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15154ForEach-Object {
15155 $cur = $_.CurrentClockSpeed
15156 $max = $_.MaxClockSpeed
15157 $load = $_.LoadPercentage
15158 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15159}
15160"#;
15161 match run_powershell(ps_freq) {
15162 Ok(o) if !o.trim().is_empty() => {
15163 for line in o.lines() {
15164 let l = line.trim();
15165 if !l.is_empty() {
15166 out.push_str(&format!("- {l}\n"));
15167 }
15168 }
15169 }
15170 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15171 }
15172
15173 out.push_str("\n=== Throttling indicators ===\n");
15175 let ps_throttle = r#"
15176$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15177if ($pwr) {
15178 $pwr | Select-Object -First 4 | ForEach-Object {
15179 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15180 "Thermal zone $($_.InstanceName): ${c}°C"
15181 }
15182} else { "Thermal zone WMI not available (normal on consumer hardware)" }
15183"#;
15184 match run_powershell(ps_throttle) {
15185 Ok(o) if !o.trim().is_empty() => {
15186 for line in o.lines() {
15187 let l = line.trim();
15188 if !l.is_empty() {
15189 out.push_str(&format!("- {l}\n"));
15190 }
15191 }
15192 }
15193 _ => out.push_str("- Thermal zone info unavailable\n"),
15194 }
15195
15196 let mut findings: Vec<String> = Vec::new();
15197 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
15198 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
15199 }
15200 if out.contains("Turbo boost mode: Disabled") {
15201 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
15202 }
15203 if out.contains("Min processor state: 100%") {
15204 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
15205 }
15206
15207 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
15208 if findings.is_empty() {
15209 result.push_str("- CPU power and frequency settings appear normal.\n");
15210 } else {
15211 for f in &findings {
15212 result.push_str(&format!("- Finding: {f}\n"));
15213 }
15214 }
15215 result.push('\n');
15216 result.push_str(&out);
15217 Ok(result)
15218}
15219
15220#[cfg(windows)]
15221fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15222 let mut out = String::new();
15223
15224 out.push_str("=== Credential vault summary ===\n");
15225 let ps_summary = r#"
15226$raw = cmdkey /list 2>&1
15227$lines = $raw -split "`n"
15228$total = ($lines | Where-Object { $_ -match "Target:" }).Count
15229"Total stored credentials: $total"
15230$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
15231$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
15232$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
15233" Windows credentials: $windows"
15234" Generic credentials: $generic"
15235" Certificate-based: $cert"
15236"#;
15237 match run_powershell(ps_summary) {
15238 Ok(o) => {
15239 for line in o.lines() {
15240 let l = line.trim();
15241 if !l.is_empty() {
15242 out.push_str(&format!("- {l}\n"));
15243 }
15244 }
15245 }
15246 Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
15247 }
15248
15249 out.push_str("\n=== Credential targets (up to 20) ===\n");
15250 let ps_list = r#"
15251$raw = cmdkey /list 2>&1
15252$entries = @(); $cur = @{}
15253foreach ($line in ($raw -split "`n")) {
15254 $l = $line.Trim()
15255 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
15256 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
15257 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
15258}
15259$entries | Select-Object -Last 20 | ForEach-Object {
15260 "[$($_.Type)] $($_.Target) (user: $($_.User))"
15261}
15262"#;
15263 match run_powershell(ps_list) {
15264 Ok(o) => {
15265 let lines: Vec<&str> = o
15266 .lines()
15267 .map(|l| l.trim())
15268 .filter(|l| !l.is_empty())
15269 .collect();
15270 if lines.is_empty() {
15271 out.push_str("- No credential entries found\n");
15272 } else {
15273 for l in &lines {
15274 out.push_str(&format!("- {l}\n"));
15275 }
15276 }
15277 }
15278 Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
15279 }
15280
15281 let total_creds: usize = {
15282 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
15283 run_powershell(ps_count)
15284 .ok()
15285 .and_then(|s| s.trim().parse().ok())
15286 .unwrap_or(0)
15287 };
15288
15289 let mut findings: Vec<String> = Vec::new();
15290 if total_creds > 30 {
15291 findings.push(format!(
15292 "{total_creds} stored credentials found — consider auditing for stale entries."
15293 ));
15294 }
15295
15296 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
15297 if findings.is_empty() {
15298 result.push_str("- Credential store looks normal.\n");
15299 } else {
15300 for f in &findings {
15301 result.push_str(&format!("- Finding: {f}\n"));
15302 }
15303 }
15304 result.push('\n');
15305 result.push_str(&out);
15306 Ok(result)
15307}
15308
15309#[cfg(not(windows))]
15310fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15311 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
15312}
15313
15314#[cfg(windows)]
15315fn inspect_tpm() -> Result<String, String> {
15316 let mut out = String::new();
15317
15318 out.push_str("=== TPM state ===\n");
15319 let ps_tpm = r#"
15320function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
15321 $text = if ($null -eq $Value) { "" } else { [string]$Value }
15322 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
15323 "$Name$text"
15324}
15325$t = Get-Tpm -ErrorAction SilentlyContinue
15326if ($t) {
15327 Emit-Field "TpmPresent: " $t.TpmPresent
15328 Emit-Field "TpmReady: " $t.TpmReady
15329 Emit-Field "TpmEnabled: " $t.TpmEnabled
15330 Emit-Field "TpmOwned: " $t.TpmOwned
15331 Emit-Field "RestartPending: " $t.RestartPending
15332 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
15333 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
15334} else { "TPM module unavailable" }
15335"#;
15336 match run_powershell(ps_tpm) {
15337 Ok(o) => {
15338 for line in o.lines() {
15339 let l = line.trim();
15340 if !l.is_empty() {
15341 out.push_str(&format!("- {l}\n"));
15342 }
15343 }
15344 }
15345 Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
15346 }
15347
15348 out.push_str("\n=== TPM spec version (WMI) ===\n");
15349 let ps_spec = r#"
15350$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
15351if ($wmi) {
15352 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
15353 "SpecVersion: $spec"
15354 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
15355 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
15356 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
15357} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
15358"#;
15359 match run_powershell(ps_spec) {
15360 Ok(o) => {
15361 for line in o.lines() {
15362 let l = line.trim();
15363 if !l.is_empty() {
15364 out.push_str(&format!("- {l}\n"));
15365 }
15366 }
15367 }
15368 Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
15369 }
15370
15371 out.push_str("\n=== Secure Boot state ===\n");
15372 let ps_sb = r#"
15373try {
15374 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
15375 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
15376} catch {
15377 $msg = $_.Exception.Message
15378 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
15379 "Secure Boot: Unknown (administrator privileges required)"
15380 } elseif ($msg -match "Cmdlet not supported on this platform") {
15381 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
15382 } else {
15383 "Secure Boot: N/A ($msg)"
15384 }
15385}
15386"#;
15387 match run_powershell(ps_sb) {
15388 Ok(o) => {
15389 for line in o.lines() {
15390 let l = line.trim();
15391 if !l.is_empty() {
15392 out.push_str(&format!("- {l}\n"));
15393 }
15394 }
15395 }
15396 Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
15397 }
15398
15399 out.push_str("\n=== Firmware type ===\n");
15400 let ps_fw = r#"
15401$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
15402switch ($fw) {
15403 1 { "Firmware type: BIOS (Legacy)" }
15404 2 { "Firmware type: UEFI" }
15405 default {
15406 $bcd = bcdedit /enum firmware 2>$null
15407 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
15408 else { "Firmware type: Unknown or not set" }
15409 }
15410}
15411"#;
15412 match run_powershell(ps_fw) {
15413 Ok(o) => {
15414 for line in o.lines() {
15415 let l = line.trim();
15416 if !l.is_empty() {
15417 out.push_str(&format!("- {l}\n"));
15418 }
15419 }
15420 }
15421 Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
15422 }
15423
15424 let mut findings: Vec<String> = Vec::new();
15425 let mut indeterminate = false;
15426 if out.contains("TpmPresent: False") {
15427 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
15428 }
15429 if out.contains("TpmReady: False") {
15430 findings.push(
15431 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
15432 );
15433 }
15434 if out.contains("SpecVersion: 1.2") {
15435 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
15436 }
15437 if out.contains("Secure Boot: DISABLED") {
15438 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
15439 }
15440 if out.contains("Firmware type: BIOS (Legacy)") {
15441 findings.push(
15442 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
15443 );
15444 }
15445
15446 if out.contains("TPM module unavailable")
15447 || out.contains("Win32_Tpm WMI class unavailable")
15448 || out.contains("Secure Boot: N/A")
15449 || out.contains("Secure Boot: Unknown")
15450 || out.contains("Firmware type: Unknown or not set")
15451 || out.contains("TpmPresent: Unknown")
15452 || out.contains("TpmReady: Unknown")
15453 || out.contains("TpmEnabled: Unknown")
15454 {
15455 indeterminate = true;
15456 }
15457 if indeterminate {
15458 findings.push(
15459 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
15460 .into(),
15461 );
15462 }
15463
15464 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
15465 if findings.is_empty() {
15466 result.push_str("- TPM and Secure Boot appear healthy.\n");
15467 } else {
15468 for f in &findings {
15469 result.push_str(&format!("- Finding: {f}\n"));
15470 }
15471 }
15472 result.push('\n');
15473 result.push_str(&out);
15474 Ok(result)
15475}
15476
15477#[cfg(not(windows))]
15478fn inspect_tpm() -> Result<String, String> {
15479 Ok(
15480 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
15481 .into(),
15482 )
15483}
15484
15485#[cfg(windows)]
15486fn inspect_latency() -> Result<String, String> {
15487 let mut out = String::new();
15488
15489 let ps_gw = r#"
15491$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
15492 Sort-Object RouteMetric | Select-Object -First 1).NextHop
15493if ($gw) { $gw } else { "" }
15494"#;
15495 let gateway = run_powershell(ps_gw)
15496 .ok()
15497 .map(|s| s.trim().to_string())
15498 .filter(|s| !s.is_empty());
15499
15500 let targets: Vec<(&str, String)> = {
15501 let mut t = Vec::new();
15502 if let Some(ref gw) = gateway {
15503 t.push(("Default gateway", gw.clone()));
15504 }
15505 t.push(("Cloudflare DNS", "1.1.1.1".into()));
15506 t.push(("Google DNS", "8.8.8.8".into()));
15507 t
15508 };
15509
15510 let mut findings: Vec<String> = Vec::new();
15511
15512 for (label, host) in &targets {
15513 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15514 let ps_ping = format!(
15516 r#"
15517$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
15518if ($r) {{
15519 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
15520 $min = ($rtts | Measure-Object -Minimum).Minimum
15521 $max = ($rtts | Measure-Object -Maximum).Maximum
15522 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
15523 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
15524 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
15525 "Packet loss: ${{loss}}%"
15526 "Sent: 4 Received: $($r.Count)"
15527}} else {{
15528 "UNREACHABLE — 100% packet loss"
15529}}
15530"#
15531 );
15532 match run_powershell(&ps_ping) {
15533 Ok(o) => {
15534 let body = o.trim().to_string();
15535 for line in body.lines() {
15536 let l = line.trim();
15537 if !l.is_empty() {
15538 out.push_str(&format!("- {l}\n"));
15539 }
15540 }
15541 if body.contains("UNREACHABLE") {
15542 findings.push(format!(
15543 "{label} ({host}) is unreachable — possible routing or firewall issue."
15544 ));
15545 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
15546 let pct: u32 = loss_line
15547 .chars()
15548 .filter(|c| c.is_ascii_digit())
15549 .collect::<String>()
15550 .parse()
15551 .unwrap_or(0);
15552 if pct >= 25 {
15553 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
15554 }
15555 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
15557 let parts: Vec<&str> = rtt_line.split('/').collect();
15559 if parts.len() >= 2 {
15560 let avg_str: String =
15561 parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
15562 let avg: u32 = avg_str.parse().unwrap_or(0);
15563 if avg > 150 {
15564 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
15565 }
15566 }
15567 }
15568 }
15569 }
15570 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
15571 }
15572 }
15573
15574 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
15575 if findings.is_empty() {
15576 result.push_str("- Latency and reachability look normal.\n");
15577 } else {
15578 for f in &findings {
15579 result.push_str(&format!("- Finding: {f}\n"));
15580 }
15581 }
15582 result.push('\n');
15583 result.push_str(&out);
15584 Ok(result)
15585}
15586
15587#[cfg(not(windows))]
15588fn inspect_latency() -> Result<String, String> {
15589 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
15590 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
15591 let mut findings: Vec<String> = Vec::new();
15592
15593 for (label, host) in &targets {
15594 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15595 let ping = std::process::Command::new("ping")
15596 .args(["-c", "4", "-W", "2", host])
15597 .output();
15598 match ping {
15599 Ok(o) => {
15600 let body = String::from_utf8_lossy(&o.stdout).into_owned();
15601 for line in body.lines() {
15602 let l = line.trim();
15603 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
15604 out.push_str(&format!("- {l}\n"));
15605 }
15606 }
15607 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
15608 findings.push(format!("{label} ({host}) is unreachable."));
15609 }
15610 }
15611 Err(e) => out.push_str(&format!("- ping error: {e}\n")),
15612 }
15613 }
15614
15615 if findings.is_empty() {
15616 out.insert_str(
15617 "Host inspection: latency\n\n=== Findings ===\n".len(),
15618 "- Latency and reachability look normal.\n",
15619 );
15620 } else {
15621 let mut prefix = String::new();
15622 for f in &findings {
15623 prefix.push_str(&format!("- Finding: {f}\n"));
15624 }
15625 out.insert_str(
15626 "Host inspection: latency\n\n=== Findings ===\n".len(),
15627 &prefix,
15628 );
15629 }
15630 Ok(out)
15631}
15632
15633#[cfg(windows)]
15634fn inspect_network_adapter() -> Result<String, String> {
15635 let mut out = String::new();
15636
15637 out.push_str("=== Network adapters ===\n");
15638 let ps_adapters = r#"
15639Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
15640 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
15641 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
15642}
15643"#;
15644 match run_powershell(ps_adapters) {
15645 Ok(o) => {
15646 for line in o.lines() {
15647 let l = line.trim();
15648 if !l.is_empty() {
15649 out.push_str(&format!("- {l}\n"));
15650 }
15651 }
15652 }
15653 Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
15654 }
15655
15656 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
15657 let ps_offload = r#"
15658Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15659 $name = $_.Name
15660 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
15661 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
15662 Select-Object DisplayName, DisplayValue
15663 if ($props) {
15664 "--- $name ---"
15665 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
15666 }
15667}
15668"#;
15669 match run_powershell(ps_offload) {
15670 Ok(o) => {
15671 let lines: Vec<&str> = o
15672 .lines()
15673 .map(|l| l.trim())
15674 .filter(|l| !l.is_empty())
15675 .collect();
15676 if lines.is_empty() {
15677 out.push_str(
15678 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
15679 );
15680 } else {
15681 for l in &lines {
15682 out.push_str(&format!("- {l}\n"));
15683 }
15684 }
15685 }
15686 Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
15687 }
15688
15689 out.push_str("\n=== Adapter error counters ===\n");
15690 let ps_errors = r#"
15691Get-NetAdapterStatistics | ForEach-Object {
15692 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
15693 if ($errs -gt 0) {
15694 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
15695 }
15696}
15697"#;
15698 match run_powershell(ps_errors) {
15699 Ok(o) => {
15700 let lines: Vec<&str> = o
15701 .lines()
15702 .map(|l| l.trim())
15703 .filter(|l| !l.is_empty())
15704 .collect();
15705 if lines.is_empty() {
15706 out.push_str("- No adapter errors or discards detected.\n");
15707 } else {
15708 for l in &lines {
15709 out.push_str(&format!("- {l}\n"));
15710 }
15711 }
15712 }
15713 Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
15714 }
15715
15716 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
15717 let ps_wol = r#"
15718Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15719 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
15720 if ($wol) {
15721 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
15722 }
15723}
15724"#;
15725 match run_powershell(ps_wol) {
15726 Ok(o) => {
15727 let lines: Vec<&str> = o
15728 .lines()
15729 .map(|l| l.trim())
15730 .filter(|l| !l.is_empty())
15731 .collect();
15732 if lines.is_empty() {
15733 out.push_str("- Power management data unavailable for active adapters.\n");
15734 } else {
15735 for l in &lines {
15736 out.push_str(&format!("- {l}\n"));
15737 }
15738 }
15739 }
15740 Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
15741 }
15742
15743 let mut findings: Vec<String> = Vec::new();
15744 if out.contains("RX errors:") || out.contains("TX errors:") {
15746 findings
15747 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
15748 }
15749 if out.contains("Half") {
15751 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
15752 }
15753
15754 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
15755 if findings.is_empty() {
15756 result.push_str("- Network adapter configuration looks normal.\n");
15757 } else {
15758 for f in &findings {
15759 result.push_str(&format!("- Finding: {f}\n"));
15760 }
15761 }
15762 result.push('\n');
15763 result.push_str(&out);
15764 Ok(result)
15765}
15766
15767#[cfg(not(windows))]
15768fn inspect_network_adapter() -> Result<String, String> {
15769 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
15770
15771 out.push_str("=== Network adapters (ip link) ===\n");
15772 let ip_link = std::process::Command::new("ip")
15773 .args(["link", "show"])
15774 .output();
15775 if let Ok(o) = ip_link {
15776 for line in String::from_utf8_lossy(&o.stdout).lines() {
15777 let l = line.trim();
15778 if !l.is_empty() {
15779 out.push_str(&format!("- {l}\n"));
15780 }
15781 }
15782 }
15783
15784 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
15785 let ip_stats = std::process::Command::new("ip")
15786 .args(["-s", "link", "show"])
15787 .output();
15788 if let Ok(o) = ip_stats {
15789 for line in String::from_utf8_lossy(&o.stdout).lines() {
15790 let l = line.trim();
15791 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
15792 {
15793 out.push_str(&format!("- {l}\n"));
15794 }
15795 }
15796 }
15797 Ok(out)
15798}
15799
15800#[cfg(windows)]
15801fn inspect_dhcp() -> Result<String, String> {
15802 let mut out = String::new();
15803
15804 out.push_str("=== DHCP lease details (per adapter) ===\n");
15805 let ps_dhcp = r#"
15806$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
15807 Where-Object { $_.IPEnabled -eq $true }
15808foreach ($a in $adapters) {
15809 "--- $($a.Description) ---"
15810 " DHCP Enabled: $($a.DHCPEnabled)"
15811 if ($a.DHCPEnabled) {
15812 " DHCP Server: $($a.DHCPServer)"
15813 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
15814 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
15815 " Lease Obtained: $obtained"
15816 " Lease Expires: $expires"
15817 }
15818 " IP Address: $($a.IPAddress -join ', ')"
15819 " Subnet Mask: $($a.IPSubnet -join ', ')"
15820 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
15821 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
15822 " MAC Address: $($a.MACAddress)"
15823 ""
15824}
15825"#;
15826 match run_powershell(ps_dhcp) {
15827 Ok(o) => {
15828 for line in o.lines() {
15829 let l = line.trim_end();
15830 if !l.is_empty() {
15831 out.push_str(&format!("{l}\n"));
15832 }
15833 }
15834 }
15835 Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
15836 }
15837
15838 let mut findings: Vec<String> = Vec::new();
15840 let ps_expiry = r#"
15841$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
15842foreach ($a in $adapters) {
15843 try {
15844 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
15845 $now = Get-Date
15846 $hrs = ($exp - $now).TotalHours
15847 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
15848 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
15849 } catch {}
15850}
15851"#;
15852 if let Ok(o) = run_powershell(ps_expiry) {
15853 for line in o.lines() {
15854 let l = line.trim();
15855 if !l.is_empty() {
15856 if l.contains("EXPIRED") {
15857 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
15858 } else if l.contains("expires in") {
15859 findings.push(format!("DHCP lease expiring soon — {l}"));
15860 }
15861 }
15862 }
15863 }
15864
15865 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
15866 if findings.is_empty() {
15867 result.push_str("- DHCP leases look healthy.\n");
15868 } else {
15869 for f in &findings {
15870 result.push_str(&format!("- Finding: {f}\n"));
15871 }
15872 }
15873 result.push('\n');
15874 result.push_str(&out);
15875 Ok(result)
15876}
15877
15878#[cfg(not(windows))]
15879fn inspect_dhcp() -> Result<String, String> {
15880 let mut out = String::from(
15881 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
15882 );
15883 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
15884 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
15885 if std::path::Path::new(path).exists() {
15886 let cat = std::process::Command::new("cat").arg(path).output();
15887 if let Ok(o) = cat {
15888 let text = String::from_utf8_lossy(&o.stdout);
15889 for line in text.lines().take(40) {
15890 let l = line.trim();
15891 if l.contains("lease")
15892 || l.contains("expire")
15893 || l.contains("server")
15894 || l.contains("address")
15895 {
15896 out.push_str(&format!("- {l}\n"));
15897 }
15898 }
15899 }
15900 }
15901 }
15902 let ip = std::process::Command::new("ip")
15904 .args(["addr", "show"])
15905 .output();
15906 if let Ok(o) = ip {
15907 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
15908 for line in String::from_utf8_lossy(&o.stdout).lines() {
15909 let l = line.trim();
15910 if l.starts_with("inet") || l.contains("dynamic") {
15911 out.push_str(&format!("- {l}\n"));
15912 }
15913 }
15914 }
15915 Ok(out)
15916}
15917
15918#[cfg(windows)]
15919fn inspect_mtu() -> Result<String, String> {
15920 let mut out = String::new();
15921
15922 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
15923 let ps_mtu = r#"
15924Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
15925 Sort-Object ConnectionState, InterfaceAlias |
15926 ForEach-Object {
15927 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
15928 }
15929"#;
15930 match run_powershell(ps_mtu) {
15931 Ok(o) => {
15932 for line in o.lines() {
15933 let l = line.trim();
15934 if !l.is_empty() {
15935 out.push_str(&format!("- {l}\n"));
15936 }
15937 }
15938 }
15939 Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
15940 }
15941
15942 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
15943 let ps_mtu6 = r#"
15944Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
15945 Sort-Object ConnectionState, InterfaceAlias |
15946 ForEach-Object {
15947 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
15948 }
15949"#;
15950 match run_powershell(ps_mtu6) {
15951 Ok(o) => {
15952 for line in o.lines() {
15953 let l = line.trim();
15954 if !l.is_empty() {
15955 out.push_str(&format!("- {l}\n"));
15956 }
15957 }
15958 }
15959 Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
15960 }
15961
15962 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
15963 let ps_pmtu = r#"
15965$sizes = @(1472, 1400, 1280, 576)
15966$result = $null
15967foreach ($s in $sizes) {
15968 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
15969 if ($r) { $result = $s; break }
15970}
15971if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
15972else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
15973"#;
15974 match run_powershell(ps_pmtu) {
15975 Ok(o) => {
15976 for line in o.lines() {
15977 let l = line.trim();
15978 if !l.is_empty() {
15979 out.push_str(&format!("- {l}\n"));
15980 }
15981 }
15982 }
15983 Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
15984 }
15985
15986 let mut findings: Vec<String> = Vec::new();
15987 if out.contains("MTU: 576 bytes") {
15988 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
15989 }
15990 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
15991 findings.push(
15992 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
15993 .into(),
15994 );
15995 }
15996 if out.contains("All test sizes failed") {
15997 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
15998 }
15999
16000 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16001 if findings.is_empty() {
16002 result.push_str("- MTU configuration looks normal.\n");
16003 } else {
16004 for f in &findings {
16005 result.push_str(&format!("- Finding: {f}\n"));
16006 }
16007 }
16008 result.push('\n');
16009 result.push_str(&out);
16010 Ok(result)
16011}
16012
16013#[cfg(not(windows))]
16014fn inspect_mtu() -> Result<String, String> {
16015 let mut out = String::from(
16016 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16017 );
16018
16019 out.push_str("=== Per-interface MTU (ip link) ===\n");
16020 let ip = std::process::Command::new("ip")
16021 .args(["link", "show"])
16022 .output();
16023 if let Ok(o) = ip {
16024 for line in String::from_utf8_lossy(&o.stdout).lines() {
16025 let l = line.trim();
16026 if l.contains("mtu") || l.starts_with("\\d") {
16027 out.push_str(&format!("- {l}\n"));
16028 }
16029 }
16030 }
16031
16032 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16033 let ping = std::process::Command::new("ping")
16034 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16035 .output();
16036 match ping {
16037 Ok(o) => {
16038 let body = String::from_utf8_lossy(&o.stdout);
16039 for line in body.lines() {
16040 let l = line.trim();
16041 if !l.is_empty() {
16042 out.push_str(&format!("- {l}\n"));
16043 }
16044 }
16045 }
16046 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16047 }
16048 Ok(out)
16049}
16050
16051#[cfg(not(windows))]
16052fn inspect_cpu_power() -> Result<String, String> {
16053 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16054
16055 out.push_str("=== CPU frequency (Linux) ===\n");
16057 let cat_scaling = std::process::Command::new("cat")
16058 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16059 .output();
16060 if let Ok(o) = cat_scaling {
16061 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16062 .trim()
16063 .parse()
16064 .unwrap_or(0);
16065 if khz > 0 {
16066 out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16067 }
16068 }
16069 let cat_max = std::process::Command::new("cat")
16070 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16071 .output();
16072 if let Ok(o) = cat_max {
16073 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16074 .trim()
16075 .parse()
16076 .unwrap_or(0);
16077 if khz > 0 {
16078 out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16079 }
16080 }
16081 let governor = std::process::Command::new("cat")
16082 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16083 .output();
16084 if let Ok(o) = governor {
16085 let g = String::from_utf8_lossy(&o.stdout);
16086 let g = g.trim();
16087 if !g.is_empty() {
16088 out.push_str(&format!("- Governor: {g}\n"));
16089 }
16090 }
16091 Ok(out)
16092}
16093
16094#[cfg(windows)]
16097fn inspect_ipv6() -> Result<String, String> {
16098 let script = r#"
16099$result = [System.Text.StringBuilder]::new()
16100
16101# Per-adapter IPv6 addresses
16102$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16103$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16104 Where-Object { $_.IPAddress -notmatch '^::1$' } |
16105 Sort-Object InterfaceAlias
16106foreach ($a in $adapters) {
16107 $prefix = $a.PrefixOrigin
16108 $suffix = $a.SuffixOrigin
16109 $scope = $a.AddressState
16110 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
16111}
16112if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
16113
16114# Default gateway IPv6
16115$result.AppendLine("") | Out-Null
16116$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16117$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16118if ($gw6) {
16119 foreach ($g in $gw6) {
16120 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
16121 }
16122} else {
16123 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
16124}
16125
16126# DHCPv6 lease info
16127$result.AppendLine("") | Out-Null
16128$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16129$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16130 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16131if ($dhcpv6) {
16132 foreach ($d in $dhcpv6) {
16133 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16134 }
16135} else {
16136 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16137}
16138
16139# Privacy extensions
16140$result.AppendLine("") | Out-Null
16141$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16142try {
16143 $priv = netsh interface ipv6 show privacy
16144 $result.AppendLine(($priv -join "`n")) | Out-Null
16145} catch {
16146 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
16147}
16148
16149# Tunnel adapters
16150$result.AppendLine("") | Out-Null
16151$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16152$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
16153if ($tunnels) {
16154 foreach ($t in $tunnels) {
16155 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
16156 }
16157} else {
16158 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
16159}
16160
16161# Findings
16162$findings = [System.Collections.Generic.List[string]]::new()
16163$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16164 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
16165if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
16166$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
16167if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
16168
16169$result.AppendLine("") | Out-Null
16170$result.AppendLine("=== Findings ===") | Out-Null
16171if ($findings.Count -eq 0) {
16172 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
16173} else {
16174 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16175}
16176
16177Write-Output $result.ToString()
16178"#;
16179 let out = run_powershell(script)?;
16180 Ok(format!("Host inspection: ipv6\n\n{out}"))
16181}
16182
16183#[cfg(not(windows))]
16184fn inspect_ipv6() -> Result<String, String> {
16185 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
16186 if let Ok(o) = std::process::Command::new("ip")
16187 .args(["-6", "addr", "show"])
16188 .output()
16189 {
16190 out.push_str(&String::from_utf8_lossy(&o.stdout));
16191 }
16192 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
16193 if let Ok(o) = std::process::Command::new("ip")
16194 .args(["-6", "route"])
16195 .output()
16196 {
16197 out.push_str(&String::from_utf8_lossy(&o.stdout));
16198 }
16199 Ok(out)
16200}
16201
16202#[cfg(windows)]
16205fn inspect_tcp_params() -> Result<String, String> {
16206 let script = r#"
16207$result = [System.Text.StringBuilder]::new()
16208
16209# Autotuning and global TCP settings
16210$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
16211try {
16212 $global = netsh interface tcp show global
16213 foreach ($line in $global) {
16214 $l = $line.Trim()
16215 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
16216 $result.AppendLine(" $l") | Out-Null
16217 }
16218 }
16219} catch {
16220 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
16221}
16222
16223# Supplemental params via Get-NetTCPSetting
16224$result.AppendLine("") | Out-Null
16225$result.AppendLine("=== TCP settings profiles ===") | Out-Null
16226try {
16227 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
16228 foreach ($s in $tcpSettings) {
16229 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
16230 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
16231 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
16232 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
16233 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
16234 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
16235 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
16236 $result.AppendLine("") | Out-Null
16237 }
16238} catch {
16239 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
16240}
16241
16242# Chimney offload state
16243$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
16244try {
16245 $chimney = netsh interface tcp show chimney
16246 $result.AppendLine(($chimney -join "`n ")) | Out-Null
16247} catch {
16248 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
16249}
16250
16251# ECN state
16252$result.AppendLine("") | Out-Null
16253$result.AppendLine("=== ECN capability ===") | Out-Null
16254try {
16255 $ecn = netsh interface tcp show ecncapability
16256 $result.AppendLine(($ecn -join "`n ")) | Out-Null
16257} catch {
16258 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
16259}
16260
16261# Findings
16262$findings = [System.Collections.Generic.List[string]]::new()
16263try {
16264 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
16265 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
16266 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
16267 }
16268 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
16269 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
16270 }
16271} catch {}
16272
16273$result.AppendLine("") | Out-Null
16274$result.AppendLine("=== Findings ===") | Out-Null
16275if ($findings.Count -eq 0) {
16276 $result.AppendLine("- TCP parameters look normal.") | Out-Null
16277} else {
16278 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16279}
16280
16281Write-Output $result.ToString()
16282"#;
16283 let out = run_powershell(script)?;
16284 Ok(format!("Host inspection: tcp_params\n\n{out}"))
16285}
16286
16287#[cfg(not(windows))]
16288fn inspect_tcp_params() -> Result<String, String> {
16289 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
16290 for key in &[
16291 "net.ipv4.tcp_congestion_control",
16292 "net.ipv4.tcp_rmem",
16293 "net.ipv4.tcp_wmem",
16294 "net.ipv4.tcp_window_scaling",
16295 "net.ipv4.tcp_ecn",
16296 "net.ipv4.tcp_timestamps",
16297 ] {
16298 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
16299 out.push_str(&format!(
16300 " {}\n",
16301 String::from_utf8_lossy(&o.stdout).trim()
16302 ));
16303 }
16304 }
16305 Ok(out)
16306}
16307
16308#[cfg(windows)]
16311fn inspect_wlan_profiles() -> Result<String, String> {
16312 let script = r#"
16313$result = [System.Text.StringBuilder]::new()
16314
16315# List all saved profiles
16316$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
16317try {
16318 $profilesRaw = netsh wlan show profiles
16319 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16320 $_.Matches[0].Groups[1].Value.Trim()
16321 }
16322
16323 if (-not $profiles) {
16324 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
16325 } else {
16326 foreach ($p in $profiles) {
16327 $result.AppendLine("") | Out-Null
16328 $result.AppendLine(" Profile: $p") | Out-Null
16329 # Get detail for each profile
16330 $detail = netsh wlan show profile name="$p" key=clear 2>$null
16331 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16332 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
16333 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
16334 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
16335 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16336 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16337 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16338 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16339 }
16340 }
16341} catch {
16342 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
16343}
16344
16345# Currently connected SSID
16346$result.AppendLine("") | Out-Null
16347$result.AppendLine("=== Currently connected ===") | Out-Null
16348try {
16349 $conn = netsh wlan show interfaces
16350 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
16351 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
16352 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
16353 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
16354 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16355 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16356 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16357 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16358 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
16359} catch {
16360 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
16361}
16362
16363# Findings
16364$findings = [System.Collections.Generic.List[string]]::new()
16365try {
16366 $allDetail = netsh wlan show profiles 2>$null
16367 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16368 $_.Matches[0].Groups[1].Value.Trim()
16369 }
16370 foreach ($pn in $profileNames) {
16371 $det = netsh wlan show profile name="$pn" key=clear 2>$null
16372 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16373 if ($authLine) {
16374 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
16375 if ($authVal -match 'Open|WEP|None') {
16376 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
16377 }
16378 }
16379 }
16380} catch {}
16381
16382$result.AppendLine("") | Out-Null
16383$result.AppendLine("=== Findings ===") | Out-Null
16384if ($findings.Count -eq 0) {
16385 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
16386} else {
16387 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16388}
16389
16390Write-Output $result.ToString()
16391"#;
16392 let out = run_powershell(script)?;
16393 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
16394}
16395
16396#[cfg(not(windows))]
16397fn inspect_wlan_profiles() -> Result<String, String> {
16398 let mut out =
16399 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
16400 if let Ok(o) = std::process::Command::new("nmcli")
16402 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
16403 .output()
16404 {
16405 for line in String::from_utf8_lossy(&o.stdout).lines() {
16406 if line.contains("wireless") || line.contains("wifi") {
16407 out.push_str(&format!(" {line}\n"));
16408 }
16409 }
16410 } else {
16411 out.push_str(" nmcli not available.\n");
16412 }
16413 Ok(out)
16414}
16415
16416#[cfg(windows)]
16419fn inspect_ipsec() -> Result<String, String> {
16420 let script = r#"
16421$result = [System.Text.StringBuilder]::new()
16422
16423# IPSec rules (firewall-integrated)
16424$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
16425try {
16426 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
16427 if ($rules) {
16428 foreach ($r in $rules) {
16429 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
16430 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
16431 $result.AppendLine(" Action: $($r.Action)") | Out-Null
16432 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
16433 }
16434 } else {
16435 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
16436 }
16437} catch {
16438 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
16439}
16440
16441# Active main-mode SAs
16442$result.AppendLine("") | Out-Null
16443$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
16444try {
16445 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
16446 if ($mmSAs) {
16447 foreach ($sa in $mmSAs) {
16448 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
16449 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
16450 }
16451 } else {
16452 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
16453 }
16454} catch {
16455 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
16456}
16457
16458# Active quick-mode SAs
16459$result.AppendLine("") | Out-Null
16460$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
16461try {
16462 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
16463 if ($qmSAs) {
16464 foreach ($sa in $qmSAs) {
16465 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
16466 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
16467 }
16468 } else {
16469 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
16470 }
16471} catch {
16472 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
16473}
16474
16475# IKE service state
16476$result.AppendLine("") | Out-Null
16477$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
16478$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
16479if ($ikeAgentSvc) {
16480 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
16481} else {
16482 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
16483}
16484
16485# Findings
16486$findings = [System.Collections.Generic.List[string]]::new()
16487$mmSACount = 0
16488try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
16489if ($mmSACount -gt 0) {
16490 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
16491}
16492
16493$result.AppendLine("") | Out-Null
16494$result.AppendLine("=== Findings ===") | Out-Null
16495if ($findings.Count -eq 0) {
16496 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
16497} else {
16498 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16499}
16500
16501Write-Output $result.ToString()
16502"#;
16503 let out = run_powershell(script)?;
16504 Ok(format!("Host inspection: ipsec\n\n{out}"))
16505}
16506
16507#[cfg(not(windows))]
16508fn inspect_ipsec() -> Result<String, String> {
16509 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
16510 if let Ok(o) = std::process::Command::new("ip")
16511 .args(["xfrm", "state"])
16512 .output()
16513 {
16514 let body = String::from_utf8_lossy(&o.stdout);
16515 if body.trim().is_empty() {
16516 out.push_str(" No active IPSec SAs.\n");
16517 } else {
16518 out.push_str(&body);
16519 }
16520 }
16521 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
16522 if let Ok(o) = std::process::Command::new("ip")
16523 .args(["xfrm", "policy"])
16524 .output()
16525 {
16526 let body = String::from_utf8_lossy(&o.stdout);
16527 if body.trim().is_empty() {
16528 out.push_str(" No IPSec policies.\n");
16529 } else {
16530 out.push_str(&body);
16531 }
16532 }
16533 Ok(out)
16534}
16535
16536#[cfg(windows)]
16539fn inspect_netbios() -> Result<String, String> {
16540 let script = r#"
16541$result = [System.Text.StringBuilder]::new()
16542
16543# NetBIOS node type and WINS per adapter
16544$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
16545try {
16546 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16547 Where-Object { $_.IPEnabled -eq $true }
16548 foreach ($a in $adapters) {
16549 $nodeType = switch ($a.TcpipNetbiosOptions) {
16550 0 { "EnableNetBIOSViaDHCP" }
16551 1 { "Enabled" }
16552 2 { "Disabled" }
16553 default { "Unknown ($($a.TcpipNetbiosOptions))" }
16554 }
16555 $result.AppendLine(" [$($a.Description)]") | Out-Null
16556 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
16557 if ($a.WINSPrimaryServer) {
16558 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
16559 }
16560 if ($a.WINSSecondaryServer) {
16561 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
16562 }
16563 }
16564} catch {
16565 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
16566}
16567
16568# nbtstat -n — registered local NetBIOS names
16569$result.AppendLine("") | Out-Null
16570$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
16571try {
16572 $nbt = nbtstat -n 2>$null
16573 foreach ($line in $nbt) {
16574 $l = $line.Trim()
16575 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
16576 $result.AppendLine(" $l") | Out-Null
16577 }
16578 }
16579} catch {
16580 $result.AppendLine(" nbtstat not available.") | Out-Null
16581}
16582
16583# NetBIOS session table
16584$result.AppendLine("") | Out-Null
16585$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
16586try {
16587 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
16588 if ($sessions) {
16589 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
16590 } else {
16591 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
16592 }
16593} catch {
16594 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
16595}
16596
16597# Findings
16598$findings = [System.Collections.Generic.List[string]]::new()
16599try {
16600 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16601 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
16602 if ($enabled) {
16603 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
16604 }
16605 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16606 Where-Object { $_.WINSPrimaryServer }
16607 if ($wins) {
16608 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
16609 }
16610} catch {}
16611
16612$result.AppendLine("") | Out-Null
16613$result.AppendLine("=== Findings ===") | Out-Null
16614if ($findings.Count -eq 0) {
16615 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
16616} else {
16617 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16618}
16619
16620Write-Output $result.ToString()
16621"#;
16622 let out = run_powershell(script)?;
16623 Ok(format!("Host inspection: netbios\n\n{out}"))
16624}
16625
16626#[cfg(not(windows))]
16627fn inspect_netbios() -> Result<String, String> {
16628 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
16629 if let Ok(o) = std::process::Command::new("nmblookup")
16630 .arg("-A")
16631 .arg("localhost")
16632 .output()
16633 {
16634 out.push_str(&String::from_utf8_lossy(&o.stdout));
16635 } else {
16636 out.push_str(" nmblookup not available (Samba not installed).\n");
16637 }
16638 Ok(out)
16639}
16640
16641#[cfg(windows)]
16644fn inspect_nic_teaming() -> Result<String, String> {
16645 let script = r#"
16646$result = [System.Text.StringBuilder]::new()
16647
16648# Team inventory
16649$result.AppendLine("=== NIC teams ===") | Out-Null
16650try {
16651 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
16652 if ($teams) {
16653 foreach ($t in $teams) {
16654 $result.AppendLine(" Team: $($t.Name)") | Out-Null
16655 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
16656 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
16657 $result.AppendLine(" Status: $($t.Status)") | Out-Null
16658 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
16659 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
16660 }
16661 } else {
16662 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
16663 }
16664} catch {
16665 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
16666}
16667
16668# Team members detail
16669$result.AppendLine("") | Out-Null
16670$result.AppendLine("=== Team member detail ===") | Out-Null
16671try {
16672 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
16673 if ($members) {
16674 foreach ($m in $members) {
16675 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
16676 }
16677 } else {
16678 $result.AppendLine(" No team members found.") | Out-Null
16679 }
16680} catch {
16681 $result.AppendLine(" Could not query team members.") | Out-Null
16682}
16683
16684# Findings
16685$findings = [System.Collections.Generic.List[string]]::new()
16686try {
16687 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
16688 if ($degraded) {
16689 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
16690 }
16691 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
16692 if ($downMembers) {
16693 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
16694 }
16695} catch {}
16696
16697$result.AppendLine("") | Out-Null
16698$result.AppendLine("=== Findings ===") | Out-Null
16699if ($findings.Count -eq 0) {
16700 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
16701} else {
16702 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16703}
16704
16705Write-Output $result.ToString()
16706"#;
16707 let out = run_powershell(script)?;
16708 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
16709}
16710
16711#[cfg(not(windows))]
16712fn inspect_nic_teaming() -> Result<String, String> {
16713 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
16714 if let Ok(o) = std::process::Command::new("cat")
16715 .arg("/proc/net/bonding/bond0")
16716 .output()
16717 {
16718 if o.status.success() {
16719 out.push_str(&String::from_utf8_lossy(&o.stdout));
16720 } else {
16721 out.push_str(" No bond0 interface found.\n");
16722 }
16723 }
16724 if let Ok(o) = std::process::Command::new("ip")
16725 .args(["link", "show", "type", "bond"])
16726 .output()
16727 {
16728 let body = String::from_utf8_lossy(&o.stdout);
16729 if !body.trim().is_empty() {
16730 out.push_str("\n=== Bond links (ip link) ===\n");
16731 out.push_str(&body);
16732 }
16733 }
16734 Ok(out)
16735}
16736
16737#[cfg(windows)]
16740fn inspect_snmp() -> Result<String, String> {
16741 let script = r#"
16742$result = [System.Text.StringBuilder]::new()
16743
16744# SNMP service state
16745$result.AppendLine("=== SNMP service state ===") | Out-Null
16746$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16747if ($svc) {
16748 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
16749} else {
16750 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
16751}
16752
16753$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
16754if ($svcTrap) {
16755 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
16756}
16757
16758# Community strings (presence only — values redacted)
16759$result.AppendLine("") | Out-Null
16760$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
16761try {
16762 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16763 if ($communities) {
16764 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
16765 if ($names) {
16766 foreach ($n in $names) {
16767 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
16768 }
16769 } else {
16770 $result.AppendLine(" No community strings configured.") | Out-Null
16771 }
16772 } else {
16773 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
16774 }
16775} catch {
16776 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
16777}
16778
16779# Permitted managers
16780$result.AppendLine("") | Out-Null
16781$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
16782try {
16783 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
16784 if ($managers) {
16785 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
16786 if ($mgrs) {
16787 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
16788 } else {
16789 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
16790 }
16791 } else {
16792 $result.AppendLine(" No manager restrictions configured.") | Out-Null
16793 }
16794} catch {
16795 $result.AppendLine(" Could not read permitted managers.") | Out-Null
16796}
16797
16798# Findings
16799$findings = [System.Collections.Generic.List[string]]::new()
16800$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16801if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
16802 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
16803 try {
16804 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16805 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
16806 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
16807 } catch {}
16808}
16809
16810$result.AppendLine("") | Out-Null
16811$result.AppendLine("=== Findings ===") | Out-Null
16812if ($findings.Count -eq 0) {
16813 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
16814} else {
16815 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16816}
16817
16818Write-Output $result.ToString()
16819"#;
16820 let out = run_powershell(script)?;
16821 Ok(format!("Host inspection: snmp\n\n{out}"))
16822}
16823
16824#[cfg(not(windows))]
16825fn inspect_snmp() -> Result<String, String> {
16826 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
16827 for svc in &["snmpd", "snmp"] {
16828 if let Ok(o) = std::process::Command::new("systemctl")
16829 .args(["is-active", svc])
16830 .output()
16831 {
16832 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
16833 out.push_str(&format!(" {svc}: {status}\n"));
16834 }
16835 }
16836 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
16837 if let Ok(o) = std::process::Command::new("grep")
16838 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
16839 .output()
16840 {
16841 if o.status.success() {
16842 for line in String::from_utf8_lossy(&o.stdout).lines() {
16843 out.push_str(&format!(" {line}\n"));
16844 }
16845 } else {
16846 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
16847 }
16848 }
16849 Ok(out)
16850}
16851
16852#[cfg(windows)]
16855fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
16856 let target_host = host.unwrap_or("8.8.8.8");
16857 let target_port = port.unwrap_or(443);
16858
16859 let script = format!(
16860 r#"
16861$result = [System.Text.StringBuilder]::new()
16862$result.AppendLine("=== Port reachability test ===") | Out-Null
16863$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
16864$result.AppendLine("") | Out-Null
16865
16866try {{
16867 $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
16868 if ($test) {{
16869 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
16870 $result.AppendLine(" Result: $status") | Out-Null
16871 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
16872 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
16873 if ($test.PingSucceeded) {{
16874 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
16875 }} else {{
16876 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
16877 }}
16878 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
16879 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
16880
16881 $result.AppendLine("") | Out-Null
16882 $result.AppendLine("=== Findings ===") | Out-Null
16883 if ($test.TcpTestSucceeded) {{
16884 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
16885 }} else {{
16886 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
16887 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
16888 }}
16889 }}
16890}} catch {{
16891 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
16892}}
16893
16894Write-Output $result.ToString()
16895"#
16896 );
16897 let out = run_powershell(&script)?;
16898 Ok(format!("Host inspection: port_test\n\n{out}"))
16899}
16900
16901#[cfg(not(windows))]
16902fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
16903 let target_host = host.unwrap_or("8.8.8.8");
16904 let target_port = port.unwrap_or(443);
16905 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
16906 let nc = std::process::Command::new("nc")
16908 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
16909 .output();
16910 match nc {
16911 Ok(o) => {
16912 let stderr = String::from_utf8_lossy(&o.stderr);
16913 let stdout = String::from_utf8_lossy(&o.stdout);
16914 let body = if !stdout.trim().is_empty() {
16915 stdout.as_ref()
16916 } else {
16917 stderr.as_ref()
16918 };
16919 out.push_str(&format!(" {}\n", body.trim()));
16920 out.push_str("\n=== Findings ===\n");
16921 if o.status.success() {
16922 out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
16923 } else {
16924 out.push_str(&format!(
16925 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
16926 ));
16927 }
16928 }
16929 Err(e) => out.push_str(&format!(" nc not available: {e}\n")),
16930 }
16931 Ok(out)
16932}
16933
16934#[cfg(windows)]
16937fn inspect_network_profile() -> Result<String, String> {
16938 let script = r#"
16939$result = [System.Text.StringBuilder]::new()
16940
16941$result.AppendLine("=== Network location profiles ===") | Out-Null
16942try {
16943 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
16944 if ($profiles) {
16945 foreach ($p in $profiles) {
16946 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
16947 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
16948 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
16949 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
16950 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
16951 $result.AppendLine("") | Out-Null
16952 }
16953 } else {
16954 $result.AppendLine(" No network connection profiles found.") | Out-Null
16955 }
16956} catch {
16957 $result.AppendLine(" Could not query network profiles.") | Out-Null
16958}
16959
16960# Findings
16961$findings = [System.Collections.Generic.List[string]]::new()
16962try {
16963 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
16964 if ($pub) {
16965 foreach ($p in $pub) {
16966 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
16967 }
16968 }
16969 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
16970 if ($domain) {
16971 foreach ($d in $domain) {
16972 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
16973 }
16974 }
16975} catch {}
16976
16977$result.AppendLine("=== Findings ===") | Out-Null
16978if ($findings.Count -eq 0) {
16979 $result.AppendLine("- Network profiles look normal.") | Out-Null
16980} else {
16981 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16982}
16983
16984Write-Output $result.ToString()
16985"#;
16986 let out = run_powershell(script)?;
16987 Ok(format!("Host inspection: network_profile\n\n{out}"))
16988}
16989
16990#[cfg(not(windows))]
16991fn inspect_network_profile() -> Result<String, String> {
16992 let mut out = String::from(
16993 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
16994 );
16995 if let Ok(o) = std::process::Command::new("nmcli")
16996 .args([
16997 "-t",
16998 "-f",
16999 "NAME,TYPE,STATE,DEVICE",
17000 "connection",
17001 "show",
17002 "--active",
17003 ])
17004 .output()
17005 {
17006 out.push_str(&String::from_utf8_lossy(&o.stdout));
17007 } else {
17008 out.push_str(" nmcli not available.\n");
17009 }
17010 Ok(out)
17011}