1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12 let mut topic = args
13 .get("topic")
14 .and_then(|v| v.as_str())
15 .unwrap_or("summary")
16 .to_string();
17 let max_entries = parse_max_entries(args);
18 let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20 if (topic == "processes" || topic == "network" || topic == "summary")
22 && (filter.contains("ad")
23 || filter.contains("sid")
24 || filter.contains("administrator")
25 || filter.contains("domain"))
26 {
27 topic = "ad_user".to_string();
28 }
29
30 let result = match topic.as_str() {
31 "summary" => inspect_summary(max_entries),
32 "toolchains" => inspect_toolchains(),
33 "path" => inspect_path(max_entries),
34 "env_doctor" => inspect_env_doctor(max_entries),
35 "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
36 "network" => inspect_network(max_entries),
37 "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
38 inspect_lan_discovery(max_entries)
39 }
40 "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
41 inspect_audio(max_entries)
42 }
43 "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
44 inspect_bluetooth(max_entries)
45 }
46 "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
47 "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
48 inspect_sign_in(max_entries)
49 }
50 "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
51 inspect_installer_health(max_entries)
52 }
53 "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
54 inspect_onedrive(max_entries)
55 }
56 "browser_health" | "browser" | "webview2" | "default_browser" => {
57 inspect_browser_health(max_entries)
58 }
59 "identity_auth"
60 | "office_auth"
61 | "m365_auth"
62 | "microsoft_365_auth"
63 | "auth_broker" => inspect_identity_auth(max_entries),
64 "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
65 "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
66 "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
67 inspect_windows_backup(max_entries)
68 }
69 "search_index" | "windows_search" | "indexing" | "search" => {
70 inspect_search_index(max_entries)
71 }
72 "services" => inspect_services(parse_name_filter(args), max_entries),
73 "processes" => inspect_processes(parse_name_filter(args), max_entries),
74 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
75 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
76 "disk" => {
77 let path = resolve_optional_path(args)?;
78 inspect_disk(path, max_entries).await
79 }
80 "ports" => inspect_ports(parse_port_filter(args), max_entries),
81 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
82 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
83 "health_report" | "system_health" => inspect_health_report(),
84 "storage" => inspect_storage(max_entries),
85 "hardware" => inspect_hardware(),
86 "updates" | "windows_update" => inspect_updates(),
87 "security" | "antivirus" | "defender" => inspect_security(),
88 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
89 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
90 "battery" => inspect_battery(),
91 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
92 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
93 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
94 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
95 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
96 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
97 "vpn" => inspect_vpn(),
98 "proxy" | "proxy_settings" => inspect_proxy(),
99 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
100 "traceroute" | "tracert" | "trace_route" | "trace" => {
101 let host = args
102 .get("host")
103 .and_then(|v| v.as_str())
104 .unwrap_or("8.8.8.8")
105 .to_string();
106 inspect_traceroute(&host, max_entries)
107 }
108 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
109 "arp" | "arp_table" => inspect_arp(),
110 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
111 "os_config" | "system_config" => inspect_os_config(),
112 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
113 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
114 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
115 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
116 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
117 inspect_docker_filesystems(max_entries)
118 }
119 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
120 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
121 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
122 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
123 "git_config" | "git_global" => inspect_git_config(),
124 "databases" | "database" | "db_services" | "db" => inspect_databases(),
125 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
126 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
127 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
128 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
129 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
130 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
131 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
132 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
133 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
134 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
135 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
136 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
137 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
138 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
139 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
140 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
141 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
142 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
143 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
144 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
145 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
146 "repo_doctor" => {
147 let path = resolve_optional_path(args)?;
148 inspect_repo_doctor(path, max_entries)
149 }
150 "directory" => {
151 let raw_path = args
152 .get("path")
153 .and_then(|v| v.as_str())
154 .ok_or_else(|| {
155 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
156 .to_string()
157 })?;
158 let resolved = resolve_path(raw_path)?;
159 inspect_directory("Directory", resolved, max_entries).await
160 }
161 "disk_benchmark" | "stress_test" | "io_intensity" => {
162 let path = resolve_optional_path(args)?;
163 inspect_disk_benchmark(path).await
164 }
165 "permissions" | "acl" | "access_control" => {
166 let path = resolve_optional_path(args)?;
167 inspect_permissions(path, max_entries)
168 }
169 "login_history" | "logon_history" | "user_logins" => {
170 inspect_login_history(max_entries)
171 }
172 "share_access" | "unc_access" | "remote_share" => {
173 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
174 inspect_share_access(path)
175 }
176 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
177 "thermal" | "throttling" | "overheating" => inspect_thermal(),
178 "activation" | "license_status" | "slmgr" => inspect_activation(),
179 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
180 "ad_user" | "ad" | "domain_user" => {
181 let identity = parse_name_filter(args).unwrap_or_default();
182 inspect_ad_user(&identity)
183 }
184 "dns_lookup" | "dig" | "nslookup" => {
185 let name = parse_name_filter(args).unwrap_or_default();
186 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
187 inspect_dns_lookup(&name, record_type)
188 }
189 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
190 "ip_config" | "ip_detail" => inspect_ip_config(),
191 "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
192 "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
193 "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
194 "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
195 "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
196 "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
197 "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
198 "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
199 "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
200 "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
201 let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
202 let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
203 inspect_port_test(pt_host.as_deref(), pt_port)
204 }
205 "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
206 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
207 "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
208 inspect_display_config(max_entries)
209 }
210 "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
211 inspect_ntp()
212 }
213 "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
214 inspect_cpu_power()
215 }
216 "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
217 inspect_credentials(max_entries)
218 }
219 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
220 inspect_tpm()
221 }
222 "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
223 inspect_latency()
224 }
225 "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
226 inspect_network_adapter()
227 }
228 "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
229 let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
230 let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
231 let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
232 let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
233 let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
234 inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
235 }
236 "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
237 let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
238 inspect_app_crashes(process_filter.as_deref(), max_entries)
239 }
240 "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
241 inspect_mdm_enrollment()
242 }
243 other => Err(format!(
244 "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, mdm_enrollment.",
245 other
246 )),
247
248 };
249
250 result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
251}
252
253fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
254 let Some(scope) = admin_sensitive_topic_scope(topic) else {
255 return body;
256 };
257 let lower = body.to_lowercase();
258 let privilege_limited = lower.contains("access denied")
259 || lower.contains("administrator privilege is required")
260 || lower.contains("administrator privileges required")
261 || lower.contains("requires administrator")
262 || lower.contains("requires elevation")
263 || lower.contains("non-admin session")
264 || lower.contains("could not be fully determined from this session");
265 if !privilege_limited || lower.contains("=== elevation note ===") {
266 return body;
267 }
268
269 let mut annotated = body;
270 annotated.push_str("\n=== Elevation note ===\n");
271 annotated.push_str("- Hematite should stay non-admin by default.\n");
272 annotated.push_str(
273 "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
274 );
275 annotated.push_str(&format!(
276 "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
277 ));
278 annotated
279}
280
281fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
282 match topic {
283 "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
284 Some("TPM / Secure Boot / firmware")
285 }
286 "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
287 "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
288 "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
289 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
290 "windows_features" | "optional_features" | "installed_features" | "features" => {
291 Some("Windows Features")
292 }
293 "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
294 _ => None,
295 }
296}
297
298#[cfg(test)]
299mod privilege_hint_tests {
300 use super::annotate_privilege_limited_output;
301
302 #[test]
303 fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
304 let body = "Host inspection: network\nError: Access denied.\n".to_string();
305 let annotated = annotate_privilege_limited_output("network", body.clone());
306 assert_eq!(annotated, body);
307 }
308
309 #[test]
310 fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
311 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();
312 let annotated = annotate_privilege_limited_output("tpm", body);
313 assert!(annotated.contains("=== Elevation note ==="));
314 assert!(annotated.contains("stay non-admin by default"));
315 assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
316 }
317}
318
319#[cfg(test)]
320mod event_query_tests {
321 use super::is_event_query_no_results_message;
322
323 #[cfg(target_os = "windows")]
324 #[test]
325 fn treats_windows_no_results_message_as_empty_query() {
326 assert!(is_event_query_no_results_message(
327 "No events were found that match the specified selection criteria."
328 ));
329 }
330
331 #[cfg(target_os = "windows")]
332 #[test]
333 fn does_not_treat_real_errors_as_empty_query() {
334 assert!(!is_event_query_no_results_message("Access is denied."));
335 }
336}
337
338fn parse_max_entries(args: &Value) -> usize {
339 args.get("max_entries")
340 .and_then(|v| v.as_u64())
341 .map(|n| n as usize)
342 .unwrap_or(DEFAULT_MAX_ENTRIES)
343 .clamp(1, MAX_ENTRIES_CAP)
344}
345
346fn parse_port_filter(args: &Value) -> Option<u16> {
347 args.get("port")
348 .and_then(|v| v.as_u64())
349 .and_then(|n| u16::try_from(n).ok())
350}
351
352fn parse_name_filter(args: &Value) -> Option<String> {
353 args.get("name")
354 .and_then(|v| v.as_str())
355 .map(str::trim)
356 .filter(|value| !value.is_empty())
357 .map(|value| value.to_string())
358}
359
360fn parse_lookback_hours(args: &Value) -> Option<u32> {
361 args.get("lookback_hours")
362 .and_then(|v| v.as_u64())
363 .map(|n| n as u32)
364}
365
366fn parse_issue_text(args: &Value) -> Option<String> {
367 args.get("issue")
368 .and_then(|v| v.as_str())
369 .map(str::trim)
370 .filter(|value| !value.is_empty())
371 .map(|value| value.to_string())
372}
373
374#[cfg(target_os = "windows")]
375fn is_event_query_no_results_message(message: &str) -> bool {
376 let lower = message.to_ascii_lowercase();
377 lower.contains("no events were found")
378 || lower.contains("no events match the specified selection criteria")
379}
380
381fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
382 match args.get("path").and_then(|v| v.as_str()) {
383 Some(raw_path) => resolve_path(raw_path),
384 None => {
385 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
386 }
387 }
388}
389
390fn inspect_summary(max_entries: usize) -> Result<String, String> {
391 let current_dir =
392 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
393 let workspace_root = crate::tools::file_ops::workspace_root();
394 let workspace_mode = workspace_mode_label(&workspace_root);
395 let path_stats = analyze_path_env();
396 let toolchains = collect_toolchains();
397
398 let mut out = String::from("Host inspection: summary\n\n");
399 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
400 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
401 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
402 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
403 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
404 out.push_str(&format!(
405 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
406 path_stats.total_entries,
407 path_stats.unique_entries,
408 path_stats.duplicate_entries.len(),
409 path_stats.missing_entries.len()
410 ));
411
412 if toolchains.found.is_empty() {
413 out.push_str(
414 "- Toolchains found: none of the common developer tools were detected on PATH\n",
415 );
416 } else {
417 out.push_str("- Toolchains found:\n");
418 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
419 out.push_str(&format!(" - {}: {}\n", label, version));
420 }
421 if toolchains.found.len() > max_entries.min(8) {
422 out.push_str(&format!(
423 " - ... {} more found tools omitted\n",
424 toolchains.found.len() - max_entries.min(8)
425 ));
426 }
427 }
428
429 if !toolchains.missing.is_empty() {
430 out.push_str(&format!(
431 "- Common tools not detected on PATH: {}\n",
432 toolchains.missing.join(", ")
433 ));
434 }
435
436 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
437 match path {
438 Some(path) if path.exists() => match count_top_level_items(&path) {
439 Ok(count) => out.push_str(&format!(
440 "- {}: {} top-level items at {}\n",
441 label,
442 count,
443 path.display()
444 )),
445 Err(e) => out.push_str(&format!(
446 "- {}: exists at {} but could not inspect ({})\n",
447 label,
448 path.display(),
449 e
450 )),
451 },
452 Some(path) => out.push_str(&format!(
453 "- {}: expected at {} but not found\n",
454 label,
455 path.display()
456 )),
457 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
458 }
459 }
460
461 Ok(out.trim_end().to_string())
462}
463
464fn inspect_toolchains() -> Result<String, String> {
465 let report = collect_toolchains();
466 let mut out = String::from("Host inspection: toolchains\n\n");
467
468 if report.found.is_empty() {
469 out.push_str("- No common developer tools were detected on PATH.");
470 } else {
471 out.push_str("Detected developer tools:\n");
472 for (label, version) in report.found {
473 out.push_str(&format!("- {}: {}\n", label, version));
474 }
475 }
476
477 if !report.missing.is_empty() {
478 out.push_str("\nNot detected on PATH:\n");
479 for label in report.missing {
480 out.push_str(&format!("- {}\n", label));
481 }
482 }
483
484 Ok(out.trim_end().to_string())
485}
486
487fn inspect_path(max_entries: usize) -> Result<String, String> {
488 let path_stats = analyze_path_env();
489 let mut out = String::from("Host inspection: PATH\n\n");
490 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
491 out.push_str(&format!(
492 "- Unique entries: {}\n",
493 path_stats.unique_entries
494 ));
495 out.push_str(&format!(
496 "- Duplicate entries: {}\n",
497 path_stats.duplicate_entries.len()
498 ));
499 out.push_str(&format!(
500 "- Missing paths: {}\n",
501 path_stats.missing_entries.len()
502 ));
503
504 out.push_str("\nPATH entries:\n");
505 for entry in path_stats.entries.iter().take(max_entries) {
506 out.push_str(&format!("- {}\n", entry));
507 }
508 if path_stats.entries.len() > max_entries {
509 out.push_str(&format!(
510 "- ... {} more entries omitted\n",
511 path_stats.entries.len() - max_entries
512 ));
513 }
514
515 if !path_stats.duplicate_entries.is_empty() {
516 out.push_str("\nDuplicate entries:\n");
517 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
518 out.push_str(&format!("- {}\n", entry));
519 }
520 if path_stats.duplicate_entries.len() > max_entries {
521 out.push_str(&format!(
522 "- ... {} more duplicates omitted\n",
523 path_stats.duplicate_entries.len() - max_entries
524 ));
525 }
526 }
527
528 if !path_stats.missing_entries.is_empty() {
529 out.push_str("\nMissing directories:\n");
530 for entry in path_stats.missing_entries.iter().take(max_entries) {
531 out.push_str(&format!("- {}\n", entry));
532 }
533 if path_stats.missing_entries.len() > max_entries {
534 out.push_str(&format!(
535 "- ... {} more missing entries omitted\n",
536 path_stats.missing_entries.len() - max_entries
537 ));
538 }
539 }
540
541 Ok(out.trim_end().to_string())
542}
543
544fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
545 let path_stats = analyze_path_env();
546 let toolchains = collect_toolchains();
547 let package_managers = collect_package_managers();
548 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
549
550 let mut out = String::from("Host inspection: env_doctor\n\n");
551 out.push_str(&format!(
552 "- PATH health: {} duplicates, {} missing entries\n",
553 path_stats.duplicate_entries.len(),
554 path_stats.missing_entries.len()
555 ));
556 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
557 out.push_str(&format!(
558 "- Package managers found: {}\n",
559 package_managers.found.len()
560 ));
561
562 if !package_managers.found.is_empty() {
563 out.push_str("\nPackage managers:\n");
564 for (label, version) in package_managers.found.iter().take(max_entries) {
565 out.push_str(&format!("- {}: {}\n", label, version));
566 }
567 if package_managers.found.len() > max_entries {
568 out.push_str(&format!(
569 "- ... {} more package managers omitted\n",
570 package_managers.found.len() - max_entries
571 ));
572 }
573 }
574
575 if !path_stats.duplicate_entries.is_empty() {
576 out.push_str("\nDuplicate PATH entries:\n");
577 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
578 out.push_str(&format!("- {}\n", entry));
579 }
580 if path_stats.duplicate_entries.len() > max_entries.min(5) {
581 out.push_str(&format!(
582 "- ... {} more duplicate entries omitted\n",
583 path_stats.duplicate_entries.len() - max_entries.min(5)
584 ));
585 }
586 }
587
588 if !path_stats.missing_entries.is_empty() {
589 out.push_str("\nMissing PATH entries:\n");
590 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
591 out.push_str(&format!("- {}\n", entry));
592 }
593 if path_stats.missing_entries.len() > max_entries.min(5) {
594 out.push_str(&format!(
595 "- ... {} more missing entries omitted\n",
596 path_stats.missing_entries.len() - max_entries.min(5)
597 ));
598 }
599 }
600
601 if !findings.is_empty() {
602 out.push_str("\nFindings:\n");
603 for finding in findings.iter().take(max_entries.max(5)) {
604 out.push_str(&format!("- {}\n", finding));
605 }
606 if findings.len() > max_entries.max(5) {
607 out.push_str(&format!(
608 "- ... {} more findings omitted\n",
609 findings.len() - max_entries.max(5)
610 ));
611 }
612 } else {
613 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
614 }
615
616 out.push_str(
617 "\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.",
618 );
619
620 Ok(out.trim_end().to_string())
621}
622
623#[derive(Clone, Copy, Debug, Eq, PartialEq)]
624enum FixPlanKind {
625 EnvPath,
626 PortConflict,
627 LmStudio,
628 DriverInstall,
629 GroupPolicy,
630 FirewallRule,
631 SshKey,
632 WslSetup,
633 ServiceConfig,
634 WindowsActivation,
635 RegistryEdit,
636 ScheduledTaskCreate,
637 DiskCleanup,
638 DnsResolution,
639 Generic,
640}
641
642async fn inspect_fix_plan(
643 issue: Option<String>,
644 port_filter: Option<u16>,
645 max_entries: usize,
646) -> Result<String, String> {
647 let issue = issue.unwrap_or_else(|| {
648 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
649 .to_string()
650 });
651 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
652 match plan_kind {
653 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
654 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
655 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
656 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
657 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
658 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
659 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
660 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
661 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
662 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
663 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
664 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
665 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
666 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
667 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
668 }
669}
670
671fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
672 let lower = issue.to_ascii_lowercase();
673 if lower.contains("firewall rule")
676 || lower.contains("inbound rule")
677 || lower.contains("outbound rule")
678 || (lower.contains("firewall")
679 && (lower.contains("allow")
680 || lower.contains("block")
681 || lower.contains("create")
682 || lower.contains("open")))
683 {
684 FixPlanKind::FirewallRule
685 } else if port_filter.is_some()
686 || lower.contains("port ")
687 || lower.contains("address already in use")
688 || lower.contains("already in use")
689 || lower.contains("what owns port")
690 || lower.contains("listening on port")
691 {
692 FixPlanKind::PortConflict
693 } else if lower.contains("lm studio")
694 || lower.contains("localhost:1234")
695 || lower.contains("/v1/models")
696 || lower.contains("no coding model loaded")
697 || lower.contains("embedding model")
698 || lower.contains("server on port 1234")
699 || lower.contains("runtime refresh")
700 {
701 FixPlanKind::LmStudio
702 } else if lower.contains("driver")
703 || lower.contains("gpu driver")
704 || lower.contains("nvidia driver")
705 || lower.contains("amd driver")
706 || lower.contains("install driver")
707 || lower.contains("update driver")
708 {
709 FixPlanKind::DriverInstall
710 } else if lower.contains("group policy")
711 || lower.contains("gpedit")
712 || lower.contains("local policy")
713 || lower.contains("secpol")
714 || lower.contains("administrative template")
715 {
716 FixPlanKind::GroupPolicy
717 } else if lower.contains("ssh key")
718 || lower.contains("ssh-keygen")
719 || lower.contains("generate ssh")
720 || lower.contains("authorized_keys")
721 || lower.contains("id_rsa")
722 || lower.contains("id_ed25519")
723 {
724 FixPlanKind::SshKey
725 } else if lower.contains("wsl")
726 || lower.contains("windows subsystem for linux")
727 || lower.contains("install ubuntu")
728 || lower.contains("install linux on windows")
729 || lower.contains("wsl2")
730 {
731 FixPlanKind::WslSetup
732 } else if lower.contains("service")
733 && (lower.contains("start ")
734 || lower.contains("stop ")
735 || lower.contains("restart ")
736 || lower.contains("enable ")
737 || lower.contains("disable ")
738 || lower.contains("configure service"))
739 {
740 FixPlanKind::ServiceConfig
741 } else if lower.contains("activate windows")
742 || lower.contains("windows activation")
743 || lower.contains("product key")
744 || lower.contains("kms")
745 || lower.contains("not activated")
746 {
747 FixPlanKind::WindowsActivation
748 } else if lower.contains("registry")
749 || lower.contains("regedit")
750 || lower.contains("hklm")
751 || lower.contains("hkcu")
752 || lower.contains("reg add")
753 || lower.contains("reg delete")
754 || lower.contains("registry key")
755 {
756 FixPlanKind::RegistryEdit
757 } else if lower.contains("scheduled task")
758 || lower.contains("task scheduler")
759 || lower.contains("schtasks")
760 || lower.contains("create task")
761 || lower.contains("run on startup")
762 || lower.contains("run on schedule")
763 || lower.contains("cron")
764 {
765 FixPlanKind::ScheduledTaskCreate
766 } else if lower.contains("disk cleanup")
767 || lower.contains("free up disk")
768 || lower.contains("free up space")
769 || lower.contains("clear cache")
770 || lower.contains("disk full")
771 || lower.contains("low disk space")
772 || lower.contains("reclaim space")
773 {
774 FixPlanKind::DiskCleanup
775 } else if lower.contains("cargo")
776 || lower.contains("rustc")
777 || lower.contains("path")
778 || lower.contains("package manager")
779 || lower.contains("package managers")
780 || lower.contains("toolchain")
781 || lower.contains("winget")
782 || lower.contains("choco")
783 || lower.contains("scoop")
784 || lower.contains("python")
785 || lower.contains("node")
786 {
787 FixPlanKind::EnvPath
788 } else if lower.contains("dns ")
789 || lower.contains("nameserver")
790 || lower.contains("cannot resolve")
791 || lower.contains("nslookup")
792 || lower.contains("flushdns")
793 {
794 FixPlanKind::DnsResolution
795 } else {
796 FixPlanKind::Generic
797 }
798}
799
800fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
801 let path_stats = analyze_path_env();
802 let toolchains = collect_toolchains();
803 let package_managers = collect_package_managers();
804 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
805 let found_tools = toolchains
806 .found
807 .iter()
808 .map(|(label, _)| label.as_str())
809 .collect::<HashSet<_>>();
810 let found_managers = package_managers
811 .found
812 .iter()
813 .map(|(label, _)| label.as_str())
814 .collect::<HashSet<_>>();
815
816 let mut out = String::from("Host inspection: fix_plan\n\n");
817 out.push_str(&format!("- Requested issue: {}\n", issue));
818 out.push_str("- Fix-plan type: environment/path\n");
819 out.push_str(&format!(
820 "- PATH health: {} duplicates, {} missing entries\n",
821 path_stats.duplicate_entries.len(),
822 path_stats.missing_entries.len()
823 ));
824 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
825 out.push_str(&format!(
826 "- Package managers found: {}\n",
827 package_managers.found.len()
828 ));
829
830 out.push_str("\nLikely causes:\n");
831 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
832 out.push_str(
833 "- 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",
834 );
835 }
836 if path_stats.duplicate_entries.is_empty()
837 && path_stats.missing_entries.is_empty()
838 && !findings.is_empty()
839 {
840 for finding in findings.iter().take(max_entries.max(4)) {
841 out.push_str(&format!("- {}\n", finding));
842 }
843 } else {
844 if !path_stats.duplicate_entries.is_empty() {
845 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
846 }
847 if !path_stats.missing_entries.is_empty() {
848 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
849 }
850 }
851 if found_tools.contains("node")
852 && !found_managers.contains("npm")
853 && !found_managers.contains("pnpm")
854 {
855 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
856 }
857 if found_tools.contains("python")
858 && !found_managers.contains("pip")
859 && !found_managers.contains("uv")
860 && !found_managers.contains("pipx")
861 {
862 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
863 }
864
865 out.push_str("\nFix plan:\n");
866 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");
867 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
868 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");
869 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
870 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");
871 }
872 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
873 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
874 }
875 if found_tools.contains("node")
876 && !found_managers.contains("npm")
877 && !found_managers.contains("pnpm")
878 {
879 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");
880 }
881 if found_tools.contains("python")
882 && !found_managers.contains("pip")
883 && !found_managers.contains("uv")
884 && !found_managers.contains("pipx")
885 {
886 out.push_str("- Repair Python or install a Python package manager explicitly. `py -m ensurepip --upgrade` is the least-invasive first check on Windows.\n");
887 }
888
889 if !path_stats.duplicate_entries.is_empty() {
890 out.push_str("\nExample duplicate PATH rows:\n");
891 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
892 out.push_str(&format!("- {}\n", entry));
893 }
894 }
895 if !path_stats.missing_entries.is_empty() {
896 out.push_str("\nExample missing PATH rows:\n");
897 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
898 out.push_str(&format!("- {}\n", entry));
899 }
900 }
901
902 out.push_str(
903 "\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.",
904 );
905 Ok(out.trim_end().to_string())
906}
907
908fn inspect_port_fix_plan(
909 issue: &str,
910 port_filter: Option<u16>,
911 max_entries: usize,
912) -> Result<String, String> {
913 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
914 let listeners = collect_listening_ports().unwrap_or_default();
915 let mut matching = listeners;
916 if let Some(port) = requested_port {
917 matching.retain(|entry| entry.port == port);
918 }
919 let processes = collect_processes().unwrap_or_default();
920
921 let mut out = String::from("Host inspection: fix_plan\n\n");
922 out.push_str(&format!("- Requested issue: {}\n", issue));
923 out.push_str("- Fix-plan type: port_conflict\n");
924 if let Some(port) = requested_port {
925 out.push_str(&format!("- Requested port: {}\n", port));
926 } else {
927 out.push_str("- Requested port: not parsed from the issue text\n");
928 }
929 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
930
931 if !matching.is_empty() {
932 out.push_str("\nCurrent listeners:\n");
933 for entry in matching.iter().take(max_entries.min(5)) {
934 let process_name = entry
935 .pid
936 .as_deref()
937 .and_then(|pid| pid.parse::<u32>().ok())
938 .and_then(|pid| {
939 processes
940 .iter()
941 .find(|process| process.pid == pid)
942 .map(|process| process.name.as_str())
943 })
944 .unwrap_or("unknown");
945 let pid = entry.pid.as_deref().unwrap_or("unknown");
946 out.push_str(&format!(
947 "- {} {} ({}) pid {} process {}\n",
948 entry.protocol, entry.local, entry.state, pid, process_name
949 ));
950 }
951 }
952
953 out.push_str("\nFix plan:\n");
954 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");
955 if !matching.is_empty() {
956 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");
957 } else {
958 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");
959 }
960 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
961 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");
962 out.push_str(
963 "\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.",
964 );
965 Ok(out.trim_end().to_string())
966}
967
968async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
969 let config = crate::agent::config::load_config();
970 let configured_api = config
971 .api_url
972 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
973 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
974 let reachability = probe_http_endpoint(&models_url).await;
975 let embed_model = detect_loaded_embed_model(&configured_api).await;
976
977 let mut out = String::from("Host inspection: fix_plan\n\n");
978 out.push_str(&format!("- Requested issue: {}\n", issue));
979 out.push_str("- Fix-plan type: lm_studio\n");
980 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
981 out.push_str(&format!("- Probe URL: {}\n", models_url));
982 match &reachability {
983 EndpointProbe::Reachable(status) => {
984 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
985 }
986 EndpointProbe::Unreachable(detail) => {
987 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
988 }
989 }
990 out.push_str(&format!(
991 "- Embedding model loaded: {}\n",
992 embed_model.as_deref().unwrap_or("none detected")
993 ));
994
995 out.push_str("\nFix plan:\n");
996 match reachability {
997 EndpointProbe::Reachable(_) => {
998 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");
999 }
1000 EndpointProbe::Unreachable(_) => {
1001 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");
1002 }
1003 }
1004 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");
1005 out.push_str("- If chat works but semantic search does not, load an embedding model as a second resident local model. Hematite expects a `nomic-embed` or similar embedding model there.\n");
1006 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");
1007 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");
1008 if let Some(model) = embed_model {
1009 out.push_str(&format!(
1010 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1011 model
1012 ));
1013 }
1014 if max_entries > 0 {
1015 out.push_str(
1016 "\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.",
1017 );
1018 }
1019 Ok(out.trim_end().to_string())
1020}
1021
1022fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1023 #[cfg(target_os = "windows")]
1025 let gpu_info = {
1026 let out = Command::new("powershell")
1027 .args([
1028 "-NoProfile",
1029 "-NonInteractive",
1030 "-Command",
1031 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1032 ])
1033 .output()
1034 .ok()
1035 .and_then(|o| String::from_utf8(o.stdout).ok())
1036 .unwrap_or_default();
1037 out.trim().to_string()
1038 };
1039 #[cfg(not(target_os = "windows"))]
1040 let gpu_info = String::from("(GPU detection not available on this platform)");
1041
1042 let mut out = String::from("Host inspection: fix_plan\n\n");
1043 out.push_str(&format!("- Requested issue: {}\n", issue));
1044 out.push_str("- Fix-plan type: driver_install\n");
1045 if !gpu_info.is_empty() {
1046 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1047 }
1048 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1049 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1050 out.push_str(
1051 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1052 );
1053 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1054 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1055 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1056 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
1057 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1058 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");
1059 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1060 out.push_str("\nVerification:\n");
1061 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1062 out.push_str("- The DriverVersion should match what you installed.\n");
1063 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.");
1064 Ok(out.trim_end().to_string())
1065}
1066
1067fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1068 #[cfg(target_os = "windows")]
1070 let edition = {
1071 Command::new("powershell")
1072 .args([
1073 "-NoProfile",
1074 "-NonInteractive",
1075 "-Command",
1076 "(Get-CimInstance Win32_OperatingSystem).Caption",
1077 ])
1078 .output()
1079 .ok()
1080 .and_then(|o| String::from_utf8(o.stdout).ok())
1081 .unwrap_or_default()
1082 .trim()
1083 .to_string()
1084 };
1085 #[cfg(not(target_os = "windows"))]
1086 let edition = String::from("(Windows edition detection not available)");
1087
1088 let is_home = edition.to_lowercase().contains("home");
1089
1090 let mut out = String::from("Host inspection: fix_plan\n\n");
1091 out.push_str(&format!("- Requested issue: {}\n", issue));
1092 out.push_str("- Fix-plan type: group_policy\n");
1093 out.push_str(&format!(
1094 "- Windows edition detected: {}\n",
1095 if edition.is_empty() {
1096 "unknown".to_string()
1097 } else {
1098 edition.clone()
1099 }
1100 ));
1101
1102 if is_home {
1103 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1104 out.push_str("Options on Home edition:\n");
1105 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");
1106 out.push_str(
1107 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1108 );
1109 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1110 } else {
1111 out.push_str("\nFix plan — Editing Local Group Policy:\n");
1112 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1113 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1114 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1115 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1116 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1117 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
1118 }
1119 out.push_str("\nVerification:\n");
1120 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1121 out.push_str(
1122 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1123 );
1124 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.");
1125 Ok(out.trim_end().to_string())
1126}
1127
1128fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1129 #[cfg(target_os = "windows")]
1130 let profile_state = {
1131 Command::new("powershell")
1132 .args([
1133 "-NoProfile",
1134 "-NonInteractive",
1135 "-Command",
1136 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1137 ])
1138 .output()
1139 .ok()
1140 .and_then(|o| String::from_utf8(o.stdout).ok())
1141 .unwrap_or_default()
1142 .trim()
1143 .to_string()
1144 };
1145 #[cfg(not(target_os = "windows"))]
1146 let profile_state = String::new();
1147
1148 let mut out = String::from("Host inspection: fix_plan\n\n");
1149 out.push_str(&format!("- Requested issue: {}\n", issue));
1150 out.push_str("- Fix-plan type: firewall_rule\n");
1151 if !profile_state.is_empty() {
1152 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1153 }
1154 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1155 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1156 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1157 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1158 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1159 out.push_str("\nTo ALLOW an application through the firewall:\n");
1160 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1161 out.push_str("\nTo REMOVE a rule you created:\n");
1162 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1163 out.push_str("\nTo see existing custom rules:\n");
1164 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1165 out.push_str("\nVerification:\n");
1166 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1167 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.");
1168 Ok(out.trim_end().to_string())
1169}
1170
1171fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1172 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1173 let ssh_dir = home.join(".ssh");
1174 let has_ssh_dir = ssh_dir.exists();
1175 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1176 let has_rsa = ssh_dir.join("id_rsa").exists();
1177 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1178
1179 let mut out = String::from("Host inspection: fix_plan\n\n");
1180 out.push_str(&format!("- Requested issue: {}\n", issue));
1181 out.push_str("- Fix-plan type: ssh_key\n");
1182 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1183 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1184 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1185 out.push_str(&format!(
1186 "- authorized_keys found: {}\n",
1187 has_authorized_keys
1188 ));
1189
1190 if has_ed25519 {
1191 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1192 }
1193
1194 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1195 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1196 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1197 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1198 out.push_str(
1199 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1200 );
1201 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1202 out.push_str("3. Start the SSH agent and add your key:\n");
1203 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1204 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1205 out.push_str(" Start-Service ssh-agent\n");
1206 out.push_str(" # Then add the key (normal PowerShell):\n");
1207 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1208 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1209 out.push_str(" # Print your public key:\n");
1210 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1211 out.push_str(" # On the target server, append it:\n");
1212 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1213 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1214 out.push_str("5. Test the connection:\n");
1215 out.push_str(" ssh user@server-address\n");
1216 out.push_str("\nFor GitHub/GitLab:\n");
1217 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1218 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1219 out.push_str("- Test: ssh -T git@github.com\n");
1220 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.");
1221 Ok(out.trim_end().to_string())
1222}
1223
1224fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1225 #[cfg(target_os = "windows")]
1226 let wsl_status = {
1227 let out = Command::new("wsl")
1228 .args(["--status"])
1229 .output()
1230 .ok()
1231 .and_then(|o| {
1232 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1233 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1234 Some(format!("{}{}", stdout, stderr))
1235 })
1236 .unwrap_or_default();
1237 out.trim().to_string()
1238 };
1239 #[cfg(not(target_os = "windows"))]
1240 let wsl_status = String::new();
1241
1242 let wsl_installed =
1243 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1244
1245 let mut out = String::from("Host inspection: fix_plan\n\n");
1246 out.push_str(&format!("- Requested issue: {}\n", issue));
1247 out.push_str("- Fix-plan type: wsl_setup\n");
1248 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1249 if !wsl_status.is_empty() {
1250 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1251 }
1252
1253 if wsl_installed {
1254 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1255 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1256 out.push_str(" Available distros: wsl --list --online\n");
1257 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1258 out.push_str("3. Create your Linux username and password when prompted.\n");
1259 } else {
1260 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1261 out.push_str("1. Open PowerShell as Administrator.\n");
1262 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1263 out.push_str(" wsl --install\n");
1264 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1265 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1266 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1267 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1268 out.push_str(" wsl --set-default-version 2\n");
1269 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1270 out.push_str(" wsl --install -d Debian\n");
1271 out.push_str(" wsl --list --online # to see all available distros\n");
1272 }
1273 out.push_str("\nVerification:\n");
1274 out.push_str("- Run: wsl --list --verbose\n");
1275 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1276 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.");
1277 Ok(out.trim_end().to_string())
1278}
1279
1280fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1281 let lower = issue.to_ascii_lowercase();
1282 let service_hint = if lower.contains("ssh") {
1284 Some("sshd")
1285 } else if lower.contains("mysql") {
1286 Some("MySQL80")
1287 } else if lower.contains("postgres") || lower.contains("postgresql") {
1288 Some("postgresql")
1289 } else if lower.contains("redis") {
1290 Some("Redis")
1291 } else if lower.contains("nginx") {
1292 Some("nginx")
1293 } else if lower.contains("apache") {
1294 Some("Apache2.4")
1295 } else {
1296 None
1297 };
1298
1299 #[cfg(target_os = "windows")]
1300 let service_state = if let Some(svc) = service_hint {
1301 Command::new("powershell")
1302 .args([
1303 "-NoProfile",
1304 "-NonInteractive",
1305 "-Command",
1306 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1307 ])
1308 .output()
1309 .ok()
1310 .and_then(|o| String::from_utf8(o.stdout).ok())
1311 .unwrap_or_default()
1312 .trim()
1313 .to_string()
1314 } else {
1315 String::new()
1316 };
1317 #[cfg(not(target_os = "windows"))]
1318 let service_state = String::new();
1319
1320 let mut out = String::from("Host inspection: fix_plan\n\n");
1321 out.push_str(&format!("- Requested issue: {}\n", issue));
1322 out.push_str("- Fix-plan type: service_config\n");
1323 if let Some(svc) = service_hint {
1324 out.push_str(&format!("- Service detected in request: {}\n", svc));
1325 }
1326 if !service_state.is_empty() {
1327 out.push_str(&format!("- Current state: {}\n", service_state));
1328 }
1329
1330 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1331 out.push_str("\nStart a service:\n");
1332 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1333 out.push_str("\nStop a service:\n");
1334 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1335 out.push_str("\nRestart a service:\n");
1336 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1337 out.push_str("\nEnable a service to start automatically:\n");
1338 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1339 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1340 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1341 out.push_str("\nFind the exact service name:\n");
1342 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1343 out.push_str("\nVerification:\n");
1344 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1345 if let Some(svc) = service_hint {
1346 out.push_str(&format!(
1347 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1348 svc, svc
1349 ));
1350 }
1351 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.");
1352 Ok(out.trim_end().to_string())
1353}
1354
1355fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1356 #[cfg(target_os = "windows")]
1357 let activation_status = {
1358 Command::new("powershell")
1359 .args([
1360 "-NoProfile",
1361 "-NonInteractive",
1362 "-Command",
1363 "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 + ')' })\" }",
1364 ])
1365 .output()
1366 .ok()
1367 .and_then(|o| String::from_utf8(o.stdout).ok())
1368 .unwrap_or_default()
1369 .trim()
1370 .to_string()
1371 };
1372 #[cfg(not(target_os = "windows"))]
1373 let activation_status = String::new();
1374
1375 let is_licensed = activation_status.to_lowercase().contains("licensed")
1376 && !activation_status.to_lowercase().contains("not licensed");
1377
1378 let mut out = String::from("Host inspection: fix_plan\n\n");
1379 out.push_str(&format!("- Requested issue: {}\n", issue));
1380 out.push_str("- Fix-plan type: windows_activation\n");
1381 if !activation_status.is_empty() {
1382 out.push_str(&format!(
1383 "- Current activation state:\n{}\n",
1384 activation_status
1385 ));
1386 }
1387
1388 if is_licensed {
1389 out.push_str(
1390 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1391 );
1392 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1393 out.push_str(" (Forces an online activation attempt)\n");
1394 out.push_str("2. Check activation details: slmgr /dli\n");
1395 } else {
1396 out.push_str("\nFix plan — Activating Windows:\n");
1397 out.push_str("1. Check your current status first:\n");
1398 out.push_str(" slmgr /dli (basic info)\n");
1399 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1400 out.push_str("\n2. If you have a retail product key:\n");
1401 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1402 out.push_str(" slmgr /ato (activate online)\n");
1403 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1404 out.push_str(" - Go to Settings → System → Activation\n");
1405 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1406 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1407 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1408 out.push_str(" - Contact your IT department for the KMS server address\n");
1409 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1410 out.push_str(" - Activate: slmgr /ato\n");
1411 }
1412 out.push_str("\nVerification:\n");
1413 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1414 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1415 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.");
1416 Ok(out.trim_end().to_string())
1417}
1418
1419fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1420 let mut out = String::from("Host inspection: fix_plan\n\n");
1421 out.push_str(&format!("- Requested issue: {}\n", issue));
1422 out.push_str("- Fix-plan type: registry_edit\n");
1423 out.push_str(
1424 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1425 );
1426 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1427 out.push_str("\n1. Back up before you touch anything:\n");
1428 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1429 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1430 out.push_str(" # Or export the whole registry (takes a while):\n");
1431 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1432 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1433 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1434 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1435 out.push_str(
1436 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1437 );
1438 out.push_str("\n4. Create a new key:\n");
1439 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1440 out.push_str("\n5. Delete a value:\n");
1441 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1442 out.push_str("\n6. Restore from backup if something breaks:\n");
1443 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1444 out.push_str("\nCommon registry hives:\n");
1445 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1446 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1447 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1448 out.push_str("\nVerification:\n");
1449 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1450 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.");
1451 Ok(out.trim_end().to_string())
1452}
1453
1454fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1455 let mut out = String::from("Host inspection: fix_plan\n\n");
1456 out.push_str(&format!("- Requested issue: {}\n", issue));
1457 out.push_str("- Fix-plan type: scheduled_task_create\n");
1458 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1459 out.push_str("\nExample: Run a script at 9 AM every day\n");
1460 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1461 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1462 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1463 out.push_str("\nExample: Run at Windows startup\n");
1464 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1465 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1466 out.push_str("\nExample: Run at user logon\n");
1467 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1468 out.push_str(
1469 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1470 );
1471 out.push_str("\nExample: Run every 30 minutes\n");
1472 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1473 out.push_str("\nView all tasks:\n");
1474 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1475 out.push_str("\nDelete a task:\n");
1476 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1477 out.push_str("\nRun a task immediately:\n");
1478 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1479 out.push_str("\nVerification:\n");
1480 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1481 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.");
1482 Ok(out.trim_end().to_string())
1483}
1484
1485fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1486 #[cfg(target_os = "windows")]
1487 let disk_info = {
1488 Command::new("powershell")
1489 .args([
1490 "-NoProfile",
1491 "-NonInteractive",
1492 "-Command",
1493 "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\" }",
1494 ])
1495 .output()
1496 .ok()
1497 .and_then(|o| String::from_utf8(o.stdout).ok())
1498 .unwrap_or_default()
1499 .trim()
1500 .to_string()
1501 };
1502 #[cfg(not(target_os = "windows"))]
1503 let disk_info = String::new();
1504
1505 let mut out = String::from("Host inspection: fix_plan\n\n");
1506 out.push_str(&format!("- Requested issue: {}\n", issue));
1507 out.push_str("- Fix-plan type: disk_cleanup\n");
1508 if !disk_info.is_empty() {
1509 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1510 }
1511 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1512 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1513 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1514 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1515 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1516 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1517 out.push_str(" Stop-Service wuauserv\n");
1518 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1519 out.push_str(" Start-Service wuauserv\n");
1520 out.push_str("\n3. Clear Windows Temp folder:\n");
1521 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1522 out.push_str(
1523 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1524 );
1525 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1526 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1527 out.push_str(" - npm cache: npm cache clean --force\n");
1528 out.push_str(" - pip cache: pip cache purge\n");
1529 out.push_str(
1530 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1531 );
1532 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1533 out.push_str("\n5. Check for large files:\n");
1534 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");
1535 out.push_str("\nVerification:\n");
1536 out.push_str(
1537 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1538 );
1539 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.");
1540 Ok(out.trim_end().to_string())
1541}
1542
1543fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1544 let mut out = String::from("Host inspection: fix_plan\n\n");
1545 out.push_str(&format!("- Requested issue: {}\n", issue));
1546 out.push_str("- Fix-plan type: generic\n");
1547 out.push_str(
1548 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1549 Structured lanes available:\n\
1550 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1551 - Port conflict (address already in use, what owns port)\n\
1552 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1553 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1554 - Group Policy (gpedit, local policy, administrative template)\n\
1555 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1556 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1557 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1558 - Service config (start/stop/restart/enable/disable a service)\n\
1559 - Windows activation (product key, not activated, kms)\n\
1560 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1561 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1562 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1563 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1564 );
1565 Ok(out.trim_end().to_string())
1566}
1567
1568fn inspect_resource_load() -> Result<String, String> {
1569 #[cfg(target_os = "windows")]
1570 {
1571 let output = Command::new("powershell")
1572 .args([
1573 "-NoProfile",
1574 "-Command",
1575 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1576 ])
1577 .output()
1578 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1579
1580 let text = String::from_utf8_lossy(&output.stdout);
1581 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1582
1583 let cpu_load = lines
1584 .next()
1585 .and_then(|l| l.parse::<u32>().ok())
1586 .unwrap_or(0);
1587 let mem_json = lines.collect::<Vec<_>>().join("");
1588 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1589
1590 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1591 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1592 let used_kb = total_kb.saturating_sub(free_kb);
1593 let mem_percent = if total_kb > 0 {
1594 (used_kb * 100) / total_kb
1595 } else {
1596 0
1597 };
1598
1599 let mut out = String::from("Host inspection: resource_load\n\n");
1600 out.push_str("**System Performance Summary:**\n");
1601 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1602 out.push_str(&format!(
1603 "- Memory Usage: {} / {} ({}%)\n",
1604 human_bytes(used_kb * 1024),
1605 human_bytes(total_kb * 1024),
1606 mem_percent
1607 ));
1608
1609 if cpu_load > 85 {
1610 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1611 }
1612 if mem_percent > 90 {
1613 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1614 }
1615
1616 Ok(out)
1617 }
1618 #[cfg(not(target_os = "windows"))]
1619 {
1620 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1621 }
1622}
1623
1624#[derive(Debug)]
1625enum EndpointProbe {
1626 Reachable(u16),
1627 Unreachable(String),
1628}
1629
1630async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1631 let client = match reqwest::Client::builder()
1632 .timeout(std::time::Duration::from_secs(3))
1633 .build()
1634 {
1635 Ok(client) => client,
1636 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1637 };
1638
1639 match client.get(url).send().await {
1640 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1641 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1642 }
1643}
1644
1645async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1646 if configured_api.contains("11434") {
1647 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1648 let url = format!("{}/api/ps", base);
1649 let client = reqwest::Client::builder()
1650 .timeout(std::time::Duration::from_secs(3))
1651 .build()
1652 .ok()?;
1653 let response = client.get(url).send().await.ok()?;
1654 let body = response.json::<serde_json::Value>().await.ok()?;
1655 let entries = body["models"].as_array()?;
1656 for entry in entries {
1657 let name = entry["name"]
1658 .as_str()
1659 .or_else(|| entry["model"].as_str())
1660 .unwrap_or_default();
1661 let lower = name.to_ascii_lowercase();
1662 if lower.contains("embed")
1663 || lower.contains("embedding")
1664 || lower.contains("minilm")
1665 || lower.contains("bge")
1666 || lower.contains("e5")
1667 {
1668 return Some(name.to_string());
1669 }
1670 }
1671 return None;
1672 }
1673
1674 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1675 let url = format!("{}/api/v0/models", base);
1676 let client = reqwest::Client::builder()
1677 .timeout(std::time::Duration::from_secs(3))
1678 .build()
1679 .ok()?;
1680
1681 #[derive(serde::Deserialize)]
1682 struct ModelList {
1683 data: Vec<ModelEntry>,
1684 }
1685 #[derive(serde::Deserialize)]
1686 struct ModelEntry {
1687 id: String,
1688 #[serde(rename = "type", default)]
1689 model_type: String,
1690 #[serde(default)]
1691 state: String,
1692 }
1693
1694 let response = client.get(url).send().await.ok()?;
1695 let models = response.json::<ModelList>().await.ok()?;
1696 models
1697 .data
1698 .into_iter()
1699 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1700 .map(|model| model.id)
1701}
1702
1703fn first_port_in_text(text: &str) -> Option<u16> {
1704 text.split(|c: char| !c.is_ascii_digit())
1705 .find(|fragment| !fragment.is_empty())
1706 .and_then(|fragment| fragment.parse::<u16>().ok())
1707}
1708
1709fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1710 let mut processes = collect_processes()?;
1711 if let Some(filter) = name_filter.as_deref() {
1712 let lowered = filter.to_ascii_lowercase();
1713 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1714 }
1715 processes.sort_by(|a, b| {
1716 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1717 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1718 b_cpu
1719 .partial_cmp(&a_cpu)
1720 .unwrap_or(std::cmp::Ordering::Equal)
1721 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1722 .then_with(|| a.name.cmp(&b.name))
1723 .then_with(|| a.pid.cmp(&b.pid))
1724 });
1725
1726 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1727
1728 let mut out = String::from("Host inspection: processes\n\n");
1729 if let Some(filter) = name_filter.as_deref() {
1730 out.push_str(&format!("- Filter name: {}\n", filter));
1731 }
1732 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1733 out.push_str(&format!(
1734 "- Total reported working set: {}\n",
1735 human_bytes(total_memory)
1736 ));
1737
1738 if processes.is_empty() {
1739 out.push_str("\nNo running processes matched.");
1740 return Ok(out);
1741 }
1742
1743 out.push_str("\nTop processes by resource usage:\n");
1744 for entry in processes.iter().take(max_entries) {
1745 let cpu_str = entry
1746 .cpu_percent
1747 .map(|p| format!(" [CPU: {:.1}%]", p))
1748 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1749 .unwrap_or_default();
1750 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1751 format!(" [I/O R:{}/W:{}]", r, w)
1752 } else {
1753 " [I/O unknown]".to_string()
1754 };
1755 out.push_str(&format!(
1756 "- {} (pid {}) - {}{}{}{}\n",
1757 entry.name,
1758 entry.pid,
1759 human_bytes(entry.memory_bytes),
1760 cpu_str,
1761 io_str,
1762 entry
1763 .detail
1764 .as_deref()
1765 .map(|detail| format!(" [{}]", detail))
1766 .unwrap_or_default()
1767 ));
1768 }
1769 if processes.len() > max_entries {
1770 out.push_str(&format!(
1771 "- ... {} more processes omitted\n",
1772 processes.len() - max_entries
1773 ));
1774 }
1775
1776 Ok(out.trim_end().to_string())
1777}
1778
1779fn inspect_network(max_entries: usize) -> Result<String, String> {
1780 let adapters = collect_network_adapters()?;
1781 let active_count = adapters
1782 .iter()
1783 .filter(|adapter| adapter.is_active())
1784 .count();
1785 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1786
1787 let mut out = String::from("Host inspection: network\n\n");
1788 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1789 out.push_str(&format!("- Active adapters: {}\n", active_count));
1790 out.push_str(&format!(
1791 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1792 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1793 ));
1794
1795 if adapters.is_empty() {
1796 out.push_str("\nNo adapter details were detected.");
1797 return Ok(out);
1798 }
1799
1800 out.push_str("\nAdapter summary:\n");
1801 for adapter in adapters.iter().take(max_entries) {
1802 let status = if adapter.is_active() {
1803 "active"
1804 } else if adapter.disconnected {
1805 "disconnected"
1806 } else {
1807 "idle"
1808 };
1809 let mut details = vec![status.to_string()];
1810 if !adapter.ipv4.is_empty() {
1811 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1812 }
1813 if !adapter.ipv6.is_empty() {
1814 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1815 }
1816 if !adapter.gateways.is_empty() {
1817 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1818 }
1819 if !adapter.dns_servers.is_empty() {
1820 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1821 }
1822 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1823 }
1824 if adapters.len() > max_entries {
1825 out.push_str(&format!(
1826 "- ... {} more adapters omitted\n",
1827 adapters.len() - max_entries
1828 ));
1829 }
1830
1831 Ok(out.trim_end().to_string())
1832}
1833
1834fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1835 let mut out = String::from("Host inspection: lan_discovery\n\n");
1836
1837 #[cfg(target_os = "windows")]
1838 {
1839 let n = max_entries.clamp(5, 20);
1840 let adapters = collect_network_adapters()?;
1841 let services = collect_services().unwrap_or_default();
1842 let active_adapters: Vec<&NetworkAdapter> = adapters
1843 .iter()
1844 .filter(|adapter| adapter.is_active())
1845 .collect();
1846 let gateways: Vec<String> = active_adapters
1847 .iter()
1848 .flat_map(|adapter| adapter.gateways.clone())
1849 .collect::<HashSet<_>>()
1850 .into_iter()
1851 .collect();
1852
1853 let neighbor_script = r#"
1854$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1855 Where-Object {
1856 $_.IPAddress -notlike '127.*' -and
1857 $_.IPAddress -notlike '169.254*' -and
1858 $_.State -notin @('Unreachable','Invalid')
1859 } |
1860 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1861$neighbors | ConvertTo-Json -Compress
1862"#;
1863 let neighbor_text = Command::new("powershell")
1864 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1865 .output()
1866 .ok()
1867 .and_then(|o| String::from_utf8(o.stdout).ok())
1868 .unwrap_or_default();
1869 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1870 .into_iter()
1871 .take(n)
1872 .collect();
1873
1874 let listener_script = r#"
1875Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1876 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1877 Select-Object LocalAddress, LocalPort, OwningProcess |
1878 ForEach-Object {
1879 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1880 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1881 }
1882"#;
1883 let listener_text = Command::new("powershell")
1884 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1885 .output()
1886 .ok()
1887 .and_then(|o| String::from_utf8(o.stdout).ok())
1888 .unwrap_or_default();
1889 let listeners: Vec<(String, u16, String, String)> = listener_text
1890 .lines()
1891 .filter_map(|line| {
1892 let parts: Vec<&str> = line.trim().split('|').collect();
1893 if parts.len() < 4 {
1894 return None;
1895 }
1896 Some((
1897 parts[0].to_string(),
1898 parts[1].parse::<u16>().ok()?,
1899 parts[2].to_string(),
1900 parts[3].to_string(),
1901 ))
1902 })
1903 .take(n)
1904 .collect();
1905
1906 let smb_mapping_script = r#"
1907Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1908 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1909"#;
1910 let smb_mappings: Vec<String> = Command::new("powershell")
1911 .args([
1912 "-NoProfile",
1913 "-NonInteractive",
1914 "-Command",
1915 smb_mapping_script,
1916 ])
1917 .output()
1918 .ok()
1919 .and_then(|o| String::from_utf8(o.stdout).ok())
1920 .unwrap_or_default()
1921 .lines()
1922 .take(n)
1923 .map(|line| line.trim().to_string())
1924 .filter(|line| !line.is_empty())
1925 .collect();
1926
1927 let smb_connections_script = r#"
1928Get-SmbConnection -ErrorAction SilentlyContinue |
1929 Select-Object ServerName, ShareName, NumOpens |
1930 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1931"#;
1932 let smb_connections: Vec<String> = Command::new("powershell")
1933 .args([
1934 "-NoProfile",
1935 "-NonInteractive",
1936 "-Command",
1937 smb_connections_script,
1938 ])
1939 .output()
1940 .ok()
1941 .and_then(|o| String::from_utf8(o.stdout).ok())
1942 .unwrap_or_default()
1943 .lines()
1944 .take(n)
1945 .map(|line| line.trim().to_string())
1946 .filter(|line| !line.is_empty())
1947 .collect();
1948
1949 let discovery_service_names = [
1950 "FDResPub",
1951 "fdPHost",
1952 "SSDPSRV",
1953 "upnphost",
1954 "LanmanServer",
1955 "LanmanWorkstation",
1956 "lmhosts",
1957 ];
1958 let discovery_services: Vec<&ServiceEntry> = services
1959 .iter()
1960 .filter(|entry| {
1961 discovery_service_names
1962 .iter()
1963 .any(|name| entry.name.eq_ignore_ascii_case(name))
1964 })
1965 .collect();
1966
1967 let mut findings = Vec::new();
1968 if active_adapters.is_empty() {
1969 findings.push(AuditFinding {
1970 finding: "No active LAN adapters were detected.".to_string(),
1971 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1972 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(),
1973 });
1974 }
1975
1976 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
1977 .iter()
1978 .copied()
1979 .filter(|entry| {
1980 !entry.status.eq_ignore_ascii_case("running")
1981 && !entry.status.eq_ignore_ascii_case("active")
1982 })
1983 .collect();
1984 if !stopped_discovery_services.is_empty() {
1985 let names = stopped_discovery_services
1986 .iter()
1987 .map(|entry| entry.name.as_str())
1988 .collect::<Vec<_>>()
1989 .join(", ");
1990 findings.push(AuditFinding {
1991 finding: format!("Discovery-related services are not running: {names}"),
1992 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
1993 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(),
1994 });
1995 }
1996
1997 if listeners.is_empty() {
1998 findings.push(AuditFinding {
1999 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2000 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2001 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(),
2002 });
2003 }
2004
2005 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2006 findings.push(AuditFinding {
2007 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2008 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2009 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(),
2010 });
2011 }
2012
2013 out.push_str("=== Findings ===\n");
2014 if findings.is_empty() {
2015 out.push_str(
2016 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2017 );
2018 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2019 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");
2020 } else {
2021 for finding in &findings {
2022 out.push_str(&format!("- Finding: {}\n", finding.finding));
2023 out.push_str(&format!(" Impact: {}\n", finding.impact));
2024 out.push_str(&format!(" Fix: {}\n", finding.fix));
2025 }
2026 }
2027
2028 out.push_str("\n=== Active adapter and gateway summary ===\n");
2029 if active_adapters.is_empty() {
2030 out.push_str("- No active adapters detected.\n");
2031 } else {
2032 for adapter in active_adapters.iter().take(n) {
2033 let ipv4 = if adapter.ipv4.is_empty() {
2034 "no IPv4".to_string()
2035 } else {
2036 adapter.ipv4.join(", ")
2037 };
2038 let gateway = if adapter.gateways.is_empty() {
2039 "no gateway".to_string()
2040 } else {
2041 adapter.gateways.join(", ")
2042 };
2043 out.push_str(&format!(
2044 "- {} | IPv4: {} | Gateway: {}\n",
2045 adapter.name, ipv4, gateway
2046 ));
2047 }
2048 }
2049
2050 out.push_str("\n=== Neighborhood evidence ===\n");
2051 out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2052 out.push_str(&format!(
2053 "- Neighbor entries observed: {}\n",
2054 neighbors.len()
2055 ));
2056 if neighbors.is_empty() {
2057 out.push_str("- No ARP/neighbor evidence retrieved.\n");
2058 } else {
2059 for (ip, mac, state, iface) in neighbors.iter().take(n) {
2060 out.push_str(&format!(
2061 "- {} on {} | MAC: {} | State: {}\n",
2062 ip, iface, mac, state
2063 ));
2064 }
2065 }
2066
2067 out.push_str("\n=== Discovery services ===\n");
2068 if discovery_services.is_empty() {
2069 out.push_str("- Discovery service status unavailable.\n");
2070 } else {
2071 for entry in discovery_services.iter().take(n) {
2072 let startup = entry.startup.as_deref().unwrap_or("unknown");
2073 out.push_str(&format!(
2074 "- {} | Status: {} | Startup: {}\n",
2075 entry.name, entry.status, startup
2076 ));
2077 }
2078 }
2079
2080 out.push_str("\n=== Discovery listener surface ===\n");
2081 if listeners.is_empty() {
2082 out.push_str("- No discovery-oriented UDP listeners detected.\n");
2083 } else {
2084 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2085 let label = match *port {
2086 137 => "NetBIOS Name Service",
2087 138 => "NetBIOS Datagram",
2088 1900 => "SSDP/UPnP",
2089 5353 => "mDNS",
2090 5355 => "LLMNR",
2091 _ => "Discovery",
2092 };
2093 let proc_label = if proc_name.is_empty() {
2094 "unknown".to_string()
2095 } else {
2096 proc_name.clone()
2097 };
2098 out.push_str(&format!(
2099 "- {}:{} | {} | PID {} ({})\n",
2100 addr, port, label, pid, proc_label
2101 ));
2102 }
2103 }
2104
2105 out.push_str("\n=== SMB and neighborhood visibility ===\n");
2106 if smb_mappings.is_empty() && smb_connections.is_empty() {
2107 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2108 } else {
2109 if !smb_mappings.is_empty() {
2110 out.push_str("- Mapped drives:\n");
2111 for mapping in smb_mappings.iter().take(n) {
2112 let parts: Vec<&str> = mapping.split('|').collect();
2113 if parts.len() >= 2 {
2114 out.push_str(&format!(" - {} -> {}\n", parts[0], parts[1]));
2115 }
2116 }
2117 }
2118 if !smb_connections.is_empty() {
2119 out.push_str("- Active SMB connections:\n");
2120 for connection in smb_connections.iter().take(n) {
2121 let parts: Vec<&str> = connection.split('|').collect();
2122 if parts.len() >= 3 {
2123 out.push_str(&format!(
2124 " - {}\\{} | Opens: {}\n",
2125 parts[0], parts[1], parts[2]
2126 ));
2127 }
2128 }
2129 }
2130 }
2131 }
2132
2133 #[cfg(not(target_os = "windows"))]
2134 {
2135 let n = max_entries.clamp(5, 20);
2136 let adapters = collect_network_adapters()?;
2137 let arp_output = Command::new("ip")
2138 .args(["neigh"])
2139 .output()
2140 .ok()
2141 .and_then(|o| String::from_utf8(o.stdout).ok())
2142 .unwrap_or_default();
2143 let neighbors: Vec<&str> = arp_output
2144 .lines()
2145 .filter(|line| !line.trim().is_empty())
2146 .take(n)
2147 .collect();
2148
2149 out.push_str("=== Findings ===\n");
2150 if adapters.iter().any(|adapter| adapter.is_active()) {
2151 out.push_str(
2152 "- Finding: LAN discovery support is partially available on this platform.\n",
2153 );
2154 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2155 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2156 } else {
2157 out.push_str("- Finding: No active LAN adapters were detected.\n");
2158 out.push_str(
2159 " Impact: Neighborhood discovery cannot work without an active interface.\n",
2160 );
2161 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2162 }
2163
2164 out.push_str("\n=== Active adapter and gateway summary ===\n");
2165 if adapters.is_empty() {
2166 out.push_str("- No adapters detected.\n");
2167 } else {
2168 for adapter in adapters.iter().take(n) {
2169 let ipv4 = if adapter.ipv4.is_empty() {
2170 "no IPv4".to_string()
2171 } else {
2172 adapter.ipv4.join(", ")
2173 };
2174 let gateway = if adapter.gateways.is_empty() {
2175 "no gateway".to_string()
2176 } else {
2177 adapter.gateways.join(", ")
2178 };
2179 out.push_str(&format!(
2180 "- {} | IPv4: {} | Gateway: {}\n",
2181 adapter.name, ipv4, gateway
2182 ));
2183 }
2184 }
2185
2186 out.push_str("\n=== Neighborhood evidence ===\n");
2187 if neighbors.is_empty() {
2188 out.push_str("- No neighbor entries detected.\n");
2189 } else {
2190 for line in neighbors {
2191 out.push_str(&format!("- {}\n", line.trim()));
2192 }
2193 }
2194 }
2195
2196 Ok(out.trim_end().to_string())
2197}
2198
2199fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2200 let mut services = collect_services()?;
2201 if let Some(filter) = name_filter.as_deref() {
2202 let lowered = filter.to_ascii_lowercase();
2203 services.retain(|entry| {
2204 entry.name.to_ascii_lowercase().contains(&lowered)
2205 || entry
2206 .display_name
2207 .as_deref()
2208 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2209 .unwrap_or(false)
2210 });
2211 }
2212
2213 services.sort_by(|a, b| {
2214 let a_running =
2215 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2216 let b_running =
2217 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2218 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2219 });
2220
2221 let running = services
2222 .iter()
2223 .filter(|entry| {
2224 entry.status.eq_ignore_ascii_case("running")
2225 || entry.status.eq_ignore_ascii_case("active")
2226 })
2227 .count();
2228 let failed = services
2229 .iter()
2230 .filter(|entry| {
2231 entry.status.eq_ignore_ascii_case("failed")
2232 || entry.status.eq_ignore_ascii_case("error")
2233 || entry.status.eq_ignore_ascii_case("stopped")
2234 })
2235 .count();
2236
2237 let mut out = String::from("Host inspection: services\n\n");
2238 if let Some(filter) = name_filter.as_deref() {
2239 out.push_str(&format!("- Filter name: {}\n", filter));
2240 }
2241 out.push_str(&format!("- Services found: {}\n", services.len()));
2242 out.push_str(&format!("- Running/active: {}\n", running));
2243 out.push_str(&format!("- Failed/stopped: {}\n", failed));
2244
2245 if services.is_empty() {
2246 out.push_str("\nNo services matched.");
2247 return Ok(out);
2248 }
2249
2250 let per_section = (max_entries / 2).max(5);
2252
2253 let running_services: Vec<_> = services
2254 .iter()
2255 .filter(|e| {
2256 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2257 })
2258 .collect();
2259 let stopped_services: Vec<_> = services
2260 .iter()
2261 .filter(|e| {
2262 e.status.eq_ignore_ascii_case("stopped")
2263 || e.status.eq_ignore_ascii_case("failed")
2264 || e.status.eq_ignore_ascii_case("error")
2265 })
2266 .collect();
2267
2268 let fmt_entry = |entry: &&ServiceEntry| {
2269 let startup = entry
2270 .startup
2271 .as_deref()
2272 .map(|v| format!(" | startup {}", v))
2273 .unwrap_or_default();
2274 let logon = entry
2275 .start_name
2276 .as_deref()
2277 .map(|v| format!(" | LogOn: {}", v))
2278 .unwrap_or_default();
2279 let display = entry
2280 .display_name
2281 .as_deref()
2282 .filter(|v| *v != &entry.name)
2283 .map(|v| format!(" [{}]", v))
2284 .unwrap_or_default();
2285 format!(
2286 "- {}{} - {}{}{}\n",
2287 entry.name, display, entry.status, startup, logon
2288 )
2289 };
2290
2291 out.push_str(&format!(
2292 "\nRunning services ({} total, showing up to {}):\n",
2293 running_services.len(),
2294 per_section
2295 ));
2296 for entry in running_services.iter().take(per_section) {
2297 out.push_str(&fmt_entry(entry));
2298 }
2299 if running_services.len() > per_section {
2300 out.push_str(&format!(
2301 "- ... {} more running services omitted\n",
2302 running_services.len() - per_section
2303 ));
2304 }
2305
2306 out.push_str(&format!(
2307 "\nStopped/failed services ({} total, showing up to {}):\n",
2308 stopped_services.len(),
2309 per_section
2310 ));
2311 for entry in stopped_services.iter().take(per_section) {
2312 out.push_str(&fmt_entry(entry));
2313 }
2314 if stopped_services.len() > per_section {
2315 out.push_str(&format!(
2316 "- ... {} more stopped services omitted\n",
2317 stopped_services.len() - per_section
2318 ));
2319 }
2320
2321 Ok(out.trim_end().to_string())
2322}
2323
2324async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2325 inspect_directory("Disk", path, max_entries).await
2326}
2327
2328fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2329 let mut listeners = collect_listening_ports()?;
2330 if let Some(port) = port_filter {
2331 listeners.retain(|entry| entry.port == port);
2332 }
2333 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2334
2335 let mut out = String::from("Host inspection: ports\n\n");
2336 if let Some(port) = port_filter {
2337 out.push_str(&format!("- Filter port: {}\n", port));
2338 }
2339 out.push_str(&format!(
2340 "- Listening endpoints found: {}\n",
2341 listeners.len()
2342 ));
2343
2344 if listeners.is_empty() {
2345 out.push_str("\nNo listening endpoints matched.");
2346 return Ok(out);
2347 }
2348
2349 out.push_str("\nListening endpoints:\n");
2350 for entry in listeners.iter().take(max_entries) {
2351 let pid_str = entry
2352 .pid
2353 .as_deref()
2354 .map(|p| format!(" pid {}", p))
2355 .unwrap_or_default();
2356 let name_str = entry
2357 .process_name
2358 .as_deref()
2359 .map(|n| format!(" [{}]", n))
2360 .unwrap_or_default();
2361 out.push_str(&format!(
2362 "- {} {} ({}){}{}\n",
2363 entry.protocol, entry.local, entry.state, pid_str, name_str
2364 ));
2365 }
2366 if listeners.len() > max_entries {
2367 out.push_str(&format!(
2368 "- ... {} more listening endpoints omitted\n",
2369 listeners.len() - max_entries
2370 ));
2371 }
2372
2373 Ok(out.trim_end().to_string())
2374}
2375
2376fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2377 if !path.exists() {
2378 return Err(format!("Path does not exist: {}", path.display()));
2379 }
2380 if !path.is_dir() {
2381 return Err(format!("Path is not a directory: {}", path.display()));
2382 }
2383
2384 let markers = collect_project_markers(&path);
2385 let hematite_state = collect_hematite_state(&path);
2386 let git_state = inspect_git_state(&path);
2387 let release_state = inspect_release_artifacts(&path);
2388
2389 let mut out = String::from("Host inspection: repo_doctor\n\n");
2390 out.push_str(&format!("- Path: {}\n", path.display()));
2391 out.push_str(&format!(
2392 "- Workspace mode: {}\n",
2393 workspace_mode_for_path(&path)
2394 ));
2395
2396 if markers.is_empty() {
2397 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");
2398 } else {
2399 out.push_str("- Project markers:\n");
2400 for marker in markers.iter().take(max_entries) {
2401 out.push_str(&format!(" - {}\n", marker));
2402 }
2403 }
2404
2405 match git_state {
2406 Some(git) => {
2407 out.push_str(&format!("- Git root: {}\n", git.root.display()));
2408 out.push_str(&format!("- Git branch: {}\n", git.branch));
2409 out.push_str(&format!("- Git status: {}\n", git.status_label()));
2410 }
2411 None => out.push_str("- Git: not inside a detected work tree\n"),
2412 }
2413
2414 out.push_str(&format!(
2415 "- Hematite docs/imports/reports: {}/{}/{}\n",
2416 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2417 ));
2418 if hematite_state.workspace_profile {
2419 out.push_str("- Workspace profile: present\n");
2420 } else {
2421 out.push_str("- Workspace profile: absent\n");
2422 }
2423
2424 if let Some(release) = release_state {
2425 out.push_str(&format!("- Cargo version: {}\n", release.version));
2426 out.push_str(&format!(
2427 "- Windows artifacts for current version: {}/{}/{}\n",
2428 bool_label(release.portable_dir),
2429 bool_label(release.portable_zip),
2430 bool_label(release.setup_exe)
2431 ));
2432 }
2433
2434 Ok(out.trim_end().to_string())
2435}
2436
2437async fn inspect_known_directory(
2438 label: &str,
2439 path: Option<PathBuf>,
2440 max_entries: usize,
2441) -> Result<String, String> {
2442 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2443 inspect_directory(label, path, max_entries).await
2444}
2445
2446async fn inspect_directory(
2447 label: &str,
2448 path: PathBuf,
2449 max_entries: usize,
2450) -> Result<String, String> {
2451 let label = label.to_string();
2452 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2453 .await
2454 .map_err(|e| format!("inspect_host task failed: {e}"))?
2455}
2456
2457fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2458 if !path.exists() {
2459 return Err(format!("Path does not exist: {}", path.display()));
2460 }
2461 if !path.is_dir() {
2462 return Err(format!("Path is not a directory: {}", path.display()));
2463 }
2464
2465 let mut top_level_entries = Vec::new();
2466 for entry in fs::read_dir(path)
2467 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2468 {
2469 match entry {
2470 Ok(entry) => top_level_entries.push(entry),
2471 Err(_) => continue,
2472 }
2473 }
2474 top_level_entries.sort_by_key(|entry| entry.file_name());
2475
2476 let top_level_count = top_level_entries.len();
2477 let mut sample_names = Vec::new();
2478 let mut largest_entries = Vec::new();
2479 let mut aggregate = PathAggregate::default();
2480 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2481
2482 for entry in top_level_entries {
2483 let name = entry.file_name().to_string_lossy().to_string();
2484 if sample_names.len() < max_entries {
2485 sample_names.push(name.clone());
2486 }
2487 let kind = match entry.file_type() {
2488 Ok(ft) if ft.is_dir() => "dir",
2489 Ok(ft) if ft.is_symlink() => "symlink",
2490 _ => "file",
2491 };
2492 let stats = measure_path(&entry.path(), &mut budget);
2493 aggregate.merge(&stats);
2494 largest_entries.push(LargestEntry {
2495 name,
2496 kind,
2497 bytes: stats.total_bytes,
2498 });
2499 }
2500
2501 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2502
2503 let mut out = format!("Directory inspection: {}\n\n", label);
2504 out.push_str(&format!("- Path: {}\n", path.display()));
2505 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2506 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2507 out.push_str(&format!(
2508 "- Recursive directories: {}\n",
2509 aggregate.dir_count
2510 ));
2511 out.push_str(&format!(
2512 "- Total size: {}{}\n",
2513 human_bytes(aggregate.total_bytes),
2514 if aggregate.partial {
2515 " (partial scan)"
2516 } else {
2517 ""
2518 }
2519 ));
2520 if aggregate.skipped_entries > 0 {
2521 out.push_str(&format!(
2522 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2523 aggregate.skipped_entries
2524 ));
2525 }
2526
2527 if !largest_entries.is_empty() {
2528 out.push_str("\nLargest top-level entries:\n");
2529 for entry in largest_entries.iter().take(max_entries) {
2530 out.push_str(&format!(
2531 "- {} [{}] - {}\n",
2532 entry.name,
2533 entry.kind,
2534 human_bytes(entry.bytes)
2535 ));
2536 }
2537 }
2538
2539 if !sample_names.is_empty() {
2540 out.push_str("\nSample names:\n");
2541 for name in sample_names {
2542 out.push_str(&format!("- {}\n", name));
2543 }
2544 }
2545
2546 Ok(out.trim_end().to_string())
2547}
2548
2549fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2550 let trimmed = raw.trim();
2551 if trimmed.is_empty() {
2552 return Err("Path must not be empty.".to_string());
2553 }
2554
2555 if let Some(rest) = trimmed
2556 .strip_prefix("~/")
2557 .or_else(|| trimmed.strip_prefix("~\\"))
2558 {
2559 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2560 return Ok(home.join(rest));
2561 }
2562
2563 let path = PathBuf::from(trimmed);
2564 if path.is_absolute() {
2565 Ok(path)
2566 } else {
2567 let cwd =
2568 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2569 let full_path = cwd.join(&path);
2570
2571 if !full_path.exists()
2574 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2575 {
2576 if let Some(home) = home::home_dir() {
2577 let home_path = home.join(trimmed);
2578 if home_path.exists() {
2579 return Ok(home_path);
2580 }
2581 }
2582 }
2583
2584 Ok(full_path)
2585 }
2586}
2587
2588fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2589 workspace_mode_for_path(workspace_root)
2590}
2591
2592fn workspace_mode_for_path(path: &Path) -> &'static str {
2593 if is_project_marker_path(path) {
2594 "project"
2595 } else if path.join(".hematite").join("docs").exists()
2596 || path.join(".hematite").join("imports").exists()
2597 || path.join(".hematite").join("reports").exists()
2598 {
2599 "docs-only"
2600 } else {
2601 "general directory"
2602 }
2603}
2604
2605fn is_project_marker_path(path: &Path) -> bool {
2606 [
2607 "Cargo.toml",
2608 "package.json",
2609 "pyproject.toml",
2610 "go.mod",
2611 "composer.json",
2612 "requirements.txt",
2613 "Makefile",
2614 "justfile",
2615 ]
2616 .iter()
2617 .any(|name| path.join(name).exists())
2618 || path.join(".git").exists()
2619}
2620
2621fn preferred_shell_label() -> &'static str {
2622 #[cfg(target_os = "windows")]
2623 {
2624 "PowerShell"
2625 }
2626 #[cfg(not(target_os = "windows"))]
2627 {
2628 "sh"
2629 }
2630}
2631
2632fn desktop_dir() -> Option<PathBuf> {
2633 home::home_dir().map(|home| home.join("Desktop"))
2634}
2635
2636fn downloads_dir() -> Option<PathBuf> {
2637 home::home_dir().map(|home| home.join("Downloads"))
2638}
2639
2640fn count_top_level_items(path: &Path) -> Result<usize, String> {
2641 let mut count = 0usize;
2642 for entry in
2643 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2644 {
2645 if entry.is_ok() {
2646 count += 1;
2647 }
2648 }
2649 Ok(count)
2650}
2651
2652#[derive(Default)]
2653struct PathAggregate {
2654 total_bytes: u64,
2655 file_count: u64,
2656 dir_count: u64,
2657 skipped_entries: u64,
2658 partial: bool,
2659}
2660
2661impl PathAggregate {
2662 fn merge(&mut self, other: &PathAggregate) {
2663 self.total_bytes += other.total_bytes;
2664 self.file_count += other.file_count;
2665 self.dir_count += other.dir_count;
2666 self.skipped_entries += other.skipped_entries;
2667 self.partial |= other.partial;
2668 }
2669}
2670
2671struct LargestEntry {
2672 name: String,
2673 kind: &'static str,
2674 bytes: u64,
2675}
2676
2677fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2678 if *budget == 0 {
2679 return PathAggregate {
2680 partial: true,
2681 skipped_entries: 1,
2682 ..PathAggregate::default()
2683 };
2684 }
2685 *budget -= 1;
2686
2687 let metadata = match fs::symlink_metadata(path) {
2688 Ok(metadata) => metadata,
2689 Err(_) => {
2690 return PathAggregate {
2691 skipped_entries: 1,
2692 ..PathAggregate::default()
2693 }
2694 }
2695 };
2696
2697 let file_type = metadata.file_type();
2698 if file_type.is_symlink() {
2699 return PathAggregate {
2700 skipped_entries: 1,
2701 ..PathAggregate::default()
2702 };
2703 }
2704
2705 if metadata.is_file() {
2706 return PathAggregate {
2707 total_bytes: metadata.len(),
2708 file_count: 1,
2709 ..PathAggregate::default()
2710 };
2711 }
2712
2713 if !metadata.is_dir() {
2714 return PathAggregate::default();
2715 }
2716
2717 let mut aggregate = PathAggregate {
2718 dir_count: 1,
2719 ..PathAggregate::default()
2720 };
2721
2722 let read_dir = match fs::read_dir(path) {
2723 Ok(read_dir) => read_dir,
2724 Err(_) => {
2725 aggregate.skipped_entries += 1;
2726 return aggregate;
2727 }
2728 };
2729
2730 for child in read_dir {
2731 match child {
2732 Ok(child) => {
2733 let child_stats = measure_path(&child.path(), budget);
2734 aggregate.merge(&child_stats);
2735 }
2736 Err(_) => aggregate.skipped_entries += 1,
2737 }
2738 }
2739
2740 aggregate
2741}
2742
2743struct PathAnalysis {
2744 total_entries: usize,
2745 unique_entries: usize,
2746 entries: Vec<String>,
2747 duplicate_entries: Vec<String>,
2748 missing_entries: Vec<String>,
2749}
2750
2751fn analyze_path_env() -> PathAnalysis {
2752 let mut entries = Vec::new();
2753 let mut duplicate_entries = Vec::new();
2754 let mut missing_entries = Vec::new();
2755 let mut seen = HashSet::new();
2756
2757 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2758 for path in std::env::split_paths(&raw_path) {
2759 let display = path.display().to_string();
2760 if display.trim().is_empty() {
2761 continue;
2762 }
2763
2764 let normalized = normalize_path_entry(&display);
2765 if !seen.insert(normalized) {
2766 duplicate_entries.push(display.clone());
2767 }
2768 if !path.exists() {
2769 missing_entries.push(display.clone());
2770 }
2771 entries.push(display);
2772 }
2773
2774 let total_entries = entries.len();
2775 let unique_entries = seen.len();
2776
2777 PathAnalysis {
2778 total_entries,
2779 unique_entries,
2780 entries,
2781 duplicate_entries,
2782 missing_entries,
2783 }
2784}
2785
2786fn normalize_path_entry(value: &str) -> String {
2787 #[cfg(target_os = "windows")]
2788 {
2789 value
2790 .replace('/', "\\")
2791 .trim_end_matches(['\\', '/'])
2792 .to_ascii_lowercase()
2793 }
2794 #[cfg(not(target_os = "windows"))]
2795 {
2796 value.trim_end_matches('/').to_string()
2797 }
2798}
2799
2800struct ToolchainReport {
2801 found: Vec<(String, String)>,
2802 missing: Vec<String>,
2803}
2804
2805struct PackageManagerReport {
2806 found: Vec<(String, String)>,
2807}
2808
2809#[derive(Debug, Clone)]
2810struct ProcessEntry {
2811 name: String,
2812 pid: u32,
2813 memory_bytes: u64,
2814 cpu_seconds: Option<f64>,
2815 cpu_percent: Option<f64>,
2816 read_ops: Option<u64>,
2817 write_ops: Option<u64>,
2818 detail: Option<String>,
2819}
2820
2821#[derive(Debug, Clone)]
2822struct ServiceEntry {
2823 name: String,
2824 status: String,
2825 startup: Option<String>,
2826 display_name: Option<String>,
2827 start_name: Option<String>,
2828}
2829
2830#[derive(Debug, Clone, Default)]
2831struct NetworkAdapter {
2832 name: String,
2833 ipv4: Vec<String>,
2834 ipv6: Vec<String>,
2835 gateways: Vec<String>,
2836 dns_servers: Vec<String>,
2837 disconnected: bool,
2838}
2839
2840impl NetworkAdapter {
2841 fn is_active(&self) -> bool {
2842 !self.disconnected
2843 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2844 }
2845}
2846
2847#[derive(Debug, Clone, Copy, Default)]
2848struct ListenerExposureSummary {
2849 loopback_only: usize,
2850 wildcard_public: usize,
2851 specific_bind: usize,
2852}
2853
2854#[derive(Debug, Clone)]
2855struct ListeningPort {
2856 protocol: String,
2857 local: String,
2858 port: u16,
2859 state: String,
2860 pid: Option<String>,
2861 process_name: Option<String>,
2862}
2863
2864fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2865 #[cfg(target_os = "windows")]
2866 {
2867 collect_windows_listening_ports()
2868 }
2869 #[cfg(not(target_os = "windows"))]
2870 {
2871 collect_unix_listening_ports()
2872 }
2873}
2874
2875fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2876 #[cfg(target_os = "windows")]
2877 {
2878 collect_windows_network_adapters()
2879 }
2880 #[cfg(not(target_os = "windows"))]
2881 {
2882 collect_unix_network_adapters()
2883 }
2884}
2885
2886fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2887 #[cfg(target_os = "windows")]
2888 {
2889 collect_windows_services()
2890 }
2891 #[cfg(not(target_os = "windows"))]
2892 {
2893 collect_unix_services()
2894 }
2895}
2896
2897#[cfg(target_os = "windows")]
2898fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2899 let output = Command::new("netstat")
2900 .args(["-ano", "-p", "tcp"])
2901 .output()
2902 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2903 if !output.status.success() {
2904 return Err("netstat returned a non-success status.".to_string());
2905 }
2906
2907 let text = String::from_utf8_lossy(&output.stdout);
2908 let mut listeners = Vec::new();
2909 for line in text.lines() {
2910 let trimmed = line.trim();
2911 if !trimmed.starts_with("TCP") {
2912 continue;
2913 }
2914 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2915 if cols.len() < 5 || cols[3] != "LISTENING" {
2916 continue;
2917 }
2918 let Some(port) = extract_port_from_socket(cols[1]) else {
2919 continue;
2920 };
2921 listeners.push(ListeningPort {
2922 protocol: cols[0].to_string(),
2923 local: cols[1].to_string(),
2924 port,
2925 state: cols[3].to_string(),
2926 pid: Some(cols[4].to_string()),
2927 process_name: None,
2928 });
2929 }
2930
2931 let unique_pids: Vec<String> = listeners
2934 .iter()
2935 .filter_map(|l| l.pid.clone())
2936 .collect::<HashSet<_>>()
2937 .into_iter()
2938 .collect();
2939
2940 if !unique_pids.is_empty() {
2941 let pid_list = unique_pids.join(",");
2942 let ps_cmd = format!(
2943 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2944 pid_list
2945 );
2946 if let Ok(ps_out) = Command::new("powershell")
2947 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2948 .output()
2949 {
2950 let mut pid_map = std::collections::HashMap::<String, String>::new();
2951 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2952 for line in ps_text.lines() {
2953 let parts: Vec<&str> = line.split_whitespace().collect();
2954 if parts.len() >= 2 {
2955 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2956 }
2957 }
2958 for listener in &mut listeners {
2959 if let Some(pid) = &listener.pid {
2960 listener.process_name = pid_map.get(pid).cloned();
2961 }
2962 }
2963 }
2964 }
2965
2966 Ok(listeners)
2967}
2968
2969#[cfg(not(target_os = "windows"))]
2970fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2971 let output = Command::new("ss")
2972 .args(["-ltn"])
2973 .output()
2974 .map_err(|e| format!("Failed to run ss: {e}"))?;
2975 if !output.status.success() {
2976 return Err("ss returned a non-success status.".to_string());
2977 }
2978
2979 let text = String::from_utf8_lossy(&output.stdout);
2980 let mut listeners = Vec::new();
2981 for line in text.lines().skip(1) {
2982 let cols: Vec<&str> = line.split_whitespace().collect();
2983 if cols.len() < 4 {
2984 continue;
2985 }
2986 let Some(port) = extract_port_from_socket(cols[3]) else {
2987 continue;
2988 };
2989 listeners.push(ListeningPort {
2990 protocol: "tcp".to_string(),
2991 local: cols[3].to_string(),
2992 port,
2993 state: cols[0].to_string(),
2994 pid: None,
2995 process_name: None,
2996 });
2997 }
2998
2999 Ok(listeners)
3000}
3001
3002fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3003 #[cfg(target_os = "windows")]
3004 {
3005 collect_windows_processes()
3006 }
3007 #[cfg(not(target_os = "windows"))]
3008 {
3009 collect_unix_processes()
3010 }
3011}
3012
3013#[cfg(target_os = "windows")]
3014fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3015 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3016 let output = Command::new("powershell")
3017 .args(["-NoProfile", "-Command", command])
3018 .output()
3019 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3020 if !output.status.success() {
3021 return Err("PowerShell service inspection returned a non-success status.".to_string());
3022 }
3023
3024 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3025}
3026
3027#[cfg(not(target_os = "windows"))]
3028fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3029 let status_output = Command::new("systemctl")
3030 .args([
3031 "list-units",
3032 "--type=service",
3033 "--all",
3034 "--no-pager",
3035 "--no-legend",
3036 "--plain",
3037 ])
3038 .output()
3039 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3040 if !status_output.status.success() {
3041 return Err("systemctl list-units returned a non-success status.".to_string());
3042 }
3043
3044 let startup_output = Command::new("systemctl")
3045 .args([
3046 "list-unit-files",
3047 "--type=service",
3048 "--no-legend",
3049 "--no-pager",
3050 "--plain",
3051 ])
3052 .output()
3053 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3054 if !startup_output.status.success() {
3055 return Err("systemctl list-unit-files returned a non-success status.".to_string());
3056 }
3057
3058 Ok(parse_unix_services(
3059 &String::from_utf8_lossy(&status_output.stdout),
3060 &String::from_utf8_lossy(&startup_output.stdout),
3061 ))
3062}
3063
3064#[cfg(target_os = "windows")]
3065fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3066 let output = Command::new("ipconfig")
3067 .args(["/all"])
3068 .output()
3069 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3070 if !output.status.success() {
3071 return Err("ipconfig returned a non-success status.".to_string());
3072 }
3073
3074 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3075 &output.stdout,
3076 )))
3077}
3078
3079#[cfg(not(target_os = "windows"))]
3080fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3081 let addr_output = Command::new("ip")
3082 .args(["-o", "addr", "show", "up"])
3083 .output()
3084 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3085 if !addr_output.status.success() {
3086 return Err("ip addr returned a non-success status.".to_string());
3087 }
3088
3089 let route_output = Command::new("ip")
3090 .args(["route", "show", "default"])
3091 .output()
3092 .map_err(|e| format!("Failed to run ip route: {e}"))?;
3093 if !route_output.status.success() {
3094 return Err("ip route returned a non-success status.".to_string());
3095 }
3096
3097 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3098 apply_unix_default_routes(
3099 &mut adapters,
3100 &String::from_utf8_lossy(&route_output.stdout),
3101 );
3102 apply_unix_dns_servers(&mut adapters);
3103 Ok(adapters)
3104}
3105
3106#[cfg(target_os = "windows")]
3107fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3108 let script = r#"
3110 $s1 = Get-Process | Select-Object Id, CPU
3111 Start-Sleep -Milliseconds 250
3112 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3113 $s2 | ForEach-Object {
3114 $p2 = $_
3115 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3116 $pct = 0.0
3117 if ($p1 -and $p2.CPU -gt $p1.CPU) {
3118 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3119 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3120 # Standard Task Manager style is (delta / interval) * 100.
3121 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3122 }
3123 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3124 }
3125 "#;
3126
3127 let output = Command::new("powershell")
3128 .args(["-NoProfile", "-Command", script])
3129 .output()
3130 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3131
3132 let text = String::from_utf8_lossy(&output.stdout);
3133 let mut out = Vec::new();
3134 for line in text.lines() {
3135 let parts: Vec<&str> = line.trim().split('|').collect();
3136 if parts.len() < 5 {
3137 continue;
3138 }
3139 let mut entry = ProcessEntry {
3140 name: "unknown".to_string(),
3141 pid: 0,
3142 memory_bytes: 0,
3143 cpu_seconds: None,
3144 cpu_percent: None,
3145 read_ops: None,
3146 write_ops: None,
3147 detail: None,
3148 };
3149 for p in parts {
3150 if let Some((k, v)) = p.split_once(':') {
3151 match k {
3152 "PID" => entry.pid = v.parse().unwrap_or(0),
3153 "NAME" => entry.name = v.to_string(),
3154 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3155 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3156 "CPU_P" => entry.cpu_percent = v.parse().ok(),
3157 "READ" => entry.read_ops = v.parse().ok(),
3158 "WRITE" => entry.write_ops = v.parse().ok(),
3159 _ => {}
3160 }
3161 }
3162 }
3163 out.push(entry);
3164 }
3165 Ok(out)
3166}
3167
3168#[cfg(not(target_os = "windows"))]
3169fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3170 let output = Command::new("ps")
3171 .args(["-eo", "pid=,rss=,comm="])
3172 .output()
3173 .map_err(|e| format!("Failed to run ps: {e}"))?;
3174 if !output.status.success() {
3175 return Err("ps returned a non-success status.".to_string());
3176 }
3177
3178 let text = String::from_utf8_lossy(&output.stdout);
3179 let mut processes = Vec::new();
3180 for line in text.lines() {
3181 let cols: Vec<&str> = line.split_whitespace().collect();
3182 if cols.len() < 3 {
3183 continue;
3184 }
3185 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3186 else {
3187 continue;
3188 };
3189 processes.push(ProcessEntry {
3190 name: cols[2..].join(" "),
3191 pid,
3192 memory_bytes: rss_kib * 1024,
3193 cpu_seconds: None,
3194 cpu_percent: None,
3195 read_ops: None,
3196 write_ops: None,
3197 detail: None,
3198 });
3199 }
3200
3201 Ok(processes)
3202}
3203
3204fn extract_port_from_socket(value: &str) -> Option<u16> {
3205 let cleaned = value.trim().trim_matches(['[', ']']);
3206 let port_str = cleaned.rsplit(':').next()?;
3207 port_str.parse::<u16>().ok()
3208}
3209
3210fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3211 let mut summary = ListenerExposureSummary::default();
3212 for entry in listeners {
3213 let local = entry.local.to_ascii_lowercase();
3214 if is_loopback_listener(&local) {
3215 summary.loopback_only += 1;
3216 } else if is_wildcard_listener(&local) {
3217 summary.wildcard_public += 1;
3218 } else {
3219 summary.specific_bind += 1;
3220 }
3221 }
3222 summary
3223}
3224
3225fn is_loopback_listener(local: &str) -> bool {
3226 local.starts_with("127.")
3227 || local.starts_with("[::1]")
3228 || local.starts_with("::1")
3229 || local.starts_with("localhost:")
3230}
3231
3232fn is_wildcard_listener(local: &str) -> bool {
3233 local.starts_with("0.0.0.0:")
3234 || local.starts_with("[::]:")
3235 || local.starts_with(":::")
3236 || local == "*:*"
3237}
3238
3239struct GitState {
3240 root: PathBuf,
3241 branch: String,
3242 dirty_entries: usize,
3243}
3244
3245impl GitState {
3246 fn status_label(&self) -> String {
3247 if self.dirty_entries == 0 {
3248 "clean".to_string()
3249 } else {
3250 format!("dirty ({} changed path(s))", self.dirty_entries)
3251 }
3252 }
3253}
3254
3255fn inspect_git_state(path: &Path) -> Option<GitState> {
3256 let root = capture_first_line(
3257 "git",
3258 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3259 )?;
3260 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3261 .unwrap_or_else(|| "detached".to_string());
3262 let output = Command::new("git")
3263 .args(["-C", path.to_str()?, "status", "--short"])
3264 .output()
3265 .ok()?;
3266 if !output.status.success() {
3267 return None;
3268 }
3269 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3270 Some(GitState {
3271 root: PathBuf::from(root),
3272 branch,
3273 dirty_entries,
3274 })
3275}
3276
3277struct HematiteState {
3278 docs_count: usize,
3279 import_count: usize,
3280 report_count: usize,
3281 workspace_profile: bool,
3282}
3283
3284fn collect_hematite_state(path: &Path) -> HematiteState {
3285 let root = path.join(".hematite");
3286 HematiteState {
3287 docs_count: count_entries_if_exists(&root.join("docs")),
3288 import_count: count_entries_if_exists(&root.join("imports")),
3289 report_count: count_entries_if_exists(&root.join("reports")),
3290 workspace_profile: root.join("workspace_profile.json").exists(),
3291 }
3292}
3293
3294fn count_entries_if_exists(path: &Path) -> usize {
3295 if !path.exists() || !path.is_dir() {
3296 return 0;
3297 }
3298 fs::read_dir(path)
3299 .ok()
3300 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3301 .unwrap_or(0)
3302}
3303
3304fn collect_project_markers(path: &Path) -> Vec<String> {
3305 [
3306 "Cargo.toml",
3307 "package.json",
3308 "pyproject.toml",
3309 "go.mod",
3310 "justfile",
3311 "Makefile",
3312 ".git",
3313 ]
3314 .iter()
3315 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3316 .collect()
3317}
3318
3319struct ReleaseArtifactState {
3320 version: String,
3321 portable_dir: bool,
3322 portable_zip: bool,
3323 setup_exe: bool,
3324}
3325
3326fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3327 let cargo_toml = path.join("Cargo.toml");
3328 if !cargo_toml.exists() {
3329 return None;
3330 }
3331 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3332 let version = [regex_line_capture(
3333 &cargo_text,
3334 r#"(?m)^version\s*=\s*"([^"]+)""#,
3335 )?]
3336 .concat();
3337 let dist_windows = path.join("dist").join("windows");
3338 let prefix = format!("Hematite-{}", version);
3339 Some(ReleaseArtifactState {
3340 version,
3341 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3342 portable_zip: dist_windows
3343 .join(format!("{}-portable.zip", prefix))
3344 .exists(),
3345 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3346 })
3347}
3348
3349fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3350 let regex = regex::Regex::new(pattern).ok()?;
3351 let captures = regex.captures(text)?;
3352 captures.get(1).map(|m| m.as_str().to_string())
3353}
3354
3355fn bool_label(value: bool) -> &'static str {
3356 if value {
3357 "yes"
3358 } else {
3359 "no"
3360 }
3361}
3362
3363fn collect_toolchains() -> ToolchainReport {
3364 let checks = [
3365 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3366 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3367 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3368 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3369 ToolCheck::new(
3370 "npm",
3371 &[
3372 CommandProbe::new("npm", &["--version"]),
3373 CommandProbe::new("npm.cmd", &["--version"]),
3374 ],
3375 ),
3376 ToolCheck::new(
3377 "pnpm",
3378 &[
3379 CommandProbe::new("pnpm", &["--version"]),
3380 CommandProbe::new("pnpm.cmd", &["--version"]),
3381 ],
3382 ),
3383 ToolCheck::new(
3384 "python",
3385 &[
3386 CommandProbe::new("python", &["--version"]),
3387 CommandProbe::new("python3", &["--version"]),
3388 CommandProbe::new("py", &["-3", "--version"]),
3389 CommandProbe::new("py", &["--version"]),
3390 ],
3391 ),
3392 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3393 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3394 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3395 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3396 ];
3397
3398 let mut found = Vec::new();
3399 let mut missing = Vec::new();
3400
3401 for check in checks {
3402 match check.detect() {
3403 Some(version) => found.push((check.label.to_string(), version)),
3404 None => missing.push(check.label.to_string()),
3405 }
3406 }
3407
3408 ToolchainReport { found, missing }
3409}
3410
3411fn collect_package_managers() -> PackageManagerReport {
3412 let checks = [
3413 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3414 ToolCheck::new(
3415 "npm",
3416 &[
3417 CommandProbe::new("npm", &["--version"]),
3418 CommandProbe::new("npm.cmd", &["--version"]),
3419 ],
3420 ),
3421 ToolCheck::new(
3422 "pnpm",
3423 &[
3424 CommandProbe::new("pnpm", &["--version"]),
3425 CommandProbe::new("pnpm.cmd", &["--version"]),
3426 ],
3427 ),
3428 ToolCheck::new(
3429 "pip",
3430 &[
3431 CommandProbe::new("python", &["-m", "pip", "--version"]),
3432 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3433 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3434 CommandProbe::new("py", &["-m", "pip", "--version"]),
3435 CommandProbe::new("pip", &["--version"]),
3436 ],
3437 ),
3438 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3439 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3440 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3441 ToolCheck::new(
3442 "choco",
3443 &[
3444 CommandProbe::new("choco", &["--version"]),
3445 CommandProbe::new("choco.exe", &["--version"]),
3446 ],
3447 ),
3448 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3449 ];
3450
3451 let mut found = Vec::new();
3452 for check in checks {
3453 match check.detect() {
3454 Some(version) => found.push((check.label.to_string(), version)),
3455 None => {}
3456 }
3457 }
3458
3459 PackageManagerReport { found }
3460}
3461
3462#[derive(Clone)]
3463struct ToolCheck {
3464 label: &'static str,
3465 probes: Vec<CommandProbe>,
3466}
3467
3468impl ToolCheck {
3469 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3470 Self {
3471 label,
3472 probes: probes.to_vec(),
3473 }
3474 }
3475
3476 fn detect(&self) -> Option<String> {
3477 for probe in &self.probes {
3478 if let Some(output) = capture_first_line(probe.program, probe.args) {
3479 return Some(output);
3480 }
3481 }
3482 None
3483 }
3484}
3485
3486#[derive(Clone, Copy)]
3487struct CommandProbe {
3488 program: &'static str,
3489 args: &'static [&'static str],
3490}
3491
3492impl CommandProbe {
3493 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3494 Self { program, args }
3495 }
3496}
3497
3498fn build_env_doctor_findings(
3499 toolchains: &ToolchainReport,
3500 package_managers: &PackageManagerReport,
3501 path_stats: &PathAnalysis,
3502) -> Vec<String> {
3503 let found_tools = toolchains
3504 .found
3505 .iter()
3506 .map(|(label, _)| label.as_str())
3507 .collect::<HashSet<_>>();
3508 let found_managers = package_managers
3509 .found
3510 .iter()
3511 .map(|(label, _)| label.as_str())
3512 .collect::<HashSet<_>>();
3513
3514 let mut findings = Vec::new();
3515
3516 if path_stats.duplicate_entries.len() > 0 {
3517 findings.push(format!(
3518 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3519 path_stats.duplicate_entries.len()
3520 ));
3521 }
3522 if path_stats.missing_entries.len() > 0 {
3523 findings.push(format!(
3524 "PATH contains {} entries that do not exist on disk.",
3525 path_stats.missing_entries.len()
3526 ));
3527 }
3528 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3529 findings.push(
3530 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3531 .to_string(),
3532 );
3533 }
3534 if found_tools.contains("node")
3535 && !found_managers.contains("npm")
3536 && !found_managers.contains("pnpm")
3537 {
3538 findings.push(
3539 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3540 .to_string(),
3541 );
3542 }
3543 if found_tools.contains("python")
3544 && !found_managers.contains("pip")
3545 && !found_managers.contains("uv")
3546 && !found_managers.contains("pipx")
3547 {
3548 findings.push(
3549 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3550 .to_string(),
3551 );
3552 }
3553 let windows_manager_count = ["winget", "choco", "scoop"]
3554 .iter()
3555 .filter(|label| found_managers.contains(**label))
3556 .count();
3557 if windows_manager_count > 1 {
3558 findings.push(
3559 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3560 .to_string(),
3561 );
3562 }
3563 if findings.is_empty() && !found_managers.is_empty() {
3564 findings.push(
3565 "Core package-manager coverage looks healthy for a normal developer workstation."
3566 .to_string(),
3567 );
3568 }
3569
3570 findings
3571}
3572
3573fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3574 let output = std::process::Command::new(program)
3575 .args(args)
3576 .output()
3577 .ok()?;
3578 if !output.status.success() {
3579 return None;
3580 }
3581
3582 let stdout = if output.stdout.is_empty() {
3583 String::from_utf8_lossy(&output.stderr).into_owned()
3584 } else {
3585 String::from_utf8_lossy(&output.stdout).into_owned()
3586 };
3587
3588 stdout
3589 .lines()
3590 .map(str::trim)
3591 .find(|line| !line.is_empty())
3592 .map(|line| line.to_string())
3593}
3594
3595fn human_bytes(bytes: u64) -> String {
3596 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3597 let mut value = bytes as f64;
3598 let mut unit_index = 0usize;
3599
3600 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3601 value /= 1024.0;
3602 unit_index += 1;
3603 }
3604
3605 if unit_index == 0 {
3606 format!("{} {}", bytes, UNITS[unit_index])
3607 } else {
3608 format!("{value:.1} {}", UNITS[unit_index])
3609 }
3610}
3611
3612#[cfg(target_os = "windows")]
3613fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3614 let mut adapters = Vec::new();
3615 let mut current: Option<NetworkAdapter> = None;
3616 let mut pending_dns = false;
3617
3618 for raw_line in text.lines() {
3619 let line = raw_line.trim_end();
3620 let trimmed = line.trim();
3621 if trimmed.is_empty() {
3622 pending_dns = false;
3623 continue;
3624 }
3625
3626 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3627 if let Some(adapter) = current.take() {
3628 adapters.push(adapter);
3629 }
3630 current = Some(NetworkAdapter {
3631 name: trimmed.trim_end_matches(':').to_string(),
3632 ..NetworkAdapter::default()
3633 });
3634 pending_dns = false;
3635 continue;
3636 }
3637
3638 let Some(adapter) = current.as_mut() else {
3639 continue;
3640 };
3641
3642 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3643 adapter.disconnected = true;
3644 }
3645
3646 if let Some(value) = value_after_colon(trimmed) {
3647 let normalized = normalize_ipconfig_value(value);
3648 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3649 adapter.ipv4.push(normalized);
3650 pending_dns = false;
3651 } else if trimmed.starts_with("IPv6 Address")
3652 || trimmed.starts_with("Temporary IPv6 Address")
3653 || trimmed.starts_with("Link-local IPv6 Address")
3654 {
3655 if !normalized.is_empty() {
3656 adapter.ipv6.push(normalized);
3657 }
3658 pending_dns = false;
3659 } else if trimmed.starts_with("Default Gateway") {
3660 if !normalized.is_empty() {
3661 adapter.gateways.push(normalized);
3662 }
3663 pending_dns = false;
3664 } else if trimmed.starts_with("DNS Servers") {
3665 if !normalized.is_empty() {
3666 adapter.dns_servers.push(normalized);
3667 }
3668 pending_dns = true;
3669 } else {
3670 pending_dns = false;
3671 }
3672 } else if pending_dns {
3673 let normalized = normalize_ipconfig_value(trimmed);
3674 if !normalized.is_empty() {
3675 adapter.dns_servers.push(normalized);
3676 }
3677 }
3678 }
3679
3680 if let Some(adapter) = current.take() {
3681 adapters.push(adapter);
3682 }
3683
3684 for adapter in &mut adapters {
3685 dedup_vec(&mut adapter.ipv4);
3686 dedup_vec(&mut adapter.ipv6);
3687 dedup_vec(&mut adapter.gateways);
3688 dedup_vec(&mut adapter.dns_servers);
3689 }
3690
3691 adapters
3692}
3693
3694#[cfg(not(target_os = "windows"))]
3695fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3696 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3697
3698 for line in text.lines() {
3699 let cols: Vec<&str> = line.split_whitespace().collect();
3700 if cols.len() < 4 {
3701 continue;
3702 }
3703 let name = cols[1].trim_end_matches(':').to_string();
3704 let family = cols[2];
3705 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3706 let entry = adapters
3707 .entry(name.clone())
3708 .or_insert_with(|| NetworkAdapter {
3709 name,
3710 ..NetworkAdapter::default()
3711 });
3712 match family {
3713 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3714 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3715 _ => {}
3716 }
3717 }
3718
3719 adapters.into_values().collect()
3720}
3721
3722#[cfg(not(target_os = "windows"))]
3723fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3724 for line in text.lines() {
3725 let cols: Vec<&str> = line.split_whitespace().collect();
3726 if cols.len() < 5 {
3727 continue;
3728 }
3729 let gateway = cols
3730 .windows(2)
3731 .find(|pair| pair[0] == "via")
3732 .map(|pair| pair[1].to_string());
3733 let dev = cols
3734 .windows(2)
3735 .find(|pair| pair[0] == "dev")
3736 .map(|pair| pair[1]);
3737 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3738 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3739 adapter.gateways.push(gateway);
3740 }
3741 }
3742 }
3743
3744 for adapter in adapters {
3745 dedup_vec(&mut adapter.gateways);
3746 }
3747}
3748
3749#[cfg(not(target_os = "windows"))]
3750fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3751 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3752 return;
3753 };
3754 let mut dns_servers = text
3755 .lines()
3756 .filter_map(|line| line.strip_prefix("nameserver "))
3757 .map(str::trim)
3758 .filter(|value| !value.is_empty())
3759 .map(|value| value.to_string())
3760 .collect::<Vec<_>>();
3761 dedup_vec(&mut dns_servers);
3762 if dns_servers.is_empty() {
3763 return;
3764 }
3765 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3766 adapter.dns_servers = dns_servers.clone();
3767 }
3768}
3769
3770#[cfg(target_os = "windows")]
3771fn value_after_colon(line: &str) -> Option<&str> {
3772 line.split_once(':').map(|(_, value)| value.trim())
3773}
3774
3775#[cfg(target_os = "windows")]
3776fn normalize_ipconfig_value(value: &str) -> String {
3777 value
3778 .trim()
3779 .trim_end_matches("(Preferred)")
3780 .trim_end_matches("(Deprecated)")
3781 .trim()
3782 .trim_matches(['(', ')'])
3783 .trim()
3784 .to_string()
3785}
3786
3787#[cfg(target_os = "windows")]
3788fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3789 let mac_upper = mac.to_ascii_uppercase();
3790 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3791 return true;
3792 }
3793
3794 ip == "255.255.255.255"
3795 || ip.starts_with("224.")
3796 || ip.starts_with("225.")
3797 || ip.starts_with("226.")
3798 || ip.starts_with("227.")
3799 || ip.starts_with("228.")
3800 || ip.starts_with("229.")
3801 || ip.starts_with("230.")
3802 || ip.starts_with("231.")
3803 || ip.starts_with("232.")
3804 || ip.starts_with("233.")
3805 || ip.starts_with("234.")
3806 || ip.starts_with("235.")
3807 || ip.starts_with("236.")
3808 || ip.starts_with("237.")
3809 || ip.starts_with("238.")
3810 || ip.starts_with("239.")
3811}
3812
3813fn dedup_vec(values: &mut Vec<String>) {
3814 let mut seen = HashSet::new();
3815 values.retain(|value| seen.insert(value.clone()));
3816}
3817
3818#[cfg(target_os = "windows")]
3819fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3820 let trimmed = text.trim();
3821 if trimmed.is_empty() {
3822 return Vec::new();
3823 }
3824
3825 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3826 return Vec::new();
3827 };
3828 let entries = match value {
3829 Value::Array(items) => items,
3830 other => vec![other],
3831 };
3832
3833 let mut neighbors = Vec::new();
3834 for entry in entries {
3835 let ip = entry
3836 .get("IPAddress")
3837 .and_then(|v| v.as_str())
3838 .unwrap_or("")
3839 .to_string();
3840 if ip.is_empty() {
3841 continue;
3842 }
3843 let mac = entry
3844 .get("LinkLayerAddress")
3845 .and_then(|v| v.as_str())
3846 .unwrap_or("unknown")
3847 .to_string();
3848 let state = entry
3849 .get("State")
3850 .and_then(|v| v.as_str())
3851 .unwrap_or("unknown")
3852 .to_string();
3853 let iface = entry
3854 .get("InterfaceAlias")
3855 .and_then(|v| v.as_str())
3856 .unwrap_or("unknown")
3857 .to_string();
3858 if is_noise_lan_neighbor(&ip, &mac) {
3859 continue;
3860 }
3861 neighbors.push((ip, mac, state, iface));
3862 }
3863
3864 neighbors
3865}
3866
3867#[cfg(target_os = "windows")]
3868fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3869 let trimmed = text.trim();
3870 if trimmed.is_empty() {
3871 return Ok(Vec::new());
3872 }
3873
3874 let value: Value = serde_json::from_str(trimmed)
3875 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3876 let entries = match value {
3877 Value::Array(items) => items,
3878 other => vec![other],
3879 };
3880
3881 let mut services = Vec::new();
3882 for entry in entries {
3883 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3884 continue;
3885 };
3886 services.push(ServiceEntry {
3887 name: name.to_string(),
3888 status: entry
3889 .get("State")
3890 .and_then(|v| v.as_str())
3891 .unwrap_or("unknown")
3892 .to_string(),
3893 startup: entry
3894 .get("StartMode")
3895 .and_then(|v| v.as_str())
3896 .map(|v| v.to_string()),
3897 display_name: entry
3898 .get("DisplayName")
3899 .and_then(|v| v.as_str())
3900 .map(|v| v.to_string()),
3901 start_name: entry
3902 .get("StartName")
3903 .and_then(|v| v.as_str())
3904 .map(|v| v.to_string()),
3905 });
3906 }
3907
3908 Ok(services)
3909}
3910
3911#[cfg(target_os = "windows")]
3912fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3913 match node.cloned() {
3914 Some(Value::Array(items)) => items,
3915 Some(other) => vec![other],
3916 None => Vec::new(),
3917 }
3918}
3919
3920#[cfg(target_os = "windows")]
3921fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3922 windows_json_entries(node)
3923 .into_iter()
3924 .filter_map(|entry| {
3925 let name = entry
3926 .get("FriendlyName")
3927 .and_then(|v| v.as_str())
3928 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3929 .unwrap_or("")
3930 .trim()
3931 .to_string();
3932 if name.is_empty() {
3933 return None;
3934 }
3935 Some(WindowsPnpDevice {
3936 name,
3937 status: entry
3938 .get("Status")
3939 .and_then(|v| v.as_str())
3940 .unwrap_or("Unknown")
3941 .trim()
3942 .to_string(),
3943 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3944 entry
3945 .get("Problem")
3946 .and_then(|v| v.as_i64())
3947 .map(|v| v as u64)
3948 }),
3949 class_name: entry
3950 .get("Class")
3951 .and_then(|v| v.as_str())
3952 .map(|v| v.trim().to_string()),
3953 instance_id: entry
3954 .get("InstanceId")
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 parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3964 windows_json_entries(node)
3965 .into_iter()
3966 .filter_map(|entry| {
3967 let name = entry
3968 .get("Name")
3969 .and_then(|v| v.as_str())
3970 .unwrap_or("")
3971 .trim()
3972 .to_string();
3973 if name.is_empty() {
3974 return None;
3975 }
3976 Some(WindowsSoundDevice {
3977 name,
3978 status: entry
3979 .get("Status")
3980 .and_then(|v| v.as_str())
3981 .unwrap_or("Unknown")
3982 .trim()
3983 .to_string(),
3984 manufacturer: entry
3985 .get("Manufacturer")
3986 .and_then(|v| v.as_str())
3987 .map(|v| v.trim().to_string()),
3988 })
3989 })
3990 .collect()
3991}
3992
3993#[cfg(target_os = "windows")]
3994fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
3995 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3996 || device.problem.unwrap_or(0) != 0
3997}
3998
3999#[cfg(target_os = "windows")]
4000fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4001 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4002}
4003
4004#[cfg(target_os = "windows")]
4005fn is_microphone_like_name(name: &str) -> bool {
4006 let lower = name.to_ascii_lowercase();
4007 lower.contains("microphone")
4008 || lower.contains("mic")
4009 || lower.contains("input")
4010 || lower.contains("array")
4011 || lower.contains("capture")
4012 || lower.contains("record")
4013}
4014
4015#[cfg(target_os = "windows")]
4016fn is_bluetooth_like_name(name: &str) -> bool {
4017 let lower = name.to_ascii_lowercase();
4018 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4019}
4020
4021#[cfg(target_os = "windows")]
4022fn service_is_running(service: &ServiceEntry) -> bool {
4023 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4024}
4025
4026#[cfg(not(target_os = "windows"))]
4027fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4028 let mut startup_modes = std::collections::HashMap::<String, String>::new();
4029 for line in startup_text.lines() {
4030 let cols: Vec<&str> = line.split_whitespace().collect();
4031 if cols.len() < 2 {
4032 continue;
4033 }
4034 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4035 }
4036
4037 let mut services = Vec::new();
4038 for line in status_text.lines() {
4039 let cols: Vec<&str> = line.split_whitespace().collect();
4040 if cols.len() < 4 {
4041 continue;
4042 }
4043 let unit = cols[0];
4044 let load = cols[1];
4045 let active = cols[2];
4046 let sub = cols[3];
4047 let description = if cols.len() > 4 {
4048 Some(cols[4..].join(" "))
4049 } else {
4050 None
4051 };
4052 services.push(ServiceEntry {
4053 name: unit.to_string(),
4054 status: format!("{}/{}", active, sub),
4055 startup: startup_modes
4056 .get(unit)
4057 .cloned()
4058 .or_else(|| Some(load.to_string())),
4059 display_name: description,
4060 start_name: None,
4061 });
4062 }
4063
4064 services
4065}
4066
4067fn inspect_health_report() -> Result<String, String> {
4073 let mut needs_fix: Vec<String> = Vec::new();
4074 let mut watch: Vec<String> = Vec::new();
4075 let mut good: Vec<String> = Vec::new();
4076 let mut tips: Vec<String> = Vec::new();
4077
4078 health_check_disk(&mut needs_fix, &mut watch, &mut good);
4079 health_check_memory(&mut watch, &mut good);
4080 health_check_tools(&mut watch, &mut good, &mut tips);
4081 health_check_recent_errors(&mut watch, &mut tips);
4082
4083 let overall = if !needs_fix.is_empty() {
4084 "ACTION REQUIRED"
4085 } else if !watch.is_empty() {
4086 "WORTH A LOOK"
4087 } else {
4088 "ALL GOOD"
4089 };
4090
4091 let mut out = format!("System Health Report — {overall}\n\n");
4092
4093 if !needs_fix.is_empty() {
4094 out.push_str("Needs fixing:\n");
4095 for item in &needs_fix {
4096 out.push_str(&format!(" [!] {item}\n"));
4097 }
4098 out.push('\n');
4099 }
4100 if !watch.is_empty() {
4101 out.push_str("Worth watching:\n");
4102 for item in &watch {
4103 out.push_str(&format!(" [-] {item}\n"));
4104 }
4105 out.push('\n');
4106 }
4107 if !good.is_empty() {
4108 out.push_str("Looking good:\n");
4109 for item in &good {
4110 out.push_str(&format!(" [+] {item}\n"));
4111 }
4112 out.push('\n');
4113 }
4114 if !tips.is_empty() {
4115 out.push_str("To dig deeper:\n");
4116 for tip in &tips {
4117 out.push_str(&format!(" {tip}\n"));
4118 }
4119 }
4120
4121 Ok(out.trim_end().to_string())
4122}
4123
4124fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4125 #[cfg(target_os = "windows")]
4126 {
4127 let script = r#"try {
4128 $d = Get-PSDrive C -ErrorAction Stop
4129 "$($d.Free)|$($d.Used)"
4130} catch { "ERR" }"#;
4131 if let Ok(out) = Command::new("powershell")
4132 .args(["-NoProfile", "-Command", script])
4133 .output()
4134 {
4135 let text = String::from_utf8_lossy(&out.stdout);
4136 let text = text.trim();
4137 if !text.starts_with("ERR") {
4138 let parts: Vec<&str> = text.split('|').collect();
4139 if parts.len() == 2 {
4140 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4141 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4142 let total = free_bytes + used_bytes;
4143 let free_gb = free_bytes / 1_073_741_824;
4144 let pct_free = if total > 0 {
4145 (free_bytes as f64 / total as f64 * 100.0) as u64
4146 } else {
4147 0
4148 };
4149 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4150 if free_gb < 5 {
4151 needs_fix.push(format!(
4152 "{msg} — very low. Free up space or your system may slow down or stop working."
4153 ));
4154 } else if free_gb < 15 {
4155 watch.push(format!("{msg} — getting low, consider cleaning up."));
4156 } else {
4157 good.push(msg);
4158 }
4159 return;
4160 }
4161 }
4162 }
4163 watch.push("Disk: could not read free space from C: drive.".to_string());
4164 }
4165
4166 #[cfg(not(target_os = "windows"))]
4167 {
4168 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4169 let text = String::from_utf8_lossy(&out.stdout);
4170 for line in text.lines().skip(1) {
4171 let cols: Vec<&str> = line.split_whitespace().collect();
4172 if cols.len() >= 5 {
4173 let avail_str = cols[3].trim_end_matches('G');
4174 let use_pct = cols[4].trim_end_matches('%');
4175 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4176 let used_pct: u64 = use_pct.parse().unwrap_or(0);
4177 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4178 if avail_gb < 5 {
4179 needs_fix.push(format!(
4180 "{msg} — very low. Free up space to prevent system issues."
4181 ));
4182 } else if avail_gb < 15 {
4183 watch.push(format!("{msg} — getting low."));
4184 } else {
4185 good.push(msg);
4186 }
4187 return;
4188 }
4189 }
4190 }
4191 watch.push("Disk: could not determine free space.".to_string());
4192 }
4193}
4194
4195fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4196 #[cfg(target_os = "windows")]
4197 {
4198 let script = r#"try {
4199 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4200 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4201} catch { "ERR" }"#;
4202 if let Ok(out) = Command::new("powershell")
4203 .args(["-NoProfile", "-Command", script])
4204 .output()
4205 {
4206 let text = String::from_utf8_lossy(&out.stdout);
4207 let text = text.trim();
4208 if !text.starts_with("ERR") {
4209 let parts: Vec<&str> = text.split('|').collect();
4210 if parts.len() == 2 {
4211 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4212 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4213 if total_kb > 0 {
4214 let free_gb = free_kb / 1_048_576;
4215 let total_gb = total_kb / 1_048_576;
4216 let free_pct = free_kb * 100 / total_kb;
4217 let msg = format!(
4218 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4219 );
4220 if free_pct < 10 {
4221 watch.push(format!(
4222 "{msg} — very low. Close unused apps to free up memory."
4223 ));
4224 } else if free_pct < 25 {
4225 watch.push(format!("{msg} — running a bit low."));
4226 } else {
4227 good.push(msg);
4228 }
4229 return;
4230 }
4231 }
4232 }
4233 }
4234 }
4235
4236 #[cfg(not(target_os = "windows"))]
4237 {
4238 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4239 let mut total_kb = 0u64;
4240 let mut avail_kb = 0u64;
4241 for line in content.lines() {
4242 if line.starts_with("MemTotal:") {
4243 total_kb = line
4244 .split_whitespace()
4245 .nth(1)
4246 .and_then(|v| v.parse().ok())
4247 .unwrap_or(0);
4248 } else if line.starts_with("MemAvailable:") {
4249 avail_kb = line
4250 .split_whitespace()
4251 .nth(1)
4252 .and_then(|v| v.parse().ok())
4253 .unwrap_or(0);
4254 }
4255 }
4256 if total_kb > 0 {
4257 let free_gb = avail_kb / 1_048_576;
4258 let total_gb = total_kb / 1_048_576;
4259 let free_pct = avail_kb * 100 / total_kb;
4260 let msg =
4261 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4262 if free_pct < 10 {
4263 watch.push(format!("{msg} — very low. Close unused apps."));
4264 } else if free_pct < 25 {
4265 watch.push(format!("{msg} — running a bit low."));
4266 } else {
4267 good.push(msg);
4268 }
4269 }
4270 }
4271 }
4272}
4273
4274fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4275 let tool_checks: &[(&str, &str, &str)] = &[
4276 ("git", "--version", "Git"),
4277 ("cargo", "--version", "Rust / Cargo"),
4278 ("node", "--version", "Node.js"),
4279 ("python", "--version", "Python"),
4280 ("python3", "--version", "Python 3"),
4281 ("npm", "--version", "npm"),
4282 ];
4283
4284 let mut found: Vec<String> = Vec::new();
4285 let mut missing: Vec<String> = Vec::new();
4286 let mut python_found = false;
4287
4288 for (cmd, arg, label) in tool_checks {
4289 if cmd.starts_with("python") && python_found {
4290 continue;
4291 }
4292 let ok = Command::new(cmd)
4293 .arg(arg)
4294 .stdout(std::process::Stdio::null())
4295 .stderr(std::process::Stdio::null())
4296 .status()
4297 .map(|s| s.success())
4298 .unwrap_or(false);
4299 if ok {
4300 found.push((*label).to_string());
4301 if cmd.starts_with("python") {
4302 python_found = true;
4303 }
4304 } else if !cmd.starts_with("python") || !python_found {
4305 missing.push((*label).to_string());
4306 }
4307 }
4308
4309 if !found.is_empty() {
4310 good.push(format!("Dev tools found: {}", found.join(", ")));
4311 }
4312 if !missing.is_empty() {
4313 watch.push(format!(
4314 "Not installed (or not on PATH): {} — only matters if you need them",
4315 missing.join(", ")
4316 ));
4317 tips.push(
4318 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4319 .to_string(),
4320 );
4321 }
4322}
4323
4324fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4325 #[cfg(target_os = "windows")]
4326 {
4327 let script = r#"try {
4328 $cutoff = (Get-Date).AddHours(-24)
4329 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4330 $count
4331} catch { "0" }"#;
4332 if let Ok(out) = Command::new("powershell")
4333 .args(["-NoProfile", "-Command", script])
4334 .output()
4335 {
4336 let text = String::from_utf8_lossy(&out.stdout);
4337 let count: u64 = text.trim().parse().unwrap_or(0);
4338 if count > 0 {
4339 watch.push(format!(
4340 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4341 if count == 1 { "" } else { "s" }
4342 ));
4343 tips.push(
4344 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4345 .to_string(),
4346 );
4347 }
4348 }
4349 }
4350
4351 #[cfg(not(target_os = "windows"))]
4352 {
4353 if let Ok(out) = Command::new("journalctl")
4354 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4355 .output()
4356 {
4357 let text = String::from_utf8_lossy(&out.stdout);
4358 if !text.trim().is_empty() {
4359 watch.push("Critical/error entries found in the system journal.".to_string());
4360 tips.push(
4361 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4362 );
4363 }
4364 }
4365 }
4366}
4367
4368fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4371 let mut out = String::from("Host inspection: log_check\n\n");
4372
4373 #[cfg(target_os = "windows")]
4374 {
4375 let hours = lookback_hours.unwrap_or(24);
4377 out.push_str(&format!(
4378 "Checking System/Application logs from the last {} hours...\n\n",
4379 hours
4380 ));
4381
4382 let n = max_entries.clamp(1, 50);
4383 let script = format!(
4384 r#"try {{
4385 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4386 if (-not $events) {{ "NO_EVENTS"; exit }}
4387 $events | Select-Object -First {n} | ForEach-Object {{
4388 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4389 $line
4390 }}
4391}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4392 hours = hours,
4393 n = n
4394 );
4395 let output = Command::new("powershell")
4396 .args(["-NoProfile", "-Command", &script])
4397 .output()
4398 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4399
4400 let raw = String::from_utf8_lossy(&output.stdout);
4401 let text = raw.trim();
4402
4403 if text.is_empty() || text == "NO_EVENTS" {
4404 out.push_str("No critical or error events found in Application/System logs.\n");
4405 return Ok(out.trim_end().to_string());
4406 }
4407 if text.starts_with("ERROR:") {
4408 out.push_str(&format!("Warning: event log query returned: {text}\n"));
4409 return Ok(out.trim_end().to_string());
4410 }
4411
4412 let mut count = 0usize;
4413 for line in text.lines() {
4414 let parts: Vec<&str> = line.splitn(4, '|').collect();
4415 if parts.len() == 4 {
4416 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4417 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4418 count += 1;
4419 }
4420 }
4421 out.push_str(&format!(
4422 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4423 ));
4424 }
4425
4426 #[cfg(not(target_os = "windows"))]
4427 {
4428 let _ = lookback_hours;
4429 let n = max_entries.clamp(1, 50).to_string();
4431 let output = Command::new("journalctl")
4432 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4433 .output();
4434
4435 match output {
4436 Ok(o) if o.status.success() => {
4437 let text = String::from_utf8_lossy(&o.stdout);
4438 let trimmed = text.trim();
4439 if trimmed.is_empty() || trimmed.contains("No entries") {
4440 out.push_str("No critical or error entries found in the system journal.\n");
4441 } else {
4442 out.push_str(trimmed);
4443 out.push('\n');
4444 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4445 }
4446 }
4447 _ => {
4448 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4450 let mut found = false;
4451 for log_path in &log_paths {
4452 if let Ok(content) = std::fs::read_to_string(log_path) {
4453 let lines: Vec<&str> = content.lines().collect();
4454 let tail: Vec<&str> = lines
4455 .iter()
4456 .rev()
4457 .filter(|l| {
4458 let l_lower = l.to_ascii_lowercase();
4459 l_lower.contains("error") || l_lower.contains("crit")
4460 })
4461 .take(max_entries)
4462 .copied()
4463 .collect::<Vec<_>>()
4464 .into_iter()
4465 .rev()
4466 .collect();
4467 if !tail.is_empty() {
4468 out.push_str(&format!("Source: {log_path}\n"));
4469 for l in &tail {
4470 out.push_str(l);
4471 out.push('\n');
4472 }
4473 found = true;
4474 break;
4475 }
4476 }
4477 }
4478 if !found {
4479 out.push_str(
4480 "journalctl not found and no readable syslog detected on this system.\n",
4481 );
4482 }
4483 }
4484 }
4485 }
4486
4487 Ok(out.trim_end().to_string())
4488}
4489
4490fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4493 let mut out = String::from("Host inspection: startup_items\n\n");
4494
4495 #[cfg(target_os = "windows")]
4496 {
4497 let script = r#"
4499$hives = @(
4500 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4501 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4502 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4503)
4504foreach ($h in $hives) {
4505 try {
4506 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4507 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4508 "$($h.Hive)|$($_.Name)|$($_.Value)"
4509 }
4510 } catch {}
4511}
4512"#;
4513 let output = Command::new("powershell")
4514 .args(["-NoProfile", "-Command", script])
4515 .output()
4516 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4517
4518 let raw = String::from_utf8_lossy(&output.stdout);
4519 let text = raw.trim();
4520
4521 let entries: Vec<(String, String, String)> = text
4522 .lines()
4523 .filter_map(|l| {
4524 let parts: Vec<&str> = l.splitn(3, '|').collect();
4525 if parts.len() == 3 {
4526 Some((
4527 parts[0].to_string(),
4528 parts[1].to_string(),
4529 parts[2].to_string(),
4530 ))
4531 } else {
4532 None
4533 }
4534 })
4535 .take(max_entries)
4536 .collect();
4537
4538 if entries.is_empty() {
4539 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4540 } else {
4541 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4542 let mut last_hive = String::new();
4543 for (hive, name, value) in &entries {
4544 if *hive != last_hive {
4545 out.push_str(&format!("[{}]\n", hive));
4546 last_hive = hive.clone();
4547 }
4548 let display = if value.len() > 100 {
4550 format!("{}…", &value[..100])
4551 } else {
4552 value.clone()
4553 };
4554 out.push_str(&format!(" {name}: {display}\n"));
4555 }
4556 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4557 }
4558
4559 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4561 if let Ok(unified_out) = Command::new("powershell")
4562 .args(["-NoProfile", "-Command", unified_script])
4563 .output()
4564 {
4565 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4566 let trimmed = unified_text.trim();
4567 if !trimmed.is_empty() {
4568 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4569 out.push_str(trimmed);
4570 out.push('\n');
4571 }
4572 }
4573 }
4574
4575 #[cfg(not(target_os = "windows"))]
4576 {
4577 let output = Command::new("systemctl")
4579 .args([
4580 "list-unit-files",
4581 "--type=service",
4582 "--state=enabled",
4583 "--no-legend",
4584 "--no-pager",
4585 "--plain",
4586 ])
4587 .output();
4588
4589 match output {
4590 Ok(o) if o.status.success() => {
4591 let text = String::from_utf8_lossy(&o.stdout);
4592 let services: Vec<&str> = text
4593 .lines()
4594 .filter(|l| !l.trim().is_empty())
4595 .take(max_entries)
4596 .collect();
4597 if services.is_empty() {
4598 out.push_str("No enabled systemd services found.\n");
4599 } else {
4600 out.push_str("Enabled systemd services (run at boot):\n\n");
4601 for s in &services {
4602 out.push_str(&format!(" {s}\n"));
4603 }
4604 out.push_str(&format!(
4605 "\nShowing {} of enabled services.\n",
4606 services.len()
4607 ));
4608 }
4609 }
4610 _ => {
4611 out.push_str(
4612 "systemctl not found on this system. Cannot enumerate startup services.\n",
4613 );
4614 }
4615 }
4616
4617 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4619 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4620 let reboot_entries: Vec<&str> = cron_text
4621 .lines()
4622 .filter(|l| l.trim_start().starts_with("@reboot"))
4623 .collect();
4624 if !reboot_entries.is_empty() {
4625 out.push_str("\nCron @reboot entries:\n");
4626 for e in reboot_entries {
4627 out.push_str(&format!(" {e}\n"));
4628 }
4629 }
4630 }
4631 }
4632
4633 Ok(out.trim_end().to_string())
4634}
4635
4636fn inspect_os_config() -> Result<String, String> {
4637 let mut out = String::from("Host inspection: OS Configuration\n\n");
4638
4639 #[cfg(target_os = "windows")]
4640 {
4641 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4643 let power_str = String::from_utf8_lossy(&power_out.stdout);
4644 out.push_str("=== Power Plan ===\n");
4645 out.push_str(power_str.trim());
4646 out.push_str("\n\n");
4647 }
4648
4649 let fw_script =
4651 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4652 if let Ok(fw_out) = Command::new("powershell")
4653 .args(["-NoProfile", "-Command", fw_script])
4654 .output()
4655 {
4656 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4657 out.push_str("=== Firewall Profiles ===\n");
4658 out.push_str(fw_str.trim());
4659 out.push_str("\n\n");
4660 }
4661
4662 let uptime_script =
4664 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4665 if let Ok(uptime_out) = Command::new("powershell")
4666 .args(["-NoProfile", "-Command", uptime_script])
4667 .output()
4668 {
4669 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4670 out.push_str("=== System Uptime (Last Boot) ===\n");
4671 out.push_str(uptime_str.trim());
4672 out.push_str("\n\n");
4673 }
4674 }
4675
4676 #[cfg(not(target_os = "windows"))]
4677 {
4678 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4680 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4681 out.push_str("=== System Uptime ===\n");
4682 out.push_str(uptime_str.trim());
4683 out.push_str("\n\n");
4684 }
4685
4686 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4688 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4689 if !ufw_str.trim().is_empty() {
4690 out.push_str("=== Firewall (UFW) ===\n");
4691 out.push_str(ufw_str.trim());
4692 out.push_str("\n\n");
4693 }
4694 }
4695 }
4696 Ok(out.trim_end().to_string())
4697}
4698
4699pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4700 let action = args
4701 .get("action")
4702 .and_then(|v| v.as_str())
4703 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4704
4705 let target = args
4706 .get("target")
4707 .and_then(|v| v.as_str())
4708 .unwrap_or("")
4709 .trim();
4710
4711 if target.is_empty() && action != "clear_temp" {
4712 return Err("Missing required argument: 'target' for this action".to_string());
4713 }
4714
4715 match action {
4716 "install_package" => {
4717 #[cfg(target_os = "windows")]
4718 {
4719 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
4720 match Command::new("powershell")
4721 .args(["-NoProfile", "-Command", &cmd])
4722 .output()
4723 {
4724 Ok(out) => Ok(format!(
4725 "Executed remediation (winget install):\n{}",
4726 String::from_utf8_lossy(&out.stdout)
4727 )),
4728 Err(e) => Err(format!("Failed to run winget: {}", e)),
4729 }
4730 }
4731 #[cfg(not(target_os = "windows"))]
4732 {
4733 Err(
4734 "install_package via wrapper is only supported on Windows currently (winget)"
4735 .to_string(),
4736 )
4737 }
4738 }
4739 "restart_service" => {
4740 #[cfg(target_os = "windows")]
4741 {
4742 let cmd = format!("Restart-Service -Name {} -Force", target);
4743 match Command::new("powershell")
4744 .args(["-NoProfile", "-Command", &cmd])
4745 .output()
4746 {
4747 Ok(out) => {
4748 let err_str = String::from_utf8_lossy(&out.stderr);
4749 if !err_str.is_empty() {
4750 return Err(format!("Error restarting service:\n{}", err_str));
4751 }
4752 Ok(format!("Successfully restarted service: {}", target))
4753 }
4754 Err(e) => Err(format!("Failed to restart service: {}", e)),
4755 }
4756 }
4757 #[cfg(not(target_os = "windows"))]
4758 {
4759 Err(
4760 "restart_service via wrapper is only supported on Windows currently"
4761 .to_string(),
4762 )
4763 }
4764 }
4765 "clear_temp" => {
4766 #[cfg(target_os = "windows")]
4767 {
4768 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4769 match Command::new("powershell")
4770 .args(["-NoProfile", "-Command", cmd])
4771 .output()
4772 {
4773 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4774 Err(e) => Err(format!("Failed to clear temp: {}", e)),
4775 }
4776 }
4777 #[cfg(not(target_os = "windows"))]
4778 {
4779 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4780 }
4781 }
4782 other => Err(format!("Unknown remediation action: {}", other)),
4783 }
4784}
4785
4786fn inspect_storage(max_entries: usize) -> Result<String, String> {
4789 let mut out = String::from("Host inspection: storage\n\n");
4790 let _ = max_entries; out.push_str("Drives:\n");
4794
4795 #[cfg(target_os = "windows")]
4796 {
4797 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4798 $free = $_.Free
4799 $used = $_.Used
4800 if ($free -eq $null) { $free = 0 }
4801 if ($used -eq $null) { $used = 0 }
4802 $total = $free + $used
4803 "$($_.Name)|$free|$used|$total"
4804}"#;
4805 match Command::new("powershell")
4806 .args(["-NoProfile", "-Command", script])
4807 .output()
4808 {
4809 Ok(o) => {
4810 let text = String::from_utf8_lossy(&o.stdout);
4811 let mut drive_count = 0usize;
4812 for line in text.lines() {
4813 let parts: Vec<&str> = line.trim().split('|').collect();
4814 if parts.len() == 4 {
4815 let name = parts[0];
4816 let free: u64 = parts[1].parse().unwrap_or(0);
4817 let total: u64 = parts[3].parse().unwrap_or(0);
4818 if total == 0 {
4819 continue;
4820 }
4821 let free_gb = free / 1_073_741_824;
4822 let total_gb = total / 1_073_741_824;
4823 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4824 let bar_len = 20usize;
4825 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4826 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4827 let warn = if free_gb < 5 {
4828 " [!] CRITICALLY LOW"
4829 } else if free_gb < 15 {
4830 " [-] LOW"
4831 } else {
4832 ""
4833 };
4834 out.push_str(&format!(
4835 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4836 ));
4837 drive_count += 1;
4838 }
4839 }
4840 if drive_count == 0 {
4841 out.push_str(" (could not enumerate drives)\n");
4842 }
4843 }
4844 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
4845 }
4846
4847 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4849 match Command::new("powershell")
4850 .args(["-NoProfile", "-Command", latency_script])
4851 .output()
4852 {
4853 Ok(o) => {
4854 out.push_str("\nReal-time Disk Intensity:\n");
4855 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4856 if !text.is_empty() {
4857 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
4858 if let Ok(q) = text.parse::<f64>() {
4859 if q > 2.0 {
4860 out.push_str(
4861 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4862 );
4863 } else {
4864 out.push_str(" [~] Disk latency is within healthy bounds.\n");
4865 }
4866 }
4867 } else {
4868 out.push_str(" Average Disk Queue Length: unavailable\n");
4869 }
4870 }
4871 Err(_) => {
4872 out.push_str("\nReal-time Disk Intensity:\n");
4873 out.push_str(" Average Disk Queue Length: unavailable\n");
4874 }
4875 }
4876 }
4877
4878 #[cfg(not(target_os = "windows"))]
4879 {
4880 match Command::new("df")
4881 .args(["-h", "--output=target,size,avail,pcent"])
4882 .output()
4883 {
4884 Ok(o) => {
4885 let text = String::from_utf8_lossy(&o.stdout);
4886 let mut count = 0usize;
4887 for line in text.lines().skip(1) {
4888 let cols: Vec<&str> = line.split_whitespace().collect();
4889 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4890 out.push_str(&format!(
4891 " {} size: {} avail: {} used: {}\n",
4892 cols[0], cols[1], cols[2], cols[3]
4893 ));
4894 count += 1;
4895 if count >= max_entries {
4896 break;
4897 }
4898 }
4899 }
4900 }
4901 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
4902 }
4903 }
4904
4905 out.push_str("\nLarge developer cache directories (if present):\n");
4907
4908 #[cfg(target_os = "windows")]
4909 {
4910 let home = std::env::var("USERPROFILE").unwrap_or_default();
4911 let check_dirs: &[(&str, &str)] = &[
4912 ("Temp", r"AppData\Local\Temp"),
4913 ("npm cache", r"AppData\Roaming\npm-cache"),
4914 ("Cargo registry", r".cargo\registry"),
4915 ("Cargo git", r".cargo\git"),
4916 ("pip cache", r"AppData\Local\pip\cache"),
4917 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4918 (".rustup toolchains", r".rustup\toolchains"),
4919 ("node_modules (home)", r"node_modules"),
4920 ];
4921
4922 let mut found_any = false;
4923 for (label, rel) in check_dirs {
4924 let full = format!(r"{}\{}", home, rel);
4925 let path = std::path::Path::new(&full);
4926 if path.exists() {
4927 let size_script = format!(
4929 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4930 full.replace('\'', "''")
4931 );
4932 let size_mb = Command::new("powershell")
4933 .args(["-NoProfile", "-Command", &size_script])
4934 .output()
4935 .ok()
4936 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4937 .unwrap_or_else(|| "?".to_string());
4938 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
4939 found_any = true;
4940 }
4941 }
4942 if !found_any {
4943 out.push_str(" (none of the common cache directories found)\n");
4944 }
4945
4946 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4947 }
4948
4949 #[cfg(not(target_os = "windows"))]
4950 {
4951 let home = std::env::var("HOME").unwrap_or_default();
4952 let check_dirs: &[(&str, &str)] = &[
4953 ("npm cache", ".npm"),
4954 ("Cargo registry", ".cargo/registry"),
4955 ("pip cache", ".cache/pip"),
4956 (".rustup toolchains", ".rustup/toolchains"),
4957 ("Yarn cache", ".cache/yarn"),
4958 ];
4959 let mut found_any = false;
4960 for (label, rel) in check_dirs {
4961 let full = format!("{}/{}", home, rel);
4962 if std::path::Path::new(&full).exists() {
4963 let size = Command::new("du")
4964 .args(["-sh", &full])
4965 .output()
4966 .ok()
4967 .map(|o| {
4968 let s = String::from_utf8_lossy(&o.stdout);
4969 s.split_whitespace().next().unwrap_or("?").to_string()
4970 })
4971 .unwrap_or_else(|| "?".to_string());
4972 out.push_str(&format!(" {label}: {size} ({full})\n"));
4973 found_any = true;
4974 }
4975 }
4976 if !found_any {
4977 out.push_str(" (none of the common cache directories found)\n");
4978 }
4979 }
4980
4981 Ok(out.trim_end().to_string())
4982}
4983
4984fn inspect_hardware() -> Result<String, String> {
4987 let mut out = String::from("Host inspection: hardware\n\n");
4988
4989 #[cfg(target_os = "windows")]
4990 {
4991 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4993 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4994} | Select-Object -First 1"#;
4995 if let Ok(o) = Command::new("powershell")
4996 .args(["-NoProfile", "-Command", cpu_script])
4997 .output()
4998 {
4999 let text = String::from_utf8_lossy(&o.stdout);
5000 let text = text.trim();
5001 let parts: Vec<&str> = text.split('|').collect();
5002 if parts.len() == 4 {
5003 out.push_str(&format!(
5004 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
5005 parts[0],
5006 parts[1],
5007 parts[2],
5008 parts[3].parse::<f32>().unwrap_or(0.0)
5009 ));
5010 } else {
5011 out.push_str(&format!("CPU: {text}\n\n"));
5012 }
5013 }
5014
5015 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5017$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5018$speed = ($sticks | Select-Object -First 1).Speed
5019"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5020 if let Ok(o) = Command::new("powershell")
5021 .args(["-NoProfile", "-Command", ram_script])
5022 .output()
5023 {
5024 let text = String::from_utf8_lossy(&o.stdout);
5025 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5026 }
5027
5028 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5030 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5031}"#;
5032 if let Ok(o) = Command::new("powershell")
5033 .args(["-NoProfile", "-Command", gpu_script])
5034 .output()
5035 {
5036 let text = String::from_utf8_lossy(&o.stdout);
5037 let lines: Vec<&str> = text.lines().collect();
5038 if !lines.is_empty() {
5039 out.push_str("GPU(s):\n");
5040 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5041 let parts: Vec<&str> = line.trim().split('|').collect();
5042 if parts.len() == 3 {
5043 let res = if parts[2] == "x" || parts[2].starts_with('0') {
5044 String::new()
5045 } else {
5046 format!(" — {}@display", parts[2])
5047 };
5048 out.push_str(&format!(
5049 " {}\n Driver: {}{}\n",
5050 parts[0], parts[1], res
5051 ));
5052 } else {
5053 out.push_str(&format!(" {}\n", line.trim()));
5054 }
5055 }
5056 out.push('\n');
5057 }
5058 }
5059
5060 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5062$bios = Get-CimInstance Win32_BIOS
5063$cs = Get-CimInstance Win32_ComputerSystem
5064$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5065$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5066"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5067 if let Ok(o) = Command::new("powershell")
5068 .args(["-NoProfile", "-Command", mb_script])
5069 .output()
5070 {
5071 let text = String::from_utf8_lossy(&o.stdout);
5072 let text = text.trim().trim_matches('"');
5073 let parts: Vec<&str> = text.split('|').collect();
5074 if parts.len() == 4 {
5075 out.push_str(&format!(
5076 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5077 parts[0].trim(),
5078 parts[1].trim(),
5079 parts[2].trim(),
5080 parts[3].trim()
5081 ));
5082 }
5083 }
5084
5085 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5087 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5088}"#;
5089 if let Ok(o) = Command::new("powershell")
5090 .args(["-NoProfile", "-Command", disp_script])
5091 .output()
5092 {
5093 let text = String::from_utf8_lossy(&o.stdout);
5094 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5095 if !lines.is_empty() {
5096 out.push_str("Display(s):\n");
5097 for line in &lines {
5098 let parts: Vec<&str> = line.trim().split('|').collect();
5099 if parts.len() == 2 {
5100 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
5101 }
5102 }
5103 }
5104 }
5105 }
5106
5107 #[cfg(not(target_os = "windows"))]
5108 {
5109 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5111 let model = content
5112 .lines()
5113 .find(|l| l.starts_with("model name"))
5114 .and_then(|l| l.split(':').nth(1))
5115 .map(str::trim)
5116 .unwrap_or("unknown");
5117 let cores = content
5118 .lines()
5119 .filter(|l| l.starts_with("processor"))
5120 .count();
5121 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
5122 }
5123
5124 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5126 let total_kb: u64 = content
5127 .lines()
5128 .find(|l| l.starts_with("MemTotal:"))
5129 .and_then(|l| l.split_whitespace().nth(1))
5130 .and_then(|v| v.parse().ok())
5131 .unwrap_or(0);
5132 let total_gb = total_kb / 1_048_576;
5133 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5134 }
5135
5136 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5138 let text = String::from_utf8_lossy(&o.stdout);
5139 let gpu_lines: Vec<&str> = text
5140 .lines()
5141 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5142 .collect();
5143 if !gpu_lines.is_empty() {
5144 out.push_str("GPU(s):\n");
5145 for l in gpu_lines {
5146 out.push_str(&format!(" {l}\n"));
5147 }
5148 out.push('\n');
5149 }
5150 }
5151
5152 if let Ok(o) = Command::new("dmidecode")
5154 .args(["-t", "baseboard", "-t", "bios"])
5155 .output()
5156 {
5157 let text = String::from_utf8_lossy(&o.stdout);
5158 out.push_str("Motherboard/BIOS:\n");
5159 for line in text
5160 .lines()
5161 .filter(|l| {
5162 l.contains("Manufacturer:")
5163 || l.contains("Product Name:")
5164 || l.contains("Version:")
5165 })
5166 .take(6)
5167 {
5168 out.push_str(&format!(" {}\n", line.trim()));
5169 }
5170 }
5171 }
5172
5173 Ok(out.trim_end().to_string())
5174}
5175
5176fn inspect_updates() -> Result<String, String> {
5179 let mut out = String::from("Host inspection: updates\n\n");
5180
5181 #[cfg(target_os = "windows")]
5182 {
5183 let script = r#"
5185try {
5186 $sess = New-Object -ComObject Microsoft.Update.Session
5187 $searcher = $sess.CreateUpdateSearcher()
5188 $count = $searcher.GetTotalHistoryCount()
5189 if ($count -gt 0) {
5190 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5191 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5192 } else { "NONE|LAST_INSTALL" }
5193} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5194"#;
5195 if let Ok(o) = Command::new("powershell")
5196 .args(["-NoProfile", "-Command", script])
5197 .output()
5198 {
5199 let raw = String::from_utf8_lossy(&o.stdout);
5200 let text = raw.trim();
5201 if text.starts_with("ERROR:") {
5202 out.push_str("Last update install: (unable to query)\n");
5203 } else if text.contains("NONE") {
5204 out.push_str("Last update install: No update history found\n");
5205 } else {
5206 let date = text.replace("|LAST_INSTALL", "");
5207 out.push_str(&format!("Last update install: {date}\n"));
5208 }
5209 }
5210
5211 let pending_script = r#"
5213try {
5214 $sess = New-Object -ComObject Microsoft.Update.Session
5215 $searcher = $sess.CreateUpdateSearcher()
5216 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5217 $results.Updates.Count.ToString() + "|PENDING"
5218} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5219"#;
5220 if let Ok(o) = Command::new("powershell")
5221 .args(["-NoProfile", "-Command", pending_script])
5222 .output()
5223 {
5224 let raw = String::from_utf8_lossy(&o.stdout);
5225 let text = raw.trim();
5226 if text.starts_with("ERROR:") {
5227 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5228 } else {
5229 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5230 if count == 0 {
5231 out.push_str("Pending updates: Up to date — no updates waiting\n");
5232 } else if count > 0 {
5233 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5234 out.push_str(
5235 " → Open Windows Update (Settings > Windows Update) to install\n",
5236 );
5237 }
5238 }
5239 }
5240
5241 let svc_script = r#"
5243$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5244if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5245"#;
5246 if let Ok(o) = Command::new("powershell")
5247 .args(["-NoProfile", "-Command", svc_script])
5248 .output()
5249 {
5250 let raw = String::from_utf8_lossy(&o.stdout);
5251 let status = raw.trim();
5252 out.push_str(&format!("Windows Update service: {status}\n"));
5253 }
5254 }
5255
5256 #[cfg(not(target_os = "windows"))]
5257 {
5258 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5259 let mut found = false;
5260 if let Ok(o) = apt_out {
5261 let text = String::from_utf8_lossy(&o.stdout);
5262 let lines: Vec<&str> = text
5263 .lines()
5264 .filter(|l| l.contains('/') && !l.contains("Listing"))
5265 .collect();
5266 if !lines.is_empty() {
5267 out.push_str(&format!(
5268 "{} package(s) can be upgraded (apt)\n",
5269 lines.len()
5270 ));
5271 out.push_str(" → Run: sudo apt upgrade\n");
5272 found = true;
5273 }
5274 }
5275 if !found {
5276 if let Ok(o) = Command::new("dnf")
5277 .args(["check-update", "--quiet"])
5278 .output()
5279 {
5280 let text = String::from_utf8_lossy(&o.stdout);
5281 let count = text
5282 .lines()
5283 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5284 .count();
5285 if count > 0 {
5286 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5287 out.push_str(" → Run: sudo dnf upgrade\n");
5288 } else {
5289 out.push_str("System is up to date.\n");
5290 }
5291 } else {
5292 out.push_str("Could not query package manager for updates.\n");
5293 }
5294 }
5295 }
5296
5297 Ok(out.trim_end().to_string())
5298}
5299
5300fn inspect_security() -> Result<String, String> {
5303 let mut out = String::from("Host inspection: security\n\n");
5304
5305 #[cfg(target_os = "windows")]
5306 {
5307 let defender_script = r#"
5309try {
5310 $status = Get-MpComputerStatus -ErrorAction Stop
5311 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5312} catch { "ERROR:" + $_.Exception.Message }
5313"#;
5314 if let Ok(o) = Command::new("powershell")
5315 .args(["-NoProfile", "-Command", defender_script])
5316 .output()
5317 {
5318 let raw = String::from_utf8_lossy(&o.stdout);
5319 let text = raw.trim();
5320 if text.starts_with("ERROR:") {
5321 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5322 } else {
5323 let get = |key: &str| -> String {
5324 text.split('|')
5325 .find(|s| s.starts_with(key))
5326 .and_then(|s| s.splitn(2, ':').nth(1))
5327 .unwrap_or("unknown")
5328 .to_string()
5329 };
5330 let rtp = get("RTP");
5331 let last_scan = {
5332 text.split('|')
5334 .find(|s| s.starts_with("SCAN:"))
5335 .and_then(|s| s.get(5..))
5336 .unwrap_or("unknown")
5337 .to_string()
5338 };
5339 let def_ver = get("VER");
5340 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5341
5342 let rtp_label = if rtp == "True" {
5343 "ENABLED"
5344 } else {
5345 "DISABLED [!]"
5346 };
5347 out.push_str(&format!(
5348 "Windows Defender real-time protection: {rtp_label}\n"
5349 ));
5350 out.push_str(&format!("Last quick scan: {last_scan}\n"));
5351 out.push_str(&format!("Signature version: {def_ver}\n"));
5352 if age_days >= 0 {
5353 let freshness = if age_days == 0 {
5354 "up to date".to_string()
5355 } else if age_days <= 3 {
5356 format!("{age_days} day(s) old — OK")
5357 } else if age_days <= 7 {
5358 format!("{age_days} day(s) old — consider updating")
5359 } else {
5360 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5361 };
5362 out.push_str(&format!("Signature age: {freshness}\n"));
5363 }
5364 if rtp != "True" {
5365 out.push_str(
5366 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5367 );
5368 out.push_str(
5369 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5370 );
5371 }
5372 }
5373 }
5374
5375 out.push('\n');
5376
5377 let fw_script = r#"
5379try {
5380 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5381} catch { "ERROR:" + $_.Exception.Message }
5382"#;
5383 if let Ok(o) = Command::new("powershell")
5384 .args(["-NoProfile", "-Command", fw_script])
5385 .output()
5386 {
5387 let raw = String::from_utf8_lossy(&o.stdout);
5388 let text = raw.trim();
5389 if !text.starts_with("ERROR:") && !text.is_empty() {
5390 out.push_str("Windows Firewall:\n");
5391 for line in text.lines() {
5392 if let Some((name, enabled)) = line.split_once(':') {
5393 let state = if enabled.trim() == "True" {
5394 "ON"
5395 } else {
5396 "OFF [!]"
5397 };
5398 out.push_str(&format!(" {name}: {state}\n"));
5399 }
5400 }
5401 out.push('\n');
5402 }
5403 }
5404
5405 let act_script = r#"
5407try {
5408 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5409 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5410} catch { "UNKNOWN" }
5411"#;
5412 if let Ok(o) = Command::new("powershell")
5413 .args(["-NoProfile", "-Command", act_script])
5414 .output()
5415 {
5416 let raw = String::from_utf8_lossy(&o.stdout);
5417 match raw.trim() {
5418 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5419 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5420 _ => out.push_str("Windows activation: Unable to determine\n"),
5421 }
5422 }
5423
5424 let uac_script = r#"
5426$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5427if ($val -eq 1) { "ON" } else { "OFF" }
5428"#;
5429 if let Ok(o) = Command::new("powershell")
5430 .args(["-NoProfile", "-Command", uac_script])
5431 .output()
5432 {
5433 let raw = String::from_utf8_lossy(&o.stdout);
5434 let state = raw.trim();
5435 let label = if state == "ON" {
5436 "Enabled"
5437 } else {
5438 "DISABLED [!] — recommended to re-enable via secpol.msc"
5439 };
5440 out.push_str(&format!("UAC (User Account Control): {label}\n"));
5441 }
5442 }
5443
5444 #[cfg(not(target_os = "windows"))]
5445 {
5446 if let Ok(o) = Command::new("ufw").arg("status").output() {
5447 let text = String::from_utf8_lossy(&o.stdout);
5448 out.push_str(&format!(
5449 "UFW: {}\n",
5450 text.lines().next().unwrap_or("unknown")
5451 ));
5452 }
5453 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5454 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5455 out.push_str(&format!("{line}\n"));
5456 }
5457 }
5458 }
5459
5460 Ok(out.trim_end().to_string())
5461}
5462
5463fn inspect_pending_reboot() -> Result<String, String> {
5466 let mut out = String::from("Host inspection: pending_reboot\n\n");
5467
5468 #[cfg(target_os = "windows")]
5469 {
5470 let script = r#"
5471$reasons = @()
5472if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5473 $reasons += "Windows Update requires a restart"
5474}
5475if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5476 $reasons += "Windows component install/update requires a restart"
5477}
5478$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5479if ($pfro -and $pfro.PendingFileRenameOperations) {
5480 $reasons += "Pending file rename operations (driver or system file replacement)"
5481}
5482if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5483"#;
5484 let output = Command::new("powershell")
5485 .args(["-NoProfile", "-Command", script])
5486 .output()
5487 .map_err(|e| format!("pending_reboot: {e}"))?;
5488
5489 let raw = String::from_utf8_lossy(&output.stdout);
5490 let text = raw.trim();
5491
5492 if text == "NO_REBOOT_NEEDED" {
5493 out.push_str("No restart required — system is up to date and stable.\n");
5494 } else if text.is_empty() {
5495 out.push_str("Could not determine reboot status.\n");
5496 } else {
5497 out.push_str("[!] A system restart is pending:\n\n");
5498 for reason in text.split("|REASON|") {
5499 out.push_str(&format!(" • {}\n", reason.trim()));
5500 }
5501 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5502 }
5503 }
5504
5505 #[cfg(not(target_os = "windows"))]
5506 {
5507 if std::path::Path::new("/var/run/reboot-required").exists() {
5508 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5509 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5510 out.push_str("Packages requiring restart:\n");
5511 for p in pkgs.lines().take(10) {
5512 out.push_str(&format!(" • {p}\n"));
5513 }
5514 }
5515 } else {
5516 out.push_str("No restart required.\n");
5517 }
5518 }
5519
5520 Ok(out.trim_end().to_string())
5521}
5522
5523fn inspect_disk_health() -> Result<String, String> {
5526 let mut out = String::from("Host inspection: disk_health\n\n");
5527
5528 #[cfg(target_os = "windows")]
5529 {
5530 let script = r#"
5531try {
5532 $disks = Get-PhysicalDisk -ErrorAction Stop
5533 foreach ($d in $disks) {
5534 $size_gb = [math]::Round($d.Size / 1GB, 0)
5535 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5536 }
5537} catch { "ERROR:" + $_.Exception.Message }
5538"#;
5539 let output = Command::new("powershell")
5540 .args(["-NoProfile", "-Command", script])
5541 .output()
5542 .map_err(|e| format!("disk_health: {e}"))?;
5543
5544 let raw = String::from_utf8_lossy(&output.stdout);
5545 let text = raw.trim();
5546
5547 if text.starts_with("ERROR:") {
5548 out.push_str(&format!("Unable to query disk health: {text}\n"));
5549 out.push_str("This may require running as administrator.\n");
5550 } else if text.is_empty() {
5551 out.push_str("No physical disks found.\n");
5552 } else {
5553 out.push_str("Physical Drive Health:\n\n");
5554 for line in text.lines() {
5555 let parts: Vec<&str> = line.splitn(5, '|').collect();
5556 if parts.len() >= 4 {
5557 let name = parts[0];
5558 let media = parts[1];
5559 let size = parts[2];
5560 let health = parts[3];
5561 let op_status = parts.get(4).unwrap_or(&"");
5562 let health_label = match health.trim() {
5563 "Healthy" => "OK",
5564 "Warning" => "[!] WARNING",
5565 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5566 other => other,
5567 };
5568 out.push_str(&format!(" {name}\n"));
5569 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
5570 out.push_str(&format!(" Health: {health_label}\n"));
5571 if !op_status.is_empty() {
5572 out.push_str(&format!(" Status: {op_status}\n"));
5573 }
5574 out.push('\n');
5575 }
5576 }
5577 }
5578
5579 let smart_script = r#"
5581try {
5582 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5583 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5584} catch { "" }
5585"#;
5586 if let Ok(o) = Command::new("powershell")
5587 .args(["-NoProfile", "-Command", smart_script])
5588 .output()
5589 {
5590 let raw2 = String::from_utf8_lossy(&o.stdout);
5591 let text2 = raw2.trim();
5592 if !text2.is_empty() {
5593 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5594 if failures.is_empty() {
5595 out.push_str("SMART failure prediction: No failures predicted\n");
5596 } else {
5597 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5598 for f in failures {
5599 let name = f.split('|').next().unwrap_or(f);
5600 out.push_str(&format!(" • {name}\n"));
5601 }
5602 out.push_str(
5603 "\nBack up your data immediately and replace the failing drive.\n",
5604 );
5605 }
5606 }
5607 }
5608 }
5609
5610 #[cfg(not(target_os = "windows"))]
5611 {
5612 if let Ok(o) = Command::new("lsblk")
5613 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5614 .output()
5615 {
5616 let text = String::from_utf8_lossy(&o.stdout);
5617 out.push_str("Block devices:\n");
5618 out.push_str(text.trim());
5619 out.push('\n');
5620 }
5621 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5622 let devices = String::from_utf8_lossy(&scan.stdout);
5623 for dev_line in devices.lines().take(4) {
5624 let dev = dev_line.split_whitespace().next().unwrap_or("");
5625 if dev.is_empty() {
5626 continue;
5627 }
5628 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5629 let health = String::from_utf8_lossy(&o.stdout);
5630 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5631 {
5632 out.push_str(&format!("{dev}: {}\n", line.trim()));
5633 }
5634 }
5635 }
5636 } else {
5637 out.push_str("(install smartmontools for SMART health data)\n");
5638 }
5639 }
5640
5641 Ok(out.trim_end().to_string())
5642}
5643
5644fn inspect_battery() -> Result<String, String> {
5647 let mut out = String::from("Host inspection: battery\n\n");
5648
5649 #[cfg(target_os = "windows")]
5650 {
5651 let script = r#"
5652try {
5653 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5654 if (-not $bats) { "NO_BATTERY"; exit }
5655
5656 # Modern Battery Health (Cycle count + Capacity health)
5657 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5658 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
5659 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5660
5661 foreach ($b in $bats) {
5662 $state = switch ($b.BatteryStatus) {
5663 1 { "Discharging" }
5664 2 { "AC Power (Fully Charged)" }
5665 3 { "AC Power (Charging)" }
5666 default { "Status $($b.BatteryStatus)" }
5667 }
5668
5669 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5670 $health = if ($static -and $full) {
5671 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5672 } else { "unknown" }
5673
5674 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5675 }
5676} catch { "ERROR:" + $_.Exception.Message }
5677"#;
5678 let output = Command::new("powershell")
5679 .args(["-NoProfile", "-Command", script])
5680 .output()
5681 .map_err(|e| format!("battery: {e}"))?;
5682
5683 let raw = String::from_utf8_lossy(&output.stdout);
5684 let text = raw.trim();
5685
5686 if text == "NO_BATTERY" {
5687 out.push_str("No battery detected — desktop or AC-only system.\n");
5688 return Ok(out.trim_end().to_string());
5689 }
5690 if text.starts_with("ERROR:") {
5691 out.push_str(&format!("Unable to query battery: {text}\n"));
5692 return Ok(out.trim_end().to_string());
5693 }
5694
5695 for line in text.lines() {
5696 let parts: Vec<&str> = line.split('|').collect();
5697 if parts.len() == 5 {
5698 let name = parts[0];
5699 let charge: i64 = parts[1].parse().unwrap_or(-1);
5700 let state = parts[2];
5701 let cycles = parts[3];
5702 let health = parts[4];
5703
5704 out.push_str(&format!("Battery: {name}\n"));
5705 if charge >= 0 {
5706 let bar_filled = (charge as usize * 20) / 100;
5707 out.push_str(&format!(
5708 " Charge: [{}{}] {}%\n",
5709 "#".repeat(bar_filled),
5710 ".".repeat(20 - bar_filled),
5711 charge
5712 ));
5713 }
5714 out.push_str(&format!(" Status: {state}\n"));
5715 out.push_str(&format!(" Cycles: {cycles}\n"));
5716 out.push_str(&format!(
5717 " Health: {health}% (Actual vs Design Capacity)\n\n"
5718 ));
5719 }
5720 }
5721 }
5722
5723 #[cfg(not(target_os = "windows"))]
5724 {
5725 let power_path = std::path::Path::new("/sys/class/power_supply");
5726 let mut found = false;
5727 if power_path.exists() {
5728 if let Ok(entries) = std::fs::read_dir(power_path) {
5729 for entry in entries.flatten() {
5730 let p = entry.path();
5731 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
5732 if t.trim() == "Battery" {
5733 found = true;
5734 let name = p
5735 .file_name()
5736 .unwrap_or_default()
5737 .to_string_lossy()
5738 .to_string();
5739 out.push_str(&format!("Battery: {name}\n"));
5740 let read = |f: &str| {
5741 std::fs::read_to_string(p.join(f))
5742 .ok()
5743 .map(|s| s.trim().to_string())
5744 };
5745 if let Some(cap) = read("capacity") {
5746 out.push_str(&format!(" Charge: {cap}%\n"));
5747 }
5748 if let Some(status) = read("status") {
5749 out.push_str(&format!(" Status: {status}\n"));
5750 }
5751 if let (Some(full), Some(design)) =
5752 (read("energy_full"), read("energy_full_design"))
5753 {
5754 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
5755 {
5756 if d > 0.0 {
5757 out.push_str(&format!(
5758 " Wear level: {:.1}% of design capacity\n",
5759 (f / d) * 100.0
5760 ));
5761 }
5762 }
5763 }
5764 }
5765 }
5766 }
5767 }
5768 }
5769 if !found {
5770 out.push_str("No battery found.\n");
5771 }
5772 }
5773
5774 Ok(out.trim_end().to_string())
5775}
5776
5777fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5780 let mut out = String::from("Host inspection: recent_crashes\n\n");
5781 let n = max_entries.clamp(1, 30);
5782
5783 #[cfg(target_os = "windows")]
5784 {
5785 let bsod_script = format!(
5787 r#"
5788try {{
5789 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5790 if ($events) {{
5791 $events | ForEach-Object {{
5792 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5793 }}
5794 }} else {{ "NO_BSOD" }}
5795}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5796 );
5797
5798 if let Ok(o) = Command::new("powershell")
5799 .args(["-NoProfile", "-Command", &bsod_script])
5800 .output()
5801 {
5802 let raw = String::from_utf8_lossy(&o.stdout);
5803 let text = raw.trim();
5804 if text == "NO_BSOD" {
5805 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5806 } else if text.starts_with("ERROR:") {
5807 out.push_str("System crashes: unable to query\n");
5808 } else {
5809 out.push_str("System crashes / unexpected shutdowns:\n");
5810 for line in text.lines() {
5811 let parts: Vec<&str> = line.splitn(3, '|').collect();
5812 if parts.len() >= 3 {
5813 let time = parts[0];
5814 let id = parts[1];
5815 let msg = parts[2];
5816 let label = if id == "41" {
5817 "Unexpected shutdown"
5818 } else {
5819 "BSOD (BugCheck)"
5820 };
5821 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
5822 }
5823 }
5824 out.push('\n');
5825 }
5826 }
5827
5828 let app_script = format!(
5830 r#"
5831try {{
5832 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5833 if ($crashes) {{
5834 $crashes | ForEach-Object {{
5835 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5836 }}
5837 }} else {{ "NO_CRASHES" }}
5838}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5839 );
5840
5841 if let Ok(o) = Command::new("powershell")
5842 .args(["-NoProfile", "-Command", &app_script])
5843 .output()
5844 {
5845 let raw = String::from_utf8_lossy(&o.stdout);
5846 let text = raw.trim();
5847 if text == "NO_CRASHES" {
5848 out.push_str("Application crashes: None in recent history\n");
5849 } else if text.starts_with("ERROR_APP:") {
5850 out.push_str("Application crashes: unable to query\n");
5851 } else {
5852 out.push_str("Application crashes:\n");
5853 for line in text.lines().take(n) {
5854 let parts: Vec<&str> = line.splitn(2, '|').collect();
5855 if parts.len() >= 2 {
5856 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
5857 }
5858 }
5859 }
5860 }
5861 }
5862
5863 #[cfg(not(target_os = "windows"))]
5864 {
5865 let n_str = n.to_string();
5866 if let Ok(o) = Command::new("journalctl")
5867 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5868 .output()
5869 {
5870 let text = String::from_utf8_lossy(&o.stdout);
5871 let trimmed = text.trim();
5872 if trimmed.is_empty() || trimmed.contains("No entries") {
5873 out.push_str("No kernel panics or critical crashes found.\n");
5874 } else {
5875 out.push_str("Kernel critical events:\n");
5876 out.push_str(trimmed);
5877 out.push('\n');
5878 }
5879 }
5880 if let Ok(o) = Command::new("coredumpctl")
5881 .args(["list", "--no-pager"])
5882 .output()
5883 {
5884 let text = String::from_utf8_lossy(&o.stdout);
5885 let count = text
5886 .lines()
5887 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5888 .count();
5889 if count > 0 {
5890 out.push_str(&format!(
5891 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
5892 ));
5893 }
5894 }
5895 }
5896
5897 Ok(out.trim_end().to_string())
5898}
5899
5900fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5903 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5904 let n = max_entries.clamp(1, 30);
5905
5906 #[cfg(target_os = "windows")]
5907 {
5908 let script = format!(
5909 r#"
5910try {{
5911 $tasks = Get-ScheduledTask -ErrorAction Stop |
5912 Where-Object {{ $_.State -ne 'Disabled' }} |
5913 ForEach-Object {{
5914 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5915 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5916 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5917 }} else {{ "never" }}
5918 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5919 $exec = ($_.Actions | Select-Object -First 1).Execute
5920 if (-not $exec) {{ $exec = "(no exec)" }}
5921 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5922 }}
5923 $tasks | Select-Object -First {n}
5924}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5925 );
5926
5927 let output = Command::new("powershell")
5928 .args(["-NoProfile", "-Command", &script])
5929 .output()
5930 .map_err(|e| format!("scheduled_tasks: {e}"))?;
5931
5932 let raw = String::from_utf8_lossy(&output.stdout);
5933 let text = raw.trim();
5934
5935 if text.starts_with("ERROR:") {
5936 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5937 } else if text.is_empty() {
5938 out.push_str("No active scheduled tasks found.\n");
5939 } else {
5940 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5941 for line in text.lines() {
5942 let parts: Vec<&str> = line.splitn(6, '|').collect();
5943 if parts.len() >= 5 {
5944 let name = parts[0];
5945 let path = parts[1];
5946 let state = parts[2];
5947 let last = parts[3];
5948 let res = parts[4];
5949 let exec = parts.get(5).unwrap_or(&"").trim();
5950 let display_path = path.trim_matches('\\');
5951 let display_path = if display_path.is_empty() {
5952 "Root"
5953 } else {
5954 display_path
5955 };
5956 out.push_str(&format!(" {name} [{display_path}]\n"));
5957 out.push_str(&format!(
5958 " State: {state} | Last run: {last} | Result: {res}\n"
5959 ));
5960 if !exec.is_empty() && exec != "(no exec)" {
5961 let short = if exec.len() > 80 { &exec[..80] } else { exec };
5962 out.push_str(&format!(" Runs: {short}\n"));
5963 }
5964 }
5965 }
5966 }
5967 }
5968
5969 #[cfg(not(target_os = "windows"))]
5970 {
5971 if let Ok(o) = Command::new("systemctl")
5972 .args(["list-timers", "--no-pager", "--all"])
5973 .output()
5974 {
5975 let text = String::from_utf8_lossy(&o.stdout);
5976 out.push_str("Systemd timers:\n");
5977 for l in text
5978 .lines()
5979 .filter(|l| {
5980 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5981 })
5982 .take(n)
5983 {
5984 out.push_str(&format!(" {l}\n"));
5985 }
5986 out.push('\n');
5987 }
5988 if let Ok(o) = Command::new("crontab").arg("-l").output() {
5989 let text = String::from_utf8_lossy(&o.stdout);
5990 let jobs: Vec<&str> = text
5991 .lines()
5992 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5993 .collect();
5994 if !jobs.is_empty() {
5995 out.push_str("User crontab:\n");
5996 for j in jobs.iter().take(n) {
5997 out.push_str(&format!(" {j}\n"));
5998 }
5999 }
6000 }
6001 }
6002
6003 Ok(out.trim_end().to_string())
6004}
6005
6006fn inspect_dev_conflicts() -> Result<String, String> {
6009 let mut out = String::from("Host inspection: dev_conflicts\n\n");
6010 let mut conflicts: Vec<String> = Vec::new();
6011 let mut notes: Vec<String> = Vec::new();
6012
6013 {
6015 let node_ver = Command::new("node")
6016 .arg("--version")
6017 .output()
6018 .ok()
6019 .and_then(|o| String::from_utf8(o.stdout).ok())
6020 .map(|s| s.trim().to_string());
6021 let nvm_active = Command::new("nvm")
6022 .arg("current")
6023 .output()
6024 .ok()
6025 .and_then(|o| String::from_utf8(o.stdout).ok())
6026 .map(|s| s.trim().to_string())
6027 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6028 let fnm_active = Command::new("fnm")
6029 .arg("current")
6030 .output()
6031 .ok()
6032 .and_then(|o| String::from_utf8(o.stdout).ok())
6033 .map(|s| s.trim().to_string())
6034 .filter(|s| !s.is_empty() && !s.contains("none"));
6035 let volta_active = Command::new("volta")
6036 .args(["which", "node"])
6037 .output()
6038 .ok()
6039 .and_then(|o| String::from_utf8(o.stdout).ok())
6040 .map(|s| s.trim().to_string())
6041 .filter(|s| !s.is_empty());
6042
6043 out.push_str("Node.js:\n");
6044 if let Some(ref v) = node_ver {
6045 out.push_str(&format!(" Active: {v}\n"));
6046 } else {
6047 out.push_str(" Not installed\n");
6048 }
6049 let managers: Vec<&str> = [
6050 nvm_active.as_deref(),
6051 fnm_active.as_deref(),
6052 volta_active.as_deref(),
6053 ]
6054 .iter()
6055 .filter_map(|x| *x)
6056 .collect();
6057 if managers.len() > 1 {
6058 conflicts.push(format!(
6059 "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
6060 ));
6061 } else if !managers.is_empty() {
6062 out.push_str(&format!(" Version manager: {}\n", managers[0]));
6063 }
6064 out.push('\n');
6065 }
6066
6067 {
6069 let py3 = Command::new("python3")
6070 .arg("--version")
6071 .output()
6072 .ok()
6073 .and_then(|o| {
6074 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6075 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6076 let v = if stdout.is_empty() { stderr } else { stdout };
6077 if v.is_empty() {
6078 None
6079 } else {
6080 Some(v)
6081 }
6082 });
6083 let py = Command::new("python")
6084 .arg("--version")
6085 .output()
6086 .ok()
6087 .and_then(|o| {
6088 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6089 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6090 let v = if stdout.is_empty() { stderr } else { stdout };
6091 if v.is_empty() {
6092 None
6093 } else {
6094 Some(v)
6095 }
6096 });
6097 let pyenv = Command::new("pyenv")
6098 .arg("version")
6099 .output()
6100 .ok()
6101 .and_then(|o| String::from_utf8(o.stdout).ok())
6102 .map(|s| s.trim().to_string())
6103 .filter(|s| !s.is_empty());
6104 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6105
6106 out.push_str("Python:\n");
6107 match (&py3, &py) {
6108 (Some(v3), Some(v)) if v3 != v => {
6109 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
6110 if v.contains("2.") {
6111 conflicts.push(
6112 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6113 );
6114 } else {
6115 notes.push(
6116 "python and python3 resolve to different minor versions.".to_string(),
6117 );
6118 }
6119 }
6120 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
6121 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
6122 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
6123 (None, None) => out.push_str(" Not installed\n"),
6124 }
6125 if let Some(ref pe) = pyenv {
6126 out.push_str(&format!(" pyenv: {pe}\n"));
6127 }
6128 if let Some(env) = conda_env {
6129 if env == "base" {
6130 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6131 } else {
6132 out.push_str(&format!(" conda env: {env}\n"));
6133 }
6134 }
6135 out.push('\n');
6136 }
6137
6138 {
6140 let toolchain = Command::new("rustup")
6141 .args(["show", "active-toolchain"])
6142 .output()
6143 .ok()
6144 .and_then(|o| String::from_utf8(o.stdout).ok())
6145 .map(|s| s.trim().to_string())
6146 .filter(|s| !s.is_empty());
6147 let cargo_ver = Command::new("cargo")
6148 .arg("--version")
6149 .output()
6150 .ok()
6151 .and_then(|o| String::from_utf8(o.stdout).ok())
6152 .map(|s| s.trim().to_string());
6153 let rustc_ver = Command::new("rustc")
6154 .arg("--version")
6155 .output()
6156 .ok()
6157 .and_then(|o| String::from_utf8(o.stdout).ok())
6158 .map(|s| s.trim().to_string());
6159
6160 out.push_str("Rust:\n");
6161 if let Some(ref t) = toolchain {
6162 out.push_str(&format!(" Active toolchain: {t}\n"));
6163 }
6164 if let Some(ref c) = cargo_ver {
6165 out.push_str(&format!(" {c}\n"));
6166 }
6167 if let Some(ref r) = rustc_ver {
6168 out.push_str(&format!(" {r}\n"));
6169 }
6170 if cargo_ver.is_none() && rustc_ver.is_none() {
6171 out.push_str(" Not installed\n");
6172 }
6173
6174 #[cfg(not(target_os = "windows"))]
6176 if let Ok(o) = Command::new("which").arg("rustc").output() {
6177 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6178 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6179 conflicts.push(format!(
6180 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6181 ));
6182 }
6183 }
6184 out.push('\n');
6185 }
6186
6187 {
6189 let git_ver = Command::new("git")
6190 .arg("--version")
6191 .output()
6192 .ok()
6193 .and_then(|o| String::from_utf8(o.stdout).ok())
6194 .map(|s| s.trim().to_string());
6195 out.push_str("Git:\n");
6196 if let Some(ref v) = git_ver {
6197 out.push_str(&format!(" {v}\n"));
6198 let email = Command::new("git")
6199 .args(["config", "--global", "user.email"])
6200 .output()
6201 .ok()
6202 .and_then(|o| String::from_utf8(o.stdout).ok())
6203 .map(|s| s.trim().to_string());
6204 if let Some(ref e) = email {
6205 if e.is_empty() {
6206 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6207 } else {
6208 out.push_str(&format!(" user.email: {e}\n"));
6209 }
6210 }
6211 let gpg_sign = Command::new("git")
6212 .args(["config", "--global", "commit.gpgsign"])
6213 .output()
6214 .ok()
6215 .and_then(|o| String::from_utf8(o.stdout).ok())
6216 .map(|s| s.trim().to_string());
6217 if gpg_sign.as_deref() == Some("true") {
6218 let key = Command::new("git")
6219 .args(["config", "--global", "user.signingkey"])
6220 .output()
6221 .ok()
6222 .and_then(|o| String::from_utf8(o.stdout).ok())
6223 .map(|s| s.trim().to_string());
6224 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6225 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6226 }
6227 }
6228 } else {
6229 out.push_str(" Not installed\n");
6230 }
6231 out.push('\n');
6232 }
6233
6234 {
6236 let path_env = std::env::var("PATH").unwrap_or_default();
6237 let sep = if cfg!(windows) { ';' } else { ':' };
6238 let mut seen = HashSet::new();
6239 let mut dupes: Vec<String> = Vec::new();
6240 for p in path_env.split(sep) {
6241 let norm = p.trim().to_lowercase();
6242 if !norm.is_empty() && !seen.insert(norm) {
6243 dupes.push(p.to_string());
6244 }
6245 }
6246 if !dupes.is_empty() {
6247 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6248 notes.push(format!(
6249 "Duplicate PATH entries: {} {}",
6250 shown.join(", "),
6251 if dupes.len() > 3 {
6252 format!("+{} more", dupes.len() - 3)
6253 } else {
6254 String::new()
6255 }
6256 ));
6257 }
6258 }
6259
6260 if conflicts.is_empty() && notes.is_empty() {
6262 out.push_str("No conflicts detected — dev environment looks clean.\n");
6263 } else {
6264 if !conflicts.is_empty() {
6265 out.push_str("CONFLICTS:\n");
6266 for c in &conflicts {
6267 out.push_str(&format!(" [!] {c}\n"));
6268 }
6269 out.push('\n');
6270 }
6271 if !notes.is_empty() {
6272 out.push_str("NOTES:\n");
6273 for n in ¬es {
6274 out.push_str(&format!(" [-] {n}\n"));
6275 }
6276 }
6277 }
6278
6279 Ok(out.trim_end().to_string())
6280}
6281
6282fn inspect_connectivity() -> Result<String, String> {
6285 let mut out = String::from("Host inspection: connectivity\n\n");
6286
6287 #[cfg(target_os = "windows")]
6288 {
6289 let inet_script = r#"
6290try {
6291 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6292 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6293} catch { "ERROR:" + $_.Exception.Message }
6294"#;
6295 if let Ok(o) = Command::new("powershell")
6296 .args(["-NoProfile", "-Command", inet_script])
6297 .output()
6298 {
6299 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6300 match text.as_str() {
6301 "REACHABLE" => out.push_str("Internet: reachable\n"),
6302 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6303 _ => out.push_str(&format!(
6304 "Internet: {}\n",
6305 text.trim_start_matches("ERROR:").trim()
6306 )),
6307 }
6308 }
6309
6310 let dns_script = r#"
6311try {
6312 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6313 "DNS:ok"
6314} catch { "DNS:fail:" + $_.Exception.Message }
6315"#;
6316 if let Ok(o) = Command::new("powershell")
6317 .args(["-NoProfile", "-Command", dns_script])
6318 .output()
6319 {
6320 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6321 if text == "DNS:ok" {
6322 out.push_str("DNS: resolving correctly\n");
6323 } else {
6324 let detail = text.trim_start_matches("DNS:fail:").trim();
6325 out.push_str(&format!("DNS: failed — {}\n", detail));
6326 }
6327 }
6328
6329 let gw_script = r#"
6330(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6331"#;
6332 if let Ok(o) = Command::new("powershell")
6333 .args(["-NoProfile", "-Command", gw_script])
6334 .output()
6335 {
6336 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6337 if !gw.is_empty() && gw != "0.0.0.0" {
6338 out.push_str(&format!("Default gateway: {}\n", gw));
6339 }
6340 }
6341 }
6342
6343 #[cfg(not(target_os = "windows"))]
6344 {
6345 let reachable = Command::new("ping")
6346 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6347 .output()
6348 .map(|o| o.status.success())
6349 .unwrap_or(false);
6350 out.push_str(if reachable {
6351 "Internet: reachable\n"
6352 } else {
6353 "Internet: unreachable\n"
6354 });
6355 let dns_ok = Command::new("getent")
6356 .args(["hosts", "dns.google"])
6357 .output()
6358 .map(|o| o.status.success())
6359 .unwrap_or(false);
6360 out.push_str(if dns_ok {
6361 "DNS: resolving correctly\n"
6362 } else {
6363 "DNS: failed\n"
6364 });
6365 if let Ok(o) = Command::new("ip")
6366 .args(["route", "show", "default"])
6367 .output()
6368 {
6369 let text = String::from_utf8_lossy(&o.stdout);
6370 if let Some(line) = text.lines().next() {
6371 out.push_str(&format!("Default gateway: {}\n", line.trim()));
6372 }
6373 }
6374 }
6375
6376 Ok(out.trim_end().to_string())
6377}
6378
6379fn inspect_wifi() -> Result<String, String> {
6382 let mut out = String::from("Host inspection: wifi\n\n");
6383
6384 #[cfg(target_os = "windows")]
6385 {
6386 let output = Command::new("netsh")
6387 .args(["wlan", "show", "interfaces"])
6388 .output()
6389 .map_err(|e| format!("wifi: {e}"))?;
6390 let text = String::from_utf8_lossy(&output.stdout).to_string();
6391
6392 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6393 out.push_str("No wireless interface detected on this machine.\n");
6394 return Ok(out.trim_end().to_string());
6395 }
6396
6397 let fields = [
6398 ("SSID", "SSID"),
6399 ("State", "State"),
6400 ("Signal", "Signal"),
6401 ("Radio type", "Radio type"),
6402 ("Channel", "Channel"),
6403 ("Receive rate (Mbps)", "Download speed (Mbps)"),
6404 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6405 ("Authentication", "Authentication"),
6406 ("Network type", "Network type"),
6407 ];
6408
6409 let mut any = false;
6410 for line in text.lines() {
6411 let trimmed = line.trim();
6412 for (key, label) in &fields {
6413 if trimmed.starts_with(key) && trimmed.contains(':') {
6414 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6415 if !val.is_empty() {
6416 out.push_str(&format!(" {label}: {val}\n"));
6417 any = true;
6418 }
6419 }
6420 }
6421 }
6422 if !any {
6423 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
6424 }
6425 }
6426
6427 #[cfg(not(target_os = "windows"))]
6428 {
6429 if let Ok(o) = Command::new("nmcli")
6430 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6431 .output()
6432 {
6433 let text = String::from_utf8_lossy(&o.stdout).to_string();
6434 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6435 if lines.is_empty() {
6436 out.push_str("No Wi-Fi devices found.\n");
6437 } else {
6438 for l in lines {
6439 out.push_str(&format!(" {l}\n"));
6440 }
6441 }
6442 } else if let Ok(o) = Command::new("iwconfig").output() {
6443 let text = String::from_utf8_lossy(&o.stdout).to_string();
6444 if !text.trim().is_empty() {
6445 out.push_str(text.trim());
6446 out.push('\n');
6447 }
6448 } else {
6449 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6450 }
6451 }
6452
6453 Ok(out.trim_end().to_string())
6454}
6455
6456fn inspect_connections(max_entries: usize) -> Result<String, String> {
6459 let mut out = String::from("Host inspection: connections\n\n");
6460 let n = max_entries.clamp(1, 25);
6461
6462 #[cfg(target_os = "windows")]
6463 {
6464 let script = format!(
6465 r#"
6466try {{
6467 $procs = @{{}}
6468 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6469 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6470 Sort-Object OwningProcess
6471 "TOTAL:" + $all.Count
6472 $all | Select-Object -First {n} | ForEach-Object {{
6473 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6474 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6475 }}
6476}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6477 );
6478
6479 let output = Command::new("powershell")
6480 .args(["-NoProfile", "-Command", &script])
6481 .output()
6482 .map_err(|e| format!("connections: {e}"))?;
6483
6484 let raw = String::from_utf8_lossy(&output.stdout);
6485 let text = raw.trim();
6486
6487 if text.starts_with("ERROR:") {
6488 out.push_str(&format!("Unable to query connections: {text}\n"));
6489 } else {
6490 let mut total = 0usize;
6491 let mut rows = Vec::new();
6492 for line in text.lines() {
6493 if let Some(rest) = line.strip_prefix("TOTAL:") {
6494 total = rest.trim().parse().unwrap_or(0);
6495 } else {
6496 rows.push(line);
6497 }
6498 }
6499 out.push_str(&format!("Established TCP connections: {total}\n\n"));
6500 for row in &rows {
6501 let parts: Vec<&str> = row.splitn(4, '|').collect();
6502 if parts.len() == 4 {
6503 out.push_str(&format!(
6504 " {:<15} (pid {:<5}) | {} → {}\n",
6505 parts[0], parts[1], parts[2], parts[3]
6506 ));
6507 }
6508 }
6509 if total > n {
6510 out.push_str(&format!(
6511 "\n ... {} more connections not shown\n",
6512 total.saturating_sub(n)
6513 ));
6514 }
6515 }
6516 }
6517
6518 #[cfg(not(target_os = "windows"))]
6519 {
6520 if let Ok(o) = Command::new("ss")
6521 .args(["-tnp", "state", "established"])
6522 .output()
6523 {
6524 let text = String::from_utf8_lossy(&o.stdout);
6525 let lines: Vec<&str> = text
6526 .lines()
6527 .skip(1)
6528 .filter(|l| !l.trim().is_empty())
6529 .collect();
6530 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6531 for line in lines.iter().take(n) {
6532 out.push_str(&format!(" {}\n", line.trim()));
6533 }
6534 if lines.len() > n {
6535 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
6536 }
6537 } else {
6538 out.push_str("ss not available — install iproute2\n");
6539 }
6540 }
6541
6542 Ok(out.trim_end().to_string())
6543}
6544
6545fn inspect_vpn() -> Result<String, String> {
6548 let mut out = String::from("Host inspection: vpn\n\n");
6549
6550 #[cfg(target_os = "windows")]
6551 {
6552 let script = r#"
6553try {
6554 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6555 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6556 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6557 }
6558 if ($vpn) {
6559 foreach ($a in $vpn) {
6560 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6561 }
6562 } else { "NONE" }
6563} catch { "ERROR:" + $_.Exception.Message }
6564"#;
6565 let output = Command::new("powershell")
6566 .args(["-NoProfile", "-Command", script])
6567 .output()
6568 .map_err(|e| format!("vpn: {e}"))?;
6569
6570 let raw = String::from_utf8_lossy(&output.stdout);
6571 let text = raw.trim();
6572
6573 if text == "NONE" {
6574 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6575 } else if text.starts_with("ERROR:") {
6576 out.push_str(&format!("Unable to query adapters: {text}\n"));
6577 } else {
6578 out.push_str("VPN adapters:\n\n");
6579 for line in text.lines() {
6580 let parts: Vec<&str> = line.splitn(4, '|').collect();
6581 if parts.len() >= 3 {
6582 let name = parts[0];
6583 let desc = parts[1];
6584 let status = parts[2];
6585 let media = parts.get(3).unwrap_or(&"unknown");
6586 let label = if status.trim() == "Up" {
6587 "CONNECTED"
6588 } else {
6589 "disconnected"
6590 };
6591 out.push_str(&format!(
6592 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
6593 ));
6594 }
6595 }
6596 }
6597
6598 let ras_script = r#"
6600try {
6601 $c = Get-VpnConnection -ErrorAction Stop
6602 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6603 else { "NO_RAS" }
6604} catch { "NO_RAS" }
6605"#;
6606 if let Ok(o) = Command::new("powershell")
6607 .args(["-NoProfile", "-Command", ras_script])
6608 .output()
6609 {
6610 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6611 if t != "NO_RAS" && !t.is_empty() {
6612 out.push_str("Windows VPN connections:\n");
6613 for line in t.lines() {
6614 let parts: Vec<&str> = line.splitn(3, '|').collect();
6615 if parts.len() >= 2 {
6616 let name = parts[0];
6617 let status = parts[1];
6618 let server = parts.get(2).unwrap_or(&"");
6619 out.push_str(&format!(" {name} → {server} [{status}]\n"));
6620 }
6621 }
6622 }
6623 }
6624 }
6625
6626 #[cfg(not(target_os = "windows"))]
6627 {
6628 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6629 let text = String::from_utf8_lossy(&o.stdout);
6630 let vpn_ifaces: Vec<&str> = text
6631 .lines()
6632 .filter(|l| {
6633 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6634 })
6635 .collect();
6636 if vpn_ifaces.is_empty() {
6637 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6638 } else {
6639 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6640 for l in vpn_ifaces {
6641 out.push_str(&format!(" {}\n", l.trim()));
6642 }
6643 }
6644 }
6645 }
6646
6647 Ok(out.trim_end().to_string())
6648}
6649
6650fn inspect_proxy() -> Result<String, String> {
6653 let mut out = String::from("Host inspection: proxy\n\n");
6654
6655 #[cfg(target_os = "windows")]
6656 {
6657 let script = r#"
6658$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6659if ($ie) {
6660 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6661} else { "NONE" }
6662"#;
6663 if let Ok(o) = Command::new("powershell")
6664 .args(["-NoProfile", "-Command", script])
6665 .output()
6666 {
6667 let raw = String::from_utf8_lossy(&o.stdout);
6668 let text = raw.trim();
6669 if text != "NONE" && !text.is_empty() {
6670 let get = |key: &str| -> &str {
6671 text.split('|')
6672 .find(|s| s.starts_with(key))
6673 .and_then(|s| s.splitn(2, ':').nth(1))
6674 .unwrap_or("")
6675 };
6676 let enabled = get("ENABLE");
6677 let server = get("SERVER");
6678 let overrides = get("OVERRIDE");
6679 out.push_str("WinINET / IE proxy:\n");
6680 out.push_str(&format!(
6681 " Enabled: {}\n",
6682 if enabled == "1" { "yes" } else { "no" }
6683 ));
6684 if !server.is_empty() && server != "None" {
6685 out.push_str(&format!(" Proxy server: {server}\n"));
6686 }
6687 if !overrides.is_empty() && overrides != "None" {
6688 out.push_str(&format!(" Bypass list: {overrides}\n"));
6689 }
6690 out.push('\n');
6691 }
6692 }
6693
6694 if let Ok(o) = Command::new("netsh")
6695 .args(["winhttp", "show", "proxy"])
6696 .output()
6697 {
6698 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6699 out.push_str("WinHTTP proxy:\n");
6700 for line in text.lines() {
6701 let l = line.trim();
6702 if !l.is_empty() {
6703 out.push_str(&format!(" {l}\n"));
6704 }
6705 }
6706 out.push('\n');
6707 }
6708
6709 let mut env_found = false;
6710 for var in &[
6711 "http_proxy",
6712 "https_proxy",
6713 "HTTP_PROXY",
6714 "HTTPS_PROXY",
6715 "no_proxy",
6716 "NO_PROXY",
6717 ] {
6718 if let Ok(val) = std::env::var(var) {
6719 if !env_found {
6720 out.push_str("Environment proxy variables:\n");
6721 env_found = true;
6722 }
6723 out.push_str(&format!(" {var}: {val}\n"));
6724 }
6725 }
6726 if !env_found {
6727 out.push_str("No proxy environment variables set.\n");
6728 }
6729 }
6730
6731 #[cfg(not(target_os = "windows"))]
6732 {
6733 let mut found = false;
6734 for var in &[
6735 "http_proxy",
6736 "https_proxy",
6737 "HTTP_PROXY",
6738 "HTTPS_PROXY",
6739 "no_proxy",
6740 "NO_PROXY",
6741 "ALL_PROXY",
6742 "all_proxy",
6743 ] {
6744 if let Ok(val) = std::env::var(var) {
6745 if !found {
6746 out.push_str("Proxy environment variables:\n");
6747 found = true;
6748 }
6749 out.push_str(&format!(" {var}: {val}\n"));
6750 }
6751 }
6752 if !found {
6753 out.push_str("No proxy environment variables set.\n");
6754 }
6755 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
6756 let proxy_lines: Vec<&str> = content
6757 .lines()
6758 .filter(|l| l.to_lowercase().contains("proxy"))
6759 .collect();
6760 if !proxy_lines.is_empty() {
6761 out.push_str("\nSystem proxy (/etc/environment):\n");
6762 for l in proxy_lines {
6763 out.push_str(&format!(" {l}\n"));
6764 }
6765 }
6766 }
6767 }
6768
6769 Ok(out.trim_end().to_string())
6770}
6771
6772fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6775 let mut out = String::from("Host inspection: firewall_rules\n\n");
6776 let n = max_entries.clamp(1, 20);
6777
6778 #[cfg(target_os = "windows")]
6779 {
6780 let script = format!(
6781 r#"
6782try {{
6783 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6784 Where-Object {{
6785 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6786 $_.Owner -eq $null
6787 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6788 "TOTAL:" + $rules.Count
6789 $rules | ForEach-Object {{
6790 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6791 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6792 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6793 }}
6794}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6795 );
6796
6797 let output = Command::new("powershell")
6798 .args(["-NoProfile", "-Command", &script])
6799 .output()
6800 .map_err(|e| format!("firewall_rules: {e}"))?;
6801
6802 let raw = String::from_utf8_lossy(&output.stdout);
6803 let text = raw.trim();
6804
6805 if text.starts_with("ERROR:") {
6806 out.push_str(&format!(
6807 "Unable to query firewall rules: {}\n",
6808 text.trim_start_matches("ERROR:").trim()
6809 ));
6810 out.push_str("This query may require running as administrator.\n");
6811 } else if text.is_empty() {
6812 out.push_str("No non-default enabled firewall rules found.\n");
6813 } else {
6814 let mut total = 0usize;
6815 for line in text.lines() {
6816 if let Some(rest) = line.strip_prefix("TOTAL:") {
6817 total = rest.trim().parse().unwrap_or(0);
6818 out.push_str(&format!(
6819 "Non-default enabled rules (showing up to {n}):\n\n"
6820 ));
6821 } else {
6822 let parts: Vec<&str> = line.splitn(4, '|').collect();
6823 if parts.len() >= 3 {
6824 let name = parts[0];
6825 let dir = parts[1];
6826 let action = parts[2];
6827 let profile = parts.get(3).unwrap_or(&"Any");
6828 let icon = if action == "Block" { "[!]" } else { " " };
6829 out.push_str(&format!(
6830 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6831 ));
6832 }
6833 }
6834 }
6835 if total == 0 {
6836 out.push_str("No non-default enabled rules found.\n");
6837 }
6838 }
6839 }
6840
6841 #[cfg(not(target_os = "windows"))]
6842 {
6843 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6844 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6845 if !text.is_empty() {
6846 out.push_str(&text);
6847 out.push('\n');
6848 }
6849 } else if let Ok(o) = Command::new("iptables")
6850 .args(["-L", "-n", "--line-numbers"])
6851 .output()
6852 {
6853 let text = String::from_utf8_lossy(&o.stdout);
6854 for l in text.lines().take(n * 2) {
6855 out.push_str(&format!(" {l}\n"));
6856 }
6857 } else {
6858 out.push_str("ufw and iptables not available or insufficient permissions.\n");
6859 }
6860 }
6861
6862 Ok(out.trim_end().to_string())
6863}
6864
6865fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6868 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6869 let hops = max_entries.clamp(5, 30);
6870
6871 #[cfg(target_os = "windows")]
6872 {
6873 let output = Command::new("tracert")
6874 .args(["-d", "-h", &hops.to_string(), host])
6875 .output()
6876 .map_err(|e| format!("tracert: {e}"))?;
6877 let raw = String::from_utf8_lossy(&output.stdout);
6878 let mut hop_count = 0usize;
6879 for line in raw.lines() {
6880 let trimmed = line.trim();
6881 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6882 hop_count += 1;
6883 out.push_str(&format!(" {trimmed}\n"));
6884 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6885 out.push_str(&format!("{trimmed}\n"));
6886 }
6887 }
6888 if hop_count == 0 {
6889 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6890 }
6891 }
6892
6893 #[cfg(not(target_os = "windows"))]
6894 {
6895 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6896 || std::path::Path::new("/usr/sbin/traceroute").exists()
6897 {
6898 "traceroute"
6899 } else {
6900 "tracepath"
6901 };
6902 let output = Command::new(cmd)
6903 .args(["-m", &hops.to_string(), "-n", host])
6904 .output()
6905 .map_err(|e| format!("{cmd}: {e}"))?;
6906 let raw = String::from_utf8_lossy(&output.stdout);
6907 let mut hop_count = 0usize;
6908 for line in raw.lines().take(hops + 2) {
6909 let trimmed = line.trim();
6910 if !trimmed.is_empty() {
6911 hop_count += 1;
6912 out.push_str(&format!(" {trimmed}\n"));
6913 }
6914 }
6915 if hop_count == 0 {
6916 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6917 }
6918 }
6919
6920 Ok(out.trim_end().to_string())
6921}
6922
6923fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6926 let mut out = String::from("Host inspection: dns_cache\n\n");
6927 let n = max_entries.clamp(10, 100);
6928
6929 #[cfg(target_os = "windows")]
6930 {
6931 let output = Command::new("powershell")
6932 .args([
6933 "-NoProfile",
6934 "-Command",
6935 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6936 ])
6937 .output()
6938 .map_err(|e| format!("dns_cache: {e}"))?;
6939
6940 let raw = String::from_utf8_lossy(&output.stdout);
6941 let lines: Vec<&str> = raw.lines().skip(1).collect();
6942 let total = lines.len();
6943
6944 if total == 0 {
6945 out.push_str("DNS cache is empty or could not be read.\n");
6946 } else {
6947 out.push_str(&format!(
6948 "DNS cache entries (showing up to {n} of {total}):\n\n"
6949 ));
6950 let mut shown = 0usize;
6951 for line in lines.iter().take(n) {
6952 let cols: Vec<&str> = line.splitn(4, ',').collect();
6953 if cols.len() >= 3 {
6954 let entry = cols[0].trim_matches('"');
6955 let rtype = cols[1].trim_matches('"');
6956 let data = cols[2].trim_matches('"');
6957 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6958 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
6959 shown += 1;
6960 }
6961 }
6962 if total > shown {
6963 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
6964 }
6965 }
6966 }
6967
6968 #[cfg(not(target_os = "windows"))]
6969 {
6970 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6971 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6972 if !text.is_empty() {
6973 out.push_str("systemd-resolved statistics:\n");
6974 for line in text.lines().take(n) {
6975 out.push_str(&format!(" {line}\n"));
6976 }
6977 out.push('\n');
6978 }
6979 }
6980 if let Ok(o) = Command::new("dscacheutil")
6981 .args(["-cachedump", "-entries", "Host"])
6982 .output()
6983 {
6984 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6985 if !text.is_empty() {
6986 out.push_str("DNS cache (macOS dscacheutil):\n");
6987 for line in text.lines().take(n) {
6988 out.push_str(&format!(" {line}\n"));
6989 }
6990 } else {
6991 out.push_str("DNS cache is empty or not accessible on this platform.\n");
6992 }
6993 } else {
6994 out.push_str(
6995 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6996 );
6997 }
6998 }
6999
7000 Ok(out.trim_end().to_string())
7001}
7002
7003fn inspect_arp() -> Result<String, String> {
7006 let mut out = String::from("Host inspection: arp\n\n");
7007
7008 #[cfg(target_os = "windows")]
7009 {
7010 let output = Command::new("arp")
7011 .args(["-a"])
7012 .output()
7013 .map_err(|e| format!("arp: {e}"))?;
7014 let raw = String::from_utf8_lossy(&output.stdout);
7015 let mut count = 0usize;
7016 for line in raw.lines() {
7017 let t = line.trim();
7018 if t.is_empty() {
7019 continue;
7020 }
7021 out.push_str(&format!(" {t}\n"));
7022 if t.contains("dynamic") || t.contains("static") {
7023 count += 1;
7024 }
7025 }
7026 out.push_str(&format!("\nTotal entries: {count}\n"));
7027 }
7028
7029 #[cfg(not(target_os = "windows"))]
7030 {
7031 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7032 let raw = String::from_utf8_lossy(&o.stdout);
7033 let mut count = 0usize;
7034 for line in raw.lines() {
7035 let t = line.trim();
7036 if !t.is_empty() {
7037 out.push_str(&format!(" {t}\n"));
7038 count += 1;
7039 }
7040 }
7041 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7042 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7043 let raw = String::from_utf8_lossy(&o.stdout);
7044 let mut count = 0usize;
7045 for line in raw.lines() {
7046 let t = line.trim();
7047 if !t.is_empty() {
7048 out.push_str(&format!(" {t}\n"));
7049 count += 1;
7050 }
7051 }
7052 out.push_str(&format!("\nTotal entries: {count}\n"));
7053 } else {
7054 out.push_str("arp and ip neigh not available.\n");
7055 }
7056 }
7057
7058 Ok(out.trim_end().to_string())
7059}
7060
7061fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7064 let mut out = String::from("Host inspection: route_table\n\n");
7065 let n = max_entries.clamp(10, 50);
7066
7067 #[cfg(target_os = "windows")]
7068 {
7069 let script = r#"
7070try {
7071 $routes = Get-NetRoute -ErrorAction Stop |
7072 Where-Object { $_.RouteMetric -lt 9000 } |
7073 Sort-Object RouteMetric |
7074 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7075 "TOTAL:" + $routes.Count
7076 $routes | ForEach-Object {
7077 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7078 }
7079} catch { "ERROR:" + $_.Exception.Message }
7080"#;
7081 let output = Command::new("powershell")
7082 .args(["-NoProfile", "-Command", script])
7083 .output()
7084 .map_err(|e| format!("route_table: {e}"))?;
7085 let raw = String::from_utf8_lossy(&output.stdout);
7086 let text = raw.trim();
7087
7088 if text.starts_with("ERROR:") {
7089 out.push_str(&format!(
7090 "Unable to read route table: {}\n",
7091 text.trim_start_matches("ERROR:").trim()
7092 ));
7093 } else {
7094 let mut shown = 0usize;
7095 for line in text.lines() {
7096 if let Some(rest) = line.strip_prefix("TOTAL:") {
7097 let total: usize = rest.trim().parse().unwrap_or(0);
7098 out.push_str(&format!(
7099 "Routing table (showing up to {n} of {total} routes):\n\n"
7100 ));
7101 out.push_str(&format!(
7102 " {:<22} {:<18} {:>8} Interface\n",
7103 "Destination", "Next Hop", "Metric"
7104 ));
7105 out.push_str(&format!(" {}\n", "-".repeat(70)));
7106 } else if shown < n {
7107 let parts: Vec<&str> = line.splitn(4, '|').collect();
7108 if parts.len() == 4 {
7109 let dest = parts[0];
7110 let hop =
7111 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7112 "on-link"
7113 } else {
7114 parts[1]
7115 };
7116 let metric = parts[2];
7117 let iface = parts[3];
7118 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
7119 shown += 1;
7120 }
7121 }
7122 }
7123 }
7124 }
7125
7126 #[cfg(not(target_os = "windows"))]
7127 {
7128 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7129 let raw = String::from_utf8_lossy(&o.stdout);
7130 let lines: Vec<&str> = raw.lines().collect();
7131 let total = lines.len();
7132 out.push_str(&format!(
7133 "Routing table (showing up to {n} of {total} routes):\n\n"
7134 ));
7135 for line in lines.iter().take(n) {
7136 out.push_str(&format!(" {line}\n"));
7137 }
7138 if total > n {
7139 out.push_str(&format!("\n ... and {} more routes\n", total - n));
7140 }
7141 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7142 let raw = String::from_utf8_lossy(&o.stdout);
7143 for line in raw.lines().take(n) {
7144 out.push_str(&format!(" {line}\n"));
7145 }
7146 } else {
7147 out.push_str("ip route and netstat not available.\n");
7148 }
7149 }
7150
7151 Ok(out.trim_end().to_string())
7152}
7153
7154fn inspect_env(max_entries: usize) -> Result<String, String> {
7157 let mut out = String::from("Host inspection: env\n\n");
7158 let n = max_entries.clamp(10, 50);
7159
7160 fn looks_like_secret(name: &str) -> bool {
7161 let n = name.to_uppercase();
7162 n.contains("KEY")
7163 || n.contains("SECRET")
7164 || n.contains("TOKEN")
7165 || n.contains("PASSWORD")
7166 || n.contains("PASSWD")
7167 || n.contains("CREDENTIAL")
7168 || n.contains("AUTH")
7169 || n.contains("CERT")
7170 || n.contains("PRIVATE")
7171 }
7172
7173 let known_dev_vars: &[&str] = &[
7174 "CARGO_HOME",
7175 "RUSTUP_HOME",
7176 "GOPATH",
7177 "GOROOT",
7178 "GOBIN",
7179 "JAVA_HOME",
7180 "ANDROID_HOME",
7181 "ANDROID_SDK_ROOT",
7182 "PYTHONPATH",
7183 "PYTHONHOME",
7184 "VIRTUAL_ENV",
7185 "CONDA_DEFAULT_ENV",
7186 "CONDA_PREFIX",
7187 "NODE_PATH",
7188 "NVM_DIR",
7189 "NVM_BIN",
7190 "PNPM_HOME",
7191 "DENO_INSTALL",
7192 "DENO_DIR",
7193 "DOTNET_ROOT",
7194 "NUGET_PACKAGES",
7195 "CMAKE_HOME",
7196 "VCPKG_ROOT",
7197 "AWS_PROFILE",
7198 "AWS_REGION",
7199 "AWS_DEFAULT_REGION",
7200 "GCP_PROJECT",
7201 "GOOGLE_CLOUD_PROJECT",
7202 "GOOGLE_APPLICATION_CREDENTIALS",
7203 "AZURE_SUBSCRIPTION_ID",
7204 "DATABASE_URL",
7205 "REDIS_URL",
7206 "MONGO_URI",
7207 "EDITOR",
7208 "VISUAL",
7209 "SHELL",
7210 "TERM",
7211 "XDG_CONFIG_HOME",
7212 "XDG_DATA_HOME",
7213 "XDG_CACHE_HOME",
7214 "HOME",
7215 "USERPROFILE",
7216 "APPDATA",
7217 "LOCALAPPDATA",
7218 "TEMP",
7219 "TMP",
7220 "COMPUTERNAME",
7221 "USERNAME",
7222 "USERDOMAIN",
7223 "PROCESSOR_ARCHITECTURE",
7224 "NUMBER_OF_PROCESSORS",
7225 "OS",
7226 "HOMEDRIVE",
7227 "HOMEPATH",
7228 "HTTP_PROXY",
7229 "HTTPS_PROXY",
7230 "NO_PROXY",
7231 "ALL_PROXY",
7232 "http_proxy",
7233 "https_proxy",
7234 "no_proxy",
7235 "DOCKER_HOST",
7236 "DOCKER_BUILDKIT",
7237 "COMPOSE_PROJECT_NAME",
7238 "KUBECONFIG",
7239 "KUBE_CONTEXT",
7240 "CI",
7241 "GITHUB_ACTIONS",
7242 "GITLAB_CI",
7243 "LMSTUDIO_HOME",
7244 "HEMATITE_URL",
7245 ];
7246
7247 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7248 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7249 let total = all_vars.len();
7250
7251 let mut dev_found: Vec<String> = Vec::new();
7252 let mut secret_found: Vec<String> = Vec::new();
7253
7254 for (k, v) in &all_vars {
7255 if k == "PATH" {
7256 continue;
7257 }
7258 if looks_like_secret(k) {
7259 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7260 } else {
7261 let k_upper = k.to_uppercase();
7262 let is_known = known_dev_vars
7263 .iter()
7264 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7265 if is_known {
7266 let display = if v.len() > 120 {
7267 format!("{k} = {}…", &v[..117])
7268 } else {
7269 format!("{k} = {v}")
7270 };
7271 dev_found.push(display);
7272 }
7273 }
7274 }
7275
7276 out.push_str(&format!("Total environment variables: {total}\n\n"));
7277
7278 if let Ok(p) = std::env::var("PATH") {
7279 let sep = if cfg!(target_os = "windows") {
7280 ';'
7281 } else {
7282 ':'
7283 };
7284 let count = p.split(sep).count();
7285 out.push_str(&format!(
7286 "PATH: {count} entries (use topic=path for full audit)\n\n"
7287 ));
7288 }
7289
7290 if !secret_found.is_empty() {
7291 out.push_str(&format!(
7292 "=== Secret/credential variables ({} detected, values hidden) ===\n",
7293 secret_found.len()
7294 ));
7295 for s in secret_found.iter().take(n) {
7296 out.push_str(&format!(" {s}\n"));
7297 }
7298 out.push('\n');
7299 }
7300
7301 if !dev_found.is_empty() {
7302 out.push_str(&format!(
7303 "=== Developer & tool variables ({}) ===\n",
7304 dev_found.len()
7305 ));
7306 for d in dev_found.iter().take(n) {
7307 out.push_str(&format!(" {d}\n"));
7308 }
7309 out.push('\n');
7310 }
7311
7312 let other_count = all_vars
7313 .iter()
7314 .filter(|(k, _)| {
7315 k != "PATH"
7316 && !looks_like_secret(k)
7317 && !known_dev_vars
7318 .iter()
7319 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7320 })
7321 .count();
7322 if other_count > 0 {
7323 out.push_str(&format!(
7324 "Other variables: {other_count} (use 'env' in shell to see all)\n"
7325 ));
7326 }
7327
7328 Ok(out.trim_end().to_string())
7329}
7330
7331fn inspect_hosts_file() -> Result<String, String> {
7334 let mut out = String::from("Host inspection: hosts_file\n\n");
7335
7336 let hosts_path = if cfg!(target_os = "windows") {
7337 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7338 } else {
7339 std::path::PathBuf::from("/etc/hosts")
7340 };
7341
7342 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7343
7344 match fs::read_to_string(&hosts_path) {
7345 Ok(content) => {
7346 let mut active_entries: Vec<String> = Vec::new();
7347 let mut comment_lines = 0usize;
7348 let mut blank_lines = 0usize;
7349
7350 for line in content.lines() {
7351 let t = line.trim();
7352 if t.is_empty() {
7353 blank_lines += 1;
7354 } else if t.starts_with('#') {
7355 comment_lines += 1;
7356 } else {
7357 active_entries.push(line.to_string());
7358 }
7359 }
7360
7361 out.push_str(&format!(
7362 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7363 active_entries.len(),
7364 comment_lines,
7365 blank_lines
7366 ));
7367
7368 if active_entries.is_empty() {
7369 out.push_str(
7370 "No active host entries (file contains only comments/blanks — standard default state).\n",
7371 );
7372 } else {
7373 out.push_str("=== Active entries ===\n");
7374 for entry in &active_entries {
7375 out.push_str(&format!(" {entry}\n"));
7376 }
7377 out.push('\n');
7378
7379 let custom: Vec<&String> = active_entries
7380 .iter()
7381 .filter(|e| {
7382 let t = e.trim_start();
7383 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7384 })
7385 .collect();
7386 if !custom.is_empty() {
7387 out.push_str(&format!(
7388 "[!] Custom (non-loopback) entries: {}\n",
7389 custom.len()
7390 ));
7391 for e in &custom {
7392 out.push_str(&format!(" {e}\n"));
7393 }
7394 } else {
7395 out.push_str("All active entries are standard loopback or block entries.\n");
7396 }
7397 }
7398
7399 out.push_str("\n=== Full file ===\n");
7400 for line in content.lines() {
7401 out.push_str(&format!(" {line}\n"));
7402 }
7403 }
7404 Err(e) => {
7405 out.push_str(&format!("Could not read hosts file: {e}\n"));
7406 if cfg!(target_os = "windows") {
7407 out.push_str(
7408 "On Windows, run Hematite as Administrator if permission is denied.\n",
7409 );
7410 }
7411 }
7412 }
7413
7414 Ok(out.trim_end().to_string())
7415}
7416
7417struct AuditFinding {
7420 finding: String,
7421 impact: String,
7422 fix: String,
7423}
7424
7425#[cfg(target_os = "windows")]
7426#[derive(Debug, Clone)]
7427struct WindowsPnpDevice {
7428 name: String,
7429 status: String,
7430 problem: Option<u64>,
7431 class_name: Option<String>,
7432 instance_id: Option<String>,
7433}
7434
7435#[cfg(target_os = "windows")]
7436#[derive(Debug, Clone)]
7437struct WindowsSoundDevice {
7438 name: String,
7439 status: String,
7440 manufacturer: Option<String>,
7441}
7442
7443struct DockerMountAudit {
7444 mount_type: String,
7445 source: Option<String>,
7446 destination: String,
7447 name: Option<String>,
7448 read_write: Option<bool>,
7449 driver: Option<String>,
7450 exists_on_host: Option<bool>,
7451}
7452
7453struct DockerContainerAudit {
7454 name: String,
7455 image: String,
7456 status: String,
7457 mounts: Vec<DockerMountAudit>,
7458}
7459
7460struct DockerVolumeAudit {
7461 name: String,
7462 driver: String,
7463 mountpoint: Option<String>,
7464 scope: Option<String>,
7465}
7466
7467#[cfg(target_os = "windows")]
7468struct WslDistroAudit {
7469 name: String,
7470 state: String,
7471 version: String,
7472}
7473
7474#[cfg(target_os = "windows")]
7475struct WslRootUsage {
7476 total_kb: u64,
7477 used_kb: u64,
7478 avail_kb: u64,
7479 use_percent: String,
7480 mnt_c_present: Option<bool>,
7481}
7482
7483fn docker_engine_version() -> Result<String, String> {
7484 let version_output = Command::new("docker")
7485 .args(["version", "--format", "{{.Server.Version}}"])
7486 .output();
7487
7488 match version_output {
7489 Err(_) => Err(
7490 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7491 ),
7492 Ok(o) if !o.status.success() => {
7493 let stderr = String::from_utf8_lossy(&o.stderr);
7494 if stderr.contains("cannot connect")
7495 || stderr.contains("Is the docker daemon running")
7496 || stderr.contains("pipe")
7497 || stderr.contains("socket")
7498 {
7499 Err(
7500 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7501 )
7502 } else {
7503 Err(format!("Docker: error - {}", stderr.trim()))
7504 }
7505 }
7506 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7507 }
7508}
7509
7510fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7511 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7512 return Vec::new();
7513 };
7514 let Value::Array(entries) = value else {
7515 return Vec::new();
7516 };
7517
7518 let mut mounts = Vec::new();
7519 for entry in entries {
7520 let mount_type = entry
7521 .get("Type")
7522 .and_then(|v| v.as_str())
7523 .unwrap_or("unknown")
7524 .to_string();
7525 let source = entry
7526 .get("Source")
7527 .and_then(|v| v.as_str())
7528 .map(|v| v.to_string());
7529 let destination = entry
7530 .get("Destination")
7531 .and_then(|v| v.as_str())
7532 .unwrap_or("?")
7533 .to_string();
7534 let name = entry
7535 .get("Name")
7536 .and_then(|v| v.as_str())
7537 .map(|v| v.to_string());
7538 let read_write = entry.get("RW").and_then(|v| v.as_bool());
7539 let driver = entry
7540 .get("Driver")
7541 .and_then(|v| v.as_str())
7542 .map(|v| v.to_string());
7543 let exists_on_host = if mount_type == "bind" {
7544 source.as_deref().map(|path| Path::new(path).exists())
7545 } else {
7546 None
7547 };
7548 mounts.push(DockerMountAudit {
7549 mount_type,
7550 source,
7551 destination,
7552 name,
7553 read_write,
7554 driver,
7555 exists_on_host,
7556 });
7557 }
7558
7559 mounts
7560}
7561
7562fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7563 let mut audit = DockerVolumeAudit {
7564 name: name.to_string(),
7565 driver: "unknown".to_string(),
7566 mountpoint: None,
7567 scope: None,
7568 };
7569
7570 if let Ok(output) = Command::new("docker")
7571 .args(["volume", "inspect", name, "--format", "{{json .}}"])
7572 .output()
7573 {
7574 if output.status.success() {
7575 if let Ok(value) =
7576 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7577 {
7578 audit.driver = value
7579 .get("Driver")
7580 .and_then(|v| v.as_str())
7581 .unwrap_or("unknown")
7582 .to_string();
7583 audit.mountpoint = value
7584 .get("Mountpoint")
7585 .and_then(|v| v.as_str())
7586 .map(|v| v.to_string());
7587 audit.scope = value
7588 .get("Scope")
7589 .and_then(|v| v.as_str())
7590 .map(|v| v.to_string());
7591 }
7592 }
7593 }
7594
7595 audit
7596}
7597
7598#[cfg(target_os = "windows")]
7599fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7600 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7601 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7602 let path = local_app_data
7603 .join("Docker")
7604 .join("wsl")
7605 .join("disk")
7606 .join(file_name);
7607 if let Ok(metadata) = fs::metadata(&path) {
7608 return Some((path, metadata.len()));
7609 }
7610 }
7611 None
7612}
7613
7614#[cfg(target_os = "windows")]
7615fn clean_wsl_text(raw: &[u8]) -> String {
7616 String::from_utf8_lossy(raw)
7617 .chars()
7618 .filter(|c| *c != '\0')
7619 .collect()
7620}
7621
7622#[cfg(target_os = "windows")]
7623fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7624 let mut distros = Vec::new();
7625 for line in raw.lines() {
7626 let trimmed = line.trim();
7627 if trimmed.is_empty()
7628 || trimmed.to_uppercase().starts_with("NAME")
7629 || trimmed.starts_with("---")
7630 {
7631 continue;
7632 }
7633 let normalized = trimmed.trim_start_matches('*').trim();
7634 let cols: Vec<&str> = normalized.split_whitespace().collect();
7635 if cols.len() < 3 {
7636 continue;
7637 }
7638 let version = cols[cols.len() - 1].to_string();
7639 let state = cols[cols.len() - 2].to_string();
7640 let name = cols[..cols.len() - 2].join(" ");
7641 if !name.is_empty() {
7642 distros.push(WslDistroAudit {
7643 name,
7644 state,
7645 version,
7646 });
7647 }
7648 }
7649 distros
7650}
7651
7652#[cfg(target_os = "windows")]
7653fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7654 let output = Command::new("wsl")
7655 .args([
7656 "-d",
7657 distro_name,
7658 "--",
7659 "sh",
7660 "-lc",
7661 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7662 ])
7663 .output()
7664 .ok()?;
7665 if !output.status.success() {
7666 return None;
7667 }
7668
7669 let text = clean_wsl_text(&output.stdout);
7670 let mut total_kb = 0;
7671 let mut used_kb = 0;
7672 let mut avail_kb = 0;
7673 let mut use_percent = String::from("unknown");
7674 let mut mnt_c_present = None;
7675
7676 for line in text.lines() {
7677 let trimmed = line.trim();
7678 if trimmed.starts_with("__MNTC__:") {
7679 mnt_c_present = Some(trimmed.ends_with("ok"));
7680 continue;
7681 }
7682 let cols: Vec<&str> = trimmed.split_whitespace().collect();
7683 if cols.len() >= 6 {
7684 total_kb = cols[1].parse::<u64>().unwrap_or(0);
7685 used_kb = cols[2].parse::<u64>().unwrap_or(0);
7686 avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7687 use_percent = cols[4].to_string();
7688 }
7689 }
7690
7691 Some(WslRootUsage {
7692 total_kb,
7693 used_kb,
7694 avail_kb,
7695 use_percent,
7696 mnt_c_present,
7697 })
7698}
7699
7700#[cfg(target_os = "windows")]
7701fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7702 let mut vhds = Vec::new();
7703 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7704 return vhds;
7705 };
7706 let packages_dir = local_app_data.join("Packages");
7707 let Ok(entries) = fs::read_dir(packages_dir) else {
7708 return vhds;
7709 };
7710
7711 for entry in entries.flatten() {
7712 let path = entry.path().join("LocalState").join("ext4.vhdx");
7713 if let Ok(metadata) = fs::metadata(&path) {
7714 vhds.push((path, metadata.len()));
7715 }
7716 }
7717 vhds.sort_by(|a, b| b.1.cmp(&a.1));
7718 vhds
7719}
7720
7721fn inspect_docker(max_entries: usize) -> Result<String, String> {
7722 let mut out = String::from("Host inspection: docker\n\n");
7723 let n = max_entries.clamp(5, 25);
7724
7725 let version_output = Command::new("docker")
7726 .args(["version", "--format", "{{.Server.Version}}"])
7727 .output();
7728
7729 match version_output {
7730 Err(_) => {
7731 out.push_str("Docker: not found on PATH.\n");
7732 out.push_str(
7733 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
7734 );
7735 return Ok(out.trim_end().to_string());
7736 }
7737 Ok(o) if !o.status.success() => {
7738 let stderr = String::from_utf8_lossy(&o.stderr);
7739 if stderr.contains("cannot connect")
7740 || stderr.contains("Is the docker daemon running")
7741 || stderr.contains("pipe")
7742 || stderr.contains("socket")
7743 {
7744 out.push_str("Docker: installed but daemon is NOT running.\n");
7745 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
7746 } else {
7747 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
7748 }
7749 return Ok(out.trim_end().to_string());
7750 }
7751 Ok(o) => {
7752 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
7753 out.push_str(&format!("Docker Engine: {version}\n"));
7754 }
7755 }
7756
7757 if let Ok(o) = Command::new("docker")
7758 .args([
7759 "info",
7760 "--format",
7761 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
7762 ])
7763 .output()
7764 {
7765 let info = String::from_utf8_lossy(&o.stdout);
7766 for line in info.lines() {
7767 let t = line.trim();
7768 if !t.is_empty() {
7769 out.push_str(&format!(" {t}\n"));
7770 }
7771 }
7772 out.push('\n');
7773 }
7774
7775 if let Ok(o) = Command::new("docker")
7776 .args([
7777 "ps",
7778 "--format",
7779 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
7780 ])
7781 .output()
7782 {
7783 let raw = String::from_utf8_lossy(&o.stdout);
7784 let lines: Vec<&str> = raw.lines().collect();
7785 if lines.len() <= 1 {
7786 out.push_str("Running containers: none\n\n");
7787 } else {
7788 out.push_str(&format!(
7789 "=== Running containers ({}) ===\n",
7790 lines.len().saturating_sub(1)
7791 ));
7792 for line in lines.iter().take(n + 1) {
7793 out.push_str(&format!(" {line}\n"));
7794 }
7795 if lines.len() > n + 1 {
7796 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
7797 }
7798 out.push('\n');
7799 }
7800 }
7801
7802 if let Ok(o) = Command::new("docker")
7803 .args([
7804 "images",
7805 "--format",
7806 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
7807 ])
7808 .output()
7809 {
7810 let raw = String::from_utf8_lossy(&o.stdout);
7811 let lines: Vec<&str> = raw.lines().collect();
7812 if lines.len() > 1 {
7813 out.push_str(&format!(
7814 "=== Local images ({}) ===\n",
7815 lines.len().saturating_sub(1)
7816 ));
7817 for line in lines.iter().take(n + 1) {
7818 out.push_str(&format!(" {line}\n"));
7819 }
7820 if lines.len() > n + 1 {
7821 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
7822 }
7823 out.push('\n');
7824 }
7825 }
7826
7827 if let Ok(o) = Command::new("docker")
7828 .args([
7829 "compose",
7830 "ls",
7831 "--format",
7832 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
7833 ])
7834 .output()
7835 {
7836 let raw = String::from_utf8_lossy(&o.stdout);
7837 let lines: Vec<&str> = raw.lines().collect();
7838 if lines.len() > 1 {
7839 out.push_str(&format!(
7840 "=== Compose projects ({}) ===\n",
7841 lines.len().saturating_sub(1)
7842 ));
7843 for line in lines.iter().take(n + 1) {
7844 out.push_str(&format!(" {line}\n"));
7845 }
7846 out.push('\n');
7847 }
7848 }
7849
7850 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7851 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7852 if !ctx.is_empty() {
7853 out.push_str(&format!("Active context: {ctx}\n"));
7854 }
7855 }
7856
7857 Ok(out.trim_end().to_string())
7858}
7859
7860fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
7863 let mut out = String::from("Host inspection: docker_filesystems\n\n");
7864 let n = max_entries.clamp(3, 12);
7865
7866 match docker_engine_version() {
7867 Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
7868 Err(message) => {
7869 out.push_str(&message);
7870 return Ok(out.trim_end().to_string());
7871 }
7872 }
7873
7874 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7875 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7876 if !ctx.is_empty() {
7877 out.push_str(&format!("Active context: {ctx}\n"));
7878 }
7879 }
7880 out.push('\n');
7881
7882 let mut containers = Vec::new();
7883 if let Ok(o) = Command::new("docker")
7884 .args([
7885 "ps",
7886 "-a",
7887 "--format",
7888 "{{.Names}}\t{{.Image}}\t{{.Status}}",
7889 ])
7890 .output()
7891 {
7892 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7893 let cols: Vec<&str> = line.split('\t').collect();
7894 if cols.len() < 3 {
7895 continue;
7896 }
7897 let name = cols[0].trim().to_string();
7898 if name.is_empty() {
7899 continue;
7900 }
7901 let inspect_output = Command::new("docker")
7902 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
7903 .output();
7904 let mounts = match inspect_output {
7905 Ok(result) if result.status.success() => {
7906 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
7907 }
7908 _ => Vec::new(),
7909 };
7910 containers.push(DockerContainerAudit {
7911 name,
7912 image: cols[1].trim().to_string(),
7913 status: cols[2].trim().to_string(),
7914 mounts,
7915 });
7916 }
7917 }
7918
7919 let mut volumes = Vec::new();
7920 if let Ok(o) = Command::new("docker")
7921 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
7922 .output()
7923 {
7924 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7925 let cols: Vec<&str> = line.split('\t').collect();
7926 let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
7927 continue;
7928 };
7929 let mut audit = inspect_docker_volume(name);
7930 if audit.driver == "unknown" {
7931 audit.driver = cols
7932 .get(1)
7933 .map(|v| v.trim())
7934 .filter(|v| !v.is_empty())
7935 .unwrap_or("unknown")
7936 .to_string();
7937 }
7938 volumes.push(audit);
7939 }
7940 }
7941
7942 let mut findings = Vec::new();
7943 for container in &containers {
7944 for mount in &container.mounts {
7945 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7946 let source = mount.source.as_deref().unwrap_or("<unknown>");
7947 findings.push(AuditFinding {
7948 finding: format!(
7949 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
7950 container.name, source, mount.destination
7951 ),
7952 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
7953 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
7954 });
7955 }
7956 }
7957 }
7958
7959 #[cfg(target_os = "windows")]
7960 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7961 if size_bytes >= 20 * 1024 * 1024 * 1024 {
7962 findings.push(AuditFinding {
7963 finding: format!(
7964 "Docker Desktop disk image is large: {} at {}",
7965 human_bytes(size_bytes),
7966 path.display()
7967 ),
7968 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
7969 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(),
7970 });
7971 }
7972 }
7973
7974 out.push_str("=== Findings ===\n");
7975 if findings.is_empty() {
7976 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
7977 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
7978 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
7979 } else {
7980 for finding in &findings {
7981 out.push_str(&format!("- Finding: {}\n", finding.finding));
7982 out.push_str(&format!(" Impact: {}\n", finding.impact));
7983 out.push_str(&format!(" Fix: {}\n", finding.fix));
7984 }
7985 }
7986
7987 out.push_str("\n=== Container mount summary ===\n");
7988 if containers.is_empty() {
7989 out.push_str("- No containers found.\n");
7990 } else {
7991 for container in &containers {
7992 out.push_str(&format!(
7993 "- {} ({}) [{}]\n",
7994 container.name, container.image, container.status
7995 ));
7996 if container.mounts.is_empty() {
7997 out.push_str(" - no mounts reported\n");
7998 continue;
7999 }
8000 for mount in &container.mounts {
8001 let mut source = mount
8002 .name
8003 .clone()
8004 .or_else(|| mount.source.clone())
8005 .unwrap_or_else(|| "<unknown>".to_string());
8006 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8007 source.push_str(" [missing]");
8008 }
8009 let mut extras = Vec::new();
8010 if let Some(rw) = mount.read_write {
8011 extras.push(if rw { "rw" } else { "ro" }.to_string());
8012 }
8013 if let Some(driver) = &mount.driver {
8014 extras.push(format!("driver={driver}"));
8015 }
8016 let extra_suffix = if extras.is_empty() {
8017 String::new()
8018 } else {
8019 format!(" ({})", extras.join(", "))
8020 };
8021 out.push_str(&format!(
8022 " - {}: {} -> {}{}\n",
8023 mount.mount_type, source, mount.destination, extra_suffix
8024 ));
8025 }
8026 }
8027 }
8028
8029 out.push_str("\n=== Named volumes ===\n");
8030 if volumes.is_empty() {
8031 out.push_str("- No named volumes found.\n");
8032 } else {
8033 for volume in &volumes {
8034 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8035 if let Some(scope) = &volume.scope {
8036 detail.push_str(&format!(", scope: {scope}"));
8037 }
8038 if let Some(mountpoint) = &volume.mountpoint {
8039 detail.push_str(&format!(", mountpoint: {mountpoint}"));
8040 }
8041 out.push_str(&format!("{detail}\n"));
8042 }
8043 }
8044
8045 #[cfg(target_os = "windows")]
8046 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8047 out.push_str("\n=== Docker Desktop disk ===\n");
8048 out.push_str(&format!(
8049 "- {} at {}\n",
8050 human_bytes(size_bytes),
8051 path.display()
8052 ));
8053 }
8054
8055 Ok(out.trim_end().to_string())
8056}
8057
8058fn inspect_wsl() -> Result<String, String> {
8059 let mut out = String::from("Host inspection: wsl\n\n");
8060
8061 #[cfg(target_os = "windows")]
8062 {
8063 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8064 let raw = String::from_utf8_lossy(&o.stdout);
8065 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8066 for line in cleaned.lines().take(4) {
8067 let t = line.trim();
8068 if !t.is_empty() {
8069 out.push_str(&format!(" {t}\n"));
8070 }
8071 }
8072 out.push('\n');
8073 }
8074
8075 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8076 match list_output {
8077 Err(e) => {
8078 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8079 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8080 }
8081 Ok(o) if !o.status.success() => {
8082 let stderr = String::from_utf8_lossy(&o.stderr);
8083 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8084 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8085 out.push_str("Run: wsl --install\n");
8086 }
8087 Ok(o) => {
8088 let raw = String::from_utf8_lossy(&o.stdout);
8089 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8090 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8091 let distro_lines: Vec<&str> = lines
8092 .iter()
8093 .filter(|l| {
8094 let t = l.trim();
8095 !t.is_empty()
8096 && !t.to_uppercase().starts_with("NAME")
8097 && !t.starts_with("---")
8098 })
8099 .copied()
8100 .collect();
8101
8102 if distro_lines.is_empty() {
8103 out.push_str("WSL: installed but no distributions found.\n");
8104 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8105 } else {
8106 out.push_str("=== WSL Distributions ===\n");
8107 for line in &lines {
8108 out.push_str(&format!(" {}\n", line.trim()));
8109 }
8110 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8111 }
8112 }
8113 }
8114
8115 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8116 let raw = String::from_utf8_lossy(&o.stdout);
8117 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8118 let status_lines: Vec<&str> = cleaned
8119 .lines()
8120 .filter(|l| !l.trim().is_empty())
8121 .take(8)
8122 .collect();
8123 if !status_lines.is_empty() {
8124 out.push_str("\n=== WSL status ===\n");
8125 for line in status_lines {
8126 out.push_str(&format!(" {}\n", line.trim()));
8127 }
8128 }
8129 }
8130 }
8131
8132 #[cfg(not(target_os = "windows"))]
8133 {
8134 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8135 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8136 }
8137
8138 Ok(out.trim_end().to_string())
8139}
8140
8141fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8144 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8145
8146 #[cfg(target_os = "windows")]
8147 {
8148 let n = max_entries.clamp(3, 12);
8149 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8150 let distros = match list_output {
8151 Err(e) => {
8152 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8153 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8154 return Ok(out.trim_end().to_string());
8155 }
8156 Ok(o) if !o.status.success() => {
8157 let cleaned = clean_wsl_text(&o.stderr);
8158 out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8159 out.push_str("Run: wsl --install\n");
8160 return Ok(out.trim_end().to_string());
8161 }
8162 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8163 };
8164
8165 out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8166
8167 let vhdx_files = collect_wsl_vhdx_files();
8168 let mut findings = Vec::new();
8169 let mut live_usage = Vec::new();
8170
8171 for distro in distros.iter().take(n) {
8172 if distro.state.eq_ignore_ascii_case("Running") {
8173 if let Some(usage) = wsl_root_usage(&distro.name) {
8174 if let Some(false) = usage.mnt_c_present {
8175 findings.push(AuditFinding {
8176 finding: format!(
8177 "Distro '{}' is running without /mnt/c available",
8178 distro.name
8179 ),
8180 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8181 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8182 });
8183 }
8184
8185 let percent_num = usage
8186 .use_percent
8187 .trim_end_matches('%')
8188 .parse::<u32>()
8189 .unwrap_or(0);
8190 if percent_num >= 85 {
8191 findings.push(AuditFinding {
8192 finding: format!(
8193 "Distro '{}' root filesystem is {} full",
8194 distro.name, usage.use_percent
8195 ),
8196 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8197 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8198 });
8199 }
8200 live_usage.push((distro.name.clone(), usage));
8201 }
8202 }
8203 }
8204
8205 for (path, size_bytes) in vhdx_files.iter().take(n) {
8206 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8207 findings.push(AuditFinding {
8208 finding: format!(
8209 "Host-side WSL disk image is large: {} at {}",
8210 human_bytes(*size_bytes),
8211 path.display()
8212 ),
8213 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8214 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8215 });
8216 }
8217 }
8218
8219 out.push_str("=== Findings ===\n");
8220 if findings.is_empty() {
8221 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8222 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8223 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8224 } else {
8225 for finding in &findings {
8226 out.push_str(&format!("- Finding: {}\n", finding.finding));
8227 out.push_str(&format!(" Impact: {}\n", finding.impact));
8228 out.push_str(&format!(" Fix: {}\n", finding.fix));
8229 }
8230 }
8231
8232 out.push_str("\n=== Distro bridge and root usage ===\n");
8233 if distros.is_empty() {
8234 out.push_str("- No WSL distributions found.\n");
8235 } else {
8236 for distro in distros.iter().take(n) {
8237 out.push_str(&format!(
8238 "- {} [state: {}, version: {}]\n",
8239 distro.name, distro.state, distro.version
8240 ));
8241 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8242 out.push_str(&format!(
8243 " - rootfs: {} used / {} total ({}), free: {}\n",
8244 human_bytes(usage.used_kb * 1024),
8245 human_bytes(usage.total_kb * 1024),
8246 usage.use_percent,
8247 human_bytes(usage.avail_kb * 1024)
8248 ));
8249 match usage.mnt_c_present {
8250 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8251 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8252 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8253 }
8254 } else if distro.state.eq_ignore_ascii_case("Running") {
8255 out.push_str(" - live rootfs check: unavailable\n");
8256 } else {
8257 out.push_str(
8258 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8259 );
8260 }
8261 }
8262 }
8263
8264 out.push_str("\n=== Host-side VHDX files ===\n");
8265 if vhdx_files.is_empty() {
8266 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8267 } else {
8268 for (path, size_bytes) in vhdx_files.iter().take(n) {
8269 out.push_str(&format!(
8270 "- {} at {}\n",
8271 human_bytes(*size_bytes),
8272 path.display()
8273 ));
8274 }
8275 }
8276 }
8277
8278 #[cfg(not(target_os = "windows"))]
8279 {
8280 let _ = max_entries;
8281 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8282 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8283 }
8284
8285 Ok(out.trim_end().to_string())
8286}
8287
8288fn dirs_home() -> Option<PathBuf> {
8289 std::env::var("HOME")
8290 .ok()
8291 .map(PathBuf::from)
8292 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8293}
8294
8295fn inspect_ssh() -> Result<String, String> {
8296 let mut out = String::from("Host inspection: ssh\n\n");
8297
8298 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8299 let ver = if o.stdout.is_empty() {
8300 String::from_utf8_lossy(&o.stderr).trim().to_string()
8301 } else {
8302 String::from_utf8_lossy(&o.stdout).trim().to_string()
8303 };
8304 if !ver.is_empty() {
8305 out.push_str(&format!("SSH client: {ver}\n"));
8306 }
8307 } else {
8308 out.push_str("SSH client: not found on PATH.\n");
8309 }
8310
8311 #[cfg(target_os = "windows")]
8312 {
8313 let script = r#"
8314$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8315if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8316else { "SSHD:not_installed" }
8317"#;
8318 if let Ok(o) = Command::new("powershell")
8319 .args(["-NoProfile", "-Command", script])
8320 .output()
8321 {
8322 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8323 if text.contains("not_installed") {
8324 out.push_str("SSH server (sshd): not installed\n");
8325 } else {
8326 out.push_str(&format!(
8327 "SSH server (sshd): {}\n",
8328 text.trim_start_matches("SSHD:")
8329 ));
8330 }
8331 }
8332 }
8333
8334 #[cfg(not(target_os = "windows"))]
8335 {
8336 if let Ok(o) = Command::new("systemctl")
8337 .args(["is-active", "sshd"])
8338 .output()
8339 {
8340 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8341 out.push_str(&format!("SSH server (sshd): {status}\n"));
8342 } else if let Ok(o) = Command::new("systemctl")
8343 .args(["is-active", "ssh"])
8344 .output()
8345 {
8346 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8347 out.push_str(&format!("SSH server (ssh): {status}\n"));
8348 }
8349 }
8350
8351 out.push('\n');
8352
8353 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8354 if ssh_dir.exists() {
8355 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8356
8357 let kh = ssh_dir.join("known_hosts");
8358 if kh.exists() {
8359 let count = fs::read_to_string(&kh)
8360 .map(|c| {
8361 c.lines()
8362 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8363 .count()
8364 })
8365 .unwrap_or(0);
8366 out.push_str(&format!(" known_hosts: {count} entries\n"));
8367 } else {
8368 out.push_str(" known_hosts: not present\n");
8369 }
8370
8371 let ak = ssh_dir.join("authorized_keys");
8372 if ak.exists() {
8373 let count = fs::read_to_string(&ak)
8374 .map(|c| {
8375 c.lines()
8376 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8377 .count()
8378 })
8379 .unwrap_or(0);
8380 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
8381 } else {
8382 out.push_str(" authorized_keys: not present\n");
8383 }
8384
8385 let key_names = [
8386 "id_rsa",
8387 "id_ed25519",
8388 "id_ecdsa",
8389 "id_dsa",
8390 "id_ecdsa_sk",
8391 "id_ed25519_sk",
8392 ];
8393 let found_keys: Vec<&str> = key_names
8394 .iter()
8395 .filter(|k| ssh_dir.join(k).exists())
8396 .copied()
8397 .collect();
8398 if !found_keys.is_empty() {
8399 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
8400 } else {
8401 out.push_str(" Private keys: none found\n");
8402 }
8403
8404 let config_path = ssh_dir.join("config");
8405 if config_path.exists() {
8406 out.push_str("\n=== SSH config hosts ===\n");
8407 match fs::read_to_string(&config_path) {
8408 Ok(content) => {
8409 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8410 let mut current: Option<(String, Vec<String>)> = None;
8411 for line in content.lines() {
8412 let t = line.trim();
8413 if t.is_empty() || t.starts_with('#') {
8414 continue;
8415 }
8416 if let Some(host) = t.strip_prefix("Host ") {
8417 if let Some(prev) = current.take() {
8418 hosts.push(prev);
8419 }
8420 current = Some((host.trim().to_string(), Vec::new()));
8421 } else if let Some((_, ref mut details)) = current {
8422 let tu = t.to_uppercase();
8423 if tu.starts_with("HOSTNAME ")
8424 || tu.starts_with("USER ")
8425 || tu.starts_with("PORT ")
8426 || tu.starts_with("IDENTITYFILE ")
8427 {
8428 details.push(t.to_string());
8429 }
8430 }
8431 }
8432 if let Some(prev) = current {
8433 hosts.push(prev);
8434 }
8435
8436 if hosts.is_empty() {
8437 out.push_str(" No Host entries found.\n");
8438 } else {
8439 for (h, details) in &hosts {
8440 if details.is_empty() {
8441 out.push_str(&format!(" Host {h}\n"));
8442 } else {
8443 out.push_str(&format!(
8444 " Host {h} [{}]\n",
8445 details.join(", ")
8446 ));
8447 }
8448 }
8449 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
8450 }
8451 }
8452 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
8453 }
8454 } else {
8455 out.push_str(" SSH config: not present\n");
8456 }
8457 } else {
8458 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8459 }
8460 }
8461
8462 Ok(out.trim_end().to_string())
8463}
8464
8465fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8468 let mut out = String::from("Host inspection: installed_software\n\n");
8469 let n = max_entries.clamp(10, 50);
8470
8471 #[cfg(target_os = "windows")]
8472 {
8473 let winget_out = Command::new("winget")
8474 .args(["list", "--accept-source-agreements"])
8475 .output();
8476
8477 if let Ok(o) = winget_out {
8478 if o.status.success() {
8479 let raw = String::from_utf8_lossy(&o.stdout);
8480 let mut header_done = false;
8481 let mut packages: Vec<&str> = Vec::new();
8482 for line in raw.lines() {
8483 let t = line.trim();
8484 if t.starts_with("---") {
8485 header_done = true;
8486 continue;
8487 }
8488 if header_done && !t.is_empty() {
8489 packages.push(line);
8490 }
8491 }
8492 let total = packages.len();
8493 out.push_str(&format!(
8494 "=== Installed software via winget ({total} packages) ===\n\n"
8495 ));
8496 for line in packages.iter().take(n) {
8497 out.push_str(&format!(" {line}\n"));
8498 }
8499 if total > n {
8500 out.push_str(&format!("\n ... and {} more packages\n", total - n));
8501 }
8502 out.push_str("\nFor full list: winget list\n");
8503 return Ok(out.trim_end().to_string());
8504 }
8505 }
8506
8507 let script = format!(
8509 r#"
8510$apps = @()
8511$reg_paths = @(
8512 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8513 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8514 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8515)
8516foreach ($p in $reg_paths) {{
8517 try {{
8518 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8519 Where-Object {{ $_.DisplayName }} |
8520 Select-Object DisplayName, DisplayVersion, Publisher
8521 }} catch {{}}
8522}}
8523$sorted = $apps | Sort-Object DisplayName -Unique
8524"TOTAL:" + $sorted.Count
8525$sorted | Select-Object -First {n} | ForEach-Object {{
8526 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8527}}
8528"#
8529 );
8530 if let Ok(o) = Command::new("powershell")
8531 .args(["-NoProfile", "-Command", &script])
8532 .output()
8533 {
8534 let raw = String::from_utf8_lossy(&o.stdout);
8535 out.push_str("=== Installed software (registry scan) ===\n");
8536 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
8537 out.push_str(&format!(" {}\n", "-".repeat(90)));
8538 for line in raw.lines() {
8539 if let Some(rest) = line.strip_prefix("TOTAL:") {
8540 let total: usize = rest.trim().parse().unwrap_or(0);
8541 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
8542 } else if !line.trim().is_empty() {
8543 let parts: Vec<&str> = line.splitn(3, '|').collect();
8544 let name = parts.first().map(|s| s.trim()).unwrap_or("");
8545 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8546 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8547 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
8548 }
8549 }
8550 } else {
8551 out.push_str(
8552 "Could not query installed software (winget and registry scan both failed).\n",
8553 );
8554 }
8555 }
8556
8557 #[cfg(target_os = "linux")]
8558 {
8559 let mut found = false;
8560 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8561 if o.status.success() {
8562 let raw = String::from_utf8_lossy(&o.stdout);
8563 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8564 let total = installed.len();
8565 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8566 for line in installed.iter().take(n) {
8567 out.push_str(&format!(" {}\n", line.trim()));
8568 }
8569 if total > n {
8570 out.push_str(&format!(" ... and {} more\n", total - n));
8571 }
8572 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8573 found = true;
8574 }
8575 }
8576 if !found {
8577 if let Ok(o) = Command::new("rpm")
8578 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8579 .output()
8580 {
8581 if o.status.success() {
8582 let raw = String::from_utf8_lossy(&o.stdout);
8583 let lines: Vec<&str> = raw.lines().collect();
8584 let total = lines.len();
8585 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8586 for line in lines.iter().take(n) {
8587 out.push_str(&format!(" {line}\n"));
8588 }
8589 if total > n {
8590 out.push_str(&format!(" ... and {} more\n", total - n));
8591 }
8592 found = true;
8593 }
8594 }
8595 }
8596 if !found {
8597 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8598 if o.status.success() {
8599 let raw = String::from_utf8_lossy(&o.stdout);
8600 let lines: Vec<&str> = raw.lines().collect();
8601 let total = lines.len();
8602 out.push_str(&format!(
8603 "=== Installed packages via pacman ({total}) ===\n"
8604 ));
8605 for line in lines.iter().take(n) {
8606 out.push_str(&format!(" {line}\n"));
8607 }
8608 if total > n {
8609 out.push_str(&format!(" ... and {} more\n", total - n));
8610 }
8611 found = true;
8612 }
8613 }
8614 }
8615 if !found {
8616 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8617 }
8618 }
8619
8620 #[cfg(target_os = "macos")]
8621 {
8622 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8623 if o.status.success() {
8624 let raw = String::from_utf8_lossy(&o.stdout);
8625 let lines: Vec<&str> = raw.lines().collect();
8626 let total = lines.len();
8627 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8628 for line in lines.iter().take(n) {
8629 out.push_str(&format!(" {line}\n"));
8630 }
8631 if total > n {
8632 out.push_str(&format!(" ... and {} more\n", total - n));
8633 }
8634 out.push_str("\nFor full list: brew list --versions\n");
8635 }
8636 } else {
8637 out.push_str("Homebrew not found.\n");
8638 }
8639 if let Ok(o) = Command::new("mas").args(["list"]).output() {
8640 if o.status.success() {
8641 let raw = String::from_utf8_lossy(&o.stdout);
8642 let lines: Vec<&str> = raw.lines().collect();
8643 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8644 for line in lines.iter().take(n) {
8645 out.push_str(&format!(" {line}\n"));
8646 }
8647 }
8648 }
8649 }
8650
8651 Ok(out.trim_end().to_string())
8652}
8653
8654fn inspect_git_config() -> Result<String, String> {
8657 let mut out = String::from("Host inspection: git_config\n\n");
8658
8659 if let Ok(o) = Command::new("git").args(["--version"]).output() {
8660 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8661 out.push_str(&format!("Git: {ver}\n\n"));
8662 } else {
8663 out.push_str("Git: not found on PATH.\n");
8664 return Ok(out.trim_end().to_string());
8665 }
8666
8667 if let Ok(o) = Command::new("git")
8668 .args(["config", "--global", "--list"])
8669 .output()
8670 {
8671 if o.status.success() {
8672 let raw = String::from_utf8_lossy(&o.stdout);
8673 let mut pairs: Vec<(String, String)> = raw
8674 .lines()
8675 .filter_map(|l| {
8676 let mut parts = l.splitn(2, '=');
8677 let k = parts.next()?.trim().to_string();
8678 let v = parts.next().unwrap_or("").trim().to_string();
8679 Some((k, v))
8680 })
8681 .collect();
8682 pairs.sort_by(|a, b| a.0.cmp(&b.0));
8683
8684 out.push_str("=== Global git config ===\n");
8685
8686 let sections: &[(&str, &[&str])] = &[
8687 ("Identity", &["user.name", "user.email", "user.signingkey"]),
8688 (
8689 "Core",
8690 &[
8691 "core.editor",
8692 "core.autocrlf",
8693 "core.eol",
8694 "core.ignorecase",
8695 "core.filemode",
8696 ],
8697 ),
8698 (
8699 "Commit/Signing",
8700 &[
8701 "commit.gpgsign",
8702 "tag.gpgsign",
8703 "gpg.format",
8704 "gpg.ssh.allowedsignersfile",
8705 ],
8706 ),
8707 (
8708 "Push/Pull",
8709 &[
8710 "push.default",
8711 "push.autosetupremote",
8712 "pull.rebase",
8713 "pull.ff",
8714 ],
8715 ),
8716 ("Credential", &["credential.helper"]),
8717 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
8718 ];
8719
8720 let mut shown_keys: HashSet<String> = HashSet::new();
8721 for (section, keys) in sections {
8722 let mut section_lines: Vec<String> = Vec::new();
8723 for key in *keys {
8724 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
8725 section_lines.push(format!(" {k} = {v}"));
8726 shown_keys.insert(k.clone());
8727 }
8728 }
8729 if !section_lines.is_empty() {
8730 out.push_str(&format!("\n[{section}]\n"));
8731 for line in section_lines {
8732 out.push_str(&format!("{line}\n"));
8733 }
8734 }
8735 }
8736
8737 let other: Vec<&(String, String)> = pairs
8738 .iter()
8739 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
8740 .collect();
8741 if !other.is_empty() {
8742 out.push_str("\n[Other]\n");
8743 for (k, v) in other.iter().take(20) {
8744 out.push_str(&format!(" {k} = {v}\n"));
8745 }
8746 if other.len() > 20 {
8747 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
8748 }
8749 }
8750
8751 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
8752 } else {
8753 out.push_str("No global git config found.\n");
8754 out.push_str("Set up with:\n");
8755 out.push_str(" git config --global user.name \"Your Name\"\n");
8756 out.push_str(" git config --global user.email \"you@example.com\"\n");
8757 }
8758 }
8759
8760 if let Ok(o) = Command::new("git")
8761 .args(["config", "--local", "--list"])
8762 .output()
8763 {
8764 if o.status.success() {
8765 let raw = String::from_utf8_lossy(&o.stdout);
8766 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8767 if !lines.is_empty() {
8768 out.push_str(&format!(
8769 "\n=== Local repo config ({} keys) ===\n",
8770 lines.len()
8771 ));
8772 for line in lines.iter().take(15) {
8773 out.push_str(&format!(" {line}\n"));
8774 }
8775 if lines.len() > 15 {
8776 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
8777 }
8778 }
8779 }
8780 }
8781
8782 if let Ok(o) = Command::new("git")
8783 .args(["config", "--global", "--get-regexp", r"alias\."])
8784 .output()
8785 {
8786 if o.status.success() {
8787 let raw = String::from_utf8_lossy(&o.stdout);
8788 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8789 if !aliases.is_empty() {
8790 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
8791 for a in aliases.iter().take(20) {
8792 out.push_str(&format!(" {a}\n"));
8793 }
8794 if aliases.len() > 20 {
8795 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
8796 }
8797 }
8798 }
8799 }
8800
8801 Ok(out.trim_end().to_string())
8802}
8803
8804fn inspect_databases() -> Result<String, String> {
8807 let mut out = String::from("Host inspection: databases\n\n");
8808 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
8809
8810 struct DbEngine {
8811 name: &'static str,
8812 service_names: &'static [&'static str],
8813 default_port: u16,
8814 cli_name: &'static str,
8815 cli_version_args: &'static [&'static str],
8816 }
8817
8818 let engines: &[DbEngine] = &[
8819 DbEngine {
8820 name: "PostgreSQL",
8821 service_names: &[
8822 "postgresql",
8823 "postgresql-x64-14",
8824 "postgresql-x64-15",
8825 "postgresql-x64-16",
8826 "postgresql-x64-17",
8827 ],
8828
8829 default_port: 5432,
8830 cli_name: "psql",
8831 cli_version_args: &["--version"],
8832 },
8833 DbEngine {
8834 name: "MySQL",
8835 service_names: &["mysql", "mysql80", "mysql57"],
8836
8837 default_port: 3306,
8838 cli_name: "mysql",
8839 cli_version_args: &["--version"],
8840 },
8841 DbEngine {
8842 name: "MariaDB",
8843 service_names: &["mariadb", "mariadb.exe"],
8844
8845 default_port: 3306,
8846 cli_name: "mariadb",
8847 cli_version_args: &["--version"],
8848 },
8849 DbEngine {
8850 name: "MongoDB",
8851 service_names: &["mongodb", "mongod"],
8852
8853 default_port: 27017,
8854 cli_name: "mongod",
8855 cli_version_args: &["--version"],
8856 },
8857 DbEngine {
8858 name: "Redis",
8859 service_names: &["redis", "redis-server"],
8860
8861 default_port: 6379,
8862 cli_name: "redis-server",
8863 cli_version_args: &["--version"],
8864 },
8865 DbEngine {
8866 name: "SQL Server",
8867 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
8868
8869 default_port: 1433,
8870 cli_name: "sqlcmd",
8871 cli_version_args: &["-?"],
8872 },
8873 DbEngine {
8874 name: "SQLite",
8875 service_names: &[], default_port: 0, cli_name: "sqlite3",
8879 cli_version_args: &["--version"],
8880 },
8881 DbEngine {
8882 name: "CouchDB",
8883 service_names: &["couchdb", "apache-couchdb"],
8884
8885 default_port: 5984,
8886 cli_name: "couchdb",
8887 cli_version_args: &["--version"],
8888 },
8889 DbEngine {
8890 name: "Cassandra",
8891 service_names: &["cassandra"],
8892
8893 default_port: 9042,
8894 cli_name: "cqlsh",
8895 cli_version_args: &["--version"],
8896 },
8897 DbEngine {
8898 name: "Elasticsearch",
8899 service_names: &["elasticsearch-service-x64", "elasticsearch"],
8900
8901 default_port: 9200,
8902 cli_name: "elasticsearch",
8903 cli_version_args: &["--version"],
8904 },
8905 ];
8906
8907 fn port_listening(port: u16) -> bool {
8909 if port == 0 {
8910 return false;
8911 }
8912 std::net::TcpStream::connect_timeout(
8914 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8915 std::time::Duration::from_millis(150),
8916 )
8917 .is_ok()
8918 }
8919
8920 let mut found_any = false;
8921
8922 for engine in engines {
8923 let mut status_parts: Vec<String> = Vec::new();
8924 let mut detected = false;
8925
8926 let version = Command::new(engine.cli_name)
8928 .args(engine.cli_version_args)
8929 .output()
8930 .ok()
8931 .and_then(|o| {
8932 let combined = if o.stdout.is_empty() {
8933 String::from_utf8_lossy(&o.stderr).trim().to_string()
8934 } else {
8935 String::from_utf8_lossy(&o.stdout).trim().to_string()
8936 };
8937 combined.lines().next().map(|l| l.trim().to_string())
8939 });
8940
8941 if let Some(ref ver) = version {
8942 if !ver.is_empty() {
8943 status_parts.push(format!("version: {ver}"));
8944 detected = true;
8945 }
8946 }
8947
8948 if engine.default_port > 0 && port_listening(engine.default_port) {
8950 status_parts.push(format!("listening on :{}", engine.default_port));
8951 detected = true;
8952 } else if engine.default_port > 0 && detected {
8953 status_parts.push(format!("not listening on :{}", engine.default_port));
8954 }
8955
8956 #[cfg(target_os = "windows")]
8958 {
8959 if !engine.service_names.is_empty() {
8960 let service_list = engine.service_names.join("','");
8961 let script = format!(
8962 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
8963 service_list
8964 );
8965 if let Ok(o) = Command::new("powershell")
8966 .args(["-NoProfile", "-Command", &script])
8967 .output()
8968 {
8969 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8970 if !text.is_empty() {
8971 let parts: Vec<&str> = text.splitn(2, ':').collect();
8972 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
8973 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
8974 status_parts.push(format!("service '{svc_name}': {svc_state}"));
8975 detected = true;
8976 }
8977 }
8978 }
8979 }
8980
8981 #[cfg(not(target_os = "windows"))]
8983 {
8984 for svc in engine.service_names {
8985 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
8986 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
8987 if !state.is_empty() && state != "inactive" {
8988 status_parts.push(format!("systemd '{svc}': {state}"));
8989 detected = true;
8990 break;
8991 }
8992 }
8993 }
8994 }
8995
8996 if detected {
8997 found_any = true;
8998 let label = if engine.default_port > 0 {
8999 format!("{} (default port: {})", engine.name, engine.default_port)
9000 } else {
9001 format!("{} (file-based, no port)", engine.name)
9002 };
9003 out.push_str(&format!("[FOUND] {label}\n"));
9004 for part in &status_parts {
9005 out.push_str(&format!(" {part}\n"));
9006 }
9007 out.push('\n');
9008 }
9009 }
9010
9011 if !found_any {
9012 out.push_str("No local database engines detected.\n");
9013 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9014 out.push_str(
9015 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9016 );
9017 } else {
9018 out.push_str("---\n");
9019 out.push_str(
9020 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9021 );
9022 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9023 }
9024
9025 Ok(out.trim_end().to_string())
9026}
9027
9028fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9031 let mut out = String::from("Host inspection: user_accounts\n\n");
9032
9033 #[cfg(target_os = "windows")]
9034 {
9035 let users_out = Command::new("powershell")
9036 .args([
9037 "-NoProfile", "-NonInteractive", "-Command",
9038 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9039 ])
9040 .output()
9041 .ok()
9042 .and_then(|o| String::from_utf8(o.stdout).ok())
9043 .unwrap_or_default();
9044
9045 out.push_str("=== Local User Accounts ===\n");
9046 if users_out.trim().is_empty() {
9047 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
9048 } else {
9049 for line in users_out.lines().take(max_entries) {
9050 if !line.trim().is_empty() {
9051 out.push_str(line);
9052 out.push('\n');
9053 }
9054 }
9055 }
9056
9057 let admins_out = Command::new("powershell")
9058 .args([
9059 "-NoProfile", "-NonInteractive", "-Command",
9060 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
9061 ])
9062 .output()
9063 .ok()
9064 .and_then(|o| String::from_utf8(o.stdout).ok())
9065 .unwrap_or_default();
9066
9067 out.push_str("\n=== Administrators Group Members ===\n");
9068 if admins_out.trim().is_empty() {
9069 out.push_str(" (unable to retrieve)\n");
9070 } else {
9071 out.push_str(admins_out.trim());
9072 out.push('\n');
9073 }
9074
9075 let sessions_out = Command::new("powershell")
9076 .args([
9077 "-NoProfile",
9078 "-NonInteractive",
9079 "-Command",
9080 "query user 2>$null",
9081 ])
9082 .output()
9083 .ok()
9084 .and_then(|o| String::from_utf8(o.stdout).ok())
9085 .unwrap_or_default();
9086
9087 out.push_str("\n=== Active Logon Sessions ===\n");
9088 if sessions_out.trim().is_empty() {
9089 out.push_str(" (none or requires elevation)\n");
9090 } else {
9091 for line in sessions_out.lines().take(max_entries) {
9092 if !line.trim().is_empty() {
9093 out.push_str(&format!(" {}\n", line));
9094 }
9095 }
9096 }
9097
9098 let is_admin = Command::new("powershell")
9099 .args([
9100 "-NoProfile", "-NonInteractive", "-Command",
9101 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9102 ])
9103 .output()
9104 .ok()
9105 .and_then(|o| String::from_utf8(o.stdout).ok())
9106 .map(|s| s.trim().to_lowercase())
9107 .unwrap_or_default();
9108
9109 out.push_str("\n=== Current Session Elevation ===\n");
9110 out.push_str(&format!(
9111 " Running as Administrator: {}\n",
9112 if is_admin.contains("true") {
9113 "YES"
9114 } else {
9115 "no"
9116 }
9117 ));
9118 }
9119
9120 #[cfg(not(target_os = "windows"))]
9121 {
9122 let who_out = Command::new("who")
9123 .output()
9124 .ok()
9125 .and_then(|o| String::from_utf8(o.stdout).ok())
9126 .unwrap_or_default();
9127 out.push_str("=== Active Sessions ===\n");
9128 if who_out.trim().is_empty() {
9129 out.push_str(" (none)\n");
9130 } else {
9131 for line in who_out.lines().take(max_entries) {
9132 out.push_str(&format!(" {}\n", line));
9133 }
9134 }
9135 let id_out = Command::new("id")
9136 .output()
9137 .ok()
9138 .and_then(|o| String::from_utf8(o.stdout).ok())
9139 .unwrap_or_default();
9140 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
9141 }
9142
9143 Ok(out.trim_end().to_string())
9144}
9145
9146fn inspect_audit_policy() -> Result<String, String> {
9149 let mut out = String::from("Host inspection: audit_policy\n\n");
9150
9151 #[cfg(target_os = "windows")]
9152 {
9153 let auditpol_out = Command::new("auditpol")
9154 .args(["/get", "/category:*"])
9155 .output()
9156 .ok()
9157 .and_then(|o| String::from_utf8(o.stdout).ok())
9158 .unwrap_or_default();
9159
9160 if auditpol_out.trim().is_empty()
9161 || auditpol_out.to_lowercase().contains("access is denied")
9162 {
9163 out.push_str("Audit policy requires Administrator elevation to read.\n");
9164 out.push_str(
9165 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9166 );
9167 } else {
9168 out.push_str("=== Windows Audit Policy ===\n");
9169 let mut any_enabled = false;
9170 for line in auditpol_out.lines() {
9171 let trimmed = line.trim();
9172 if trimmed.is_empty() {
9173 continue;
9174 }
9175 if trimmed.contains("Success") || trimmed.contains("Failure") {
9176 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
9177 any_enabled = true;
9178 } else {
9179 out.push_str(&format!(" {}\n", trimmed));
9180 }
9181 }
9182 if !any_enabled {
9183 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9184 out.push_str(
9185 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9186 );
9187 }
9188 }
9189
9190 let evtlog = Command::new("powershell")
9191 .args([
9192 "-NoProfile", "-NonInteractive", "-Command",
9193 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9194 ])
9195 .output()
9196 .ok()
9197 .and_then(|o| String::from_utf8(o.stdout).ok())
9198 .map(|s| s.trim().to_string())
9199 .unwrap_or_default();
9200
9201 out.push_str(&format!(
9202 "\n=== Windows Event Log Service ===\n Status: {}\n",
9203 if evtlog.is_empty() {
9204 "unknown".to_string()
9205 } else {
9206 evtlog
9207 }
9208 ));
9209 }
9210
9211 #[cfg(not(target_os = "windows"))]
9212 {
9213 let auditd_status = Command::new("systemctl")
9214 .args(["is-active", "auditd"])
9215 .output()
9216 .ok()
9217 .and_then(|o| String::from_utf8(o.stdout).ok())
9218 .map(|s| s.trim().to_string())
9219 .unwrap_or_else(|| "not found".to_string());
9220
9221 out.push_str(&format!(
9222 "=== auditd service ===\n Status: {}\n",
9223 auditd_status
9224 ));
9225
9226 if auditd_status == "active" {
9227 let rules = Command::new("auditctl")
9228 .args(["-l"])
9229 .output()
9230 .ok()
9231 .and_then(|o| String::from_utf8(o.stdout).ok())
9232 .unwrap_or_default();
9233 out.push_str("\n=== Active Audit Rules ===\n");
9234 if rules.trim().is_empty() || rules.contains("No rules") {
9235 out.push_str(" No rules configured.\n");
9236 } else {
9237 for line in rules.lines() {
9238 out.push_str(&format!(" {}\n", line));
9239 }
9240 }
9241 }
9242 }
9243
9244 Ok(out.trim_end().to_string())
9245}
9246
9247fn inspect_shares(max_entries: usize) -> Result<String, String> {
9250 let mut out = String::from("Host inspection: shares\n\n");
9251
9252 #[cfg(target_os = "windows")]
9253 {
9254 let smb_out = Command::new("powershell")
9255 .args([
9256 "-NoProfile", "-NonInteractive", "-Command",
9257 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9258 ])
9259 .output()
9260 .ok()
9261 .and_then(|o| String::from_utf8(o.stdout).ok())
9262 .unwrap_or_default();
9263
9264 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9265 let smb_lines: Vec<&str> = smb_out
9266 .lines()
9267 .filter(|l| !l.trim().is_empty())
9268 .take(max_entries)
9269 .collect();
9270 if smb_lines.is_empty() {
9271 out.push_str(" No SMB shares or unable to retrieve.\n");
9272 } else {
9273 for line in &smb_lines {
9274 let name = line.trim().split('|').next().unwrap_or("").trim();
9275 if name.ends_with('$') {
9276 out.push_str(&format!(" {}\n", line.trim()));
9277 } else {
9278 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
9279 }
9280 }
9281 }
9282
9283 let smb_security = Command::new("powershell")
9284 .args([
9285 "-NoProfile", "-NonInteractive", "-Command",
9286 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9287 ])
9288 .output()
9289 .ok()
9290 .and_then(|o| String::from_utf8(o.stdout).ok())
9291 .unwrap_or_default();
9292
9293 out.push_str("\n=== SMB Server Security Settings ===\n");
9294 if smb_security.trim().is_empty() {
9295 out.push_str(" (unable to retrieve)\n");
9296 } else {
9297 out.push_str(smb_security.trim());
9298 out.push('\n');
9299 if smb_security.to_lowercase().contains("smb1: true") {
9300 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9301 }
9302 }
9303
9304 let drives_out = Command::new("powershell")
9305 .args([
9306 "-NoProfile", "-NonInteractive", "-Command",
9307 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9308 ])
9309 .output()
9310 .ok()
9311 .and_then(|o| String::from_utf8(o.stdout).ok())
9312 .unwrap_or_default();
9313
9314 out.push_str("\n=== Mapped Network Drives ===\n");
9315 if drives_out.trim().is_empty() {
9316 out.push_str(" None.\n");
9317 } else {
9318 for line in drives_out.lines().take(max_entries) {
9319 if !line.trim().is_empty() {
9320 out.push_str(line);
9321 out.push('\n');
9322 }
9323 }
9324 }
9325 }
9326
9327 #[cfg(not(target_os = "windows"))]
9328 {
9329 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9330 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9331 if smb_conf.is_empty() {
9332 out.push_str(" Not found or Samba not installed.\n");
9333 } else {
9334 for line in smb_conf.lines().take(max_entries) {
9335 out.push_str(&format!(" {}\n", line));
9336 }
9337 }
9338 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9339 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9340 if nfs_exports.is_empty() {
9341 out.push_str(" Not configured.\n");
9342 } else {
9343 for line in nfs_exports.lines().take(max_entries) {
9344 out.push_str(&format!(" {}\n", line));
9345 }
9346 }
9347 }
9348
9349 Ok(out.trim_end().to_string())
9350}
9351
9352fn inspect_dns_servers() -> Result<String, String> {
9355 let mut out = String::from("Host inspection: dns_servers\n\n");
9356
9357 #[cfg(target_os = "windows")]
9358 {
9359 let dns_out = Command::new("powershell")
9360 .args([
9361 "-NoProfile", "-NonInteractive", "-Command",
9362 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9363 ])
9364 .output()
9365 .ok()
9366 .and_then(|o| String::from_utf8(o.stdout).ok())
9367 .unwrap_or_default();
9368
9369 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9370 if dns_out.trim().is_empty() {
9371 out.push_str(" (unable to retrieve)\n");
9372 } else {
9373 for line in dns_out.lines() {
9374 if line.trim().is_empty() {
9375 continue;
9376 }
9377 let mut annotation = "";
9378 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9379 annotation = " <- Google Public DNS";
9380 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9381 annotation = " <- Cloudflare DNS";
9382 } else if line.contains("9.9.9.9") {
9383 annotation = " <- Quad9";
9384 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9385 annotation = " <- OpenDNS";
9386 }
9387 out.push_str(line);
9388 out.push_str(annotation);
9389 out.push('\n');
9390 }
9391 }
9392
9393 let doh_out = Command::new("powershell")
9394 .args([
9395 "-NoProfile", "-NonInteractive", "-Command",
9396 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
9397 ])
9398 .output()
9399 .ok()
9400 .and_then(|o| String::from_utf8(o.stdout).ok())
9401 .unwrap_or_default();
9402
9403 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9404 if doh_out.trim().is_empty() {
9405 out.push_str(" Not configured (plain DNS).\n");
9406 } else {
9407 out.push_str(doh_out.trim());
9408 out.push('\n');
9409 }
9410
9411 let suffixes = Command::new("powershell")
9412 .args([
9413 "-NoProfile", "-NonInteractive", "-Command",
9414 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
9415 ])
9416 .output()
9417 .ok()
9418 .and_then(|o| String::from_utf8(o.stdout).ok())
9419 .unwrap_or_default();
9420
9421 if !suffixes.trim().is_empty() {
9422 out.push_str("\n=== DNS Search Suffix List ===\n");
9423 out.push_str(suffixes.trim());
9424 out.push('\n');
9425 }
9426 }
9427
9428 #[cfg(not(target_os = "windows"))]
9429 {
9430 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9431 out.push_str("=== /etc/resolv.conf ===\n");
9432 if resolv.is_empty() {
9433 out.push_str(" Not found.\n");
9434 } else {
9435 for line in resolv.lines() {
9436 if !line.trim().is_empty() && !line.starts_with('#') {
9437 out.push_str(&format!(" {}\n", line));
9438 }
9439 }
9440 }
9441 let resolved_out = Command::new("resolvectl")
9442 .args(["status", "--no-pager"])
9443 .output()
9444 .ok()
9445 .and_then(|o| String::from_utf8(o.stdout).ok())
9446 .unwrap_or_default();
9447 if !resolved_out.is_empty() {
9448 out.push_str("\n=== systemd-resolved ===\n");
9449 for line in resolved_out.lines().take(30) {
9450 out.push_str(&format!(" {}\n", line));
9451 }
9452 }
9453 }
9454
9455 Ok(out.trim_end().to_string())
9456}
9457
9458fn inspect_bitlocker() -> Result<String, String> {
9459 let mut out = String::from("Host inspection: bitlocker\n\n");
9460
9461 #[cfg(target_os = "windows")]
9462 {
9463 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9464 let output = Command::new("powershell")
9465 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9466 .output()
9467 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9468
9469 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9470 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9471
9472 if !stdout.trim().is_empty() {
9473 out.push_str("=== BitLocker Volumes ===\n");
9474 for line in stdout.lines() {
9475 out.push_str(&format!(" {}\n", line));
9476 }
9477 } else if !stderr.trim().is_empty() {
9478 if stderr.contains("Access is denied") {
9479 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9480 } else {
9481 out.push_str(&format!(
9482 "Error retrieving BitLocker info: {}\n",
9483 stderr.trim()
9484 ));
9485 }
9486 } else {
9487 out.push_str("No BitLocker volumes detected or access denied.\n");
9488 }
9489 }
9490
9491 #[cfg(not(target_os = "windows"))]
9492 {
9493 out.push_str(
9494 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9495 );
9496 let lsblk = Command::new("lsblk")
9497 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9498 .output()
9499 .ok()
9500 .and_then(|o| String::from_utf8(o.stdout).ok())
9501 .unwrap_or_default();
9502 if lsblk.contains("crypto_LUKS") {
9503 out.push_str("=== LUKS Encrypted Volumes ===\n");
9504 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9505 out.push_str(&format!(" {}\n", line));
9506 }
9507 } else {
9508 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9509 }
9510 }
9511
9512 Ok(out.trim_end().to_string())
9513}
9514
9515fn inspect_rdp() -> Result<String, String> {
9516 let mut out = String::from("Host inspection: rdp\n\n");
9517
9518 #[cfg(target_os = "windows")]
9519 {
9520 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9521 let f_deny = Command::new("powershell")
9522 .args([
9523 "-NoProfile",
9524 "-Command",
9525 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9526 ])
9527 .output()
9528 .ok()
9529 .and_then(|o| String::from_utf8(o.stdout).ok())
9530 .unwrap_or_default()
9531 .trim()
9532 .to_string();
9533
9534 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9535 out.push_str(&format!("=== RDP Status: {} ===\n", status));
9536
9537 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"])
9538 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9539 out.push_str(&format!(
9540 " Port: {}\n",
9541 if port.is_empty() {
9542 "3389 (default)"
9543 } else {
9544 &port
9545 }
9546 ));
9547
9548 let nla = Command::new("powershell")
9549 .args([
9550 "-NoProfile",
9551 "-Command",
9552 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9553 ])
9554 .output()
9555 .ok()
9556 .and_then(|o| String::from_utf8(o.stdout).ok())
9557 .unwrap_or_default()
9558 .trim()
9559 .to_string();
9560 out.push_str(&format!(
9561 " NLA Required: {}\n",
9562 if nla == "1" { "Yes" } else { "No" }
9563 ));
9564
9565 out.push_str("\n=== Active Sessions ===\n");
9566 let qwinsta = Command::new("qwinsta")
9567 .output()
9568 .ok()
9569 .and_then(|o| String::from_utf8(o.stdout).ok())
9570 .unwrap_or_default();
9571 if qwinsta.trim().is_empty() {
9572 out.push_str(" No active sessions listed.\n");
9573 } else {
9574 for line in qwinsta.lines() {
9575 out.push_str(&format!(" {}\n", line));
9576 }
9577 }
9578
9579 out.push_str("\n=== Firewall Rule Check ===\n");
9580 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))\" }"])
9581 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9582 if fw.trim().is_empty() {
9583 out.push_str(" No enabled RDP firewall rules found.\n");
9584 } else {
9585 out.push_str(fw.trim_end());
9586 out.push('\n');
9587 }
9588 }
9589
9590 #[cfg(not(target_os = "windows"))]
9591 {
9592 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9593 let ss = Command::new("ss")
9594 .args(["-tlnp"])
9595 .output()
9596 .ok()
9597 .and_then(|o| String::from_utf8(o.stdout).ok())
9598 .unwrap_or_default();
9599 let matches: Vec<&str> = ss
9600 .lines()
9601 .filter(|l| l.contains(":3389") || l.contains(":590"))
9602 .collect();
9603 if matches.is_empty() {
9604 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
9605 } else {
9606 for m in matches {
9607 out.push_str(&format!(" {}\n", m));
9608 }
9609 }
9610 }
9611
9612 Ok(out.trim_end().to_string())
9613}
9614
9615fn inspect_shadow_copies() -> Result<String, String> {
9616 let mut out = String::from("Host inspection: shadow_copies\n\n");
9617
9618 #[cfg(target_os = "windows")]
9619 {
9620 let output = Command::new("vssadmin")
9621 .args(["list", "shadows"])
9622 .output()
9623 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9624 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9625
9626 if stdout.contains("No items found") || stdout.trim().is_empty() {
9627 out.push_str("No Volume Shadow Copies found.\n");
9628 } else {
9629 out.push_str("=== Volume Shadow Copies ===\n");
9630 for line in stdout.lines().take(50) {
9631 if line.contains("Creation Time:")
9632 || line.contains("Contents:")
9633 || line.contains("Volume Name:")
9634 {
9635 out.push_str(&format!(" {}\n", line.trim()));
9636 }
9637 }
9638 }
9639
9640 out.push_str("\n=== Shadow Copy Storage ===\n");
9641 let storage_out = Command::new("vssadmin")
9642 .args(["list", "shadowstorage"])
9643 .output()
9644 .ok();
9645 if let Some(o) = storage_out {
9646 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9647 for line in stdout.lines() {
9648 if line.contains("Used Shadow Copy Storage space:")
9649 || line.contains("Max Shadow Copy Storage space:")
9650 {
9651 out.push_str(&format!(" {}\n", line.trim()));
9652 }
9653 }
9654 }
9655 }
9656
9657 #[cfg(not(target_os = "windows"))]
9658 {
9659 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
9660 let lvs = Command::new("lvs")
9661 .output()
9662 .ok()
9663 .and_then(|o| String::from_utf8(o.stdout).ok())
9664 .unwrap_or_default();
9665 if !lvs.is_empty() {
9666 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
9667 out.push_str(&lvs);
9668 } else {
9669 out.push_str("No LVM volumes detected.\n");
9670 }
9671 }
9672
9673 Ok(out.trim_end().to_string())
9674}
9675
9676fn inspect_pagefile() -> Result<String, String> {
9677 let mut out = String::from("Host inspection: pagefile\n\n");
9678
9679 #[cfg(target_os = "windows")]
9680 {
9681 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)\" }";
9682 let output = Command::new("powershell")
9683 .args(["-NoProfile", "-Command", ps_cmd])
9684 .output()
9685 .ok()
9686 .and_then(|o| String::from_utf8(o.stdout).ok())
9687 .unwrap_or_default();
9688
9689 if output.trim().is_empty() {
9690 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
9691 let managed = Command::new("powershell")
9692 .args([
9693 "-NoProfile",
9694 "-Command",
9695 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
9696 ])
9697 .output()
9698 .ok()
9699 .and_then(|o| String::from_utf8(o.stdout).ok())
9700 .unwrap_or_default()
9701 .trim()
9702 .to_string();
9703 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
9704 } else {
9705 out.push_str("=== Page File Usage ===\n");
9706 out.push_str(&output);
9707 }
9708 }
9709
9710 #[cfg(not(target_os = "windows"))]
9711 {
9712 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
9713 let swap = Command::new("swapon")
9714 .args(["--show"])
9715 .output()
9716 .ok()
9717 .and_then(|o| String::from_utf8(o.stdout).ok())
9718 .unwrap_or_default();
9719 if swap.is_empty() {
9720 let free = Command::new("free")
9721 .args(["-h"])
9722 .output()
9723 .ok()
9724 .and_then(|o| String::from_utf8(o.stdout).ok())
9725 .unwrap_or_default();
9726 out.push_str(&free);
9727 } else {
9728 out.push_str(&swap);
9729 }
9730 }
9731
9732 Ok(out.trim_end().to_string())
9733}
9734
9735fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
9736 let mut out = String::from("Host inspection: windows_features\n\n");
9737
9738 #[cfg(target_os = "windows")]
9739 {
9740 out.push_str("=== Quick Check: Notable Features ===\n");
9741 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
9742 let output = Command::new("powershell")
9743 .args(["-NoProfile", "-Command", quick_ps])
9744 .output()
9745 .ok();
9746
9747 if let Some(o) = output {
9748 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9749 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
9750
9751 if !stdout.trim().is_empty() {
9752 for f in stdout.lines() {
9753 out.push_str(&format!(" [ENABLED] {}\n", f));
9754 }
9755 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
9756 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
9757 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
9758 out.push_str(
9759 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
9760 );
9761 }
9762 }
9763
9764 out.push_str(&format!(
9765 "\n=== All Enabled Features (capped at {}) ===\n",
9766 max_entries
9767 ));
9768 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
9769 let all_out = Command::new("powershell")
9770 .args(["-NoProfile", "-Command", &all_ps])
9771 .output()
9772 .ok();
9773 if let Some(o) = all_out {
9774 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9775 if !stdout.trim().is_empty() {
9776 out.push_str(&stdout);
9777 }
9778 }
9779 }
9780
9781 #[cfg(not(target_os = "windows"))]
9782 {
9783 let _ = max_entries;
9784 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
9785 }
9786
9787 Ok(out.trim_end().to_string())
9788}
9789
9790fn inspect_audio(max_entries: usize) -> Result<String, String> {
9791 let mut out = String::from("Host inspection: audio\n\n");
9792
9793 #[cfg(target_os = "windows")]
9794 {
9795 let n = max_entries.clamp(5, 20);
9796 let services = collect_services().unwrap_or_default();
9797 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
9798 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
9799
9800 let core_services: Vec<&ServiceEntry> = services
9801 .iter()
9802 .filter(|entry| {
9803 core_service_names
9804 .iter()
9805 .any(|name| entry.name.eq_ignore_ascii_case(name))
9806 })
9807 .collect();
9808 let bluetooth_audio_services: Vec<&ServiceEntry> = services
9809 .iter()
9810 .filter(|entry| {
9811 bluetooth_audio_service_names
9812 .iter()
9813 .any(|name| entry.name.eq_ignore_ascii_case(name))
9814 })
9815 .collect();
9816
9817 let probe_script = r#"
9818$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
9819 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9820$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9821 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9822$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
9823 Select-Object Name, Status, Manufacturer, PNPDeviceID)
9824[pscustomobject]@{
9825 Media = $media
9826 Endpoints = $endpoints
9827 SoundDevices = $sound
9828} | ConvertTo-Json -Compress -Depth 4
9829"#;
9830 let probe_raw = Command::new("powershell")
9831 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9832 .output()
9833 .ok()
9834 .and_then(|o| String::from_utf8(o.stdout).ok())
9835 .unwrap_or_default();
9836 let probe_loaded = !probe_raw.trim().is_empty();
9837 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9838
9839 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
9840 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
9841 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
9842
9843 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
9844 .iter()
9845 .filter(|device| !is_microphone_like_name(&device.name))
9846 .collect();
9847 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
9848 .iter()
9849 .filter(|device| is_microphone_like_name(&device.name))
9850 .collect();
9851 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
9852 .iter()
9853 .filter(|device| is_bluetooth_like_name(&device.name))
9854 .collect();
9855 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
9856 .iter()
9857 .filter(|device| windows_device_has_issue(device))
9858 .collect();
9859 let media_problems: Vec<&WindowsPnpDevice> = media_devices
9860 .iter()
9861 .filter(|device| windows_device_has_issue(device))
9862 .collect();
9863 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
9864 .iter()
9865 .filter(|device| windows_sound_device_has_issue(device))
9866 .collect();
9867
9868 let mut findings = Vec::new();
9869
9870 let stopped_core_services: Vec<&ServiceEntry> = core_services
9871 .iter()
9872 .copied()
9873 .filter(|service| !service_is_running(service))
9874 .collect();
9875 if !stopped_core_services.is_empty() {
9876 let names = stopped_core_services
9877 .iter()
9878 .map(|service| service.name.as_str())
9879 .collect::<Vec<_>>()
9880 .join(", ");
9881 findings.push(AuditFinding {
9882 finding: format!("Core audio services are not running: {names}"),
9883 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
9884 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
9885 });
9886 }
9887
9888 if probe_loaded
9889 && endpoints.is_empty()
9890 && media_devices.is_empty()
9891 && sound_devices.is_empty()
9892 {
9893 findings.push(AuditFinding {
9894 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
9895 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(),
9896 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(),
9897 });
9898 }
9899
9900 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
9901 {
9902 let mut problem_labels = Vec::new();
9903 problem_labels.extend(
9904 endpoint_problems
9905 .iter()
9906 .take(3)
9907 .map(|device| device.name.clone()),
9908 );
9909 problem_labels.extend(
9910 media_problems
9911 .iter()
9912 .take(3)
9913 .map(|device| device.name.clone()),
9914 );
9915 problem_labels.extend(
9916 sound_problems
9917 .iter()
9918 .take(3)
9919 .map(|device| device.name.clone()),
9920 );
9921 findings.push(AuditFinding {
9922 finding: format!(
9923 "Windows reports audio device issues for: {}",
9924 problem_labels.join(", ")
9925 ),
9926 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
9927 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(),
9928 });
9929 }
9930
9931 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
9932 .iter()
9933 .copied()
9934 .filter(|service| !service_is_running(service))
9935 .collect();
9936 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
9937 let names = stopped_bt_audio_services
9938 .iter()
9939 .map(|service| service.name.as_str())
9940 .collect::<Vec<_>>()
9941 .join(", ");
9942 findings.push(AuditFinding {
9943 finding: format!(
9944 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
9945 ),
9946 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
9947 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
9948 });
9949 }
9950
9951 out.push_str("=== Findings ===\n");
9952 if findings.is_empty() {
9953 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
9954 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
9955 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");
9956 } else {
9957 for finding in &findings {
9958 out.push_str(&format!("- Finding: {}\n", finding.finding));
9959 out.push_str(&format!(" Impact: {}\n", finding.impact));
9960 out.push_str(&format!(" Fix: {}\n", finding.fix));
9961 }
9962 }
9963
9964 out.push_str("\n=== Audio services ===\n");
9965 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
9966 out.push_str(
9967 "- No Windows audio services were retrieved from the service inventory.\n",
9968 );
9969 } else {
9970 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
9971 out.push_str(&format!(
9972 "- {} | Status: {} | Startup: {}\n",
9973 service.name,
9974 service.status,
9975 service.startup.as_deref().unwrap_or("Unknown")
9976 ));
9977 }
9978 }
9979
9980 out.push_str("\n=== Playback and recording endpoints ===\n");
9981 if !probe_loaded {
9982 out.push_str("- Windows endpoint inventory probe returned no data.\n");
9983 } else if endpoints.is_empty() {
9984 out.push_str("- No audio endpoints detected.\n");
9985 } else {
9986 out.push_str(&format!(
9987 "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
9988 playback_endpoints.len(),
9989 recording_endpoints.len()
9990 ));
9991 for device in playback_endpoints.iter().take(n) {
9992 out.push_str(&format!(
9993 "- [PLAYBACK] {} | Status: {}{}\n",
9994 device.name,
9995 device.status,
9996 device
9997 .problem
9998 .filter(|problem| *problem != 0)
9999 .map(|problem| format!(" | ProblemCode: {problem}"))
10000 .unwrap_or_default()
10001 ));
10002 }
10003 for device in recording_endpoints.iter().take(n) {
10004 out.push_str(&format!(
10005 "- [MIC] {} | Status: {}{}\n",
10006 device.name,
10007 device.status,
10008 device
10009 .problem
10010 .filter(|problem| *problem != 0)
10011 .map(|problem| format!(" | ProblemCode: {problem}"))
10012 .unwrap_or_default()
10013 ));
10014 }
10015 }
10016
10017 out.push_str("\n=== Sound hardware devices ===\n");
10018 if sound_devices.is_empty() {
10019 out.push_str("- No Win32_SoundDevice entries were returned.\n");
10020 } else {
10021 for device in sound_devices.iter().take(n) {
10022 out.push_str(&format!(
10023 "- {} | Status: {}{}\n",
10024 device.name,
10025 device.status,
10026 device
10027 .manufacturer
10028 .as_deref()
10029 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10030 .unwrap_or_default()
10031 ));
10032 }
10033 }
10034
10035 out.push_str("\n=== Media-class device inventory ===\n");
10036 if media_devices.is_empty() {
10037 out.push_str("- No media-class PnP devices were returned.\n");
10038 } else {
10039 for device in media_devices.iter().take(n) {
10040 out.push_str(&format!(
10041 "- {} | Status: {}{}\n",
10042 device.name,
10043 device.status,
10044 device
10045 .class_name
10046 .as_deref()
10047 .map(|class_name| format!(" | Class: {class_name}"))
10048 .unwrap_or_default()
10049 ));
10050 }
10051 }
10052 }
10053
10054 #[cfg(not(target_os = "windows"))]
10055 {
10056 let _ = max_entries;
10057 out.push_str(
10058 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10059 );
10060 out.push_str(
10061 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10062 );
10063 }
10064
10065 Ok(out.trim_end().to_string())
10066}
10067
10068fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10069 let mut out = String::from("Host inspection: bluetooth\n\n");
10070
10071 #[cfg(target_os = "windows")]
10072 {
10073 let n = max_entries.clamp(5, 20);
10074 let services = collect_services().unwrap_or_default();
10075 let bluetooth_services: Vec<&ServiceEntry> = services
10076 .iter()
10077 .filter(|entry| {
10078 entry.name.eq_ignore_ascii_case("bthserv")
10079 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10080 || entry.name.eq_ignore_ascii_case("BTAGService")
10081 || entry.name.starts_with("BluetoothUserService")
10082 || entry
10083 .display_name
10084 .as_deref()
10085 .unwrap_or("")
10086 .to_ascii_lowercase()
10087 .contains("bluetooth")
10088 })
10089 .collect();
10090
10091 let probe_script = r#"
10092$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10093 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10094$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10095 Where-Object {
10096 $_.Class -eq 'Bluetooth' -or
10097 $_.FriendlyName -match 'Bluetooth' -or
10098 $_.InstanceId -like 'BTH*'
10099 } |
10100 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10101$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10102 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10103 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10104[pscustomobject]@{
10105 Radios = $radios
10106 Devices = $devices
10107 AudioEndpoints = $audio
10108} | ConvertTo-Json -Compress -Depth 4
10109"#;
10110 let probe_raw = Command::new("powershell")
10111 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10112 .output()
10113 .ok()
10114 .and_then(|o| String::from_utf8(o.stdout).ok())
10115 .unwrap_or_default();
10116 let probe_loaded = !probe_raw.trim().is_empty();
10117 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10118
10119 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10120 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10121 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10122 let radio_problems: Vec<&WindowsPnpDevice> = radios
10123 .iter()
10124 .filter(|device| windows_device_has_issue(device))
10125 .collect();
10126 let device_problems: Vec<&WindowsPnpDevice> = devices
10127 .iter()
10128 .filter(|device| windows_device_has_issue(device))
10129 .collect();
10130
10131 let mut findings = Vec::new();
10132
10133 if probe_loaded && radios.is_empty() {
10134 findings.push(AuditFinding {
10135 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10136 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10137 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10138 });
10139 }
10140
10141 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10142 .iter()
10143 .copied()
10144 .filter(|service| !service_is_running(service))
10145 .collect();
10146 if !stopped_bluetooth_services.is_empty() {
10147 let names = stopped_bluetooth_services
10148 .iter()
10149 .map(|service| service.name.as_str())
10150 .collect::<Vec<_>>()
10151 .join(", ");
10152 findings.push(AuditFinding {
10153 finding: format!("Bluetooth-related services are not fully running: {names}"),
10154 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10155 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10156 });
10157 }
10158
10159 if !radio_problems.is_empty() || !device_problems.is_empty() {
10160 let problem_labels = radio_problems
10161 .iter()
10162 .chain(device_problems.iter())
10163 .take(5)
10164 .map(|device| device.name.as_str())
10165 .collect::<Vec<_>>()
10166 .join(", ");
10167 findings.push(AuditFinding {
10168 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10169 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10170 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(),
10171 });
10172 }
10173
10174 if !audio_endpoints.is_empty()
10175 && bluetooth_services
10176 .iter()
10177 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10178 && bluetooth_services
10179 .iter()
10180 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10181 .any(|service| !service_is_running(service))
10182 {
10183 findings.push(AuditFinding {
10184 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10185 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10186 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10187 });
10188 }
10189
10190 out.push_str("=== Findings ===\n");
10191 if findings.is_empty() {
10192 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10193 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10194 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");
10195 } else {
10196 for finding in &findings {
10197 out.push_str(&format!("- Finding: {}\n", finding.finding));
10198 out.push_str(&format!(" Impact: {}\n", finding.impact));
10199 out.push_str(&format!(" Fix: {}\n", finding.fix));
10200 }
10201 }
10202
10203 out.push_str("\n=== Bluetooth services ===\n");
10204 if bluetooth_services.is_empty() {
10205 out.push_str(
10206 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10207 );
10208 } else {
10209 for service in bluetooth_services.iter().take(n) {
10210 out.push_str(&format!(
10211 "- {} | Status: {} | Startup: {}\n",
10212 service.name,
10213 service.status,
10214 service.startup.as_deref().unwrap_or("Unknown")
10215 ));
10216 }
10217 }
10218
10219 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10220 if !probe_loaded {
10221 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10222 } else if radios.is_empty() {
10223 out.push_str("- No Bluetooth radios detected.\n");
10224 } else {
10225 for device in radios.iter().take(n) {
10226 out.push_str(&format!(
10227 "- {} | Status: {}{}\n",
10228 device.name,
10229 device.status,
10230 device
10231 .problem
10232 .filter(|problem| *problem != 0)
10233 .map(|problem| format!(" | ProblemCode: {problem}"))
10234 .unwrap_or_default()
10235 ));
10236 }
10237 }
10238
10239 out.push_str("\n=== Bluetooth-associated devices ===\n");
10240 if devices.is_empty() {
10241 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10242 } else {
10243 for device in devices.iter().take(n) {
10244 out.push_str(&format!(
10245 "- {} | Status: {}{}\n",
10246 device.name,
10247 device.status,
10248 device
10249 .class_name
10250 .as_deref()
10251 .map(|class_name| format!(" | Class: {class_name}"))
10252 .unwrap_or_default()
10253 ));
10254 }
10255 }
10256
10257 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10258 if audio_endpoints.is_empty() {
10259 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10260 } else {
10261 for device in audio_endpoints.iter().take(n) {
10262 out.push_str(&format!(
10263 "- {} | Status: {}{}\n",
10264 device.name,
10265 device.status,
10266 device
10267 .instance_id
10268 .as_deref()
10269 .map(|instance_id| format!(" | Instance: {instance_id}"))
10270 .unwrap_or_default()
10271 ));
10272 }
10273 }
10274 }
10275
10276 #[cfg(not(target_os = "windows"))]
10277 {
10278 let _ = max_entries;
10279 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10280 out.push_str(
10281 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10282 );
10283 }
10284
10285 Ok(out.trim_end().to_string())
10286}
10287
10288fn inspect_printers(max_entries: usize) -> Result<String, String> {
10289 let mut out = String::from("Host inspection: printers\n\n");
10290
10291 #[cfg(target_os = "windows")]
10292 {
10293 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)])
10294 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10295 if list.trim().is_empty() {
10296 out.push_str("No printers detected.\n");
10297 } else {
10298 out.push_str("=== Installed Printers ===\n");
10299 out.push_str(&list);
10300 }
10301
10302 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10303 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10304 if !jobs.trim().is_empty() {
10305 out.push_str("\n=== Active Print Jobs ===\n");
10306 out.push_str(&jobs);
10307 }
10308 }
10309
10310 #[cfg(not(target_os = "windows"))]
10311 {
10312 let _ = max_entries;
10313 out.push_str("Checking LPSTAT for printers...\n");
10314 let lpstat = Command::new("lpstat")
10315 .args(["-p", "-d"])
10316 .output()
10317 .ok()
10318 .and_then(|o| String::from_utf8(o.stdout).ok())
10319 .unwrap_or_default();
10320 if lpstat.is_empty() {
10321 out.push_str(" No CUPS/LP printers found.\n");
10322 } else {
10323 out.push_str(&lpstat);
10324 }
10325 }
10326
10327 Ok(out.trim_end().to_string())
10328}
10329
10330fn inspect_winrm() -> Result<String, String> {
10331 let mut out = String::from("Host inspection: winrm\n\n");
10332
10333 #[cfg(target_os = "windows")]
10334 {
10335 let svc = Command::new("powershell")
10336 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10337 .output()
10338 .ok()
10339 .and_then(|o| String::from_utf8(o.stdout).ok())
10340 .unwrap_or_default()
10341 .trim()
10342 .to_string();
10343 out.push_str(&format!(
10344 "WinRM Service Status: {}\n\n",
10345 if svc.is_empty() { "NOT_FOUND" } else { &svc }
10346 ));
10347
10348 out.push_str("=== WinRM Listeners ===\n");
10349 let output = Command::new("powershell")
10350 .args([
10351 "-NoProfile",
10352 "-Command",
10353 "winrm enumerate winrm/config/listener 2>$null",
10354 ])
10355 .output()
10356 .ok();
10357 if let Some(o) = output {
10358 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10359 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10360
10361 if !stdout.trim().is_empty() {
10362 for line in stdout.lines() {
10363 if line.contains("Address =")
10364 || line.contains("Transport =")
10365 || line.contains("Port =")
10366 {
10367 out.push_str(&format!(" {}\n", line.trim()));
10368 }
10369 }
10370 } else if stderr.contains("Access is denied") {
10371 out.push_str(" Error: Access denied to WinRM configuration.\n");
10372 } else {
10373 out.push_str(" No listeners configured.\n");
10374 }
10375 }
10376
10377 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10378 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))\" }"])
10379 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10380 if test_out.trim().is_empty() {
10381 out.push_str(" WinRM not responding to local WS-Man requests.\n");
10382 } else {
10383 out.push_str(&test_out);
10384 }
10385 }
10386
10387 #[cfg(not(target_os = "windows"))]
10388 {
10389 out.push_str(
10390 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10391 );
10392 let ss = Command::new("ss")
10393 .args(["-tln"])
10394 .output()
10395 .ok()
10396 .and_then(|o| String::from_utf8(o.stdout).ok())
10397 .unwrap_or_default();
10398 if ss.contains(":5985") || ss.contains(":5986") {
10399 out.push_str(" WinRM ports (5985/5986) are listening.\n");
10400 } else {
10401 out.push_str(" WinRM ports not detected.\n");
10402 }
10403 }
10404
10405 Ok(out.trim_end().to_string())
10406}
10407
10408fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10409 let mut out = String::from("Host inspection: network_stats\n\n");
10410
10411 #[cfg(target_os = "windows")]
10412 {
10413 let ps_cmd = format!(
10414 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10415 Start-Sleep -Milliseconds 250; \
10416 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10417 $s2 | ForEach-Object {{ \
10418 $name = $_.Name; \
10419 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10420 if ($prev) {{ \
10421 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10422 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10423 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10424 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10425 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10426 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10427 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10428 }} \
10429 }}",
10430 max_entries
10431 );
10432 let output = Command::new("powershell")
10433 .args(["-NoProfile", "-Command", &ps_cmd])
10434 .output()
10435 .ok()
10436 .and_then(|o| String::from_utf8(o.stdout).ok())
10437 .unwrap_or_default();
10438 if output.trim().is_empty() {
10439 out.push_str("No network adapter statistics available.\n");
10440 } else {
10441 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10442 out.push_str(&output);
10443 }
10444
10445 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)\" } }"])
10446 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10447 if !discards.trim().is_empty() {
10448 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10449 out.push_str(&discards);
10450 }
10451 }
10452
10453 #[cfg(not(target_os = "windows"))]
10454 {
10455 let _ = max_entries;
10456 out.push_str("=== Network Stats (ip -s link) ===\n");
10457 let ip_s = Command::new("ip")
10458 .args(["-s", "link"])
10459 .output()
10460 .ok()
10461 .and_then(|o| String::from_utf8(o.stdout).ok())
10462 .unwrap_or_default();
10463 if ip_s.is_empty() {
10464 let netstat = Command::new("netstat")
10465 .args(["-i"])
10466 .output()
10467 .ok()
10468 .and_then(|o| String::from_utf8(o.stdout).ok())
10469 .unwrap_or_default();
10470 out.push_str(&netstat);
10471 } else {
10472 out.push_str(&ip_s);
10473 }
10474 }
10475
10476 Ok(out.trim_end().to_string())
10477}
10478
10479fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10480 let mut out = String::from("Host inspection: udp_ports\n\n");
10481
10482 #[cfg(target_os = "windows")]
10483 {
10484 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);
10485 let output = Command::new("powershell")
10486 .args(["-NoProfile", "-Command", &ps_cmd])
10487 .output()
10488 .ok();
10489
10490 if let Some(o) = output {
10491 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10492 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10493
10494 if !stdout.trim().is_empty() {
10495 out.push_str("=== UDP Listeners (Local:Port) ===\n");
10496 for line in stdout.lines() {
10497 let mut note = "";
10498 if line.contains(":53 ") {
10499 note = " [DNS]";
10500 } else if line.contains(":67 ") || line.contains(":68 ") {
10501 note = " [DHCP]";
10502 } else if line.contains(":123 ") {
10503 note = " [NTP]";
10504 } else if line.contains(":161 ") {
10505 note = " [SNMP]";
10506 } else if line.contains(":1900 ") {
10507 note = " [SSDP/UPnP]";
10508 } else if line.contains(":5353 ") {
10509 note = " [mDNS]";
10510 }
10511
10512 out.push_str(&format!("{}{}\n", line, note));
10513 }
10514 } else if stderr.contains("Access is denied") {
10515 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10516 } else {
10517 out.push_str("No UDP listeners detected.\n");
10518 }
10519 }
10520 }
10521
10522 #[cfg(not(target_os = "windows"))]
10523 {
10524 let ss_out = Command::new("ss")
10525 .args(["-ulnp"])
10526 .output()
10527 .ok()
10528 .and_then(|o| String::from_utf8(o.stdout).ok())
10529 .unwrap_or_default();
10530 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10531 if ss_out.is_empty() {
10532 let netstat_out = Command::new("netstat")
10533 .args(["-ulnp"])
10534 .output()
10535 .ok()
10536 .and_then(|o| String::from_utf8(o.stdout).ok())
10537 .unwrap_or_default();
10538 if netstat_out.is_empty() {
10539 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
10540 } else {
10541 for line in netstat_out.lines().take(max_entries) {
10542 out.push_str(&format!(" {}\n", line));
10543 }
10544 }
10545 } else {
10546 for line in ss_out.lines().take(max_entries) {
10547 out.push_str(&format!(" {}\n", line));
10548 }
10549 }
10550 }
10551
10552 Ok(out.trim_end().to_string())
10553}
10554
10555fn inspect_gpo() -> Result<String, String> {
10556 let mut out = String::from("Host inspection: gpo\n\n");
10557
10558 #[cfg(target_os = "windows")]
10559 {
10560 let output = Command::new("gpresult")
10561 .args(["/r", "/scope", "computer"])
10562 .output()
10563 .ok();
10564
10565 if let Some(o) = output {
10566 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10567 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10568
10569 if stdout.contains("Applied Group Policy Objects") {
10570 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10571 let mut capture = false;
10572 for line in stdout.lines() {
10573 if line.contains("Applied Group Policy Objects") {
10574 capture = true;
10575 } else if capture && line.contains("The following GPOs were not applied") {
10576 break;
10577 }
10578 if capture && !line.trim().is_empty() {
10579 out.push_str(&format!(" {}\n", line.trim()));
10580 }
10581 }
10582 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10583 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10584 } else {
10585 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10586 }
10587 }
10588 }
10589
10590 #[cfg(not(target_os = "windows"))]
10591 {
10592 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10593 }
10594
10595 Ok(out.trim_end().to_string())
10596}
10597
10598fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10599 let mut out = String::from("Host inspection: certificates\n\n");
10600
10601 #[cfg(target_os = "windows")]
10602 {
10603 let ps_cmd = format!(
10604 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10605 $days = ($_.NotAfter - (Get-Date)).Days; \
10606 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10607 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10608 }}",
10609 max_entries
10610 );
10611 let output = Command::new("powershell")
10612 .args(["-NoProfile", "-Command", &ps_cmd])
10613 .output()
10614 .ok();
10615
10616 if let Some(o) = output {
10617 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10618 if !stdout.trim().is_empty() {
10619 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10620 out.push_str(&stdout);
10621 } else {
10622 out.push_str("No certificates found in the Local Machine Personal store.\n");
10623 }
10624 }
10625 }
10626
10627 #[cfg(not(target_os = "windows"))]
10628 {
10629 let _ = max_entries;
10630 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10631 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10633 if Path::new(path).exists() {
10634 out.push_str(&format!(" Cert directory found: {}\n", path));
10635 }
10636 }
10637 }
10638
10639 Ok(out.trim_end().to_string())
10640}
10641
10642fn inspect_integrity() -> Result<String, String> {
10643 let mut out = String::from("Host inspection: integrity\n\n");
10644
10645 #[cfg(target_os = "windows")]
10646 {
10647 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
10648 let output = Command::new("powershell")
10649 .args(["-NoProfile", "-Command", &ps_cmd])
10650 .output()
10651 .ok();
10652
10653 if let Some(o) = output {
10654 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10655 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10656 out.push_str("=== Windows Component Store Health (CBS) ===\n");
10657 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
10658 let repair = val
10659 .get("AutoRepairNeeded")
10660 .and_then(|v| v.as_u64())
10661 .unwrap_or(0);
10662
10663 out.push_str(&format!(
10664 " Corruption Detected: {}\n",
10665 if corrupt != 0 {
10666 "YES (SFC/DISM recommended)"
10667 } else {
10668 "No"
10669 }
10670 ));
10671 out.push_str(&format!(
10672 " Auto-Repair Needed: {}\n",
10673 if repair != 0 { "YES" } else { "No" }
10674 ));
10675
10676 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
10677 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
10678 }
10679 } else {
10680 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
10681 }
10682 }
10683
10684 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
10685 out.push_str(
10686 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
10687 );
10688 }
10689 }
10690
10691 #[cfg(not(target_os = "windows"))]
10692 {
10693 out.push_str("System integrity check (Linux)\n\n");
10694 let pkg_check = Command::new("rpm")
10695 .args(["-Va"])
10696 .output()
10697 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
10698 .ok();
10699 if let Some(o) = pkg_check {
10700 out.push_str(" Package verification system active.\n");
10701 if o.status.success() {
10702 out.push_str(" No major package integrity issues detected.\n");
10703 }
10704 }
10705 }
10706
10707 Ok(out.trim_end().to_string())
10708}
10709
10710fn inspect_domain() -> Result<String, String> {
10711 let mut out = String::from("Host inspection: domain\n\n");
10712
10713 #[cfg(target_os = "windows")]
10714 {
10715 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
10716 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
10717 let output = Command::new("powershell")
10718 .args(["-NoProfile", "-Command", &ps_cmd])
10719 .output()
10720 .ok();
10721
10722 if let Some(o) = output {
10723 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10724 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10725 let part_of_domain = val
10726 .get("PartOfDomain")
10727 .and_then(|v| v.as_bool())
10728 .unwrap_or(false);
10729 let domain = val
10730 .get("Domain")
10731 .and_then(|v| v.as_str())
10732 .unwrap_or("Unknown");
10733 let workgroup = val
10734 .get("Workgroup")
10735 .and_then(|v| v.as_str())
10736 .unwrap_or("Unknown");
10737
10738 out.push_str(&format!(
10739 " Join Status: {}\n",
10740 if part_of_domain {
10741 "DOMAIN JOINED"
10742 } else {
10743 "WORKGROUP"
10744 }
10745 ));
10746 if part_of_domain {
10747 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
10748 } else {
10749 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
10750 }
10751
10752 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
10753 out.push_str(&format!(" NetBIOS Name: {}\n", name));
10754 }
10755 } else {
10756 out.push_str(" Domain identity data unavailable from WMI.\n");
10757 }
10758 } else {
10759 out.push_str(" Domain identity data unavailable from WMI.\n");
10760 }
10761 }
10762
10763 #[cfg(not(target_os = "windows"))]
10764 {
10765 let domainname = Command::new("domainname")
10766 .output()
10767 .ok()
10768 .and_then(|o| String::from_utf8(o.stdout).ok())
10769 .unwrap_or_default();
10770 out.push_str("=== Linux Domain Identity ===\n");
10771 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
10772 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
10773 } else {
10774 out.push_str(" No NIS domain configured.\n");
10775 }
10776 }
10777
10778 Ok(out.trim_end().to_string())
10779}
10780
10781fn inspect_device_health() -> Result<String, String> {
10782 let mut out = String::from("Host inspection: device_health\n\n");
10783
10784 #[cfg(target_os = "windows")]
10785 {
10786 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)\" }";
10787 let output = Command::new("powershell")
10788 .args(["-NoProfile", "-Command", ps_cmd])
10789 .output()
10790 .ok()
10791 .and_then(|o| String::from_utf8(o.stdout).ok())
10792 .unwrap_or_default();
10793
10794 if output.trim().is_empty() {
10795 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
10796 } else {
10797 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
10798 out.push_str(&output);
10799 out.push_str(
10800 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
10801 );
10802 }
10803 }
10804
10805 #[cfg(not(target_os = "windows"))]
10806 {
10807 out.push_str("Checking dmesg for hardware errors...\n");
10808 let dmesg = Command::new("dmesg")
10809 .args(["--level=err,crit,alert"])
10810 .output()
10811 .ok()
10812 .and_then(|o| String::from_utf8(o.stdout).ok())
10813 .unwrap_or_default();
10814 if dmesg.is_empty() {
10815 out.push_str(" No critical hardware errors found in dmesg.\n");
10816 } else {
10817 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
10818 }
10819 }
10820
10821 Ok(out.trim_end().to_string())
10822}
10823
10824fn inspect_drivers(max_entries: usize) -> Result<String, String> {
10825 let mut out = String::from("Host inspection: drivers\n\n");
10826
10827 #[cfg(target_os = "windows")]
10828 {
10829 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
10830 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);
10831 let output = Command::new("powershell")
10832 .args(["-NoProfile", "-Command", &ps_cmd])
10833 .output()
10834 .ok()
10835 .and_then(|o| String::from_utf8(o.stdout).ok())
10836 .unwrap_or_default();
10837
10838 if output.trim().is_empty() {
10839 out.push_str(" No drivers retrieved via WMI.\n");
10840 } else {
10841 out.push_str(&output);
10842 }
10843 }
10844
10845 #[cfg(not(target_os = "windows"))]
10846 {
10847 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
10848 let lsmod = Command::new("lsmod")
10849 .output()
10850 .ok()
10851 .and_then(|o| String::from_utf8(o.stdout).ok())
10852 .unwrap_or_default();
10853 out.push_str(
10854 &lsmod
10855 .lines()
10856 .take(max_entries)
10857 .collect::<Vec<_>>()
10858 .join("\n"),
10859 );
10860 }
10861
10862 Ok(out.trim_end().to_string())
10863}
10864
10865fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
10866 let mut out = String::from("Host inspection: peripherals\n\n");
10867
10868 #[cfg(target_os = "windows")]
10869 {
10870 let _ = max_entries;
10871 out.push_str("=== USB Controllers & Hubs ===\n");
10872 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
10873 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10874 out.push_str(if usb.is_empty() {
10875 " None detected.\n"
10876 } else {
10877 &usb
10878 });
10879
10880 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
10881 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
10882 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10883 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
10884 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10885 out.push_str(&kb);
10886 out.push_str(&mouse);
10887
10888 out.push_str("\n=== Connected Monitors (WMI) ===\n");
10889 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
10890 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10891 out.push_str(if mon.is_empty() {
10892 " No active monitors identified via WMI.\n"
10893 } else {
10894 &mon
10895 });
10896 }
10897
10898 #[cfg(not(target_os = "windows"))]
10899 {
10900 out.push_str("=== Connected USB Devices (lsusb) ===\n");
10901 let lsusb = Command::new("lsusb")
10902 .output()
10903 .ok()
10904 .and_then(|o| String::from_utf8(o.stdout).ok())
10905 .unwrap_or_default();
10906 out.push_str(
10907 &lsusb
10908 .lines()
10909 .take(max_entries)
10910 .collect::<Vec<_>>()
10911 .join("\n"),
10912 );
10913 }
10914
10915 Ok(out.trim_end().to_string())
10916}
10917
10918fn inspect_sessions(max_entries: usize) -> Result<String, String> {
10919 let mut out = String::from("Host inspection: sessions\n\n");
10920
10921 #[cfg(target_os = "windows")]
10922 {
10923 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
10924 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
10925 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
10926}"#;
10927 if let Ok(o) = Command::new("powershell")
10928 .args(["-NoProfile", "-Command", script])
10929 .output()
10930 {
10931 let text = String::from_utf8_lossy(&o.stdout);
10932 let lines: Vec<&str> = text.lines().collect();
10933 if lines.is_empty() {
10934 out.push_str(" No active logon sessions enumerated via WMI.\n");
10935 } else {
10936 for line in lines
10937 .iter()
10938 .take(max_entries)
10939 .filter(|l| !l.trim().is_empty())
10940 {
10941 let parts: Vec<&str> = line.trim().split('|').collect();
10942 if parts.len() == 4 {
10943 let logon_type = match parts[2] {
10944 "2" => "Interactive",
10945 "3" => "Network",
10946 "4" => "Batch",
10947 "5" => "Service",
10948 "7" => "Unlock",
10949 "8" => "NetworkCleartext",
10950 "9" => "NewCredentials",
10951 "10" => "RemoteInteractive",
10952 "11" => "CachedInteractive",
10953 _ => "Other",
10954 };
10955 out.push_str(&format!(
10956 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
10957 parts[0], logon_type, parts[1], parts[3]
10958 ));
10959 }
10960 }
10961 }
10962 } else {
10963 out.push_str(" Active logon session data unavailable from WMI.\n");
10964 }
10965 }
10966
10967 #[cfg(not(target_os = "windows"))]
10968 {
10969 out.push_str("=== Logged-in Users (who) ===\n");
10970 let who = Command::new("who")
10971 .output()
10972 .ok()
10973 .and_then(|o| String::from_utf8(o.stdout).ok())
10974 .unwrap_or_default();
10975 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
10976 }
10977
10978 Ok(out.trim_end().to_string())
10979}
10980
10981async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
10982 let mut out = String::from("Host inspection: disk_benchmark\n\n");
10983 let mut final_path = path;
10984
10985 if !final_path.exists() {
10986 if let Ok(current_exe) = std::env::current_exe() {
10987 out.push_str(&format!(
10988 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
10989 final_path.display()
10990 ));
10991 final_path = current_exe;
10992 } else {
10993 return Err(format!("Target not found: {}", final_path.display()));
10994 }
10995 }
10996
10997 let target = if final_path.is_dir() {
10998 let mut target_file = final_path.join("Cargo.toml");
11000 if !target_file.exists() {
11001 target_file = final_path.join("README.md");
11002 }
11003 if !target_file.exists() {
11004 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11005 }
11006 target_file
11007 } else {
11008 final_path
11009 };
11010
11011 out.push_str(&format!("Target: {}\n", target.display()));
11012 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11013
11014 #[cfg(target_os = "windows")]
11015 {
11016 let script = format!(
11017 r#"
11018$target = "{}"
11019if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11020
11021$diskQueue = @()
11022$readStats = @()
11023$startTime = Get-Date
11024$duration = 5
11025
11026# Background reader job
11027$job = Start-Job -ScriptBlock {{
11028 param($t, $d)
11029 $stop = (Get-Date).AddSeconds($d)
11030 while ((Get-Date) -lt $stop) {{
11031 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11032 }}
11033}} -ArgumentList $target, $duration
11034
11035# Metrics collector loop
11036$stopTime = (Get-Date).AddSeconds($duration)
11037while ((Get-Date) -lt $stopTime) {{
11038 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11039 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11040
11041 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11042 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11043
11044 Start-Sleep -Milliseconds 250
11045}}
11046
11047Stop-Job $job
11048Receive-Job $job | Out-Null
11049Remove-Job $job
11050
11051$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11052$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11053$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11054
11055"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11056"#,
11057 target.display()
11058 );
11059
11060 let output = Command::new("powershell")
11061 .args(["-NoProfile", "-Command", &script])
11062 .output()
11063 .map_err(|e| format!("Benchmark failed: {e}"))?;
11064
11065 let raw = String::from_utf8_lossy(&output.stdout);
11066 let text = raw.trim();
11067
11068 if text.starts_with("ERROR") {
11069 return Err(text.to_string());
11070 }
11071
11072 let mut lines = text.lines();
11073 if let Some(metrics_line) = lines.next() {
11074 let parts: Vec<&str> = metrics_line.split('|').collect();
11075 let mut avg_q = "unknown".to_string();
11076 let mut max_q = "unknown".to_string();
11077 let mut avg_r = "unknown".to_string();
11078
11079 for p in parts {
11080 if let Some((k, v)) = p.split_once(':') {
11081 match k {
11082 "AVG_Q" => avg_q = v.to_string(),
11083 "MAX_Q" => max_q = v.to_string(),
11084 "AVG_R" => avg_r = v.to_string(),
11085 _ => {}
11086 }
11087 }
11088 }
11089
11090 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11091 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11092 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11093 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
11094 out.push_str("\nVerdict: ");
11095 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11096 if q_num > 1.0 {
11097 out.push_str(
11098 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11099 );
11100 } else if q_num > 0.1 {
11101 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11102 } else {
11103 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11104 }
11105 }
11106 }
11107
11108 #[cfg(not(target_os = "windows"))]
11109 {
11110 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11111 out.push_str("Generic disk load simulated.\n");
11112 }
11113
11114 Ok(out)
11115}
11116
11117fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11118 let mut out = String::from("Host inspection: permissions\n\n");
11119 out.push_str(&format!(
11120 "Auditing access control for: {}\n\n",
11121 path.display()
11122 ));
11123
11124 #[cfg(target_os = "windows")]
11125 {
11126 let script = format!(
11127 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11128 path.display()
11129 );
11130 let output = Command::new("powershell")
11131 .args(["-NoProfile", "-Command", &script])
11132 .output()
11133 .map_err(|e| format!("ACL check failed: {e}"))?;
11134
11135 let text = String::from_utf8_lossy(&output.stdout);
11136 if text.trim().is_empty() {
11137 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11138 } else {
11139 out.push_str("=== Windows NTFS Permissions ===\n");
11140 out.push_str(&text);
11141 }
11142 }
11143
11144 #[cfg(not(target_os = "windows"))]
11145 {
11146 let output = Command::new("ls")
11147 .args(["-ld", &path.to_string_lossy()])
11148 .output()
11149 .map_err(|e| format!("ls check failed: {e}"))?;
11150 out.push_str("=== Unix File Permissions ===\n");
11151 out.push_str(&String::from_utf8_lossy(&output.stdout));
11152 }
11153
11154 Ok(out.trim_end().to_string())
11155}
11156
11157fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11158 let mut out = String::from("Host inspection: login_history\n\n");
11159
11160 #[cfg(target_os = "windows")]
11161 {
11162 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11163 out.push_str("Note: This typically requires Administrator elevation.\n\n");
11164
11165 let n = max_entries.clamp(1, 50);
11166 let script = format!(
11167 r#"try {{
11168 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11169 $events | ForEach-Object {{
11170 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11171 # Extract target user name from the XML/Properties if possible
11172 $user = $_.Properties[5].Value
11173 $type = $_.Properties[8].Value
11174 "[$time] User: $user | Type: $type"
11175 }}
11176}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11177 );
11178
11179 let output = Command::new("powershell")
11180 .args(["-NoProfile", "-Command", &script])
11181 .output()
11182 .map_err(|e| format!("Login history query failed: {e}"))?;
11183
11184 let text = String::from_utf8_lossy(&output.stdout);
11185 if text.starts_with("ERROR:") {
11186 out.push_str(&format!("Unable to query Security Log: {}\n", text));
11187 } else if text.trim().is_empty() {
11188 out.push_str("No recent logon events found or access denied.\n");
11189 } else {
11190 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11191 out.push_str(&text);
11192 }
11193 }
11194
11195 #[cfg(not(target_os = "windows"))]
11196 {
11197 let output = Command::new("last")
11198 .args(["-n", &max_entries.to_string()])
11199 .output()
11200 .map_err(|e| format!("last command failed: {e}"))?;
11201 out.push_str("=== Unix Login History (last) ===\n");
11202 out.push_str(&String::from_utf8_lossy(&output.stdout));
11203 }
11204
11205 Ok(out.trim_end().to_string())
11206}
11207
11208fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11209 let mut out = String::from("Host inspection: share_access\n\n");
11210 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11211
11212 #[cfg(target_os = "windows")]
11213 {
11214 let script = format!(
11215 r#"
11216$p = '{}'
11217$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11218if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11219 $res.Reachable = $true
11220 try {{
11221 $null = Get-ChildItem -Path $p -ErrorAction Stop
11222 $res.Readable = $true
11223 }} catch {{
11224 $res.Error = $_.Exception.Message
11225 }}
11226}} else {{
11227 $res.Error = "Server unreachable (Ping failed)"
11228}}
11229"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11230 path.display()
11231 );
11232
11233 let output = Command::new("powershell")
11234 .args(["-NoProfile", "-Command", &script])
11235 .output()
11236 .map_err(|e| format!("Share test failed: {e}"))?;
11237
11238 let text = String::from_utf8_lossy(&output.stdout);
11239 out.push_str("=== Share Triage Results ===\n");
11240 out.push_str(&text);
11241 }
11242
11243 #[cfg(not(target_os = "windows"))]
11244 {
11245 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11246 }
11247
11248 Ok(out.trim_end().to_string())
11249}
11250
11251fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11252 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11253 out.push_str(&format!("Issue: {}\n\n", issue));
11254 out.push_str("Proposed Remediation Steps:\n");
11255 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11256 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11257 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11258 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11259 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11260 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11261 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11262 out.push_str(
11263 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11264 );
11265
11266 Ok(out)
11267}
11268
11269fn inspect_registry_audit() -> Result<String, String> {
11270 let mut out = String::from("Host inspection: registry_audit\n\n");
11271 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11272
11273 #[cfg(target_os = "windows")]
11274 {
11275 let script = r#"
11276$findings = @()
11277
11278# 1. Image File Execution Options (Debugger Hijacking)
11279$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11280if (Test-Path $ifeo) {
11281 Get-ChildItem $ifeo | ForEach-Object {
11282 $p = Get-ItemProperty $_.PSPath
11283 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11284 }
11285}
11286
11287# 2. Winlogon Shell Integrity
11288$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11289$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11290if ($shell -and $shell -ne "explorer.exe") {
11291 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11292}
11293
11294# 3. Session Manager BootExecute
11295$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11296$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11297if ($boot -and $boot -notcontains "autocheck autochk *") {
11298 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11299}
11300
11301if ($findings.Count -eq 0) {
11302 "PASS: No common registry hijacking or shell overrides detected."
11303} else {
11304 $findings -join "`n"
11305}
11306"#;
11307 let output = Command::new("powershell")
11308 .args(["-NoProfile", "-Command", &script])
11309 .output()
11310 .map_err(|e| format!("Registry audit failed: {e}"))?;
11311
11312 let text = String::from_utf8_lossy(&output.stdout);
11313 out.push_str("=== Persistence & Integrity Check ===\n");
11314 out.push_str(&text);
11315 }
11316
11317 #[cfg(not(target_os = "windows"))]
11318 {
11319 out.push_str("Registry auditing is specific to Windows environments.\n");
11320 }
11321
11322 Ok(out.trim_end().to_string())
11323}
11324
11325fn inspect_thermal() -> Result<String, String> {
11326 let mut out = String::from("Host inspection: thermal\n\n");
11327 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11328
11329 #[cfg(target_os = "windows")]
11330 {
11331 let script = r#"
11332$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11333if ($thermal) {
11334 $thermal | ForEach-Object {
11335 $temp = [math]::Round(($_.Temperature - 273.15), 1)
11336 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11337 }
11338} else {
11339 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11340 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11341 "Current CPU Load: $throttling%"
11342}
11343"#;
11344 let output = Command::new("powershell")
11345 .args(["-NoProfile", "-Command", script])
11346 .output()
11347 .map_err(|e| format!("Thermal check failed: {e}"))?;
11348 out.push_str("=== Windows Thermal State ===\n");
11349 out.push_str(&String::from_utf8_lossy(&output.stdout));
11350 }
11351
11352 #[cfg(not(target_os = "windows"))]
11353 {
11354 out.push_str(
11355 "Thermal inspection is currently optimized for Windows performance counters.\n",
11356 );
11357 }
11358
11359 Ok(out.trim_end().to_string())
11360}
11361
11362fn inspect_activation() -> Result<String, String> {
11363 let mut out = String::from("Host inspection: activation\n\n");
11364 out.push_str("Auditing Windows activation and license state...\n\n");
11365
11366 #[cfg(target_os = "windows")]
11367 {
11368 let script = r#"
11369$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11370$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11371"Status: $($xpr.Trim())"
11372"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11373"#;
11374 let output = Command::new("powershell")
11375 .args(["-NoProfile", "-Command", script])
11376 .output()
11377 .map_err(|e| format!("Activation check failed: {e}"))?;
11378 out.push_str("=== Windows License Report ===\n");
11379 out.push_str(&String::from_utf8_lossy(&output.stdout));
11380 }
11381
11382 #[cfg(not(target_os = "windows"))]
11383 {
11384 out.push_str("Windows activation check is specific to the Windows platform.\n");
11385 }
11386
11387 Ok(out.trim_end().to_string())
11388}
11389
11390fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11391 let mut out = String::from("Host inspection: patch_history\n\n");
11392 out.push_str(&format!(
11393 "Listing the last {} installed Windows updates (KBs)...\n\n",
11394 max_entries
11395 ));
11396
11397 #[cfg(target_os = "windows")]
11398 {
11399 let n = max_entries.clamp(1, 50);
11400 let script = format!(
11401 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11402 n
11403 );
11404 let output = Command::new("powershell")
11405 .args(["-NoProfile", "-Command", &script])
11406 .output()
11407 .map_err(|e| format!("Patch history query failed: {e}"))?;
11408 out.push_str("=== Recent HotFixes (KBs) ===\n");
11409 out.push_str(&String::from_utf8_lossy(&output.stdout));
11410 }
11411
11412 #[cfg(not(target_os = "windows"))]
11413 {
11414 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11415 }
11416
11417 Ok(out.trim_end().to_string())
11418}
11419
11420fn inspect_ad_user(identity: &str) -> Result<String, String> {
11423 let mut out = String::from("Host inspection: ad_user\n\n");
11424 let ident = identity.trim();
11425 if ident.is_empty() {
11426 out.push_str("Status: No identity specified. Performing self-discovery...\n");
11427 #[cfg(target_os = "windows")]
11428 {
11429 let script = r#"
11430$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11431"USER: " + $u.Name
11432"SID: " + $u.User.Value
11433"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11434"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11435"#;
11436 let output = Command::new("powershell")
11437 .args(["-NoProfile", "-Command", script])
11438 .output()
11439 .ok();
11440 if let Some(o) = output {
11441 out.push_str(&String::from_utf8_lossy(&o.stdout));
11442 }
11443 }
11444 return Ok(out);
11445 }
11446
11447 #[cfg(target_os = "windows")]
11448 {
11449 let script = format!(
11450 r#"
11451try {{
11452 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11453 "NAME: " + $u.Name
11454 "SID: " + $u.SID
11455 "ENABLED: " + $u.Enabled
11456 "EXPIRED: " + $u.PasswordExpired
11457 "LOGON: " + $u.LastLogonDate
11458 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11459}} catch {{
11460 # Fallback to net user if AD module is missing or fails
11461 $net = net user "{ident}" /domain 2>&1
11462 if ($LASTEXITCODE -eq 0) {{
11463 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11464 }} else {{
11465 "ERROR: " + $_.Exception.Message
11466 }}
11467}}"#
11468 );
11469
11470 let output = Command::new("powershell")
11471 .args(["-NoProfile", "-Command", &script])
11472 .output()
11473 .ok();
11474
11475 if let Some(o) = output {
11476 let stdout = String::from_utf8_lossy(&o.stdout);
11477 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11478 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11479 }
11480 out.push_str(&stdout);
11481 }
11482 }
11483
11484 #[cfg(not(target_os = "windows"))]
11485 {
11486 let _ = ident;
11487 out.push_str("(AD User lookup only available on Windows nodes)\n");
11488 }
11489
11490 Ok(out.trim_end().to_string())
11491}
11492
11493fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11496 let mut out = String::from("Host inspection: dns_lookup\n\n");
11497 let target = name.trim();
11498 if target.is_empty() {
11499 return Err("Missing required target name for dns_lookup.".to_string());
11500 }
11501
11502 #[cfg(target_os = "windows")]
11503 {
11504 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11505 let output = Command::new("powershell")
11506 .args(["-NoProfile", "-Command", &script])
11507 .output()
11508 .ok();
11509 if let Some(o) = output {
11510 let stdout = String::from_utf8_lossy(&o.stdout);
11511 if stdout.trim().is_empty() {
11512 out.push_str(&format!("No {record_type} records found for {target}.\n"));
11513 } else {
11514 out.push_str(&stdout);
11515 }
11516 }
11517 }
11518
11519 #[cfg(not(target_os = "windows"))]
11520 {
11521 let output = Command::new("dig")
11522 .args([target, record_type, "+short"])
11523 .output()
11524 .ok();
11525 if let Some(o) = output {
11526 out.push_str(&String::from_utf8_lossy(&o.stdout));
11527 }
11528 }
11529
11530 Ok(out.trim_end().to_string())
11531}
11532
11533#[cfg(target_os = "windows")]
11536fn ps_exec(script: &str) -> String {
11537 Command::new("powershell")
11538 .args(["-NoProfile", "-NonInteractive", "-Command", script])
11539 .output()
11540 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
11541 .unwrap_or_default()
11542}
11543
11544fn inspect_mdm_enrollment() -> Result<String, String> {
11545 #[cfg(target_os = "windows")]
11546 {
11547 let mut out = String::from("Host inspection: mdm_enrollment\n\n");
11548
11549 out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
11551 let ps_dsreg = r#"
11552$raw = dsregcmd /status 2>$null
11553$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
11554 'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
11555foreach ($line in $raw) {
11556 $t = $line.Trim()
11557 foreach ($f in $fields) {
11558 if ($t -like "$f :*") {
11559 $val = ($t -split ':',2)[1].Trim()
11560 "$f`: $val"
11561 }
11562 }
11563}
11564"#;
11565 match run_powershell(ps_dsreg) {
11566 Ok(o) if !o.trim().is_empty() => {
11567 for line in o.lines() {
11568 let l = line.trim();
11569 if !l.is_empty() {
11570 out.push_str(&format!("- {l}\n"));
11571 }
11572 }
11573 }
11574 Ok(_) => out.push_str(
11575 "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
11576 ),
11577 Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
11578 }
11579
11580 out.push_str("\n=== Enrollment accounts (registry) ===\n");
11582 let ps_enroll = r#"
11583$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
11584if (Test-Path $base) {
11585 $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
11586 if ($accounts) {
11587 foreach ($acct in $accounts) {
11588 $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
11589 $upn = if ($p.UPN) { $p.UPN } else { '(none)' }
11590 $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
11591 $type = switch ($p.EnrollmentType) {
11592 6 { 'MDM' }
11593 13 { 'MAM' }
11594 default { "Type=$($p.EnrollmentType)" }
11595 }
11596 $state = switch ($p.EnrollmentState) {
11597 1 { 'Enrolled' }
11598 2 { 'InProgress' }
11599 6 { 'Unenrolled' }
11600 default { "State=$($p.EnrollmentState)" }
11601 }
11602 "Account: $upn | $type | $state | $server"
11603 }
11604 } else { "No enrollment accounts found under $base" }
11605} else { "Enrollment registry key not found — device is not MDM-enrolled" }
11606"#;
11607 match run_powershell(ps_enroll) {
11608 Ok(o) => {
11609 for line in o.lines() {
11610 let l = line.trim();
11611 if !l.is_empty() {
11612 out.push_str(&format!("- {l}\n"));
11613 }
11614 }
11615 }
11616 Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
11617 }
11618
11619 out.push_str("\n=== MDM services ===\n");
11621 let ps_svc = r#"
11622$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
11623foreach ($n in $names) {
11624 $s = Get-Service -Name $n -ErrorAction SilentlyContinue
11625 if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
11626}
11627"#;
11628 match run_powershell(ps_svc) {
11629 Ok(o) if !o.trim().is_empty() => {
11630 for line in o.lines() {
11631 let l = line.trim();
11632 if !l.is_empty() {
11633 out.push_str(&format!("- {l}\n"));
11634 }
11635 }
11636 }
11637 Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
11638 Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
11639 }
11640
11641 out.push_str("\n=== Recent MDM events (last 24h) ===\n");
11643 let ps_evt = r#"
11644$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
11645 'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
11646$cutoff = (Get-Date).AddHours(-24)
11647$found = $false
11648foreach ($log in $logs) {
11649 $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
11650 Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
11651 foreach ($e in $evts) {
11652 $found = $true
11653 $ts = $e.TimeCreated.ToString('HH:mm')
11654 $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
11655 "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
11656 }
11657}
11658if (-not $found) { "No MDM warning/error events in the last 24 hours" }
11659"#;
11660 match run_powershell(ps_evt) {
11661 Ok(o) => {
11662 for line in o.lines() {
11663 let l = line.trim();
11664 if !l.is_empty() {
11665 out.push_str(&format!("- {l}\n"));
11666 }
11667 }
11668 }
11669 Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
11670 }
11671
11672 out.push_str("\n=== Findings ===\n");
11674 let body = out.clone();
11675 let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
11676 let intune_running = body.contains("IntuneManagementExtension: Running");
11677 let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
11678
11679 if !enrolled {
11680 out.push_str("- NOT ENROLLED: Device shows no active MDM enrollment. If Intune enrollment is expected, check AAD join state and re-run device enrollment from Settings > Accounts > Access work or school.\n");
11681 } else {
11682 out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
11683 if !intune_running {
11684 out.push_str("- WARNING: Intune Management Extension service is not running — policies and app deployments may stall. Check service health and restart if needed.\n");
11685 }
11686 }
11687 if has_errors {
11688 out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
11689 }
11690 if !enrolled && !has_errors {
11691 out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
11692 }
11693
11694 Ok(out)
11695 }
11696
11697 #[cfg(not(target_os = "windows"))]
11698 {
11699 Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
11700 }
11701}
11702
11703fn inspect_hyperv() -> Result<String, String> {
11704 #[cfg(target_os = "windows")]
11705 {
11706 let mut findings: Vec<String> = Vec::new();
11707 let mut out = String::new();
11708
11709 let ps_role = r#"
11711$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
11712$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
11713$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
11714$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
11715"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
11716 $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
11717 $(if ($feature) { $feature.State } else { "Unknown" }),
11718 $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
11719 $(if ($ram) { $ram } else { "0" })
11720"#;
11721 let role_out = ps_exec(ps_role);
11722 out.push_str("=== Hyper-V role state ===\n");
11723
11724 let mut vmms_running = false;
11725 let mut host_ram_bytes: u64 = 0;
11726
11727 if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
11728 let kv: std::collections::HashMap<&str, &str> = line
11729 .split('|')
11730 .filter_map(|p| {
11731 let mut it = p.splitn(2, ':');
11732 Some((it.next()?, it.next()?))
11733 })
11734 .collect();
11735 let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
11736 let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
11737 let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
11738 host_ram_bytes = kv
11739 .get("HostRAMBytes")
11740 .and_then(|v| v.parse().ok())
11741 .unwrap_or(0);
11742
11743 let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
11744 vmms_running = vmms_status.starts_with("Running");
11745
11746 out.push_str(&format!("- Host: {host_name}\n"));
11747 out.push_str(&format!(
11748 "- Hyper-V feature: {}\n",
11749 if hyperv_installed {
11750 "Enabled"
11751 } else {
11752 "Not installed"
11753 }
11754 ));
11755 out.push_str(&format!("- VMMS service: {vmms_status}\n"));
11756 if host_ram_bytes > 0 {
11757 out.push_str(&format!(
11758 "- Host physical RAM: {} GB\n",
11759 host_ram_bytes / 1_073_741_824
11760 ));
11761 }
11762
11763 if !hyperv_installed {
11764 findings.push(
11765 "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
11766 );
11767 } else if !vmms_running {
11768 findings.push(
11769 "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
11770 );
11771 }
11772 } else {
11773 out.push_str("- Could not determine Hyper-V role state\n");
11774 findings.push("Hyper-V does not appear to be installed on this machine.".into());
11775 }
11776
11777 out.push_str("\n=== Virtual machines ===\n");
11779 if vmms_running {
11780 let ps_vms = r#"
11781Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
11782 $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
11783 "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
11784 $_.Name, $_.State, $_.CPUUsage, $ram_gb,
11785 $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
11786 $_.Status, $_.Generation
11787}
11788"#;
11789 let vms_out = ps_exec(ps_vms);
11790 let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
11791
11792 if vm_lines.is_empty() {
11793 out.push_str("- No virtual machines found on this host\n");
11794 } else {
11795 let mut total_ram_bytes: u64 = 0;
11796 let mut saved_vms: Vec<String> = Vec::new();
11797 for line in &vm_lines {
11798 let kv: std::collections::HashMap<&str, &str> = line
11799 .split('|')
11800 .filter_map(|p| {
11801 let mut it = p.splitn(2, ':');
11802 Some((it.next()?, it.next()?))
11803 })
11804 .collect();
11805 let name = kv.get("VM").copied().unwrap_or("Unknown");
11806 let state = kv.get("State").copied().unwrap_or("Unknown");
11807 let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
11808 let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
11809 let uptime = kv.get("Uptime").copied().unwrap_or("Off");
11810 let status = kv.get("Status").copied().unwrap_or("");
11811 let gen = kv.get("Generation").copied().unwrap_or("?");
11812
11813 if let Ok(r) = ram.parse::<f64>() {
11814 total_ram_bytes += (r * 1_073_741_824.0) as u64;
11815 }
11816 if state.eq_ignore_ascii_case("Saved") {
11817 saved_vms.push(name.to_string());
11818 }
11819
11820 out.push_str(&format!(
11821 "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
11822 ));
11823 if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
11824 out.push_str(&format!(" Status: {status}\n"));
11825 }
11826 }
11827
11828 out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
11829 if total_ram_bytes > 0 && host_ram_bytes > 0 {
11830 let pct = (total_ram_bytes * 100) / host_ram_bytes;
11831 out.push_str(&format!(
11832 "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
11833 total_ram_bytes / 1_073_741_824
11834 ));
11835 if pct > 90 {
11836 findings.push(format!(
11837 "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
11838 ));
11839 }
11840 }
11841 if !saved_vms.is_empty() {
11842 findings.push(format!(
11843 "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
11844 saved_vms.join(", ")
11845 ));
11846 }
11847 }
11848 } else {
11849 out.push_str("- VMMS not running — cannot enumerate VMs\n");
11850 }
11851
11852 out.push_str("\n=== VM network switches ===\n");
11854 if vmms_running {
11855 let ps_switches = r#"
11856Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
11857 "Switch:{0}|Type:{1}|Adapter:{2}" -f `
11858 $_.Name, $_.SwitchType,
11859 $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
11860}
11861"#;
11862 let sw_out = ps_exec(ps_switches);
11863 let switch_lines: Vec<&str> = sw_out
11864 .lines()
11865 .filter(|l| l.starts_with("Switch:"))
11866 .collect();
11867
11868 if switch_lines.is_empty() {
11869 out.push_str("- No VM switches configured\n");
11870 } else {
11871 for line in &switch_lines {
11872 let kv: std::collections::HashMap<&str, &str> = line
11873 .split('|')
11874 .filter_map(|p| {
11875 let mut it = p.splitn(2, ':');
11876 Some((it.next()?, it.next()?))
11877 })
11878 .collect();
11879 let name = kv.get("Switch").copied().unwrap_or("Unknown");
11880 let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
11881 let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
11882 out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
11883 }
11884 }
11885 } else {
11886 out.push_str("- VMMS not running — cannot enumerate switches\n");
11887 }
11888
11889 out.push_str("\n=== VM checkpoints ===\n");
11891 if vmms_running {
11892 let ps_checkpoints = r#"
11893$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
11894if ($all) {
11895 $all | ForEach-Object {
11896 "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
11897 $_.Name, $_.VMName,
11898 $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
11899 $_.SnapshotType
11900 }
11901} else {
11902 "NONE"
11903}
11904"#;
11905 let cp_out = ps_exec(ps_checkpoints);
11906 if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
11907 out.push_str("- No checkpoints found\n");
11908 } else {
11909 let cp_lines: Vec<&str> = cp_out
11910 .lines()
11911 .filter(|l| l.starts_with("Checkpoint:"))
11912 .collect();
11913 let mut per_vm: std::collections::HashMap<&str, usize> =
11914 std::collections::HashMap::new();
11915 for line in &cp_lines {
11916 let kv: std::collections::HashMap<&str, &str> = line
11917 .split('|')
11918 .filter_map(|p| {
11919 let mut it = p.splitn(2, ':');
11920 Some((it.next()?, it.next()?))
11921 })
11922 .collect();
11923 let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
11924 let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
11925 let created = kv.get("Created").copied().unwrap_or("");
11926 let cp_type = kv.get("Type").copied().unwrap_or("");
11927 out.push_str(&format!(
11928 "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
11929 ));
11930 *per_vm.entry(vm_name).or_insert(0) += 1;
11931 }
11932 for (vm, count) in &per_vm {
11933 if *count >= 3 {
11934 findings.push(format!(
11935 "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
11936 ));
11937 }
11938 }
11939 }
11940 } else {
11941 out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
11942 }
11943
11944 let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
11945 if findings.is_empty() {
11946 result.push_str("- No Hyper-V health issues detected.\n");
11947 } else {
11948 for f in &findings {
11949 result.push_str(&format!("- Finding: {f}\n"));
11950 }
11951 }
11952 result.push('\n');
11953 result.push_str(&out);
11954 return Ok(result.trim_end().to_string());
11955 }
11956
11957 #[cfg(not(target_os = "windows"))]
11958 Ok(
11959 "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
11960 .into(),
11961 )
11962}
11963
11964fn inspect_ip_config() -> Result<String, String> {
11967 let mut out = String::from("Host inspection: ip_config\n\n");
11968
11969 #[cfg(target_os = "windows")]
11970 {
11971 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
11972 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
11973 '\\n Status: ' + $_.NetAdapter.Status + \
11974 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
11975 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
11976 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11977 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11978 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
11979 }";
11980 let output = Command::new("powershell")
11981 .args(["-NoProfile", "-Command", script])
11982 .output()
11983 .ok();
11984 if let Some(o) = output {
11985 out.push_str(&String::from_utf8_lossy(&o.stdout));
11986 }
11987 }
11988
11989 #[cfg(not(target_os = "windows"))]
11990 {
11991 let output = Command::new("ip").args(["addr", "show"]).output().ok();
11992 if let Some(o) = output {
11993 out.push_str(&String::from_utf8_lossy(&o.stdout));
11994 }
11995 }
11996
11997 Ok(out.trim_end().to_string())
11998}
11999
12000fn inspect_event_query(
12003 event_id: Option<u32>,
12004 log_name: Option<&str>,
12005 source: Option<&str>,
12006 hours: u32,
12007 level: Option<&str>,
12008 max_entries: usize,
12009) -> Result<String, String> {
12010 #[cfg(target_os = "windows")]
12011 {
12012 let mut findings: Vec<String> = Vec::new();
12013
12014 let log = log_name.unwrap_or("*");
12016 let cap = max_entries.min(50);
12017
12018 let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12020 Some("error") | Some("errors") => Some(2u8),
12021 Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12022 Some("information") | Some("info") => Some(4u8),
12023 _ => None,
12024 };
12025
12026 let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12028 if log != "*" {
12029 filter_parts.push(format!("LogName = '{log}'"));
12030 }
12031 if let Some(id) = event_id {
12032 filter_parts.push(format!("Id = {id}"));
12033 }
12034 if let Some(src) = source {
12035 filter_parts.push(format!("ProviderName = '{src}'"));
12036 }
12037 if let Some(lvl) = level_filter {
12038 filter_parts.push(format!("Level = {lvl}"));
12039 }
12040
12041 let filter_ht = filter_parts.join("; ");
12042
12043 let ps = format!(
12044 r#"
12045$filter = @{{ {filter_ht} }}
12046try {{
12047 $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12048 Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12049 @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12050 if ($events) {{
12051 $events | ForEach-Object {{
12052 "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12053 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12054 $_.Id, $_.LevelDisplayName, $_.ProviderName,
12055 ($_.Msg -replace '\|','/')
12056 }}
12057 }} else {{
12058 "NONE"
12059 }}
12060}} catch {{
12061 "ERROR:$($_.Exception.Message)"
12062}}
12063"#
12064 );
12065
12066 let raw = ps_exec(&ps);
12067 let lines: Vec<&str> = raw.lines().collect();
12068
12069 let mut query_desc = format!("last {hours}h");
12071 if let Some(id) = event_id {
12072 query_desc.push_str(&format!(", Event ID {id}"));
12073 }
12074 if let Some(src) = source {
12075 query_desc.push_str(&format!(", source '{src}'"));
12076 }
12077 if log != "*" {
12078 query_desc.push_str(&format!(", log '{log}'"));
12079 }
12080 if let Some(l) = level {
12081 query_desc.push_str(&format!(", level '{l}'"));
12082 }
12083
12084 let mut out = format!("=== Event query: {query_desc} ===\n");
12085
12086 if lines
12087 .iter()
12088 .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12089 {
12090 out.push_str("- No matching events found.\n");
12091 } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12092 let msg = err_line.trim_start_matches("ERROR:").trim();
12093 if is_event_query_no_results_message(msg) {
12094 out.push_str("- No matching events found.\n");
12095 } else {
12096 out.push_str(&format!("- Query error: {msg}\n"));
12097 findings.push(format!("Event query failed: {msg}"));
12098 }
12099 } else {
12100 let event_lines: Vec<&str> = lines
12101 .iter()
12102 .filter(|l| l.starts_with("TIME:"))
12103 .copied()
12104 .collect();
12105 if event_lines.is_empty() {
12106 out.push_str("- No matching events found.\n");
12107 } else {
12108 let mut error_count = 0usize;
12110 let mut warning_count = 0usize;
12111
12112 for line in &event_lines {
12113 let kv: std::collections::HashMap<&str, &str> = line
12114 .split('|')
12115 .filter_map(|p| {
12116 let mut it = p.splitn(2, ':');
12117 Some((it.next()?, it.next()?))
12118 })
12119 .collect();
12120 let time = kv.get("TIME").copied().unwrap_or("?");
12121 let id = kv.get("ID").copied().unwrap_or("?");
12122 let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12123 let src = kv.get("SOURCE").copied().unwrap_or("?");
12124 let msg = kv.get("MSG").copied().unwrap_or("").trim();
12125
12126 let msg_display = if msg.len() > 120 {
12128 format!("{}…", &msg[..120])
12129 } else {
12130 msg.to_string()
12131 };
12132
12133 out.push_str(&format!(
12134 "- [{time}] ID {id} | {lvl} | {src}\n {msg_display}\n"
12135 ));
12136
12137 if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12138 error_count += 1;
12139 } else if lvl.eq_ignore_ascii_case("warning") {
12140 warning_count += 1;
12141 }
12142 }
12143
12144 out.push_str(&format!(
12145 "\n- Total shown: {} event(s)\n",
12146 event_lines.len()
12147 ));
12148
12149 if error_count > 0 {
12150 findings.push(format!(
12151 "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12152 ));
12153 }
12154 if warning_count > 5 {
12155 findings.push(format!(
12156 "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12157 ));
12158 }
12159 }
12160 }
12161
12162 let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12163 if findings.is_empty() {
12164 result.push_str("- No actionable findings from this event query.\n");
12165 } else {
12166 for f in &findings {
12167 result.push_str(&format!("- Finding: {f}\n"));
12168 }
12169 }
12170 result.push('\n');
12171 result.push_str(&out);
12172 return Ok(result.trim_end().to_string());
12173 }
12174
12175 #[cfg(not(target_os = "windows"))]
12176 {
12177 let _ = (event_id, log_name, source, hours, level, max_entries);
12178 Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12179 }
12180}
12181
12182fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12185 let n = max_entries.clamp(5, 50);
12186 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12187 let mut findings: Vec<String> = Vec::new();
12188 #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12189 let mut sections = String::new();
12190
12191 #[cfg(target_os = "windows")]
12192 {
12193 let proc_filter_ps = match process_filter {
12194 Some(proc) => format!(
12195 "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12196 proc.replace('\'', "''")
12197 ),
12198 None => String::new(),
12199 };
12200
12201 let ps = format!(
12202 r#"
12203$results = @()
12204try {{
12205 $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12206 if ($events) {{
12207 foreach ($e in $events) {{
12208 $msg = $e.Message
12209 $app = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12210 $ver = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12211 $mod = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12212 $exc = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12213 $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12214 $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12215 }}
12216 $results
12217 }} else {{ 'NONE' }}
12218}} catch {{ 'ERROR:' + $_.Exception.Message }}
12219"#
12220 );
12221
12222 let raw = ps_exec(&ps);
12223 let text = raw.trim();
12224
12225 let wer_ps = r#"
12227$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12228$count = 0
12229if (Test-Path $wer) {
12230 $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12231}
12232$count
12233"#;
12234 let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12235
12236 if text == "NONE" {
12237 sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12238 } else if text.starts_with("ERROR:") {
12239 let msg = text.trim_start_matches("ERROR:").trim();
12240 sections.push_str(&format!(
12241 "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12242 ));
12243 } else {
12244 let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12245 let crash_count = events
12246 .iter()
12247 .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12248 .count();
12249 let hang_count = events
12250 .iter()
12251 .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12252 .count();
12253
12254 let mut app_counts: std::collections::HashMap<String, usize> =
12256 std::collections::HashMap::new();
12257 for line in &events {
12258 let parts: Vec<&str> = line.splitn(6, '|').collect();
12259 if parts.len() >= 3 {
12260 *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12261 }
12262 }
12263
12264 if crash_count > 0 {
12265 findings.push(format!(
12266 "{crash_count} application crash event(s) — review below for faulting app and exception code."
12267 ));
12268 }
12269 if hang_count > 0 {
12270 findings.push(format!(
12271 "{hang_count} application hang event(s) — process stopped responding."
12272 ));
12273 }
12274 if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12275 if count > 1 {
12276 findings.push(format!(
12277 "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12278 ));
12279 }
12280 }
12281 if wer_count > 10 {
12282 findings.push(format!(
12283 "{wer_count} WER reports archived — elevated crash history on this machine."
12284 ));
12285 }
12286
12287 let filter_note = match process_filter {
12288 Some(p) => format!(" (filtered: {p})"),
12289 None => String::new(),
12290 };
12291 sections.push_str(&format!(
12292 "=== Application crashes and hangs{filter_note} ===\n"
12293 ));
12294
12295 for line in &events {
12296 let parts: Vec<&str> = line.splitn(6, '|').collect();
12297 if parts.len() >= 6 {
12298 let time = parts[0];
12299 let kind = parts[1];
12300 let app = parts[2];
12301 let ver = parts[3];
12302 let module = parts[4];
12303 let exc = parts[5];
12304 let ver_note = if !ver.is_empty() {
12305 format!(" v{ver}")
12306 } else {
12307 String::new()
12308 };
12309 sections.push_str(&format!(" [{time}] {kind}: {app}{ver_note}\n"));
12310 if !module.is_empty() && module != "?" {
12311 let exc_note = if !exc.is_empty() {
12312 format!(" (exc {exc})")
12313 } else {
12314 String::new()
12315 };
12316 sections.push_str(&format!(" faulting module: {module}{exc_note}\n"));
12317 } else if !exc.is_empty() {
12318 sections.push_str(&format!(" exception: {exc}\n"));
12319 }
12320 }
12321 }
12322 sections.push_str(&format!(
12323 "\n Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12324 ));
12325
12326 if wer_count > 0 {
12327 sections.push_str(&format!(
12328 "\n=== Windows Error Reporting ===\n WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12329 ));
12330 }
12331 }
12332 }
12333
12334 #[cfg(not(target_os = "windows"))]
12335 {
12336 let _ = (process_filter, n);
12337 sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12338 }
12339
12340 let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12341 if findings.is_empty() {
12342 result.push_str("- No actionable findings.\n");
12343 } else {
12344 for f in &findings {
12345 result.push_str(&format!("- Finding: {f}\n"));
12346 }
12347 }
12348 result.push('\n');
12349 result.push_str(§ions);
12350 Ok(result.trim_end().to_string())
12351}
12352
12353#[cfg(target_os = "windows")]
12354fn gpu_voltage_telemetry_note() -> String {
12355 let output = Command::new("nvidia-smi")
12356 .args(["--help-query-gpu"])
12357 .output();
12358
12359 match output {
12360 Ok(o) => {
12361 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12362 if text.contains("\"voltage\"") || text.contains("voltage.") {
12363 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12364 } else {
12365 "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()
12366 }
12367 }
12368 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12369 }
12370}
12371
12372#[cfg(target_os = "windows")]
12373fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
12374 if raw == 0 {
12375 return None;
12376 }
12377 if raw & 0x80 != 0 {
12378 let tenths = raw & 0x7f;
12379 return Some(format!(
12380 "{:.1} V (firmware-reported WMI current voltage)",
12381 tenths as f64 / 10.0
12382 ));
12383 }
12384
12385 let legacy = match raw {
12386 1 => Some("5.0 V"),
12387 2 => Some("3.3 V"),
12388 4 => Some("2.9 V"),
12389 _ => None,
12390 }?;
12391 Some(format!(
12392 "{} (legacy WMI voltage capability flag, not live telemetry)",
12393 legacy
12394 ))
12395}
12396
12397async fn inspect_overclocker() -> Result<String, String> {
12398 let mut out = String::from("Host inspection: overclocker\n\n");
12399
12400 #[cfg(target_os = "windows")]
12401 {
12402 out.push_str(
12403 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
12404 );
12405
12406 let nvidia = Command::new("nvidia-smi")
12408 .args([
12409 "--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",
12410 "--format=csv,noheader,nounits",
12411 ])
12412 .output();
12413
12414 if let Ok(o) = nvidia {
12415 let stdout = String::from_utf8_lossy(&o.stdout);
12416 if !stdout.trim().is_empty() {
12417 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
12418 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
12419 if parts.len() >= 10 {
12420 out.push_str(&format!("- Model: {}\n", parts[0]));
12421 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
12422 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
12423 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
12424 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
12425 if !parts[6].eq_ignore_ascii_case("[N/A]") {
12426 out.push_str(&format!("- Power Avg: {} W\n", parts[6]));
12427 }
12428 if !parts[7].eq_ignore_ascii_case("[N/A]") {
12429 out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
12430 }
12431 if !parts[8].eq_ignore_ascii_case("[N/A]") {
12432 out.push_str(&format!("- Power Cap: {} W requested\n", parts[8]));
12433 }
12434 if !parts[9].eq_ignore_ascii_case("[N/A]") {
12435 out.push_str(&format!("- Power Enf: {} W enforced\n", parts[9]));
12436 }
12437 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
12438
12439 if parts.len() > 10 {
12440 let throttle_hex = parts[10];
12441 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
12442 if !reasons.is_empty() {
12443 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
12444 } else {
12445 out.push_str("- Throttling: None (Performance State: Max)\n");
12446 }
12447 }
12448 }
12449 out.push_str("\n");
12450 }
12451 }
12452
12453 out.push_str("=== VOLTAGE TELEMETRY ===\n");
12454 out.push_str(&format!(
12455 "- GPU Voltage: {}\n\n",
12456 gpu_voltage_telemetry_note()
12457 ));
12458
12459 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
12461 let history = gpu_state.history.lock().unwrap();
12462 if history.len() >= 2 {
12463 out.push_str("=== SILICON TRENDS (Session) ===\n");
12464 let first = history.front().unwrap();
12465 let last = history.back().unwrap();
12466
12467 let temp_diff = last.temperature as i32 - first.temperature as i32;
12468 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
12469
12470 let temp_trend = if temp_diff > 1 {
12471 "Rising"
12472 } else if temp_diff < -1 {
12473 "Falling"
12474 } else {
12475 "Stable"
12476 };
12477 let clock_trend = if clock_diff > 10 {
12478 "Increasing"
12479 } else if clock_diff < -10 {
12480 "Decreasing"
12481 } else {
12482 "Stable"
12483 };
12484
12485 out.push_str(&format!(
12486 "- Temperature: {} ({}°C anomaly)\n",
12487 temp_trend, temp_diff
12488 ));
12489 out.push_str(&format!(
12490 "- Core Clock: {} ({} MHz delta)\n",
12491 clock_trend, clock_diff
12492 ));
12493 out.push_str("\n");
12494 }
12495
12496 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))\" }";
12498 let cpu_stats = Command::new("powershell")
12499 .args(["-NoProfile", "-Command", ps_cmd])
12500 .output();
12501
12502 if let Ok(o) = cpu_stats {
12503 let stdout = String::from_utf8_lossy(&o.stdout);
12504 if !stdout.trim().is_empty() {
12505 out.push_str("=== SILICON CORE (CPU) ===\n");
12506 for line in stdout.lines() {
12507 if let Some((path, val)) = line.split_once(':') {
12508 if path.to_lowercase().contains("processor frequency") {
12509 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
12510 } else if path.to_lowercase().contains("% of maximum frequency") {
12511 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
12512 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
12513 if throttle_num < 95.0 {
12514 out.push_str(
12515 " [WARNING] Active downclocking or power-saving detected.\n",
12516 );
12517 }
12518 }
12519 }
12520 }
12521 }
12522 }
12523
12524 let thermal = Command::new("powershell")
12526 .args([
12527 "-NoProfile",
12528 "-Command",
12529 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
12530 ])
12531 .output();
12532 if let Ok(o) = thermal {
12533 let stdout = String::from_utf8_lossy(&o.stdout);
12534 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12535 let temp = if v.is_array() {
12536 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12537 } else {
12538 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12539 };
12540 if temp > 1.0 {
12541 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
12542 }
12543 }
12544 }
12545
12546 let wmi = Command::new("powershell")
12548 .args([
12549 "-NoProfile",
12550 "-Command",
12551 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
12552 ])
12553 .output();
12554
12555 if let Ok(o) = wmi {
12556 let stdout = String::from_utf8_lossy(&o.stdout);
12557 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12558 out.push_str("\n=== HARDWARE DNA ===\n");
12559 out.push_str(&format!(
12560 "- Rated Max: {} MHz\n",
12561 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
12562 ));
12563 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
12564 Some(raw) => {
12565 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
12566 out.push_str(&format!("- CPU Voltage: {}\n", decoded));
12567 } else {
12568 out.push_str(
12569 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
12570 );
12571 }
12572 }
12573 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
12574 }
12575 }
12576 }
12577 }
12578
12579 #[cfg(not(target_os = "windows"))]
12580 {
12581 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
12582 }
12583
12584 Ok(out.trim_end().to_string())
12585}
12586
12587#[cfg(target_os = "windows")]
12589fn decode_nvidia_throttle_reasons(hex: &str) -> String {
12590 let hex = hex.trim().trim_start_matches("0x");
12591 let val = match u64::from_str_radix(hex, 16) {
12592 Ok(v) => v,
12593 Err(_) => return String::new(),
12594 };
12595
12596 if val == 0 {
12597 return String::new();
12598 }
12599
12600 let mut reasons = Vec::new();
12601 if val & 0x01 != 0 {
12602 reasons.push("GPU Idle");
12603 }
12604 if val & 0x02 != 0 {
12605 reasons.push("Applications Clocks Setting");
12606 }
12607 if val & 0x04 != 0 {
12608 reasons.push("SW Power Cap (PL1/PL2)");
12609 }
12610 if val & 0x08 != 0 {
12611 reasons.push("HW Slowdown (Thermal/Power)");
12612 }
12613 if val & 0x10 != 0 {
12614 reasons.push("Sync Boost");
12615 }
12616 if val & 0x20 != 0 {
12617 reasons.push("SW Thermal Slowdown");
12618 }
12619 if val & 0x40 != 0 {
12620 reasons.push("HW Thermal Slowdown");
12621 }
12622 if val & 0x80 != 0 {
12623 reasons.push("HW Power Brake Slowdown");
12624 }
12625 if val & 0x100 != 0 {
12626 reasons.push("Display Clock Setting");
12627 }
12628
12629 reasons.join(", ")
12630}
12631
12632#[cfg(windows)]
12635fn run_powershell(script: &str) -> Result<String, String> {
12636 use std::process::Command;
12637 let out = Command::new("powershell")
12638 .args(["-NoProfile", "-NonInteractive", "-Command", script])
12639 .output()
12640 .map_err(|e| format!("powershell launch failed: {e}"))?;
12641 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
12642}
12643
12644#[cfg(windows)]
12647fn inspect_camera(max_entries: usize) -> Result<String, String> {
12648 let mut out = String::from("=== Camera devices ===\n");
12649
12650 let ps_devices = r#"
12652Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
12653ForEach-Object {
12654 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
12655 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
12656}
12657"#;
12658 match run_powershell(ps_devices) {
12659 Ok(o) if !o.trim().is_empty() => {
12660 for line in o.lines().take(max_entries) {
12661 let l = line.trim();
12662 if !l.is_empty() {
12663 out.push_str(&format!("- {l}\n"));
12664 }
12665 }
12666 }
12667 _ => out.push_str("- No camera devices found via PnP\n"),
12668 }
12669
12670 out.push_str("\n=== Windows camera privacy ===\n");
12672 let ps_privacy = r#"
12673$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
12674$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
12675"Global: $global"
12676$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
12677 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
12678 ForEach-Object {
12679 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
12680 if ($v) { " $($_.PSChildName): $v" }
12681 }
12682$apps
12683"#;
12684 match run_powershell(ps_privacy) {
12685 Ok(o) if !o.trim().is_empty() => {
12686 for line in o.lines().take(max_entries) {
12687 let l = line.trim_end();
12688 if !l.is_empty() {
12689 out.push_str(&format!("{l}\n"));
12690 }
12691 }
12692 }
12693 _ => out.push_str("- Could not read camera privacy registry\n"),
12694 }
12695
12696 out.push_str("\n=== Biometric / Hello camera ===\n");
12698 let ps_bio = r#"
12699Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
12700ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
12701"#;
12702 match run_powershell(ps_bio) {
12703 Ok(o) if !o.trim().is_empty() => {
12704 for line in o.lines().take(max_entries) {
12705 let l = line.trim();
12706 if !l.is_empty() {
12707 out.push_str(&format!("- {l}\n"));
12708 }
12709 }
12710 }
12711 _ => out.push_str("- No biometric devices found\n"),
12712 }
12713
12714 let mut findings: Vec<String> = Vec::new();
12716 if out.contains("Status: Error") || out.contains("Status: Unknown") {
12717 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
12718 }
12719 if out.contains("Global: Deny") {
12720 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());
12721 }
12722
12723 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
12724 if findings.is_empty() {
12725 result.push_str("- No obvious camera or privacy gate issue detected.\n");
12726 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
12727 } else {
12728 for f in &findings {
12729 result.push_str(&format!("- Finding: {f}\n"));
12730 }
12731 }
12732 result.push('\n');
12733 result.push_str(&out);
12734 Ok(result)
12735}
12736
12737#[cfg(not(windows))]
12738fn inspect_camera(_max_entries: usize) -> Result<String, String> {
12739 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
12740}
12741
12742#[cfg(windows)]
12745fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
12746 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
12747
12748 let ps_hello = r#"
12750$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
12751$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
12752$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
12753"PIN-style logon path: $helloKey"
12754"WbioSrvc start type: $faceConfigured"
12755"FingerPrint key present: $pinConfigured"
12756"#;
12757 match run_powershell(ps_hello) {
12758 Ok(o) => {
12759 for line in o.lines().take(max_entries) {
12760 let l = line.trim();
12761 if !l.is_empty() {
12762 out.push_str(&format!("- {l}\n"));
12763 }
12764 }
12765 }
12766 Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
12767 }
12768
12769 out.push_str("\n=== Biometric service ===\n");
12771 let ps_bio_svc = r#"
12772$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
12773if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
12774else { "WbioSrvc not found" }
12775"#;
12776 match run_powershell(ps_bio_svc) {
12777 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
12778 Err(_) => out.push_str("- Could not query biometric service\n"),
12779 }
12780
12781 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
12783 let ps_events = r#"
12784$cutoff = (Get-Date).AddHours(-24)
12785Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
12786ForEach-Object {
12787 $xml = [xml]$_.ToXml()
12788 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
12789 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
12790 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
12791} | Select-Object -First 10
12792"#;
12793 match run_powershell(ps_events) {
12794 Ok(o) if !o.trim().is_empty() => {
12795 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
12796 out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
12797 for line in o.lines().take(max_entries) {
12798 let l = line.trim();
12799 if !l.is_empty() {
12800 out.push_str(&format!(" {l}\n"));
12801 }
12802 }
12803 }
12804 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
12805 }
12806
12807 out.push_str("\n=== Active credential providers ===\n");
12809 let ps_cp = r#"
12810Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
12811ForEach-Object {
12812 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
12813 if ($name) { $name }
12814} | Select-Object -First 15
12815"#;
12816 match run_powershell(ps_cp) {
12817 Ok(o) if !o.trim().is_empty() => {
12818 for line in o.lines().take(max_entries) {
12819 let l = line.trim();
12820 if !l.is_empty() {
12821 out.push_str(&format!("- {l}\n"));
12822 }
12823 }
12824 }
12825 _ => out.push_str("- Could not enumerate credential providers\n"),
12826 }
12827
12828 let mut findings: Vec<String> = Vec::new();
12829 if out.contains("WbioSrvc | Status: Stopped") {
12830 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
12831 }
12832 if out.contains("recent logon failure") && !out.contains("0 recent") {
12833 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
12834 }
12835
12836 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
12837 if findings.is_empty() {
12838 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
12839 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
12840 } else {
12841 for f in &findings {
12842 result.push_str(&format!("- Finding: {f}\n"));
12843 }
12844 }
12845 result.push('\n');
12846 result.push_str(&out);
12847 Ok(result)
12848}
12849
12850#[cfg(not(windows))]
12851fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
12852 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
12853}
12854
12855#[cfg(windows)]
12858fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
12859 let mut out = String::from("=== Installer engines ===\n");
12860
12861 let ps_engines = r#"
12862$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
12863foreach ($name in $services) {
12864 $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
12865 if ($svc) {
12866 $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
12867 $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
12868 "$name | Status: $($svc.Status) | StartType: $startType"
12869 } else {
12870 "$name | Not present"
12871 }
12872}
12873if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
12874 "msiexec.exe | Present: Yes"
12875} else {
12876 "msiexec.exe | Present: No"
12877}
12878"#;
12879 match run_powershell(ps_engines) {
12880 Ok(o) if !o.trim().is_empty() => {
12881 for line in o.lines().take(max_entries + 6) {
12882 let l = line.trim();
12883 if !l.is_empty() {
12884 out.push_str(&format!("- {l}\n"));
12885 }
12886 }
12887 }
12888 _ => out.push_str("- Could not inspect installer engine services\n"),
12889 }
12890
12891 out.push_str("\n=== winget and App Installer ===\n");
12892 let ps_winget = r#"
12893$cmd = Get-Command winget -ErrorAction SilentlyContinue
12894if ($cmd) {
12895 try {
12896 $v = & winget --version 2>$null
12897 if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
12898 } catch { "winget | Present but invocation failed" }
12899} else {
12900 "winget | Missing"
12901}
12902$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
12903if ($appInstaller) {
12904 "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
12905} else {
12906 "DesktopAppInstaller | Status: Missing"
12907}
12908"#;
12909 match run_powershell(ps_winget) {
12910 Ok(o) if !o.trim().is_empty() => {
12911 for line in o.lines().take(max_entries) {
12912 let l = line.trim();
12913 if !l.is_empty() {
12914 out.push_str(&format!("- {l}\n"));
12915 }
12916 }
12917 }
12918 _ => out.push_str("- Could not inspect winget/App Installer state\n"),
12919 }
12920
12921 out.push_str("\n=== Microsoft Store packages ===\n");
12922 let ps_store = r#"
12923$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
12924if ($store) {
12925 "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
12926} else {
12927 "Microsoft.WindowsStore | Status: Missing"
12928}
12929"#;
12930 match run_powershell(ps_store) {
12931 Ok(o) if !o.trim().is_empty() => {
12932 for line in o.lines().take(max_entries) {
12933 let l = line.trim();
12934 if !l.is_empty() {
12935 out.push_str(&format!("- {l}\n"));
12936 }
12937 }
12938 }
12939 _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
12940 }
12941
12942 out.push_str("\n=== Reboot and transaction blockers ===\n");
12943 let ps_blockers = r#"
12944$pending = $false
12945if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
12946 "RebootPending: CBS"
12947 $pending = $true
12948}
12949if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
12950 "RebootPending: WindowsUpdate"
12951 $pending = $true
12952}
12953$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
12954if ($rename) {
12955 "PendingFileRenameOperations: Yes"
12956 $pending = $true
12957}
12958if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
12959 "InstallerInProgress: Yes"
12960 $pending = $true
12961}
12962if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
12963"#;
12964 match run_powershell(ps_blockers) {
12965 Ok(o) if !o.trim().is_empty() => {
12966 for line in o.lines().take(max_entries) {
12967 let l = line.trim();
12968 if !l.is_empty() {
12969 out.push_str(&format!("- {l}\n"));
12970 }
12971 }
12972 }
12973 _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
12974 }
12975
12976 out.push_str("\n=== Recent installer failures (7d) ===\n");
12977 let ps_failures = r#"
12978$cutoff = (Get-Date).AddDays(-7)
12979$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
12980 ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12981$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
12982 Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
12983 Select-Object -First 6 |
12984 ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12985$all = @($msi) + @($appx)
12986if ($all.Count -eq 0) {
12987 "No recent MSI/AppX installer errors detected"
12988} else {
12989 $all | Select-Object -First 8
12990}
12991"#;
12992 match run_powershell(ps_failures) {
12993 Ok(o) if !o.trim().is_empty() => {
12994 for line in o.lines().take(max_entries + 2) {
12995 let l = line.trim();
12996 if !l.is_empty() {
12997 out.push_str(&format!("- {l}\n"));
12998 }
12999 }
13000 }
13001 _ => out.push_str("- Could not inspect recent installer failure events\n"),
13002 }
13003
13004 let mut findings: Vec<String> = Vec::new();
13005 if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13006 findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13007 }
13008 if out.contains("msiexec.exe | Present: No") {
13009 findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13010 }
13011 if out.contains("winget | Missing") {
13012 findings.push(
13013 "winget is missing - App Installer may not be installed or registered for this user."
13014 .into(),
13015 );
13016 }
13017 if out.contains("DesktopAppInstaller | Status: Missing") {
13018 findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13019 }
13020 if out.contains("Microsoft.WindowsStore | Status: Missing") {
13021 findings.push(
13022 "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13023 .into(),
13024 );
13025 }
13026 if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13027 findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13028 }
13029 if out.contains("InstallerInProgress: Yes") {
13030 findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13031 }
13032 if out.contains("MSI | ") || out.contains("AppX | ") {
13033 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());
13034 }
13035
13036 let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13037 if findings.is_empty() {
13038 result.push_str("- No obvious installer-platform blocker detected.\n");
13039 } else {
13040 for finding in &findings {
13041 result.push_str(&format!("- Finding: {finding}\n"));
13042 }
13043 }
13044 result.push('\n');
13045 result.push_str(&out);
13046 Ok(result)
13047}
13048
13049#[cfg(not(windows))]
13050fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13051 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())
13052}
13053
13054#[cfg(windows)]
13057fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13058 let mut out = String::from("=== OneDrive client ===\n");
13059
13060 let ps_client = r#"
13061$candidatePaths = @(
13062 (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13063 (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13064 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13065) | Where-Object { $_ -and (Test-Path $_) }
13066$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13067$exe = $candidatePaths | Select-Object -First 1
13068if (-not $exe -and $proc) {
13069 try { $exe = $proc.Path } catch {}
13070}
13071if ($exe) {
13072 "Installed: Yes"
13073 "Executable: $exe"
13074 try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13075} else {
13076 "Installed: Unknown"
13077}
13078if ($proc) {
13079 "Process: Running | PID: $($proc.Id)"
13080} else {
13081 "Process: Not running"
13082}
13083"#;
13084 match run_powershell(ps_client) {
13085 Ok(o) if !o.trim().is_empty() => {
13086 for line in o.lines().take(max_entries) {
13087 let l = line.trim();
13088 if !l.is_empty() {
13089 out.push_str(&format!("- {l}\n"));
13090 }
13091 }
13092 }
13093 _ => out.push_str("- Could not inspect OneDrive client state\n"),
13094 }
13095
13096 out.push_str("\n=== OneDrive accounts ===\n");
13097 let ps_accounts = r#"
13098function MaskEmail([string]$Email) {
13099 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13100 $parts = $Email.Split('@', 2)
13101 $local = $parts[0]
13102 $domain = $parts[1]
13103 if ($local.Length -le 1) { return "*@$domain" }
13104 return ($local.Substring(0,1) + "***@" + $domain)
13105}
13106$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13107if (Test-Path $base) {
13108 Get-ChildItem $base -ErrorAction SilentlyContinue |
13109 Sort-Object PSChildName |
13110 Select-Object -First 12 |
13111 ForEach-Object {
13112 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13113 $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13114 $mail = MaskEmail ([string]$p.UserEmail)
13115 $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13116 $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13117 "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13118 }
13119} else {
13120 "No OneDrive accounts configured"
13121}
13122"#;
13123 match run_powershell(ps_accounts) {
13124 Ok(o) if !o.trim().is_empty() => {
13125 for line in o.lines().take(max_entries) {
13126 let l = line.trim();
13127 if !l.is_empty() {
13128 out.push_str(&format!("- {l}\n"));
13129 }
13130 }
13131 }
13132 _ => out.push_str("- Could not read OneDrive account registry state\n"),
13133 }
13134
13135 out.push_str("\n=== OneDrive policy overrides ===\n");
13136 let ps_policy = r#"
13137$paths = @(
13138 'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13139 'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13140)
13141$names = @(
13142 'DisableFileSyncNGSC',
13143 'DisableLibrariesDefaultSaveToOneDrive',
13144 'KFMSilentOptIn',
13145 'KFMBlockOptIn',
13146 'SilentAccountConfig'
13147)
13148$found = $false
13149foreach ($path in $paths) {
13150 if (Test-Path $path) {
13151 $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13152 foreach ($name in $names) {
13153 $value = $p.$name
13154 if ($null -ne $value -and [string]$value -ne '') {
13155 "$path | $name=$value"
13156 $found = $true
13157 }
13158 }
13159 }
13160}
13161if (-not $found) { "No OneDrive policy overrides detected" }
13162"#;
13163 match run_powershell(ps_policy) {
13164 Ok(o) if !o.trim().is_empty() => {
13165 for line in o.lines().take(max_entries) {
13166 let l = line.trim();
13167 if !l.is_empty() {
13168 out.push_str(&format!("- {l}\n"));
13169 }
13170 }
13171 }
13172 _ => out.push_str("- Could not read OneDrive policy state\n"),
13173 }
13174
13175 out.push_str("\n=== Known Folder Backup ===\n");
13176 let ps_kfm = r#"
13177$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13178$roots = @()
13179if (Test-Path $base) {
13180 Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13181 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13182 if ($p.UserFolder) {
13183 $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13184 }
13185 }
13186}
13187$roots = $roots | Select-Object -Unique
13188$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13189if (Test-Path $shell) {
13190 $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13191 $folders = @(
13192 @{ Name='Desktop'; Value=$props.Desktop },
13193 @{ Name='Documents'; Value=$props.Personal },
13194 @{ Name='Pictures'; Value=$props.'My Pictures' }
13195 )
13196 foreach ($folder in $folders) {
13197 $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13198 if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13199 $protected = $false
13200 foreach ($root in $roots) {
13201 if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13202 $protected = $true
13203 break
13204 }
13205 }
13206 "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13207 }
13208} else {
13209 "Explorer shell folders unavailable"
13210}
13211"#;
13212 match run_powershell(ps_kfm) {
13213 Ok(o) if !o.trim().is_empty() => {
13214 for line in o.lines().take(max_entries) {
13215 let l = line.trim();
13216 if !l.is_empty() {
13217 out.push_str(&format!("- {l}\n"));
13218 }
13219 }
13220 }
13221 _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13222 }
13223
13224 let mut findings: Vec<String> = Vec::new();
13225 if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13226 findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13227 }
13228 if out.contains("No OneDrive accounts configured") {
13229 findings.push(
13230 "No OneDrive accounts are configured - sync cannot start until the user signs in."
13231 .into(),
13232 );
13233 }
13234 if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13235 findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13236 }
13237 if out.contains("Exists: No") {
13238 findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13239 }
13240 if out.contains("DisableFileSyncNGSC=1") {
13241 findings
13242 .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13243 }
13244 if out.contains("KFMBlockOptIn=1") {
13245 findings
13246 .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13247 }
13248 if out.contains("SyncRoot: C:\\") {
13249 let mut missing_kfm: Vec<&str> = Vec::new();
13250 for folder in ["Desktop", "Documents", "Pictures"] {
13251 if out.lines().any(|line| {
13252 line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13253 }) {
13254 missing_kfm.push(folder);
13255 }
13256 }
13257 if !missing_kfm.is_empty() {
13258 findings.push(format!(
13259 "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13260 missing_kfm.join(", ")
13261 ));
13262 }
13263 }
13264
13265 let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13266 if findings.is_empty() {
13267 result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13268 } else {
13269 for finding in &findings {
13270 result.push_str(&format!("- Finding: {finding}\n"));
13271 }
13272 }
13273 result.push('\n');
13274 result.push_str(&out);
13275 Ok(result)
13276}
13277
13278#[cfg(not(windows))]
13279fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13280 Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13281}
13282
13283#[cfg(windows)]
13284fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13285 let mut out = String::from("=== Browser inventory ===\n");
13286
13287 let ps_inventory = r#"
13288$browsers = @(
13289 @{ Name='Edge'; Paths=@(
13290 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13291 (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13292 ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13293 @{ Name='Chrome'; Paths=@(
13294 (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13295 (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13296 (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13297 ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13298 @{ Name='Firefox'; Paths=@(
13299 (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13300 (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13301 ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13302)
13303foreach ($browser in $browsers) {
13304 $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13305 if ($exe) {
13306 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13307 $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13308 "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13309 } else {
13310 "$($browser.Name) | Installed: No"
13311 }
13312}
13313$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13314$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13315$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13316"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13317"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13318"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13319"#;
13320 match run_powershell(ps_inventory) {
13321 Ok(o) if !o.trim().is_empty() => {
13322 for line in o.lines().take(max_entries + 6) {
13323 let l = line.trim();
13324 if !l.is_empty() {
13325 out.push_str(&format!("- {l}\n"));
13326 }
13327 }
13328 }
13329 _ => out.push_str("- Could not inspect installed browser inventory\n"),
13330 }
13331
13332 out.push_str("\n=== Runtime state ===\n");
13333 let ps_runtime = r#"
13334$targets = 'msedge','chrome','firefox','msedgewebview2'
13335foreach ($name in $targets) {
13336 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13337 if ($procs) {
13338 $count = @($procs).Count
13339 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13340 "$name | Processes: $count | WorkingSetMB: $wsMb"
13341 } else {
13342 "$name | Processes: 0 | WorkingSetMB: 0"
13343 }
13344}
13345"#;
13346 match run_powershell(ps_runtime) {
13347 Ok(o) if !o.trim().is_empty() => {
13348 for line in o.lines().take(max_entries + 4) {
13349 let l = line.trim();
13350 if !l.is_empty() {
13351 out.push_str(&format!("- {l}\n"));
13352 }
13353 }
13354 }
13355 _ => out.push_str("- Could not inspect browser runtime state\n"),
13356 }
13357
13358 out.push_str("\n=== WebView2 runtime ===\n");
13359 let ps_webview = r#"
13360$paths = @(
13361 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13362 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13363) | Where-Object { $_ -and (Test-Path $_) }
13364$runtimeDir = $paths | ForEach-Object {
13365 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13366 Where-Object { $_.Name -match '^\d+\.' } |
13367 Sort-Object Name -Descending |
13368 Select-Object -First 1
13369} | Select-Object -First 1
13370if ($runtimeDir) {
13371 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13372 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13373 "Installed: Yes"
13374 "Version: $version"
13375 "Executable: $exe"
13376} else {
13377 "Installed: No"
13378}
13379$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
13380"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
13381"#;
13382 match run_powershell(ps_webview) {
13383 Ok(o) if !o.trim().is_empty() => {
13384 for line in o.lines().take(max_entries) {
13385 let l = line.trim();
13386 if !l.is_empty() {
13387 out.push_str(&format!("- {l}\n"));
13388 }
13389 }
13390 }
13391 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13392 }
13393
13394 out.push_str("\n=== Policy and proxy surface ===\n");
13395 let ps_policy = r#"
13396$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
13397$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
13398$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
13399$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
13400$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
13401"UserProxyEnabled: $proxyEnabled"
13402"UserProxyServer: $proxyServer"
13403"UserAutoConfigURL: $autoConfig"
13404"UserAutoDetect: $autoDetect"
13405$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
13406if ($winhttp) {
13407 $normalized = ($winhttp -replace '\s+', ' ').Trim()
13408 $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
13409 "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
13410 "WinHTTP: $normalized"
13411}
13412$policyTargets = @(
13413 @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
13414 @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
13415)
13416foreach ($policy in $policyTargets) {
13417 if (Test-Path $policy.Path) {
13418 $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
13419 foreach ($key in $policy.Keys) {
13420 $value = $item.$key
13421 if ($null -ne $value -and [string]$value -ne '') {
13422 if ($value -is [array]) {
13423 "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
13424 } else {
13425 "$($policy.Name)Policy | $key=$value"
13426 }
13427 }
13428 }
13429 }
13430}
13431"#;
13432 match run_powershell(ps_policy) {
13433 Ok(o) if !o.trim().is_empty() => {
13434 for line in o.lines().take(max_entries + 8) {
13435 let l = line.trim();
13436 if !l.is_empty() {
13437 out.push_str(&format!("- {l}\n"));
13438 }
13439 }
13440 }
13441 _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
13442 }
13443
13444 out.push_str("\n=== Profile and cache pressure ===\n");
13445 let ps_profiles = r#"
13446$profiles = @(
13447 @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
13448 @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
13449 @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
13450)
13451foreach ($profile in $profiles) {
13452 if (Test-Path $profile.Root) {
13453 if ($profile.Name -eq 'Firefox') {
13454 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
13455 } else {
13456 $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
13457 Where-Object {
13458 $_.Name -eq 'Default' -or
13459 $_.Name -eq 'Guest Profile' -or
13460 $_.Name -eq 'System Profile' -or
13461 $_.Name -like 'Profile *'
13462 }
13463 }
13464 $profileCount = @($dirs).Count
13465 $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
13466 if (-not $sizeBytes) { $sizeBytes = 0 }
13467 $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
13468 $extCount = 'Unknown'
13469 if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
13470 $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
13471 }
13472 "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
13473 } else {
13474 "$($profile.Name) | ProfileRoot: Missing"
13475 }
13476}
13477"#;
13478 match run_powershell(ps_profiles) {
13479 Ok(o) if !o.trim().is_empty() => {
13480 for line in o.lines().take(max_entries + 4) {
13481 let l = line.trim();
13482 if !l.is_empty() {
13483 out.push_str(&format!("- {l}\n"));
13484 }
13485 }
13486 }
13487 _ => out.push_str("- Could not inspect browser profile pressure\n"),
13488 }
13489
13490 out.push_str("\n=== Recent browser failures (7d) ===\n");
13491 let ps_failures = r#"
13492$cutoff = (Get-Date).AddDays(-7)
13493$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
13494$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
13495 Where-Object {
13496 $msg = [string]$_.Message
13497 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
13498 ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
13499 } |
13500 Select-Object -First 6
13501if ($events) {
13502 foreach ($event in $events) {
13503 $msg = ($event.Message -replace '\s+', ' ')
13504 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13505 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13506 }
13507} else {
13508 "No recent browser crash or WER events detected"
13509}
13510"#;
13511 match run_powershell(ps_failures) {
13512 Ok(o) if !o.trim().is_empty() => {
13513 for line in o.lines().take(max_entries + 2) {
13514 let l = line.trim();
13515 if !l.is_empty() {
13516 out.push_str(&format!("- {l}\n"));
13517 }
13518 }
13519 }
13520 _ => out.push_str("- Could not inspect recent browser failure events\n"),
13521 }
13522
13523 let mut findings: Vec<String> = Vec::new();
13524 if out.contains("Edge | Installed: No")
13525 && out.contains("Chrome | Installed: No")
13526 && out.contains("Firefox | Installed: No")
13527 {
13528 findings.push(
13529 "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
13530 .into(),
13531 );
13532 }
13533 if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
13534 findings.push(
13535 "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
13536 .into(),
13537 );
13538 }
13539 if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
13540 findings.push(
13541 "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
13542 .into(),
13543 );
13544 }
13545 if out.contains("EdgePolicy | Proxy")
13546 || out.contains("ChromePolicy | Proxy")
13547 || out.contains("ExtensionInstallForcelist=")
13548 {
13549 findings.push(
13550 "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
13551 .into(),
13552 );
13553 }
13554 for browser in ["msedge", "chrome", "firefox"] {
13555 let process_marker = format!("{browser} | Processes: ");
13556 if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
13557 let count = line
13558 .split("| Processes: ")
13559 .nth(1)
13560 .and_then(|rest| rest.split(" |").next())
13561 .and_then(|value| value.trim().parse::<usize>().ok())
13562 .unwrap_or(0);
13563 let ws_mb = line
13564 .split("| WorkingSetMB: ")
13565 .nth(1)
13566 .and_then(|value| value.trim().parse::<f64>().ok())
13567 .unwrap_or(0.0);
13568 if count >= 25 {
13569 findings.push(format!(
13570 "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
13571 ));
13572 } else if ws_mb >= 2500.0 {
13573 findings.push(format!(
13574 "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
13575 ));
13576 }
13577 }
13578 }
13579 if out.contains("=== WebView2 runtime ===\n- Installed: No")
13580 || (out.contains("=== WebView2 runtime ===")
13581 && out.contains("- Installed: No")
13582 && out.contains("- ProcessCount: 0"))
13583 {
13584 findings.push(
13585 "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
13586 .into(),
13587 );
13588 }
13589 for browser in ["Edge", "Chrome", "Firefox"] {
13590 let prefix = format!("{browser} | ProfileRoot:");
13591 if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
13592 let size_gb = line
13593 .split("| SizeGB: ")
13594 .nth(1)
13595 .and_then(|rest| rest.split(" |").next())
13596 .and_then(|value| value.trim().parse::<f64>().ok())
13597 .unwrap_or(0.0);
13598 let ext_count = line
13599 .split("| Extensions: ")
13600 .nth(1)
13601 .and_then(|value| value.trim().parse::<usize>().ok())
13602 .unwrap_or(0);
13603 if size_gb >= 2.5 {
13604 findings.push(format!(
13605 "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
13606 ));
13607 }
13608 if ext_count >= 20 {
13609 findings.push(format!(
13610 "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
13611 ));
13612 }
13613 }
13614 }
13615 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13616 findings.push(
13617 "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
13618 .into(),
13619 );
13620 }
13621
13622 let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
13623 if findings.is_empty() {
13624 result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
13625 } else {
13626 for finding in &findings {
13627 result.push_str(&format!("- Finding: {finding}\n"));
13628 }
13629 }
13630 result.push('\n');
13631 result.push_str(&out);
13632 Ok(result)
13633}
13634
13635#[cfg(not(windows))]
13636fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
13637 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())
13638}
13639
13640#[cfg(windows)]
13641fn inspect_outlook(max_entries: usize) -> Result<String, String> {
13642 let mut out = String::from("=== Outlook install inventory ===\n");
13643
13644 let ps_install = r#"
13645$installPaths = @(
13646 (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13647 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13648 (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
13649 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
13650 (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
13651 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
13652)
13653$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13654if ($exe) {
13655 $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13656 $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
13657 "Installed: Yes"
13658 "Executable: $exe"
13659 "Version: $version"
13660 "Product: $productName"
13661} else {
13662 "Installed: No"
13663}
13664$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
13665if ($newOutlook) {
13666 "NewOutlook: Installed | Version: $($newOutlook.Version)"
13667} else {
13668 "NewOutlook: Not installed"
13669}
13670"#;
13671 match run_powershell(ps_install) {
13672 Ok(o) if !o.trim().is_empty() => {
13673 for line in o.lines().take(max_entries + 4) {
13674 let l = line.trim();
13675 if !l.is_empty() {
13676 out.push_str(&format!("- {l}\n"));
13677 }
13678 }
13679 }
13680 _ => out.push_str("- Could not inspect Outlook install paths\n"),
13681 }
13682
13683 out.push_str("\n=== Runtime state ===\n");
13684 let ps_runtime = r#"
13685$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
13686if ($proc) {
13687 $count = @($proc).Count
13688 $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13689 $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
13690 "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
13691} else {
13692 "Running: No"
13693}
13694"#;
13695 match run_powershell(ps_runtime) {
13696 Ok(o) if !o.trim().is_empty() => {
13697 for line in o.lines().take(4) {
13698 let l = line.trim();
13699 if !l.is_empty() {
13700 out.push_str(&format!("- {l}\n"));
13701 }
13702 }
13703 }
13704 _ => out.push_str("- Could not inspect Outlook runtime state\n"),
13705 }
13706
13707 out.push_str("\n=== Mail profiles ===\n");
13708 let ps_profiles = r#"
13709$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
13710if (-not (Test-Path $profileKey)) {
13711 $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
13712}
13713if (Test-Path $profileKey) {
13714 $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
13715 $count = @($profiles).Count
13716 "ProfileCount: $count"
13717 foreach ($p in $profiles | Select-Object -First 10) {
13718 "Profile: $($p.PSChildName)"
13719 }
13720} else {
13721 "ProfileCount: 0"
13722 "No Outlook profiles found in registry"
13723}
13724"#;
13725 match run_powershell(ps_profiles) {
13726 Ok(o) if !o.trim().is_empty() => {
13727 for line in o.lines().take(max_entries + 2) {
13728 let l = line.trim();
13729 if !l.is_empty() {
13730 out.push_str(&format!("- {l}\n"));
13731 }
13732 }
13733 }
13734 _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
13735 }
13736
13737 out.push_str("\n=== OST and PST data files ===\n");
13738 let ps_datafiles = r#"
13739$searchRoots = @(
13740 (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
13741 (Join-Path $env:USERPROFILE 'Documents'),
13742 (Join-Path $env:USERPROFILE 'OneDrive\Documents')
13743) | Where-Object { $_ -and (Test-Path $_) }
13744$files = foreach ($root in $searchRoots) {
13745 Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
13746 Select-Object FullName,
13747 @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
13748 @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
13749 LastWriteTime
13750}
13751if ($files) {
13752 foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
13753 "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
13754 }
13755} else {
13756 "No OST or PST files found in standard locations"
13757}
13758"#;
13759 match run_powershell(ps_datafiles) {
13760 Ok(o) if !o.trim().is_empty() => {
13761 for line in o.lines().take(max_entries + 4) {
13762 let l = line.trim();
13763 if !l.is_empty() {
13764 out.push_str(&format!("- {l}\n"));
13765 }
13766 }
13767 }
13768 _ => out.push_str("- Could not inspect OST/PST data files\n"),
13769 }
13770
13771 out.push_str("\n=== Add-in pressure ===\n");
13772 let ps_addins = r#"
13773$addinPaths = @(
13774 'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13775 'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13776 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
13777)
13778$addins = foreach ($path in $addinPaths) {
13779 if (Test-Path $path) {
13780 Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
13781 $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13782 $loadBehavior = $item.LoadBehavior
13783 $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
13784 [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
13785 }
13786 }
13787}
13788$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
13789$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
13790"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
13791foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
13792 $state = switch ($a.LoadBehavior) {
13793 0 { 'Disabled' }
13794 2 { 'LoadOnStart(inactive)' }
13795 3 { 'ActiveOnStart' }
13796 8 { 'DemandLoad' }
13797 9 { 'ActiveDemand' }
13798 16 { 'ConnectedFirst' }
13799 default { "LoadBehavior=$($a.LoadBehavior)" }
13800 }
13801 "$($a.Name) | $state"
13802}
13803$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
13804$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
13805if (Test-Path $disabledByResiliency) {
13806 $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
13807 $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
13808 if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
13809}
13810"#;
13811 match run_powershell(ps_addins) {
13812 Ok(o) if !o.trim().is_empty() => {
13813 for line in o.lines().take(max_entries + 8) {
13814 let l = line.trim();
13815 if !l.is_empty() {
13816 out.push_str(&format!("- {l}\n"));
13817 }
13818 }
13819 }
13820 _ => out.push_str("- Could not inspect Outlook add-ins\n"),
13821 }
13822
13823 out.push_str("\n=== Authentication and cache friction ===\n");
13824 let ps_auth = r#"
13825$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
13826$tokenCount = if (Test-Path $tokenCache) {
13827 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
13828} else { 0 }
13829"TokenBrokerCacheFiles: $tokenCount"
13830$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
13831$credsCount = @($credentialManager).Count
13832"OfficeCredentialsInVault: $credsCount"
13833$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
13834if (Test-Path $samlKey) {
13835 $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
13836 $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
13837 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
13838 "WAMOverride: $connected"
13839 "SignedInUserId: $signedIn"
13840}
13841$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
13842if (Test-Path $outlookReg) {
13843 $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
13844 if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
13845}
13846"#;
13847 match run_powershell(ps_auth) {
13848 Ok(o) if !o.trim().is_empty() => {
13849 for line in o.lines().take(max_entries + 4) {
13850 let l = line.trim();
13851 if !l.is_empty() {
13852 out.push_str(&format!("- {l}\n"));
13853 }
13854 }
13855 }
13856 _ => out.push_str("- Could not inspect Outlook auth state\n"),
13857 }
13858
13859 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
13860 let ps_events = r#"
13861$cutoff = (Get-Date).AddDays(-7)
13862$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
13863 Where-Object {
13864 $msg = [string]$_.Message
13865 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
13866 ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
13867 } |
13868 Select-Object -First 8
13869if ($events) {
13870 foreach ($event in $events) {
13871 $msg = ($event.Message -replace '\s+', ' ')
13872 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13873 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13874 }
13875} else {
13876 "No recent Outlook crash or error events detected in Application log"
13877}
13878"#;
13879 match run_powershell(ps_events) {
13880 Ok(o) if !o.trim().is_empty() => {
13881 for line in o.lines().take(max_entries + 4) {
13882 let l = line.trim();
13883 if !l.is_empty() {
13884 out.push_str(&format!("- {l}\n"));
13885 }
13886 }
13887 }
13888 _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
13889 }
13890
13891 let mut findings: Vec<String> = Vec::new();
13892
13893 if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
13894 findings.push(
13895 "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
13896 .into(),
13897 );
13898 }
13899
13900 if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
13901 let ws_mb = line
13902 .split("WorkingSetMB: ")
13903 .nth(1)
13904 .and_then(|r| r.split(" |").next())
13905 .and_then(|v| v.trim().parse::<f64>().ok())
13906 .unwrap_or(0.0);
13907 if ws_mb >= 1500.0 {
13908 findings.push(format!(
13909 "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
13910 ));
13911 }
13912 }
13913
13914 let large_ost: Vec<String> = out
13915 .lines()
13916 .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
13917 .filter_map(|l| {
13918 let mb = l
13919 .split("SizeMB: ")
13920 .nth(1)
13921 .and_then(|r| r.split(" |").next())
13922 .and_then(|v| v.trim().parse::<f64>().ok())
13923 .unwrap_or(0.0);
13924 if mb >= 10_000.0 {
13925 Some(format!("{mb:.0} MB OST file detected"))
13926 } else {
13927 None
13928 }
13929 })
13930 .collect();
13931 for msg in large_ost {
13932 findings.push(format!(
13933 "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
13934 ));
13935 }
13936
13937 if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
13938 let active_count = line
13939 .split("Active: ")
13940 .nth(1)
13941 .and_then(|r| r.split(" |").next())
13942 .and_then(|v| v.trim().parse::<usize>().ok())
13943 .unwrap_or(0);
13944 if active_count >= 8 {
13945 findings.push(format!(
13946 "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
13947 ));
13948 }
13949 }
13950
13951 if out.contains("ResiliencyDisabledItems:") {
13952 findings.push(
13953 "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
13954 .into(),
13955 );
13956 }
13957
13958 if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
13959 findings.push(
13960 "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
13961 .into(),
13962 );
13963 }
13964
13965 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13966 findings.push(
13967 "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)."
13968 .into(),
13969 );
13970 }
13971
13972 let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
13973 if findings.is_empty() {
13974 result.push_str("- No obvious Outlook health blocker detected.\n");
13975 } else {
13976 for finding in &findings {
13977 result.push_str(&format!("- Finding: {finding}\n"));
13978 }
13979 }
13980 result.push('\n');
13981 result.push_str(&out);
13982 Ok(result)
13983}
13984
13985#[cfg(not(windows))]
13986fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
13987 Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
13988}
13989
13990#[cfg(windows)]
13991fn inspect_teams(max_entries: usize) -> Result<String, String> {
13992 let mut out = String::from("=== Teams install inventory ===\n");
13993
13994 let ps_install = r#"
13995# Classic Teams (Teams 1.0)
13996$classicExe = @(
13997 (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
13998 (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
13999) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14000
14001if ($classicExe) {
14002 $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14003 "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14004} else {
14005 "ClassicTeams: Not installed"
14006}
14007
14008# New Teams (Teams 2.0 / ms-teams.exe)
14009$newTeamsExe = @(
14010 (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14011 (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14012) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14013
14014$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14015if ($newTeamsPkg) {
14016 "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14017} elseif ($newTeamsExe) {
14018 $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14019 "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14020} else {
14021 "NewTeams: Not installed"
14022}
14023
14024# Teams Machine-Wide Installer (MSI/per-machine)
14025$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14026 Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14027 Select-Object -First 1
14028if ($mwi) {
14029 "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14030} else {
14031 "MachineWideInstaller: Not found"
14032}
14033"#;
14034 match run_powershell(ps_install) {
14035 Ok(o) if !o.trim().is_empty() => {
14036 for line in o.lines().take(max_entries + 4) {
14037 let l = line.trim();
14038 if !l.is_empty() {
14039 out.push_str(&format!("- {l}\n"));
14040 }
14041 }
14042 }
14043 _ => out.push_str("- Could not inspect Teams install paths\n"),
14044 }
14045
14046 out.push_str("\n=== Runtime state ===\n");
14047 let ps_runtime = r#"
14048$targets = @('Teams','ms-teams')
14049foreach ($name in $targets) {
14050 $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14051 if ($procs) {
14052 $count = @($procs).Count
14053 $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14054 "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14055 } else {
14056 "$name | Running: No"
14057 }
14058}
14059"#;
14060 match run_powershell(ps_runtime) {
14061 Ok(o) if !o.trim().is_empty() => {
14062 for line in o.lines().take(6) {
14063 let l = line.trim();
14064 if !l.is_empty() {
14065 out.push_str(&format!("- {l}\n"));
14066 }
14067 }
14068 }
14069 _ => out.push_str("- Could not inspect Teams runtime state\n"),
14070 }
14071
14072 out.push_str("\n=== Cache directory sizing ===\n");
14073 let ps_cache = r#"
14074$cachePaths = @(
14075 @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14076 @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14077 @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14078 @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14079)
14080foreach ($entry in $cachePaths) {
14081 if (Test-Path $entry.Path) {
14082 $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14083 if (-not $sizeBytes) { $sizeBytes = 0 }
14084 $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14085 "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14086 } else {
14087 "$($entry.Name) | Path: $($entry.Path) | Not found"
14088 }
14089}
14090"#;
14091 match run_powershell(ps_cache) {
14092 Ok(o) if !o.trim().is_empty() => {
14093 for line in o.lines().take(max_entries + 4) {
14094 let l = line.trim();
14095 if !l.is_empty() {
14096 out.push_str(&format!("- {l}\n"));
14097 }
14098 }
14099 }
14100 _ => out.push_str("- Could not inspect Teams cache directories\n"),
14101 }
14102
14103 out.push_str("\n=== WebView2 runtime ===\n");
14104 let ps_webview = r#"
14105$paths = @(
14106 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14107 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14108) | Where-Object { $_ -and (Test-Path $_) }
14109$runtimeDir = $paths | ForEach-Object {
14110 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14111 Where-Object { $_.Name -match '^\d+\.' } |
14112 Sort-Object Name -Descending |
14113 Select-Object -First 1
14114} | Select-Object -First 1
14115if ($runtimeDir) {
14116 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14117 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14118 "Installed: Yes | Version: $version"
14119} else {
14120 "Installed: No -- New Teams and some Office features require WebView2"
14121}
14122"#;
14123 match run_powershell(ps_webview) {
14124 Ok(o) if !o.trim().is_empty() => {
14125 for line in o.lines().take(4) {
14126 let l = line.trim();
14127 if !l.is_empty() {
14128 out.push_str(&format!("- {l}\n"));
14129 }
14130 }
14131 }
14132 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14133 }
14134
14135 out.push_str("\n=== Account and sign-in state ===\n");
14136 let ps_auth = r#"
14137# Classic Teams account registry
14138$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14139if (Test-Path $classicAcct) {
14140 $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14141 $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14142 "ClassicTeamsAccount: $email"
14143} else {
14144 "ClassicTeamsAccount: Not configured"
14145}
14146# WAM / token broker state for Teams
14147$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14148$tokenCount = if (Test-Path $tokenCache) {
14149 @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14150} else { 0 }
14151"TokenBrokerCacheFiles: $tokenCount"
14152# Office identity
14153$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14154if (Test-Path $officeId) {
14155 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14156 $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14157 "OfficeSignedInUserId: $signedIn"
14158}
14159# Check if Teams is in startup
14160$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14161$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14162"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14163"#;
14164 match run_powershell(ps_auth) {
14165 Ok(o) if !o.trim().is_empty() => {
14166 for line in o.lines().take(max_entries + 4) {
14167 let l = line.trim();
14168 if !l.is_empty() {
14169 out.push_str(&format!("- {l}\n"));
14170 }
14171 }
14172 }
14173 _ => out.push_str("- Could not inspect Teams account state\n"),
14174 }
14175
14176 out.push_str("\n=== Audio and video device binding ===\n");
14177 let ps_devices = r#"
14178# Teams stores device prefs in the settings file
14179$settingsPaths = @(
14180 (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14181 (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14182)
14183$found = $false
14184foreach ($sp in $settingsPaths) {
14185 if (Test-Path $sp) {
14186 $found = $true
14187 $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14188 if ($raw) {
14189 $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14190 if ($json) {
14191 $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14192 $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14193 $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14194 "ConfigFile: $sp"
14195 "Microphone: $mic"
14196 "Speaker: $spk"
14197 "Camera: $cam"
14198 } else {
14199 "ConfigFile: $sp (not parseable as JSON)"
14200 }
14201 } else {
14202 "ConfigFile: $sp (empty)"
14203 }
14204 break
14205 }
14206}
14207if (-not $found) {
14208 "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14209}
14210"#;
14211 match run_powershell(ps_devices) {
14212 Ok(o) if !o.trim().is_empty() => {
14213 for line in o.lines().take(max_entries + 4) {
14214 let l = line.trim();
14215 if !l.is_empty() {
14216 out.push_str(&format!("- {l}\n"));
14217 }
14218 }
14219 }
14220 _ => out.push_str("- Could not inspect Teams device binding\n"),
14221 }
14222
14223 out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14224 let ps_events = r#"
14225$cutoff = (Get-Date).AddDays(-7)
14226$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14227 Where-Object {
14228 $msg = [string]$_.Message
14229 ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14230 ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14231 } |
14232 Select-Object -First 8
14233if ($events) {
14234 foreach ($event in $events) {
14235 $msg = ($event.Message -replace '\s+', ' ')
14236 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14237 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14238 }
14239} else {
14240 "No recent Teams crash or error events detected in Application log"
14241}
14242"#;
14243 match run_powershell(ps_events) {
14244 Ok(o) if !o.trim().is_empty() => {
14245 for line in o.lines().take(max_entries + 4) {
14246 let l = line.trim();
14247 if !l.is_empty() {
14248 out.push_str(&format!("- {l}\n"));
14249 }
14250 }
14251 }
14252 _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14253 }
14254
14255 let mut findings: Vec<String> = Vec::new();
14256
14257 let classic_installed = out.contains("- ClassicTeams: Installed");
14258 let new_installed = out.contains("- NewTeams: Installed");
14259 if !classic_installed && !new_installed {
14260 findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14261 }
14262
14263 for name in ["Teams", "ms-teams"] {
14264 let marker = format!("{name} | Running: Yes | Processes:");
14265 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14266 let ws_mb = line
14267 .split("WorkingSetMB: ")
14268 .nth(1)
14269 .and_then(|v| v.trim().parse::<f64>().ok())
14270 .unwrap_or(0.0);
14271 if ws_mb >= 1000.0 {
14272 findings.push(format!(
14273 "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14274 ));
14275 }
14276 }
14277 }
14278
14279 for (label, threshold_mb) in [
14280 ("ClassicTeamsCache", 500.0_f64),
14281 ("ClassicTeamsSquirrel", 2000.0),
14282 ("NewTeamsCache", 500.0),
14283 ("NewTeamsAppData", 3000.0),
14284 ] {
14285 let marker = format!("{label} |");
14286 if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14287 let mb = line
14288 .split("SizeMB: ")
14289 .nth(1)
14290 .and_then(|v| v.trim().parse::<f64>().ok())
14291 .unwrap_or(0.0);
14292 if mb >= threshold_mb {
14293 findings.push(format!(
14294 "{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."
14295 ));
14296 }
14297 }
14298 }
14299
14300 if out.contains("- Installed: No -- New Teams") {
14301 findings.push(
14302 "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14303 .into(),
14304 );
14305 }
14306
14307 if out.contains("- ClassicTeamsAccount: Not configured")
14308 && out.contains("- OfficeSignedInUserId: None")
14309 {
14310 findings.push(
14311 "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14312 .into(),
14313 );
14314 }
14315
14316 if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14317 findings.push(
14318 "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14319 .into(),
14320 );
14321 }
14322
14323 let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14324 if findings.is_empty() {
14325 result.push_str("- No obvious Teams health blocker detected.\n");
14326 } else {
14327 for finding in &findings {
14328 result.push_str(&format!("- Finding: {finding}\n"));
14329 }
14330 }
14331 result.push('\n');
14332 result.push_str(&out);
14333 Ok(result)
14334}
14335
14336#[cfg(not(windows))]
14337fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14338 Ok(
14339 "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14340 .into(),
14341 )
14342}
14343
14344#[cfg(windows)]
14345fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14346 let mut out = String::from("=== Identity broker services ===\n");
14347
14348 let ps_services = r#"
14349$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14350foreach ($name in $serviceNames) {
14351 $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14352 if ($svc) {
14353 "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14354 } else {
14355 "$name | Not found"
14356 }
14357}
14358"#;
14359 match run_powershell(ps_services) {
14360 Ok(o) if !o.trim().is_empty() => {
14361 for line in o.lines().take(max_entries) {
14362 let l = line.trim();
14363 if !l.is_empty() {
14364 out.push_str(&format!("- {l}\n"));
14365 }
14366 }
14367 }
14368 _ => out.push_str("- Could not inspect identity broker services\n"),
14369 }
14370
14371 out.push_str("\n=== Device registration ===\n");
14372 let ps_device = r#"
14373$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
14374if ($dsreg) {
14375 try {
14376 $raw = & $dsreg.Source /status 2>$null
14377 $text = ($raw -join "`n")
14378 $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
14379 $seen = $false
14380 foreach ($key in $keys) {
14381 $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
14382 if ($match.Success) {
14383 "${key}: $($match.Groups[1].Value.Trim())"
14384 $seen = $true
14385 }
14386 }
14387 if (-not $seen) {
14388 "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
14389 }
14390 } catch {
14391 "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
14392 }
14393} else {
14394 "DeviceRegistration: dsregcmd unavailable"
14395}
14396"#;
14397 match run_powershell(ps_device) {
14398 Ok(o) if !o.trim().is_empty() => {
14399 for line in o.lines().take(max_entries + 4) {
14400 let l = line.trim();
14401 if !l.is_empty() {
14402 out.push_str(&format!("- {l}\n"));
14403 }
14404 }
14405 }
14406 _ => out.push_str(
14407 "- DeviceRegistration: Could not inspect device registration state in this session\n",
14408 ),
14409 }
14410
14411 out.push_str("\n=== Broker packages and caches ===\n");
14412 let ps_broker = r#"
14413$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
14414if ($pkg) {
14415 "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
14416} else {
14417 "AADBrokerPlugin: Not installed"
14418}
14419$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14420$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14421"TokenBrokerCacheFiles: $tokenCount"
14422$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
14423$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14424"IdentityCacheFiles: $identityCount"
14425$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
14426$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14427"OneAuthFiles: $oneAuthCount"
14428"#;
14429 match run_powershell(ps_broker) {
14430 Ok(o) if !o.trim().is_empty() => {
14431 for line in o.lines().take(max_entries + 4) {
14432 let l = line.trim();
14433 if !l.is_empty() {
14434 out.push_str(&format!("- {l}\n"));
14435 }
14436 }
14437 }
14438 _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
14439 }
14440
14441 out.push_str("\n=== Microsoft app account signals ===\n");
14442 let ps_accounts = r#"
14443function MaskEmail([string]$Email) {
14444 if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14445 $parts = $Email.Split('@', 2)
14446 $local = $parts[0]
14447 $domain = $parts[1]
14448 if ($local.Length -le 1) { return "*@$domain" }
14449 return ($local.Substring(0,1) + "***@" + $domain)
14450}
14451$allAccounts = @()
14452$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14453if (Test-Path $officeId) {
14454 $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14455 if ($id.SignedInUserId) {
14456 $allAccounts += [string]$id.SignedInUserId
14457 "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
14458 } else {
14459 "OfficeSignedInUserId: None"
14460 }
14461} else {
14462 "OfficeSignedInUserId: Not configured"
14463}
14464$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14465if (Test-Path $teamsAcct) {
14466 $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
14467 $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
14468 if (-not [string]::IsNullOrWhiteSpace($email)) {
14469 $allAccounts += $email
14470 "TeamsAccount: $(MaskEmail $email)"
14471 } else {
14472 "TeamsAccount: Unknown"
14473 }
14474} else {
14475 "TeamsAccount: Not configured"
14476}
14477$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14478$oneDriveEmails = @()
14479if (Test-Path $oneDriveBase) {
14480 $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
14481 ForEach-Object {
14482 $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14483 if ($p.UserEmail) { [string]$p.UserEmail }
14484 } |
14485 Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
14486 Sort-Object -Unique
14487}
14488$allAccounts += $oneDriveEmails
14489"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
14490if (@($oneDriveEmails).Count -gt 0) {
14491 "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14492}
14493$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
14494"DistinctIdentityCount: $($distinct.Count)"
14495if ($distinct.Count -gt 0) {
14496 "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14497}
14498"#;
14499 match run_powershell(ps_accounts) {
14500 Ok(o) if !o.trim().is_empty() => {
14501 for line in o.lines().take(max_entries + 6) {
14502 let l = line.trim();
14503 if !l.is_empty() {
14504 out.push_str(&format!("- {l}\n"));
14505 }
14506 }
14507 }
14508 _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
14509 }
14510
14511 out.push_str("\n=== WebView2 auth dependency ===\n");
14512 let ps_webview = r#"
14513$paths = @(
14514 (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14515 (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14516) | Where-Object { $_ -and (Test-Path $_) }
14517$runtimeDir = $paths | ForEach-Object {
14518 Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14519 Where-Object { $_.Name -match '^\d+\.' } |
14520 Sort-Object Name -Descending |
14521 Select-Object -First 1
14522} | Select-Object -First 1
14523if ($runtimeDir) {
14524 $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14525 $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14526 "WebView2: Installed | Version: $version"
14527} else {
14528 "WebView2: Not installed"
14529}
14530"#;
14531 match run_powershell(ps_webview) {
14532 Ok(o) if !o.trim().is_empty() => {
14533 for line in o.lines().take(4) {
14534 let l = line.trim();
14535 if !l.is_empty() {
14536 out.push_str(&format!("- {l}\n"));
14537 }
14538 }
14539 }
14540 _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14541 }
14542
14543 out.push_str("\n=== Recent auth-related events (24h) ===\n");
14544 let ps_events = r#"
14545try {
14546 $cutoff = (Get-Date).AddHours(-24)
14547 $events = @()
14548 if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
14549 $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
14550 Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
14551 Select-Object -First 4
14552 }
14553 $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
14554 Where-Object {
14555 ($_.LevelDisplayName -in @('Error','Warning')) -and (
14556 $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
14557 -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
14558 )
14559 } |
14560 Select-Object -First 6
14561 $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
14562 "AuthEventCount: $(@($events).Count)"
14563 if ($events) {
14564 foreach ($e in $events) {
14565 $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
14566 'No message'
14567 } else {
14568 ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
14569 }
14570 "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
14571 }
14572 } else {
14573 "No auth-related warning/error events detected"
14574 }
14575} catch {
14576 "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
14577}
14578"#;
14579 match run_powershell(ps_events) {
14580 Ok(o) if !o.trim().is_empty() => {
14581 for line in o.lines().take(max_entries + 8) {
14582 let l = line.trim();
14583 if !l.is_empty() {
14584 out.push_str(&format!("- {l}\n"));
14585 }
14586 }
14587 }
14588 _ => out
14589 .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
14590 }
14591
14592 let parse_count = |prefix: &str| -> Option<u64> {
14593 out.lines().find_map(|line| {
14594 line.trim()
14595 .strip_prefix(prefix)
14596 .and_then(|value| value.trim().parse::<u64>().ok())
14597 })
14598 };
14599
14600 let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
14601 let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
14602
14603 let mut findings: Vec<String> = Vec::new();
14604 if out.contains("TokenBroker | Status: Stopped")
14605 || out.contains("wlidsvc | Status: Stopped")
14606 || out.contains("OneAuth | Status: Stopped")
14607 {
14608 findings.push(
14609 "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."
14610 .into(),
14611 );
14612 }
14613 if out.contains("AADBrokerPlugin: Not installed") {
14614 findings.push(
14615 "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
14616 .into(),
14617 );
14618 }
14619 if out.contains("WebView2: Not installed") {
14620 findings.push(
14621 "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
14622 .into(),
14623 );
14624 }
14625 if distinct_identity_count > 1 {
14626 findings.push(format!(
14627 "{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."
14628 ));
14629 }
14630 if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
14631 && distinct_identity_count > 0
14632 {
14633 findings.push(
14634 "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
14635 .into(),
14636 );
14637 }
14638 if out.contains("DeviceRegistration: dsregcmd")
14639 || out.contains("DeviceRegistration: Could not inspect device registration state")
14640 {
14641 findings.push(
14642 "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."
14643 .into(),
14644 );
14645 }
14646 if auth_event_count > 0 {
14647 findings.push(format!(
14648 "{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."
14649 ));
14650 } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
14651 findings.push(
14652 "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."
14653 .into(),
14654 );
14655 }
14656
14657 let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
14658 if findings.is_empty() {
14659 result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
14660 } else {
14661 for finding in &findings {
14662 result.push_str(&format!("- Finding: {finding}\n"));
14663 }
14664 }
14665 result.push('\n');
14666 result.push_str(&out);
14667 Ok(result)
14668}
14669
14670#[cfg(not(windows))]
14671fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
14672 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())
14673}
14674
14675#[cfg(windows)]
14676fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14677 let mut out = String::from("=== File History ===\n");
14678
14679 let ps_fh = r#"
14680$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
14681if ($svc) {
14682 "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
14683} else {
14684 "FileHistoryService: Not found"
14685}
14686# File History config in registry
14687$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
14688$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
14689if (Test-Path $fhUser) {
14690 $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
14691 $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
14692 $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
14693 $lastBackup = if ($fh.ProtectedUpToTime) {
14694 try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
14695 } else { 'Never' }
14696 "Enabled: $enabled"
14697 "BackupDrive: $target"
14698 "LastBackup: $lastBackup"
14699} else {
14700 "Enabled: Not configured"
14701 "BackupDrive: Not configured"
14702 "LastBackup: Never"
14703}
14704"#;
14705 match run_powershell(ps_fh) {
14706 Ok(o) if !o.trim().is_empty() => {
14707 for line in o.lines().take(6) {
14708 let l = line.trim();
14709 if !l.is_empty() {
14710 out.push_str(&format!("- {l}\n"));
14711 }
14712 }
14713 }
14714 _ => out.push_str("- Could not inspect File History state\n"),
14715 }
14716
14717 out.push_str("\n=== Windows Backup (wbadmin) ===\n");
14718 let ps_wbadmin = r#"
14719$svc = Get-Service wbengine -ErrorAction SilentlyContinue
14720"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
14721# Last backup from wbadmin
14722$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
14723if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
14724 $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
14725 $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
14726 if ($lastDate) { $lastDate.Trim() }
14727 if ($lastTarget) { $lastTarget.Trim() }
14728} else {
14729 "LastWbadminBackup: No backup versions found"
14730}
14731# Task-based backup
14732$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
14733foreach ($t in $task) {
14734 "BackupTask: $($t.TaskName) | State: $($t.State)"
14735}
14736"#;
14737 match run_powershell(ps_wbadmin) {
14738 Ok(o) if !o.trim().is_empty() => {
14739 for line in o.lines().take(8) {
14740 let l = line.trim();
14741 if !l.is_empty() {
14742 out.push_str(&format!("- {l}\n"));
14743 }
14744 }
14745 }
14746 _ => out.push_str("- Could not inspect Windows Backup state\n"),
14747 }
14748
14749 out.push_str("\n=== System Restore ===\n");
14750 let ps_sr = r#"
14751$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
14752 Select-Object -ExpandProperty DeviceID
14753foreach ($drive in $drives) {
14754 $protection = try {
14755 (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
14756 } catch { $null }
14757 $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
14758 $rpConf = try {
14759 Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
14760 } catch { $null }
14761 # Check if SR is disabled for this drive
14762 $disabled = $false
14763 $vssService = Get-Service VSS -ErrorAction SilentlyContinue
14764 "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
14765}
14766# Most recent restore point
14767$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
14768if ($points) {
14769 $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
14770 $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
14771 "MostRecentRestorePoint: $($latest.Description) | Created: $date"
14772} else {
14773 "MostRecentRestorePoint: None found"
14774}
14775$srEnabled = try {
14776 $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
14777 if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
14778} catch { 'Unknown' }
14779"SystemRestoreState: $srEnabled"
14780"#;
14781 match run_powershell(ps_sr) {
14782 Ok(o) if !o.trim().is_empty() => {
14783 for line in o.lines().take(8) {
14784 let l = line.trim();
14785 if !l.is_empty() {
14786 out.push_str(&format!("- {l}\n"));
14787 }
14788 }
14789 }
14790 _ => out.push_str("- Could not inspect System Restore state\n"),
14791 }
14792
14793 out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
14794 let ps_kfm = r#"
14795$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
14796if (Test-Path $kfmKey) {
14797 $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
14798 foreach ($acct in $accounts | Select-Object -First 3) {
14799 $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
14800 $email = $props.UserEmail
14801 $kfmDesktop = $props.'KFMSilentOptInDesktop'
14802 $kfmDocs = $props.'KFMSilentOptInDocuments'
14803 $kfmPics = $props.'KFMSilentOptInPictures'
14804 "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' })"
14805 }
14806} else {
14807 "OneDriveKFM: No OneDrive accounts found"
14808}
14809"#;
14810 match run_powershell(ps_kfm) {
14811 Ok(o) if !o.trim().is_empty() => {
14812 for line in o.lines().take(6) {
14813 let l = line.trim();
14814 if !l.is_empty() {
14815 out.push_str(&format!("- {l}\n"));
14816 }
14817 }
14818 }
14819 _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
14820 }
14821
14822 out.push_str("\n=== Recent backup failure events (7d) ===\n");
14823 let ps_events = r#"
14824$cutoff = (Get-Date).AddDays(-7)
14825$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14826 Where-Object {
14827 $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
14828 ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
14829 } |
14830 Where-Object { $_.Level -le 3 } |
14831 Select-Object -First 6
14832if ($events) {
14833 foreach ($event in $events) {
14834 $msg = ($event.Message -replace '\s+', ' ')
14835 if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14836 "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14837 }
14838} else {
14839 "No recent backup failure events detected"
14840}
14841"#;
14842 match run_powershell(ps_events) {
14843 Ok(o) if !o.trim().is_empty() => {
14844 for line in o.lines().take(8) {
14845 let l = line.trim();
14846 if !l.is_empty() {
14847 out.push_str(&format!("- {l}\n"));
14848 }
14849 }
14850 }
14851 _ => out.push_str("- Could not inspect backup failure events\n"),
14852 }
14853
14854 let mut findings: Vec<String> = Vec::new();
14855
14856 let fh_enabled = out.contains("- Enabled: Enabled");
14857 let fh_never =
14858 out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
14859 let no_wbadmin = out.contains("No backup versions found");
14860 let no_restore_point = out.contains("MostRecentRestorePoint: None found");
14861
14862 if !fh_enabled && no_wbadmin {
14863 findings.push(
14864 "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(),
14865 );
14866 } else if fh_enabled && fh_never {
14867 findings.push(
14868 "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
14869 );
14870 }
14871
14872 if no_restore_point {
14873 findings.push(
14874 "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
14875 );
14876 }
14877
14878 if out.contains("- FileHistoryService: Stopped")
14879 || out.contains("- FileHistoryService: Not found")
14880 {
14881 findings.push(
14882 "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
14883 );
14884 }
14885
14886 if out.contains("Application Error |")
14887 || out.contains("Microsoft-Windows-Backup |")
14888 || out.contains("wbengine |")
14889 {
14890 findings.push(
14891 "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
14892 );
14893 }
14894
14895 let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
14896 if findings.is_empty() {
14897 result.push_str("- No obvious backup health blocker detected.\n");
14898 } else {
14899 for finding in &findings {
14900 result.push_str(&format!("- Finding: {finding}\n"));
14901 }
14902 }
14903 result.push('\n');
14904 result.push_str(&out);
14905 Ok(result)
14906}
14907
14908#[cfg(not(windows))]
14909fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14910 Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
14911}
14912
14913#[cfg(windows)]
14914fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
14915 let mut out = String::from("=== Windows Search service ===\n");
14916
14917 let ps_svc = r#"
14919$svc = Get-Service WSearch -ErrorAction SilentlyContinue
14920if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
14921else { "WSearch service not found" }
14922"#;
14923 match run_powershell(ps_svc) {
14924 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
14925 Err(_) => out.push_str("- Could not query WSearch service\n"),
14926 }
14927
14928 out.push_str("\n=== Indexer state ===\n");
14930 let ps_idx = r#"
14931$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
14932$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
14933if ($props) {
14934 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
14935 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
14936 "DataDirectory: $($props.DataDirectory)"
14937} else { "Registry key not found" }
14938"#;
14939 match run_powershell(ps_idx) {
14940 Ok(o) => {
14941 for line in o.lines() {
14942 let l = line.trim();
14943 if !l.is_empty() {
14944 out.push_str(&format!("- {l}\n"));
14945 }
14946 }
14947 }
14948 Err(_) => out.push_str("- Could not read indexer registry\n"),
14949 }
14950
14951 out.push_str("\n=== Indexed locations ===\n");
14953 let ps_locs = r#"
14954$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
14955if ($comObj) {
14956 $catalog = $comObj.GetCatalog('SystemIndex')
14957 $manager = $catalog.GetCrawlScopeManager()
14958 $rules = $manager.EnumerateRoots()
14959 while ($true) {
14960 try {
14961 $root = $rules.Next(1)
14962 if ($root.Count -eq 0) { break }
14963 $r = $root[0]
14964 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
14965 } catch { break }
14966 }
14967} else { " COM admin interface not available (normal on non-admin sessions)" }
14968"#;
14969 match run_powershell(ps_locs) {
14970 Ok(o) if !o.trim().is_empty() => {
14971 for line in o.lines() {
14972 let l = line.trim_end();
14973 if !l.is_empty() {
14974 out.push_str(&format!("{l}\n"));
14975 }
14976 }
14977 }
14978 _ => {
14979 let ps_reg = r#"
14981Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
14982ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
14983"#;
14984 match run_powershell(ps_reg) {
14985 Ok(o) if !o.trim().is_empty() => {
14986 for line in o.lines() {
14987 let l = line.trim_end();
14988 if !l.is_empty() {
14989 out.push_str(&format!("{l}\n"));
14990 }
14991 }
14992 }
14993 _ => out.push_str(" - Could not enumerate indexed locations\n"),
14994 }
14995 }
14996 }
14997
14998 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15000 let ps_evts = r#"
15001Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15002Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15003ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15004"#;
15005 match run_powershell(ps_evts) {
15006 Ok(o) if !o.trim().is_empty() => {
15007 for line in o.lines() {
15008 let l = line.trim();
15009 if !l.is_empty() {
15010 out.push_str(&format!("- {l}\n"));
15011 }
15012 }
15013 }
15014 _ => out.push_str("- No recent indexer errors found\n"),
15015 }
15016
15017 let mut findings: Vec<String> = Vec::new();
15018 if out.contains("Status: Stopped") {
15019 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15020 }
15021 if out.contains("IsContentIndexingEnabled: 0")
15022 || out.contains("IsContentIndexingEnabled: False")
15023 {
15024 findings.push(
15025 "Content indexing is disabled — file content won't be searchable, only filenames."
15026 .into(),
15027 );
15028 }
15029 if out.contains("SetupCompletedSuccessfully: 0")
15030 || out.contains("SetupCompletedSuccessfully: False")
15031 {
15032 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15033 }
15034
15035 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15036 if findings.is_empty() {
15037 result.push_str("- Windows Search service and indexer appear healthy.\n");
15038 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15039 } else {
15040 for f in &findings {
15041 result.push_str(&format!("- Finding: {f}\n"));
15042 }
15043 }
15044 result.push('\n');
15045 result.push_str(&out);
15046 Ok(result)
15047}
15048
15049#[cfg(not(windows))]
15050fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15051 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15052}
15053
15054#[cfg(windows)]
15057fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15058 let mut out = String::new();
15059
15060 out.push_str("=== Active displays ===\n");
15062 let ps_displays = r#"
15063Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15064Select-Object -First 20 |
15065ForEach-Object {
15066 "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15067}
15068"#;
15069 match run_powershell(ps_displays) {
15070 Ok(o) if !o.trim().is_empty() => {
15071 for line in o.lines().take(max_entries) {
15072 let l = line.trim();
15073 if !l.is_empty() {
15074 out.push_str(&format!("- {l}\n"));
15075 }
15076 }
15077 }
15078 _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15079 }
15080
15081 out.push_str("\n=== Video adapters ===\n");
15083 let ps_gpu = r#"
15084Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15085ForEach-Object {
15086 $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15087 $hz = "$($_.CurrentRefreshRate) Hz"
15088 $bits = "$($_.CurrentBitsPerPixel) bpp"
15089 "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15090}
15091"#;
15092 match run_powershell(ps_gpu) {
15093 Ok(o) if !o.trim().is_empty() => {
15094 for line in o.lines().take(max_entries) {
15095 let l = line.trim();
15096 if !l.is_empty() {
15097 out.push_str(&format!("- {l}\n"));
15098 }
15099 }
15100 }
15101 _ => out.push_str("- Could not query video adapter info\n"),
15102 }
15103
15104 out.push_str("\n=== Connected monitors ===\n");
15106 let ps_monitors = r#"
15107Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15108ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15109"#;
15110 match run_powershell(ps_monitors) {
15111 Ok(o) if !o.trim().is_empty() => {
15112 for line in o.lines().take(max_entries) {
15113 let l = line.trim();
15114 if !l.is_empty() {
15115 out.push_str(&format!("- {l}\n"));
15116 }
15117 }
15118 }
15119 _ => out.push_str("- No monitor info available via WMI\n"),
15120 }
15121
15122 out.push_str("\n=== DPI / scaling ===\n");
15124 let ps_dpi = r#"
15125Add-Type -TypeDefinition @'
15126using System; using System.Runtime.InteropServices;
15127public class DPI {
15128 [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15129 [DllImport("gdi32")] public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15130 [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15131}
15132'@ -ErrorAction SilentlyContinue
15133try {
15134 $hdc = [DPI]::GetDC([IntPtr]::Zero)
15135 $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15136 $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15137 [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15138 $scale = [Math]::Round($dpiX / 96.0 * 100)
15139 "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15140} catch { "DPI query unavailable" }
15141"#;
15142 match run_powershell(ps_dpi) {
15143 Ok(o) if !o.trim().is_empty() => {
15144 out.push_str(&format!("- {}\n", o.trim()));
15145 }
15146 _ => out.push_str("- DPI info unavailable\n"),
15147 }
15148
15149 let mut findings: Vec<String> = Vec::new();
15150 if out.contains("0x0") || out.contains("@ 0 Hz") {
15151 findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15152 }
15153
15154 let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15155 if findings.is_empty() {
15156 result.push_str("- Display configuration appears normal.\n");
15157 } else {
15158 for f in &findings {
15159 result.push_str(&format!("- Finding: {f}\n"));
15160 }
15161 }
15162 result.push('\n');
15163 result.push_str(&out);
15164 Ok(result)
15165}
15166
15167#[cfg(not(windows))]
15168fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15169 Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15170}
15171
15172#[cfg(windows)]
15175fn inspect_ntp() -> Result<String, String> {
15176 let mut out = String::new();
15177
15178 out.push_str("=== Windows Time service ===\n");
15180 let ps_svc = r#"
15181$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15182if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15183else { "W32Time service not found" }
15184"#;
15185 match run_powershell(ps_svc) {
15186 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15187 Err(_) => out.push_str("- Could not query W32Time service\n"),
15188 }
15189
15190 out.push_str("\n=== NTP source and sync status ===\n");
15192 let ps_sync = r#"
15193$q = w32tm /query /status 2>$null
15194if ($q) { $q } else { "w32tm query unavailable" }
15195"#;
15196 match run_powershell(ps_sync) {
15197 Ok(o) if !o.trim().is_empty() => {
15198 for line in o.lines() {
15199 let l = line.trim();
15200 if !l.is_empty() {
15201 out.push_str(&format!(" {l}\n"));
15202 }
15203 }
15204 }
15205 _ => out.push_str(" - Could not query w32tm status\n"),
15206 }
15207
15208 out.push_str("\n=== Configured NTP servers ===\n");
15210 let ps_peers = r#"
15211w32tm /query /peers 2>$null | Select-Object -First 10
15212"#;
15213 match run_powershell(ps_peers) {
15214 Ok(o) if !o.trim().is_empty() => {
15215 for line in o.lines() {
15216 let l = line.trim();
15217 if !l.is_empty() {
15218 out.push_str(&format!(" {l}\n"));
15219 }
15220 }
15221 }
15222 _ => {
15223 let ps_reg = r#"
15225(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15226"#;
15227 match run_powershell(ps_reg) {
15228 Ok(o) if !o.trim().is_empty() => {
15229 out.push_str(&format!(" NtpServer (registry): {}\n", o.trim()));
15230 }
15231 _ => out.push_str(" - Could not enumerate NTP peers\n"),
15232 }
15233 }
15234 }
15235
15236 let mut findings: Vec<String> = Vec::new();
15237 if out.contains("W32Time | Status: Stopped") {
15238 findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15239 }
15240 if out.contains("The computer did not resync") || out.contains("Error") {
15241 findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15242 }
15243
15244 let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15245 if findings.is_empty() {
15246 result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15247 } else {
15248 for f in &findings {
15249 result.push_str(&format!("- Finding: {f}\n"));
15250 }
15251 }
15252 result.push('\n');
15253 result.push_str(&out);
15254 Ok(result)
15255}
15256
15257#[cfg(not(windows))]
15258fn inspect_ntp() -> Result<String, String> {
15259 let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15261
15262 let timedatectl = std::process::Command::new("timedatectl")
15263 .arg("status")
15264 .output();
15265
15266 if let Ok(o) = timedatectl {
15267 let text = String::from_utf8_lossy(&o.stdout);
15268 if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15269 out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15270 } else {
15271 out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15272 }
15273 for line in text.lines() {
15274 let l = line.trim();
15275 if !l.is_empty() {
15276 out.push_str(&format!(" {l}\n"));
15277 }
15278 }
15279 return Ok(out);
15280 }
15281
15282 let sntp = std::process::Command::new("sntp")
15284 .args(["-d", "time.apple.com"])
15285 .output();
15286 if let Ok(o) = sntp {
15287 out.push_str("- NTP check via sntp:\n");
15288 out.push_str(&String::from_utf8_lossy(&o.stdout));
15289 return Ok(out);
15290 }
15291
15292 out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15293 Ok(out)
15294}
15295
15296#[cfg(windows)]
15299fn inspect_cpu_power() -> Result<String, String> {
15300 let mut out = String::new();
15301
15302 out.push_str("=== Active power plan ===\n");
15304 let ps_plan = r#"
15305$plan = powercfg /getactivescheme 2>$null
15306if ($plan) { $plan } else { "Could not query power scheme" }
15307"#;
15308 match run_powershell(ps_plan) {
15309 Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15310 _ => out.push_str("- Could not read active power plan\n"),
15311 }
15312
15313 out.push_str("\n=== Processor performance policy ===\n");
15315 let ps_proc = r#"
15316$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15317$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15318$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15319$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15320if ($min) { "Min processor state: $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15321if ($max) { "Max processor state: $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15322if ($boost) {
15323 $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15324 $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15325 "Turbo boost mode: $bname"
15326}
15327"#;
15328 match run_powershell(ps_proc) {
15329 Ok(o) if !o.trim().is_empty() => {
15330 for line in o.lines() {
15331 let l = line.trim();
15332 if !l.is_empty() {
15333 out.push_str(&format!("- {l}\n"));
15334 }
15335 }
15336 }
15337 _ => out.push_str("- Could not query processor performance settings\n"),
15338 }
15339
15340 out.push_str("\n=== CPU frequency ===\n");
15342 let ps_freq = r#"
15343Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15344ForEach-Object {
15345 $cur = $_.CurrentClockSpeed
15346 $max = $_.MaxClockSpeed
15347 $load = $_.LoadPercentage
15348 "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15349}
15350"#;
15351 match run_powershell(ps_freq) {
15352 Ok(o) if !o.trim().is_empty() => {
15353 for line in o.lines() {
15354 let l = line.trim();
15355 if !l.is_empty() {
15356 out.push_str(&format!("- {l}\n"));
15357 }
15358 }
15359 }
15360 _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15361 }
15362
15363 out.push_str("\n=== Throttling indicators ===\n");
15365 let ps_throttle = r#"
15366$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15367if ($pwr) {
15368 $pwr | Select-Object -First 4 | ForEach-Object {
15369 $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15370 "Thermal zone $($_.InstanceName): ${c}°C"
15371 }
15372} else { "Thermal zone WMI not available (normal on consumer hardware)" }
15373"#;
15374 match run_powershell(ps_throttle) {
15375 Ok(o) if !o.trim().is_empty() => {
15376 for line in o.lines() {
15377 let l = line.trim();
15378 if !l.is_empty() {
15379 out.push_str(&format!("- {l}\n"));
15380 }
15381 }
15382 }
15383 _ => out.push_str("- Thermal zone info unavailable\n"),
15384 }
15385
15386 let mut findings: Vec<String> = Vec::new();
15387 if out.contains("Max processor state: 0%") || out.contains("Max processor state: 1%") {
15388 findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
15389 }
15390 if out.contains("Turbo boost mode: Disabled") {
15391 findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
15392 }
15393 if out.contains("Min processor state: 100%") {
15394 findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
15395 }
15396
15397 let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
15398 if findings.is_empty() {
15399 result.push_str("- CPU power and frequency settings appear normal.\n");
15400 } else {
15401 for f in &findings {
15402 result.push_str(&format!("- Finding: {f}\n"));
15403 }
15404 }
15405 result.push('\n');
15406 result.push_str(&out);
15407 Ok(result)
15408}
15409
15410#[cfg(windows)]
15411fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15412 let mut out = String::new();
15413
15414 out.push_str("=== Credential vault summary ===\n");
15415 let ps_summary = r#"
15416$raw = cmdkey /list 2>&1
15417$lines = $raw -split "`n"
15418$total = ($lines | Where-Object { $_ -match "Target:" }).Count
15419"Total stored credentials: $total"
15420$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
15421$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
15422$cert = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
15423" Windows credentials: $windows"
15424" Generic credentials: $generic"
15425" Certificate-based: $cert"
15426"#;
15427 match run_powershell(ps_summary) {
15428 Ok(o) => {
15429 for line in o.lines() {
15430 let l = line.trim();
15431 if !l.is_empty() {
15432 out.push_str(&format!("- {l}\n"));
15433 }
15434 }
15435 }
15436 Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
15437 }
15438
15439 out.push_str("\n=== Credential targets (up to 20) ===\n");
15440 let ps_list = r#"
15441$raw = cmdkey /list 2>&1
15442$entries = @(); $cur = @{}
15443foreach ($line in ($raw -split "`n")) {
15444 $l = $line.Trim()
15445 if ($l -match "^Target:\s*(.+)") { $cur = @{ Target=$Matches[1] } }
15446 elseif ($l -match "^Type:\s*(.+)" -and $cur.Target) { $cur.Type=$Matches[1] }
15447 elseif ($l -match "^User:\s*(.+)" -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
15448}
15449$entries | Select-Object -Last 20 | ForEach-Object {
15450 "[$($_.Type)] $($_.Target) (user: $($_.User))"
15451}
15452"#;
15453 match run_powershell(ps_list) {
15454 Ok(o) => {
15455 let lines: Vec<&str> = o
15456 .lines()
15457 .map(|l| l.trim())
15458 .filter(|l| !l.is_empty())
15459 .collect();
15460 if lines.is_empty() {
15461 out.push_str("- No credential entries found\n");
15462 } else {
15463 for l in &lines {
15464 out.push_str(&format!("- {l}\n"));
15465 }
15466 }
15467 }
15468 Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
15469 }
15470
15471 let total_creds: usize = {
15472 let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
15473 run_powershell(ps_count)
15474 .ok()
15475 .and_then(|s| s.trim().parse().ok())
15476 .unwrap_or(0)
15477 };
15478
15479 let mut findings: Vec<String> = Vec::new();
15480 if total_creds > 30 {
15481 findings.push(format!(
15482 "{total_creds} stored credentials found — consider auditing for stale entries."
15483 ));
15484 }
15485
15486 let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
15487 if findings.is_empty() {
15488 result.push_str("- Credential store looks normal.\n");
15489 } else {
15490 for f in &findings {
15491 result.push_str(&format!("- Finding: {f}\n"));
15492 }
15493 }
15494 result.push('\n');
15495 result.push_str(&out);
15496 Ok(result)
15497}
15498
15499#[cfg(not(windows))]
15500fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15501 Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
15502}
15503
15504#[cfg(windows)]
15505fn inspect_tpm() -> Result<String, String> {
15506 let mut out = String::new();
15507
15508 out.push_str("=== TPM state ===\n");
15509 let ps_tpm = r#"
15510function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
15511 $text = if ($null -eq $Value) { "" } else { [string]$Value }
15512 if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
15513 "$Name$text"
15514}
15515$t = Get-Tpm -ErrorAction SilentlyContinue
15516if ($t) {
15517 Emit-Field "TpmPresent: " $t.TpmPresent
15518 Emit-Field "TpmReady: " $t.TpmReady
15519 Emit-Field "TpmEnabled: " $t.TpmEnabled
15520 Emit-Field "TpmOwned: " $t.TpmOwned
15521 Emit-Field "RestartPending: " $t.RestartPending
15522 Emit-Field "ManufacturerIdTxt: " $t.ManufacturerIdTxt
15523 Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
15524} else { "TPM module unavailable" }
15525"#;
15526 match run_powershell(ps_tpm) {
15527 Ok(o) => {
15528 for line in o.lines() {
15529 let l = line.trim();
15530 if !l.is_empty() {
15531 out.push_str(&format!("- {l}\n"));
15532 }
15533 }
15534 }
15535 Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
15536 }
15537
15538 out.push_str("\n=== TPM spec version (WMI) ===\n");
15539 let ps_spec = r#"
15540$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
15541if ($wmi) {
15542 $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
15543 "SpecVersion: $spec"
15544 "IsActivated: $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
15545 "IsEnabled: $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
15546 "IsOwned: $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
15547} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
15548"#;
15549 match run_powershell(ps_spec) {
15550 Ok(o) => {
15551 for line in o.lines() {
15552 let l = line.trim();
15553 if !l.is_empty() {
15554 out.push_str(&format!("- {l}\n"));
15555 }
15556 }
15557 }
15558 Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
15559 }
15560
15561 out.push_str("\n=== Secure Boot state ===\n");
15562 let ps_sb = r#"
15563try {
15564 $sb = Confirm-SecureBootUEFI -ErrorAction Stop
15565 if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
15566} catch {
15567 $msg = $_.Exception.Message
15568 if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
15569 "Secure Boot: Unknown (administrator privileges required)"
15570 } elseif ($msg -match "Cmdlet not supported on this platform") {
15571 "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
15572 } else {
15573 "Secure Boot: N/A ($msg)"
15574 }
15575}
15576"#;
15577 match run_powershell(ps_sb) {
15578 Ok(o) => {
15579 for line in o.lines() {
15580 let l = line.trim();
15581 if !l.is_empty() {
15582 out.push_str(&format!("- {l}\n"));
15583 }
15584 }
15585 }
15586 Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
15587 }
15588
15589 out.push_str("\n=== Firmware type ===\n");
15590 let ps_fw = r#"
15591$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
15592switch ($fw) {
15593 1 { "Firmware type: BIOS (Legacy)" }
15594 2 { "Firmware type: UEFI" }
15595 default {
15596 $bcd = bcdedit /enum firmware 2>$null
15597 if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
15598 else { "Firmware type: Unknown or not set" }
15599 }
15600}
15601"#;
15602 match run_powershell(ps_fw) {
15603 Ok(o) => {
15604 for line in o.lines() {
15605 let l = line.trim();
15606 if !l.is_empty() {
15607 out.push_str(&format!("- {l}\n"));
15608 }
15609 }
15610 }
15611 Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
15612 }
15613
15614 let mut findings: Vec<String> = Vec::new();
15615 let mut indeterminate = false;
15616 if out.contains("TpmPresent: False") {
15617 findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
15618 }
15619 if out.contains("TpmReady: False") {
15620 findings.push(
15621 "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
15622 );
15623 }
15624 if out.contains("SpecVersion: 1.2") {
15625 findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
15626 }
15627 if out.contains("Secure Boot: DISABLED") {
15628 findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
15629 }
15630 if out.contains("Firmware type: BIOS (Legacy)") {
15631 findings.push(
15632 "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
15633 );
15634 }
15635
15636 if out.contains("TPM module unavailable")
15637 || out.contains("Win32_Tpm WMI class unavailable")
15638 || out.contains("Secure Boot: N/A")
15639 || out.contains("Secure Boot: Unknown")
15640 || out.contains("Firmware type: Unknown or not set")
15641 || out.contains("TpmPresent: Unknown")
15642 || out.contains("TpmReady: Unknown")
15643 || out.contains("TpmEnabled: Unknown")
15644 {
15645 indeterminate = true;
15646 }
15647 if indeterminate {
15648 findings.push(
15649 "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
15650 .into(),
15651 );
15652 }
15653
15654 let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
15655 if findings.is_empty() {
15656 result.push_str("- TPM and Secure Boot appear healthy.\n");
15657 } else {
15658 for f in &findings {
15659 result.push_str(&format!("- Finding: {f}\n"));
15660 }
15661 }
15662 result.push('\n');
15663 result.push_str(&out);
15664 Ok(result)
15665}
15666
15667#[cfg(not(windows))]
15668fn inspect_tpm() -> Result<String, String> {
15669 Ok(
15670 "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
15671 .into(),
15672 )
15673}
15674
15675#[cfg(windows)]
15676fn inspect_latency() -> Result<String, String> {
15677 let mut out = String::new();
15678
15679 let ps_gw = r#"
15681$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
15682 Sort-Object RouteMetric | Select-Object -First 1).NextHop
15683if ($gw) { $gw } else { "" }
15684"#;
15685 let gateway = run_powershell(ps_gw)
15686 .ok()
15687 .map(|s| s.trim().to_string())
15688 .filter(|s| !s.is_empty());
15689
15690 let targets: Vec<(&str, String)> = {
15691 let mut t = Vec::new();
15692 if let Some(ref gw) = gateway {
15693 t.push(("Default gateway", gw.clone()));
15694 }
15695 t.push(("Cloudflare DNS", "1.1.1.1".into()));
15696 t.push(("Google DNS", "8.8.8.8".into()));
15697 t
15698 };
15699
15700 let mut findings: Vec<String> = Vec::new();
15701
15702 for (label, host) in &targets {
15703 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15704 let ps_ping = format!(
15706 r#"
15707$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
15708if ($r) {{
15709 $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
15710 $min = ($rtts | Measure-Object -Minimum).Minimum
15711 $max = ($rtts | Measure-Object -Maximum).Maximum
15712 $avg = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
15713 $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
15714 "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
15715 "Packet loss: ${{loss}}%"
15716 "Sent: 4 Received: $($r.Count)"
15717}} else {{
15718 "UNREACHABLE — 100% packet loss"
15719}}
15720"#
15721 );
15722 match run_powershell(&ps_ping) {
15723 Ok(o) => {
15724 let body = o.trim().to_string();
15725 for line in body.lines() {
15726 let l = line.trim();
15727 if !l.is_empty() {
15728 out.push_str(&format!("- {l}\n"));
15729 }
15730 }
15731 if body.contains("UNREACHABLE") {
15732 findings.push(format!(
15733 "{label} ({host}) is unreachable — possible routing or firewall issue."
15734 ));
15735 } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
15736 let pct: u32 = loss_line
15737 .chars()
15738 .filter(|c| c.is_ascii_digit())
15739 .collect::<String>()
15740 .parse()
15741 .unwrap_or(0);
15742 if pct >= 25 {
15743 findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
15744 }
15745 if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
15747 let parts: Vec<&str> = rtt_line.split('/').collect();
15749 if parts.len() >= 2 {
15750 let avg_str: String =
15751 parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
15752 let avg: u32 = avg_str.parse().unwrap_or(0);
15753 if avg > 150 {
15754 findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
15755 }
15756 }
15757 }
15758 }
15759 }
15760 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
15761 }
15762 }
15763
15764 let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
15765 if findings.is_empty() {
15766 result.push_str("- Latency and reachability look normal.\n");
15767 } else {
15768 for f in &findings {
15769 result.push_str(&format!("- Finding: {f}\n"));
15770 }
15771 }
15772 result.push('\n');
15773 result.push_str(&out);
15774 Ok(result)
15775}
15776
15777#[cfg(not(windows))]
15778fn inspect_latency() -> Result<String, String> {
15779 let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
15780 let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
15781 let mut findings: Vec<String> = Vec::new();
15782
15783 for (label, host) in &targets {
15784 out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15785 let ping = std::process::Command::new("ping")
15786 .args(["-c", "4", "-W", "2", host])
15787 .output();
15788 match ping {
15789 Ok(o) => {
15790 let body = String::from_utf8_lossy(&o.stdout).into_owned();
15791 for line in body.lines() {
15792 let l = line.trim();
15793 if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
15794 out.push_str(&format!("- {l}\n"));
15795 }
15796 }
15797 if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
15798 findings.push(format!("{label} ({host}) is unreachable."));
15799 }
15800 }
15801 Err(e) => out.push_str(&format!("- ping error: {e}\n")),
15802 }
15803 }
15804
15805 if findings.is_empty() {
15806 out.insert_str(
15807 "Host inspection: latency\n\n=== Findings ===\n".len(),
15808 "- Latency and reachability look normal.\n",
15809 );
15810 } else {
15811 let mut prefix = String::new();
15812 for f in &findings {
15813 prefix.push_str(&format!("- Finding: {f}\n"));
15814 }
15815 out.insert_str(
15816 "Host inspection: latency\n\n=== Findings ===\n".len(),
15817 &prefix,
15818 );
15819 }
15820 Ok(out)
15821}
15822
15823#[cfg(windows)]
15824fn inspect_network_adapter() -> Result<String, String> {
15825 let mut out = String::new();
15826
15827 out.push_str("=== Network adapters ===\n");
15828 let ps_adapters = r#"
15829Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
15830 $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
15831 "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
15832}
15833"#;
15834 match run_powershell(ps_adapters) {
15835 Ok(o) => {
15836 for line in o.lines() {
15837 let l = line.trim();
15838 if !l.is_empty() {
15839 out.push_str(&format!("- {l}\n"));
15840 }
15841 }
15842 }
15843 Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
15844 }
15845
15846 out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
15847 let ps_offload = r#"
15848Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15849 $name = $_.Name
15850 $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
15851 Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
15852 Select-Object DisplayName, DisplayValue
15853 if ($props) {
15854 "--- $name ---"
15855 $props | ForEach-Object { " $($_.DisplayName): $($_.DisplayValue)" }
15856 }
15857}
15858"#;
15859 match run_powershell(ps_offload) {
15860 Ok(o) => {
15861 let lines: Vec<&str> = o
15862 .lines()
15863 .map(|l| l.trim())
15864 .filter(|l| !l.is_empty())
15865 .collect();
15866 if lines.is_empty() {
15867 out.push_str(
15868 "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
15869 );
15870 } else {
15871 for l in &lines {
15872 out.push_str(&format!("- {l}\n"));
15873 }
15874 }
15875 }
15876 Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
15877 }
15878
15879 out.push_str("\n=== Adapter error counters ===\n");
15880 let ps_errors = r#"
15881Get-NetAdapterStatistics | ForEach-Object {
15882 $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
15883 if ($errs -gt 0) {
15884 "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
15885 }
15886}
15887"#;
15888 match run_powershell(ps_errors) {
15889 Ok(o) => {
15890 let lines: Vec<&str> = o
15891 .lines()
15892 .map(|l| l.trim())
15893 .filter(|l| !l.is_empty())
15894 .collect();
15895 if lines.is_empty() {
15896 out.push_str("- No adapter errors or discards detected.\n");
15897 } else {
15898 for l in &lines {
15899 out.push_str(&format!("- {l}\n"));
15900 }
15901 }
15902 }
15903 Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
15904 }
15905
15906 out.push_str("\n=== Wake-on-LAN and power settings ===\n");
15907 let ps_wol = r#"
15908Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15909 $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
15910 if ($wol) {
15911 "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
15912 }
15913}
15914"#;
15915 match run_powershell(ps_wol) {
15916 Ok(o) => {
15917 let lines: Vec<&str> = o
15918 .lines()
15919 .map(|l| l.trim())
15920 .filter(|l| !l.is_empty())
15921 .collect();
15922 if lines.is_empty() {
15923 out.push_str("- Power management data unavailable for active adapters.\n");
15924 } else {
15925 for l in &lines {
15926 out.push_str(&format!("- {l}\n"));
15927 }
15928 }
15929 }
15930 Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
15931 }
15932
15933 let mut findings: Vec<String> = Vec::new();
15934 if out.contains("RX errors:") || out.contains("TX errors:") {
15936 findings
15937 .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
15938 }
15939 if out.contains("Half") {
15941 findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
15942 }
15943
15944 let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
15945 if findings.is_empty() {
15946 result.push_str("- Network adapter configuration looks normal.\n");
15947 } else {
15948 for f in &findings {
15949 result.push_str(&format!("- Finding: {f}\n"));
15950 }
15951 }
15952 result.push('\n');
15953 result.push_str(&out);
15954 Ok(result)
15955}
15956
15957#[cfg(not(windows))]
15958fn inspect_network_adapter() -> Result<String, String> {
15959 let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
15960
15961 out.push_str("=== Network adapters (ip link) ===\n");
15962 let ip_link = std::process::Command::new("ip")
15963 .args(["link", "show"])
15964 .output();
15965 if let Ok(o) = ip_link {
15966 for line in String::from_utf8_lossy(&o.stdout).lines() {
15967 let l = line.trim();
15968 if !l.is_empty() {
15969 out.push_str(&format!("- {l}\n"));
15970 }
15971 }
15972 }
15973
15974 out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
15975 let ip_stats = std::process::Command::new("ip")
15976 .args(["-s", "link", "show"])
15977 .output();
15978 if let Ok(o) = ip_stats {
15979 for line in String::from_utf8_lossy(&o.stdout).lines() {
15980 let l = line.trim();
15981 if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
15982 {
15983 out.push_str(&format!("- {l}\n"));
15984 }
15985 }
15986 }
15987 Ok(out)
15988}
15989
15990#[cfg(windows)]
15991fn inspect_dhcp() -> Result<String, String> {
15992 let mut out = String::new();
15993
15994 out.push_str("=== DHCP lease details (per adapter) ===\n");
15995 let ps_dhcp = r#"
15996$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
15997 Where-Object { $_.IPEnabled -eq $true }
15998foreach ($a in $adapters) {
15999 "--- $($a.Description) ---"
16000 " DHCP Enabled: $($a.DHCPEnabled)"
16001 if ($a.DHCPEnabled) {
16002 " DHCP Server: $($a.DHCPServer)"
16003 $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16004 $expires = $a.ConvertToDateTime($a.DHCPLeaseExpires) 2>$null
16005 " Lease Obtained: $obtained"
16006 " Lease Expires: $expires"
16007 }
16008 " IP Address: $($a.IPAddress -join ', ')"
16009 " Subnet Mask: $($a.IPSubnet -join ', ')"
16010 " Default Gateway: $($a.DefaultIPGateway -join ', ')"
16011 " DNS Servers: $($a.DNSServerSearchOrder -join ', ')"
16012 " MAC Address: $($a.MACAddress)"
16013 ""
16014}
16015"#;
16016 match run_powershell(ps_dhcp) {
16017 Ok(o) => {
16018 for line in o.lines() {
16019 let l = line.trim_end();
16020 if !l.is_empty() {
16021 out.push_str(&format!("{l}\n"));
16022 }
16023 }
16024 }
16025 Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16026 }
16027
16028 let mut findings: Vec<String> = Vec::new();
16030 let ps_expiry = r#"
16031$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16032foreach ($a in $adapters) {
16033 try {
16034 $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16035 $now = Get-Date
16036 $hrs = ($exp - $now).TotalHours
16037 if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16038 elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16039 } catch {}
16040}
16041"#;
16042 if let Ok(o) = run_powershell(ps_expiry) {
16043 for line in o.lines() {
16044 let l = line.trim();
16045 if !l.is_empty() {
16046 if l.contains("EXPIRED") {
16047 findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16048 } else if l.contains("expires in") {
16049 findings.push(format!("DHCP lease expiring soon — {l}"));
16050 }
16051 }
16052 }
16053 }
16054
16055 let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16056 if findings.is_empty() {
16057 result.push_str("- DHCP leases look healthy.\n");
16058 } else {
16059 for f in &findings {
16060 result.push_str(&format!("- Finding: {f}\n"));
16061 }
16062 }
16063 result.push('\n');
16064 result.push_str(&out);
16065 Ok(result)
16066}
16067
16068#[cfg(not(windows))]
16069fn inspect_dhcp() -> Result<String, String> {
16070 let mut out = String::from(
16071 "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16072 );
16073 out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16074 for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16075 if std::path::Path::new(path).exists() {
16076 let cat = std::process::Command::new("cat").arg(path).output();
16077 if let Ok(o) = cat {
16078 let text = String::from_utf8_lossy(&o.stdout);
16079 for line in text.lines().take(40) {
16080 let l = line.trim();
16081 if l.contains("lease")
16082 || l.contains("expire")
16083 || l.contains("server")
16084 || l.contains("address")
16085 {
16086 out.push_str(&format!("- {l}\n"));
16087 }
16088 }
16089 }
16090 }
16091 }
16092 let ip = std::process::Command::new("ip")
16094 .args(["addr", "show"])
16095 .output();
16096 if let Ok(o) = ip {
16097 out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16098 for line in String::from_utf8_lossy(&o.stdout).lines() {
16099 let l = line.trim();
16100 if l.starts_with("inet") || l.contains("dynamic") {
16101 out.push_str(&format!("- {l}\n"));
16102 }
16103 }
16104 }
16105 Ok(out)
16106}
16107
16108#[cfg(windows)]
16109fn inspect_mtu() -> Result<String, String> {
16110 let mut out = String::new();
16111
16112 out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16113 let ps_mtu = r#"
16114Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16115 Sort-Object ConnectionState, InterfaceAlias |
16116 ForEach-Object {
16117 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16118 }
16119"#;
16120 match run_powershell(ps_mtu) {
16121 Ok(o) => {
16122 for line in o.lines() {
16123 let l = line.trim();
16124 if !l.is_empty() {
16125 out.push_str(&format!("- {l}\n"));
16126 }
16127 }
16128 }
16129 Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16130 }
16131
16132 out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16133 let ps_mtu6 = r#"
16134Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16135 Sort-Object ConnectionState, InterfaceAlias |
16136 ForEach-Object {
16137 "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16138 }
16139"#;
16140 match run_powershell(ps_mtu6) {
16141 Ok(o) => {
16142 for line in o.lines() {
16143 let l = line.trim();
16144 if !l.is_empty() {
16145 out.push_str(&format!("- {l}\n"));
16146 }
16147 }
16148 }
16149 Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16150 }
16151
16152 out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16153 let ps_pmtu = r#"
16155$sizes = @(1472, 1400, 1280, 576)
16156$result = $null
16157foreach ($s in $sizes) {
16158 $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16159 if ($r) { $result = $s; break }
16160}
16161if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16162else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16163"#;
16164 match run_powershell(ps_pmtu) {
16165 Ok(o) => {
16166 for line in o.lines() {
16167 let l = line.trim();
16168 if !l.is_empty() {
16169 out.push_str(&format!("- {l}\n"));
16170 }
16171 }
16172 }
16173 Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16174 }
16175
16176 let mut findings: Vec<String> = Vec::new();
16177 if out.contains("MTU: 576 bytes") {
16178 findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16179 }
16180 if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16181 findings.push(
16182 "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16183 .into(),
16184 );
16185 }
16186 if out.contains("All test sizes failed") {
16187 findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16188 }
16189
16190 let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16191 if findings.is_empty() {
16192 result.push_str("- MTU configuration looks normal.\n");
16193 } else {
16194 for f in &findings {
16195 result.push_str(&format!("- Finding: {f}\n"));
16196 }
16197 }
16198 result.push('\n');
16199 result.push_str(&out);
16200 Ok(result)
16201}
16202
16203#[cfg(not(windows))]
16204fn inspect_mtu() -> Result<String, String> {
16205 let mut out = String::from(
16206 "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16207 );
16208
16209 out.push_str("=== Per-interface MTU (ip link) ===\n");
16210 let ip = std::process::Command::new("ip")
16211 .args(["link", "show"])
16212 .output();
16213 if let Ok(o) = ip {
16214 for line in String::from_utf8_lossy(&o.stdout).lines() {
16215 let l = line.trim();
16216 if l.contains("mtu") || l.starts_with("\\d") {
16217 out.push_str(&format!("- {l}\n"));
16218 }
16219 }
16220 }
16221
16222 out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16223 let ping = std::process::Command::new("ping")
16224 .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16225 .output();
16226 match ping {
16227 Ok(o) => {
16228 let body = String::from_utf8_lossy(&o.stdout);
16229 for line in body.lines() {
16230 let l = line.trim();
16231 if !l.is_empty() {
16232 out.push_str(&format!("- {l}\n"));
16233 }
16234 }
16235 }
16236 Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16237 }
16238 Ok(out)
16239}
16240
16241#[cfg(not(windows))]
16242fn inspect_cpu_power() -> Result<String, String> {
16243 let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16244
16245 out.push_str("=== CPU frequency (Linux) ===\n");
16247 let cat_scaling = std::process::Command::new("cat")
16248 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16249 .output();
16250 if let Ok(o) = cat_scaling {
16251 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16252 .trim()
16253 .parse()
16254 .unwrap_or(0);
16255 if khz > 0 {
16256 out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16257 }
16258 }
16259 let cat_max = std::process::Command::new("cat")
16260 .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16261 .output();
16262 if let Ok(o) = cat_max {
16263 let khz: u64 = String::from_utf8_lossy(&o.stdout)
16264 .trim()
16265 .parse()
16266 .unwrap_or(0);
16267 if khz > 0 {
16268 out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16269 }
16270 }
16271 let governor = std::process::Command::new("cat")
16272 .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16273 .output();
16274 if let Ok(o) = governor {
16275 let g = String::from_utf8_lossy(&o.stdout);
16276 let g = g.trim();
16277 if !g.is_empty() {
16278 out.push_str(&format!("- Governor: {g}\n"));
16279 }
16280 }
16281 Ok(out)
16282}
16283
16284#[cfg(windows)]
16287fn inspect_ipv6() -> Result<String, String> {
16288 let script = r#"
16289$result = [System.Text.StringBuilder]::new()
16290
16291# Per-adapter IPv6 addresses
16292$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16293$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16294 Where-Object { $_.IPAddress -notmatch '^::1$' } |
16295 Sort-Object InterfaceAlias
16296foreach ($a in $adapters) {
16297 $prefix = $a.PrefixOrigin
16298 $suffix = $a.SuffixOrigin
16299 $scope = $a.AddressState
16300 $result.AppendLine(" [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength) origin=$prefix/$suffix state=$scope") | Out-Null
16301}
16302if (-not $adapters) { $result.AppendLine(" No global/link-local IPv6 addresses found.") | Out-Null }
16303
16304# Default gateway IPv6
16305$result.AppendLine("") | Out-Null
16306$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16307$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16308if ($gw6) {
16309 foreach ($g in $gw6) {
16310 $result.AppendLine(" [$($g.InterfaceAlias)] via $($g.NextHop) metric=$($g.RouteMetric)") | Out-Null
16311 }
16312} else {
16313 $result.AppendLine(" No IPv6 default gateway configured.") | Out-Null
16314}
16315
16316# DHCPv6 lease info
16317$result.AppendLine("") | Out-Null
16318$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16319$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16320 Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16321if ($dhcpv6) {
16322 foreach ($d in $dhcpv6) {
16323 $result.AppendLine(" [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16324 }
16325} else {
16326 $result.AppendLine(" No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16327}
16328
16329# Privacy extensions
16330$result.AppendLine("") | Out-Null
16331$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16332try {
16333 $priv = netsh interface ipv6 show privacy
16334 $result.AppendLine(($priv -join "`n")) | Out-Null
16335} catch {
16336 $result.AppendLine(" Could not retrieve privacy extension state.") | Out-Null
16337}
16338
16339# Tunnel adapters
16340$result.AppendLine("") | Out-Null
16341$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16342$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
16343if ($tunnels) {
16344 foreach ($t in $tunnels) {
16345 $result.AppendLine(" $($t.Name): $($t.InterfaceDescription) Status=$($t.Status)") | Out-Null
16346 }
16347} else {
16348 $result.AppendLine(" No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
16349}
16350
16351# Findings
16352$findings = [System.Collections.Generic.List[string]]::new()
16353$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16354 Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
16355if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
16356$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
16357if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
16358
16359$result.AppendLine("") | Out-Null
16360$result.AppendLine("=== Findings ===") | Out-Null
16361if ($findings.Count -eq 0) {
16362 $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
16363} else {
16364 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16365}
16366
16367Write-Output $result.ToString()
16368"#;
16369 let out = run_powershell(script)?;
16370 Ok(format!("Host inspection: ipv6\n\n{out}"))
16371}
16372
16373#[cfg(not(windows))]
16374fn inspect_ipv6() -> Result<String, String> {
16375 let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
16376 if let Ok(o) = std::process::Command::new("ip")
16377 .args(["-6", "addr", "show"])
16378 .output()
16379 {
16380 out.push_str(&String::from_utf8_lossy(&o.stdout));
16381 }
16382 out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
16383 if let Ok(o) = std::process::Command::new("ip")
16384 .args(["-6", "route"])
16385 .output()
16386 {
16387 out.push_str(&String::from_utf8_lossy(&o.stdout));
16388 }
16389 Ok(out)
16390}
16391
16392#[cfg(windows)]
16395fn inspect_tcp_params() -> Result<String, String> {
16396 let script = r#"
16397$result = [System.Text.StringBuilder]::new()
16398
16399# Autotuning and global TCP settings
16400$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
16401try {
16402 $global = netsh interface tcp show global
16403 foreach ($line in $global) {
16404 $l = $line.Trim()
16405 if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
16406 $result.AppendLine(" $l") | Out-Null
16407 }
16408 }
16409} catch {
16410 $result.AppendLine(" Could not retrieve TCP global settings.") | Out-Null
16411}
16412
16413# Supplemental params via Get-NetTCPSetting
16414$result.AppendLine("") | Out-Null
16415$result.AppendLine("=== TCP settings profiles ===") | Out-Null
16416try {
16417 $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
16418 foreach ($s in $tcpSettings) {
16419 $result.AppendLine(" Profile: $($s.SettingName)") | Out-Null
16420 $result.AppendLine(" CongestionProvider: $($s.CongestionProvider)") | Out-Null
16421 $result.AppendLine(" InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
16422 $result.AppendLine(" AutoTuningLevelLocal: $($s.AutoTuningLevelLocal)") | Out-Null
16423 $result.AppendLine(" ScalingHeuristics: $($s.ScalingHeuristics)") | Out-Null
16424 $result.AppendLine(" DynamicPortRangeStart: $($s.DynamicPortRangeStartPort)") | Out-Null
16425 $result.AppendLine(" DynamicPortRangeEnd: $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
16426 $result.AppendLine("") | Out-Null
16427 }
16428} catch {
16429 $result.AppendLine(" Get-NetTCPSetting unavailable.") | Out-Null
16430}
16431
16432# Chimney offload state
16433$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
16434try {
16435 $chimney = netsh interface tcp show chimney
16436 $result.AppendLine(($chimney -join "`n ")) | Out-Null
16437} catch {
16438 $result.AppendLine(" Could not retrieve chimney state.") | Out-Null
16439}
16440
16441# ECN state
16442$result.AppendLine("") | Out-Null
16443$result.AppendLine("=== ECN capability ===") | Out-Null
16444try {
16445 $ecn = netsh interface tcp show ecncapability
16446 $result.AppendLine(($ecn -join "`n ")) | Out-Null
16447} catch {
16448 $result.AppendLine(" Could not retrieve ECN state.") | Out-Null
16449}
16450
16451# Findings
16452$findings = [System.Collections.Generic.List[string]]::new()
16453try {
16454 $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
16455 if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
16456 $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
16457 }
16458 if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
16459 $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
16460 }
16461} catch {}
16462
16463$result.AppendLine("") | Out-Null
16464$result.AppendLine("=== Findings ===") | Out-Null
16465if ($findings.Count -eq 0) {
16466 $result.AppendLine("- TCP parameters look normal.") | Out-Null
16467} else {
16468 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16469}
16470
16471Write-Output $result.ToString()
16472"#;
16473 let out = run_powershell(script)?;
16474 Ok(format!("Host inspection: tcp_params\n\n{out}"))
16475}
16476
16477#[cfg(not(windows))]
16478fn inspect_tcp_params() -> Result<String, String> {
16479 let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
16480 for key in &[
16481 "net.ipv4.tcp_congestion_control",
16482 "net.ipv4.tcp_rmem",
16483 "net.ipv4.tcp_wmem",
16484 "net.ipv4.tcp_window_scaling",
16485 "net.ipv4.tcp_ecn",
16486 "net.ipv4.tcp_timestamps",
16487 ] {
16488 if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
16489 out.push_str(&format!(
16490 " {}\n",
16491 String::from_utf8_lossy(&o.stdout).trim()
16492 ));
16493 }
16494 }
16495 Ok(out)
16496}
16497
16498#[cfg(windows)]
16501fn inspect_wlan_profiles() -> Result<String, String> {
16502 let script = r#"
16503$result = [System.Text.StringBuilder]::new()
16504
16505# List all saved profiles
16506$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
16507try {
16508 $profilesRaw = netsh wlan show profiles
16509 $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16510 $_.Matches[0].Groups[1].Value.Trim()
16511 }
16512
16513 if (-not $profiles) {
16514 $result.AppendLine(" No saved wireless profiles found.") | Out-Null
16515 } else {
16516 foreach ($p in $profiles) {
16517 $result.AppendLine("") | Out-Null
16518 $result.AppendLine(" Profile: $p") | Out-Null
16519 # Get detail for each profile
16520 $detail = netsh wlan show profile name="$p" key=clear 2>$null
16521 $auth = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16522 $cipher = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
16523 $conn = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
16524 $autoConn = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
16525 if ($auth) { $result.AppendLine(" Authentication: $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16526 if ($cipher) { $result.AppendLine(" Cipher: $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16527 if ($conn) { $result.AppendLine(" Connection mode: $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16528 if ($autoConn) { $result.AppendLine(" Auto-connect: $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16529 }
16530 }
16531} catch {
16532 $result.AppendLine(" netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
16533}
16534
16535# Currently connected SSID
16536$result.AppendLine("") | Out-Null
16537$result.AppendLine("=== Currently connected ===") | Out-Null
16538try {
16539 $conn = netsh wlan show interfaces
16540 $ssid = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
16541 $bssid = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
16542 $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
16543 $radio = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
16544 if ($ssid) { $result.AppendLine(" SSID: $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16545 if ($bssid) { $result.AppendLine(" BSSID: $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16546 if ($signal) { $result.AppendLine(" Signal: $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16547 if ($radio) { $result.AppendLine(" Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16548 if (-not $ssid) { $result.AppendLine(" Not connected to any wireless network.") | Out-Null }
16549} catch {
16550 $result.AppendLine(" Could not query wireless interface state.") | Out-Null
16551}
16552
16553# Findings
16554$findings = [System.Collections.Generic.List[string]]::new()
16555try {
16556 $allDetail = netsh wlan show profiles 2>$null
16557 $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16558 $_.Matches[0].Groups[1].Value.Trim()
16559 }
16560 foreach ($pn in $profileNames) {
16561 $det = netsh wlan show profile name="$pn" key=clear 2>$null
16562 $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16563 if ($authLine) {
16564 $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
16565 if ($authVal -match 'Open|WEP|None') {
16566 $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
16567 }
16568 }
16569 }
16570} catch {}
16571
16572$result.AppendLine("") | Out-Null
16573$result.AppendLine("=== Findings ===") | Out-Null
16574if ($findings.Count -eq 0) {
16575 $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
16576} else {
16577 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16578}
16579
16580Write-Output $result.ToString()
16581"#;
16582 let out = run_powershell(script)?;
16583 Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
16584}
16585
16586#[cfg(not(windows))]
16587fn inspect_wlan_profiles() -> Result<String, String> {
16588 let mut out =
16589 String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
16590 if let Ok(o) = std::process::Command::new("nmcli")
16592 .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
16593 .output()
16594 {
16595 for line in String::from_utf8_lossy(&o.stdout).lines() {
16596 if line.contains("wireless") || line.contains("wifi") {
16597 out.push_str(&format!(" {line}\n"));
16598 }
16599 }
16600 } else {
16601 out.push_str(" nmcli not available.\n");
16602 }
16603 Ok(out)
16604}
16605
16606#[cfg(windows)]
16609fn inspect_ipsec() -> Result<String, String> {
16610 let script = r#"
16611$result = [System.Text.StringBuilder]::new()
16612
16613# IPSec rules (firewall-integrated)
16614$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
16615try {
16616 $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
16617 if ($rules) {
16618 foreach ($r in $rules) {
16619 $result.AppendLine(" [$($r.DisplayName)]") | Out-Null
16620 $result.AppendLine(" Mode: $($r.Mode)") | Out-Null
16621 $result.AppendLine(" Action: $($r.Action)") | Out-Null
16622 $result.AppendLine(" InProfile: $($r.Profile)") | Out-Null
16623 }
16624 } else {
16625 $result.AppendLine(" No enabled IPSec connection security rules found.") | Out-Null
16626 }
16627} catch {
16628 $result.AppendLine(" Get-NetIPsecRule unavailable.") | Out-Null
16629}
16630
16631# Active main-mode SAs
16632$result.AppendLine("") | Out-Null
16633$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
16634try {
16635 $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
16636 if ($mmSAs) {
16637 foreach ($sa in $mmSAs) {
16638 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
16639 $result.AppendLine(" AuthMethod: $($sa.LocalFirstId) Cipher: $($sa.Cipher)") | Out-Null
16640 }
16641 } else {
16642 $result.AppendLine(" No active main-mode IPSec SAs.") | Out-Null
16643 }
16644} catch {
16645 $result.AppendLine(" Get-NetIPsecMainModeSA unavailable.") | Out-Null
16646}
16647
16648# Active quick-mode SAs
16649$result.AppendLine("") | Out-Null
16650$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
16651try {
16652 $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
16653 if ($qmSAs) {
16654 foreach ($sa in $qmSAs) {
16655 $result.AppendLine(" Local: $($sa.LocalAddress) <--> Remote: $($sa.RemoteAddress)") | Out-Null
16656 $result.AppendLine(" Encapsulation: $($sa.EncapsulationMode) Protocol: $($sa.TransportLayerProtocol)") | Out-Null
16657 }
16658 } else {
16659 $result.AppendLine(" No active quick-mode IPSec SAs.") | Out-Null
16660 }
16661} catch {
16662 $result.AppendLine(" Get-NetIPsecQuickModeSA unavailable.") | Out-Null
16663}
16664
16665# IKE service state
16666$result.AppendLine("") | Out-Null
16667$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
16668$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
16669if ($ikeAgentSvc) {
16670 $result.AppendLine(" PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
16671} else {
16672 $result.AppendLine(" PolicyAgent service not found.") | Out-Null
16673}
16674
16675# Findings
16676$findings = [System.Collections.Generic.List[string]]::new()
16677$mmSACount = 0
16678try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
16679if ($mmSACount -gt 0) {
16680 $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
16681}
16682
16683$result.AppendLine("") | Out-Null
16684$result.AppendLine("=== Findings ===") | Out-Null
16685if ($findings.Count -eq 0) {
16686 $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
16687} else {
16688 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16689}
16690
16691Write-Output $result.ToString()
16692"#;
16693 let out = run_powershell(script)?;
16694 Ok(format!("Host inspection: ipsec\n\n{out}"))
16695}
16696
16697#[cfg(not(windows))]
16698fn inspect_ipsec() -> Result<String, String> {
16699 let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
16700 if let Ok(o) = std::process::Command::new("ip")
16701 .args(["xfrm", "state"])
16702 .output()
16703 {
16704 let body = String::from_utf8_lossy(&o.stdout);
16705 if body.trim().is_empty() {
16706 out.push_str(" No active IPSec SAs.\n");
16707 } else {
16708 out.push_str(&body);
16709 }
16710 }
16711 out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
16712 if let Ok(o) = std::process::Command::new("ip")
16713 .args(["xfrm", "policy"])
16714 .output()
16715 {
16716 let body = String::from_utf8_lossy(&o.stdout);
16717 if body.trim().is_empty() {
16718 out.push_str(" No IPSec policies.\n");
16719 } else {
16720 out.push_str(&body);
16721 }
16722 }
16723 Ok(out)
16724}
16725
16726#[cfg(windows)]
16729fn inspect_netbios() -> Result<String, String> {
16730 let script = r#"
16731$result = [System.Text.StringBuilder]::new()
16732
16733# NetBIOS node type and WINS per adapter
16734$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
16735try {
16736 $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16737 Where-Object { $_.IPEnabled -eq $true }
16738 foreach ($a in $adapters) {
16739 $nodeType = switch ($a.TcpipNetbiosOptions) {
16740 0 { "EnableNetBIOSViaDHCP" }
16741 1 { "Enabled" }
16742 2 { "Disabled" }
16743 default { "Unknown ($($a.TcpipNetbiosOptions))" }
16744 }
16745 $result.AppendLine(" [$($a.Description)]") | Out-Null
16746 $result.AppendLine(" NetBIOS over TCP/IP: $nodeType") | Out-Null
16747 if ($a.WINSPrimaryServer) {
16748 $result.AppendLine(" WINS Primary: $($a.WINSPrimaryServer)") | Out-Null
16749 }
16750 if ($a.WINSSecondaryServer) {
16751 $result.AppendLine(" WINS Secondary: $($a.WINSSecondaryServer)") | Out-Null
16752 }
16753 }
16754} catch {
16755 $result.AppendLine(" Could not query NetBIOS adapter config.") | Out-Null
16756}
16757
16758# nbtstat -n — registered local NetBIOS names
16759$result.AppendLine("") | Out-Null
16760$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
16761try {
16762 $nbt = nbtstat -n 2>$null
16763 foreach ($line in $nbt) {
16764 $l = $line.Trim()
16765 if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
16766 $result.AppendLine(" $l") | Out-Null
16767 }
16768 }
16769} catch {
16770 $result.AppendLine(" nbtstat not available.") | Out-Null
16771}
16772
16773# NetBIOS session table
16774$result.AppendLine("") | Out-Null
16775$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
16776try {
16777 $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
16778 if ($sessions) {
16779 foreach ($s in $sessions) { $result.AppendLine(" $($s.Trim())") | Out-Null }
16780 } else {
16781 $result.AppendLine(" No active NetBIOS sessions.") | Out-Null
16782 }
16783} catch {
16784 $result.AppendLine(" Could not query NetBIOS sessions.") | Out-Null
16785}
16786
16787# Findings
16788$findings = [System.Collections.Generic.List[string]]::new()
16789try {
16790 $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16791 Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
16792 if ($enabled) {
16793 $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
16794 }
16795 $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16796 Where-Object { $_.WINSPrimaryServer }
16797 if ($wins) {
16798 $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
16799 }
16800} catch {}
16801
16802$result.AppendLine("") | Out-Null
16803$result.AppendLine("=== Findings ===") | Out-Null
16804if ($findings.Count -eq 0) {
16805 $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
16806} else {
16807 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16808}
16809
16810Write-Output $result.ToString()
16811"#;
16812 let out = run_powershell(script)?;
16813 Ok(format!("Host inspection: netbios\n\n{out}"))
16814}
16815
16816#[cfg(not(windows))]
16817fn inspect_netbios() -> Result<String, String> {
16818 let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
16819 if let Ok(o) = std::process::Command::new("nmblookup")
16820 .arg("-A")
16821 .arg("localhost")
16822 .output()
16823 {
16824 out.push_str(&String::from_utf8_lossy(&o.stdout));
16825 } else {
16826 out.push_str(" nmblookup not available (Samba not installed).\n");
16827 }
16828 Ok(out)
16829}
16830
16831#[cfg(windows)]
16834fn inspect_nic_teaming() -> Result<String, String> {
16835 let script = r#"
16836$result = [System.Text.StringBuilder]::new()
16837
16838# Team inventory
16839$result.AppendLine("=== NIC teams ===") | Out-Null
16840try {
16841 $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
16842 if ($teams) {
16843 foreach ($t in $teams) {
16844 $result.AppendLine(" Team: $($t.Name)") | Out-Null
16845 $result.AppendLine(" Mode: $($t.TeamingMode)") | Out-Null
16846 $result.AppendLine(" LB Algorithm: $($t.LoadBalancingAlgorithm)") | Out-Null
16847 $result.AppendLine(" Status: $($t.Status)") | Out-Null
16848 $result.AppendLine(" Members: $($t.Members -join ', ')") | Out-Null
16849 $result.AppendLine(" VLANs: $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
16850 }
16851 } else {
16852 $result.AppendLine(" No NIC teams configured on this machine.") | Out-Null
16853 }
16854} catch {
16855 $result.AppendLine(" Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
16856}
16857
16858# Team members detail
16859$result.AppendLine("") | Out-Null
16860$result.AppendLine("=== Team member detail ===") | Out-Null
16861try {
16862 $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
16863 if ($members) {
16864 foreach ($m in $members) {
16865 $result.AppendLine(" [$($m.Team)] $($m.Name) Role=$($m.AdministrativeMode) Status=$($m.OperationalStatus)") | Out-Null
16866 }
16867 } else {
16868 $result.AppendLine(" No team members found.") | Out-Null
16869 }
16870} catch {
16871 $result.AppendLine(" Could not query team members.") | Out-Null
16872}
16873
16874# Findings
16875$findings = [System.Collections.Generic.List[string]]::new()
16876try {
16877 $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
16878 if ($degraded) {
16879 foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
16880 }
16881 $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
16882 if ($downMembers) {
16883 foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
16884 }
16885} catch {}
16886
16887$result.AppendLine("") | Out-Null
16888$result.AppendLine("=== Findings ===") | Out-Null
16889if ($findings.Count -eq 0) {
16890 $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
16891} else {
16892 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16893}
16894
16895Write-Output $result.ToString()
16896"#;
16897 let out = run_powershell(script)?;
16898 Ok(format!("Host inspection: nic_teaming\n\n{out}"))
16899}
16900
16901#[cfg(not(windows))]
16902fn inspect_nic_teaming() -> Result<String, String> {
16903 let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
16904 if let Ok(o) = std::process::Command::new("cat")
16905 .arg("/proc/net/bonding/bond0")
16906 .output()
16907 {
16908 if o.status.success() {
16909 out.push_str(&String::from_utf8_lossy(&o.stdout));
16910 } else {
16911 out.push_str(" No bond0 interface found.\n");
16912 }
16913 }
16914 if let Ok(o) = std::process::Command::new("ip")
16915 .args(["link", "show", "type", "bond"])
16916 .output()
16917 {
16918 let body = String::from_utf8_lossy(&o.stdout);
16919 if !body.trim().is_empty() {
16920 out.push_str("\n=== Bond links (ip link) ===\n");
16921 out.push_str(&body);
16922 }
16923 }
16924 Ok(out)
16925}
16926
16927#[cfg(windows)]
16930fn inspect_snmp() -> Result<String, String> {
16931 let script = r#"
16932$result = [System.Text.StringBuilder]::new()
16933
16934# SNMP service state
16935$result.AppendLine("=== SNMP service state ===") | Out-Null
16936$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16937if ($svc) {
16938 $result.AppendLine(" SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
16939} else {
16940 $result.AppendLine(" SNMP Agent service not installed.") | Out-Null
16941}
16942
16943$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
16944if ($svcTrap) {
16945 $result.AppendLine(" SNMP Trap service: $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
16946}
16947
16948# Community strings (presence only — values redacted)
16949$result.AppendLine("") | Out-Null
16950$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
16951try {
16952 $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16953 if ($communities) {
16954 $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
16955 if ($names) {
16956 foreach ($n in $names) {
16957 $result.AppendLine(" Community: '$n' (value redacted)") | Out-Null
16958 }
16959 } else {
16960 $result.AppendLine(" No community strings configured.") | Out-Null
16961 }
16962 } else {
16963 $result.AppendLine(" Registry key not found (SNMP may not be configured).") | Out-Null
16964 }
16965} catch {
16966 $result.AppendLine(" Could not read community strings (SNMP not configured or access denied).") | Out-Null
16967}
16968
16969# Permitted managers
16970$result.AppendLine("") | Out-Null
16971$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
16972try {
16973 $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
16974 if ($managers) {
16975 $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
16976 if ($mgrs) {
16977 foreach ($m in $mgrs) { $result.AppendLine(" $m") | Out-Null }
16978 } else {
16979 $result.AppendLine(" No permitted managers configured (accepts from any host).") | Out-Null
16980 }
16981 } else {
16982 $result.AppendLine(" No manager restrictions configured.") | Out-Null
16983 }
16984} catch {
16985 $result.AppendLine(" Could not read permitted managers.") | Out-Null
16986}
16987
16988# Findings
16989$findings = [System.Collections.Generic.List[string]]::new()
16990$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16991if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
16992 $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
16993 try {
16994 $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16995 $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
16996 if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
16997 } catch {}
16998}
16999
17000$result.AppendLine("") | Out-Null
17001$result.AppendLine("=== Findings ===") | Out-Null
17002if ($findings.Count -eq 0) {
17003 $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17004} else {
17005 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17006}
17007
17008Write-Output $result.ToString()
17009"#;
17010 let out = run_powershell(script)?;
17011 Ok(format!("Host inspection: snmp\n\n{out}"))
17012}
17013
17014#[cfg(not(windows))]
17015fn inspect_snmp() -> Result<String, String> {
17016 let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17017 for svc in &["snmpd", "snmp"] {
17018 if let Ok(o) = std::process::Command::new("systemctl")
17019 .args(["is-active", svc])
17020 .output()
17021 {
17022 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17023 out.push_str(&format!(" {svc}: {status}\n"));
17024 }
17025 }
17026 out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17027 if let Ok(o) = std::process::Command::new("grep")
17028 .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17029 .output()
17030 {
17031 if o.status.success() {
17032 for line in String::from_utf8_lossy(&o.stdout).lines() {
17033 out.push_str(&format!(" {line}\n"));
17034 }
17035 } else {
17036 out.push_str(" /etc/snmp/snmpd.conf not found or no community lines.\n");
17037 }
17038 }
17039 Ok(out)
17040}
17041
17042#[cfg(windows)]
17045fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17046 let target_host = host.unwrap_or("8.8.8.8");
17047 let target_port = port.unwrap_or(443);
17048
17049 let script = format!(
17050 r#"
17051$result = [System.Text.StringBuilder]::new()
17052$result.AppendLine("=== Port reachability test ===") | Out-Null
17053$result.AppendLine(" Target: {target_host}:{target_port}") | Out-Null
17054$result.AppendLine("") | Out-Null
17055
17056try {{
17057 $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17058 if ($test) {{
17059 $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17060 $result.AppendLine(" Result: $status") | Out-Null
17061 $result.AppendLine(" Remote address: $($test.RemoteAddress)") | Out-Null
17062 $result.AppendLine(" Remote port: $($test.RemotePort)") | Out-Null
17063 if ($test.PingSucceeded) {{
17064 $result.AppendLine(" ICMP ping: Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17065 }} else {{
17066 $result.AppendLine(" ICMP ping: Failed (host may block ICMP)") | Out-Null
17067 }}
17068 $result.AppendLine(" Interface used: $($test.InterfaceAlias)") | Out-Null
17069 $result.AppendLine(" Source address: $($test.SourceAddress.IPAddress)") | Out-Null
17070
17071 $result.AppendLine("") | Out-Null
17072 $result.AppendLine("=== Findings ===") | Out-Null
17073 if ($test.TcpTestSucceeded) {{
17074 $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17075 }} else {{
17076 $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17077 $result.AppendLine(" Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17078 }}
17079 }}
17080}} catch {{
17081 $result.AppendLine(" Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17082}}
17083
17084Write-Output $result.ToString()
17085"#
17086 );
17087 let out = run_powershell(&script)?;
17088 Ok(format!("Host inspection: port_test\n\n{out}"))
17089}
17090
17091#[cfg(not(windows))]
17092fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17093 let target_host = host.unwrap_or("8.8.8.8");
17094 let target_port = port.unwrap_or(443);
17095 let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n Target: {target_host}:{target_port}\n\n");
17096 let nc = std::process::Command::new("nc")
17098 .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17099 .output();
17100 match nc {
17101 Ok(o) => {
17102 let stderr = String::from_utf8_lossy(&o.stderr);
17103 let stdout = String::from_utf8_lossy(&o.stdout);
17104 let body = if !stdout.trim().is_empty() {
17105 stdout.as_ref()
17106 } else {
17107 stderr.as_ref()
17108 };
17109 out.push_str(&format!(" {}\n", body.trim()));
17110 out.push_str("\n=== Findings ===\n");
17111 if o.status.success() {
17112 out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17113 } else {
17114 out.push_str(&format!(
17115 "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17116 ));
17117 }
17118 }
17119 Err(e) => out.push_str(&format!(" nc not available: {e}\n")),
17120 }
17121 Ok(out)
17122}
17123
17124#[cfg(windows)]
17127fn inspect_network_profile() -> Result<String, String> {
17128 let script = r#"
17129$result = [System.Text.StringBuilder]::new()
17130
17131$result.AppendLine("=== Network location profiles ===") | Out-Null
17132try {
17133 $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17134 if ($profiles) {
17135 foreach ($p in $profiles) {
17136 $result.AppendLine(" Interface: $($p.InterfaceAlias)") | Out-Null
17137 $result.AppendLine(" Network name: $($p.Name)") | Out-Null
17138 $result.AppendLine(" Category: $($p.NetworkCategory)") | Out-Null
17139 $result.AppendLine(" IPv4 conn: $($p.IPv4Connectivity)") | Out-Null
17140 $result.AppendLine(" IPv6 conn: $($p.IPv6Connectivity)") | Out-Null
17141 $result.AppendLine("") | Out-Null
17142 }
17143 } else {
17144 $result.AppendLine(" No network connection profiles found.") | Out-Null
17145 }
17146} catch {
17147 $result.AppendLine(" Could not query network profiles.") | Out-Null
17148}
17149
17150# Findings
17151$findings = [System.Collections.Generic.List[string]]::new()
17152try {
17153 $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17154 if ($pub) {
17155 foreach ($p in $pub) {
17156 $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17157 }
17158 }
17159 $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17160 if ($domain) {
17161 foreach ($d in $domain) {
17162 $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17163 }
17164 }
17165} catch {}
17166
17167$result.AppendLine("=== Findings ===") | Out-Null
17168if ($findings.Count -eq 0) {
17169 $result.AppendLine("- Network profiles look normal.") | Out-Null
17170} else {
17171 foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17172}
17173
17174Write-Output $result.ToString()
17175"#;
17176 let out = run_powershell(script)?;
17177 Ok(format!("Host inspection: network_profile\n\n{out}"))
17178}
17179
17180#[cfg(not(windows))]
17181fn inspect_network_profile() -> Result<String, String> {
17182 let mut out = String::from(
17183 "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17184 );
17185 if let Ok(o) = std::process::Command::new("nmcli")
17186 .args([
17187 "-t",
17188 "-f",
17189 "NAME,TYPE,STATE,DEVICE",
17190 "connection",
17191 "show",
17192 "--active",
17193 ])
17194 .output()
17195 {
17196 out.push_str(&String::from_utf8_lossy(&o.stdout));
17197 } else {
17198 out.push_str(" nmcli not available.\n");
17199 }
17200 Ok(out)
17201}