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 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 "search_index" | "windows_search" | "indexing" | "search" => {
51 inspect_search_index(max_entries)
52 }
53 "services" => inspect_services(parse_name_filter(args), max_entries),
54 "processes" => inspect_processes(parse_name_filter(args), max_entries),
55 "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
56 "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
57 "disk" => {
58 let path = resolve_optional_path(args)?;
59 inspect_disk(path, max_entries).await
60 }
61 "ports" => inspect_ports(parse_port_filter(args), max_entries),
62 "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
63 "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
64 "health_report" | "system_health" => inspect_health_report(),
65 "storage" => inspect_storage(max_entries),
66 "hardware" => inspect_hardware(),
67 "updates" | "windows_update" => inspect_updates(),
68 "security" | "antivirus" | "defender" => inspect_security(),
69 "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
70 "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
71 "battery" => inspect_battery(),
72 "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
73 "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
74 "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
75 "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
76 "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
77 "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
78 "vpn" => inspect_vpn(),
79 "proxy" | "proxy_settings" => inspect_proxy(),
80 "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
81 "traceroute" | "tracert" | "trace_route" | "trace" => {
82 let host = args
83 .get("host")
84 .and_then(|v| v.as_str())
85 .unwrap_or("8.8.8.8")
86 .to_string();
87 inspect_traceroute(&host, max_entries)
88 }
89 "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
90 "arp" | "arp_table" => inspect_arp(),
91 "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
92 "os_config" | "system_config" => inspect_os_config(),
93 "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
94 "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
95 "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
96 "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
97 "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
98 inspect_docker_filesystems(max_entries)
99 }
100 "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
101 "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
102 "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
103 "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
104 "git_config" | "git_global" => inspect_git_config(),
105 "databases" | "database" | "db_services" | "db" => inspect_databases(),
106 "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
107 "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
108 "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
109 "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
110 "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
111 "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
112 "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
113 "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
114 "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
115 "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
116 "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
117 "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
118 "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
119 "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
120 "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
121 "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
122 "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
123 "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
124 "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
125 "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
126 "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
127 "repo_doctor" => {
128 let path = resolve_optional_path(args)?;
129 inspect_repo_doctor(path, max_entries)
130 }
131 "directory" => {
132 let raw_path = args
133 .get("path")
134 .and_then(|v| v.as_str())
135 .ok_or_else(|| {
136 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
137 .to_string()
138 })?;
139 let resolved = resolve_path(raw_path)?;
140 inspect_directory("Directory", resolved, max_entries).await
141 }
142 "disk_benchmark" | "stress_test" | "io_intensity" => {
143 let path = resolve_optional_path(args)?;
144 inspect_disk_benchmark(path).await
145 }
146 "permissions" | "acl" | "access_control" => {
147 let path = resolve_optional_path(args)?;
148 inspect_permissions(path, max_entries)
149 }
150 "login_history" | "logon_history" | "user_logins" => {
151 inspect_login_history(max_entries)
152 }
153 "share_access" | "unc_access" | "remote_share" => {
154 let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
155 inspect_share_access(path)
156 }
157 "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
158 "thermal" | "throttling" | "overheating" => inspect_thermal(),
159 "activation" | "license_status" | "slmgr" => inspect_activation(),
160 "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
161 "ad_user" | "ad" | "domain_user" => {
162 let identity = parse_name_filter(args).unwrap_or_default();
163 inspect_ad_user(&identity)
164 }
165 "dns_lookup" | "dig" | "nslookup" => {
166 let name = parse_name_filter(args).unwrap_or_default();
167 let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("SRV");
168 inspect_dns_lookup(&name, record_type)
169 }
170 "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
171 "ip_config" | "ip_detail" | "dhcp" => inspect_ip_config(),
172 "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
173 other => Err(format!(
174 "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, search_index, 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, 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.",
175 other
176 )),
177
178 }
179}
180
181fn parse_max_entries(args: &Value) -> usize {
182 args.get("max_entries")
183 .and_then(|v| v.as_u64())
184 .map(|n| n as usize)
185 .unwrap_or(DEFAULT_MAX_ENTRIES)
186 .clamp(1, MAX_ENTRIES_CAP)
187}
188
189fn parse_port_filter(args: &Value) -> Option<u16> {
190 args.get("port")
191 .and_then(|v| v.as_u64())
192 .and_then(|n| u16::try_from(n).ok())
193}
194
195fn parse_name_filter(args: &Value) -> Option<String> {
196 args.get("name")
197 .and_then(|v| v.as_str())
198 .map(str::trim)
199 .filter(|value| !value.is_empty())
200 .map(|value| value.to_string())
201}
202
203fn parse_lookback_hours(args: &Value) -> Option<u32> {
204 args.get("lookback_hours")
205 .and_then(|v| v.as_u64())
206 .map(|n| n as u32)
207}
208
209fn parse_issue_text(args: &Value) -> Option<String> {
210 args.get("issue")
211 .and_then(|v| v.as_str())
212 .map(str::trim)
213 .filter(|value| !value.is_empty())
214 .map(|value| value.to_string())
215}
216
217fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
218 match args.get("path").and_then(|v| v.as_str()) {
219 Some(raw_path) => resolve_path(raw_path),
220 None => {
221 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
222 }
223 }
224}
225
226fn inspect_summary(max_entries: usize) -> Result<String, String> {
227 let current_dir =
228 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
229 let workspace_root = crate::tools::file_ops::workspace_root();
230 let workspace_mode = workspace_mode_label(&workspace_root);
231 let path_stats = analyze_path_env();
232 let toolchains = collect_toolchains();
233
234 let mut out = String::from("Host inspection: summary\n\n");
235 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
236 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
237 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
238 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
239 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
240 out.push_str(&format!(
241 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
242 path_stats.total_entries,
243 path_stats.unique_entries,
244 path_stats.duplicate_entries.len(),
245 path_stats.missing_entries.len()
246 ));
247
248 if toolchains.found.is_empty() {
249 out.push_str(
250 "- Toolchains found: none of the common developer tools were detected on PATH\n",
251 );
252 } else {
253 out.push_str("- Toolchains found:\n");
254 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
255 out.push_str(&format!(" - {}: {}\n", label, version));
256 }
257 if toolchains.found.len() > max_entries.min(8) {
258 out.push_str(&format!(
259 " - ... {} more found tools omitted\n",
260 toolchains.found.len() - max_entries.min(8)
261 ));
262 }
263 }
264
265 if !toolchains.missing.is_empty() {
266 out.push_str(&format!(
267 "- Common tools not detected on PATH: {}\n",
268 toolchains.missing.join(", ")
269 ));
270 }
271
272 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
273 match path {
274 Some(path) if path.exists() => match count_top_level_items(&path) {
275 Ok(count) => out.push_str(&format!(
276 "- {}: {} top-level items at {}\n",
277 label,
278 count,
279 path.display()
280 )),
281 Err(e) => out.push_str(&format!(
282 "- {}: exists at {} but could not inspect ({})\n",
283 label,
284 path.display(),
285 e
286 )),
287 },
288 Some(path) => out.push_str(&format!(
289 "- {}: expected at {} but not found\n",
290 label,
291 path.display()
292 )),
293 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
294 }
295 }
296
297 Ok(out.trim_end().to_string())
298}
299
300fn inspect_toolchains() -> Result<String, String> {
301 let report = collect_toolchains();
302 let mut out = String::from("Host inspection: toolchains\n\n");
303
304 if report.found.is_empty() {
305 out.push_str("- No common developer tools were detected on PATH.");
306 } else {
307 out.push_str("Detected developer tools:\n");
308 for (label, version) in report.found {
309 out.push_str(&format!("- {}: {}\n", label, version));
310 }
311 }
312
313 if !report.missing.is_empty() {
314 out.push_str("\nNot detected on PATH:\n");
315 for label in report.missing {
316 out.push_str(&format!("- {}\n", label));
317 }
318 }
319
320 Ok(out.trim_end().to_string())
321}
322
323fn inspect_path(max_entries: usize) -> Result<String, String> {
324 let path_stats = analyze_path_env();
325 let mut out = String::from("Host inspection: PATH\n\n");
326 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
327 out.push_str(&format!(
328 "- Unique entries: {}\n",
329 path_stats.unique_entries
330 ));
331 out.push_str(&format!(
332 "- Duplicate entries: {}\n",
333 path_stats.duplicate_entries.len()
334 ));
335 out.push_str(&format!(
336 "- Missing paths: {}\n",
337 path_stats.missing_entries.len()
338 ));
339
340 out.push_str("\nPATH entries:\n");
341 for entry in path_stats.entries.iter().take(max_entries) {
342 out.push_str(&format!("- {}\n", entry));
343 }
344 if path_stats.entries.len() > max_entries {
345 out.push_str(&format!(
346 "- ... {} more entries omitted\n",
347 path_stats.entries.len() - max_entries
348 ));
349 }
350
351 if !path_stats.duplicate_entries.is_empty() {
352 out.push_str("\nDuplicate entries:\n");
353 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
354 out.push_str(&format!("- {}\n", entry));
355 }
356 if path_stats.duplicate_entries.len() > max_entries {
357 out.push_str(&format!(
358 "- ... {} more duplicates omitted\n",
359 path_stats.duplicate_entries.len() - max_entries
360 ));
361 }
362 }
363
364 if !path_stats.missing_entries.is_empty() {
365 out.push_str("\nMissing directories:\n");
366 for entry in path_stats.missing_entries.iter().take(max_entries) {
367 out.push_str(&format!("- {}\n", entry));
368 }
369 if path_stats.missing_entries.len() > max_entries {
370 out.push_str(&format!(
371 "- ... {} more missing entries omitted\n",
372 path_stats.missing_entries.len() - max_entries
373 ));
374 }
375 }
376
377 Ok(out.trim_end().to_string())
378}
379
380fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
381 let path_stats = analyze_path_env();
382 let toolchains = collect_toolchains();
383 let package_managers = collect_package_managers();
384 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
385
386 let mut out = String::from("Host inspection: env_doctor\n\n");
387 out.push_str(&format!(
388 "- PATH health: {} duplicates, {} missing entries\n",
389 path_stats.duplicate_entries.len(),
390 path_stats.missing_entries.len()
391 ));
392 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
393 out.push_str(&format!(
394 "- Package managers found: {}\n",
395 package_managers.found.len()
396 ));
397
398 if !package_managers.found.is_empty() {
399 out.push_str("\nPackage managers:\n");
400 for (label, version) in package_managers.found.iter().take(max_entries) {
401 out.push_str(&format!("- {}: {}\n", label, version));
402 }
403 if package_managers.found.len() > max_entries {
404 out.push_str(&format!(
405 "- ... {} more package managers omitted\n",
406 package_managers.found.len() - max_entries
407 ));
408 }
409 }
410
411 if !path_stats.duplicate_entries.is_empty() {
412 out.push_str("\nDuplicate PATH entries:\n");
413 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
414 out.push_str(&format!("- {}\n", entry));
415 }
416 if path_stats.duplicate_entries.len() > max_entries.min(5) {
417 out.push_str(&format!(
418 "- ... {} more duplicate entries omitted\n",
419 path_stats.duplicate_entries.len() - max_entries.min(5)
420 ));
421 }
422 }
423
424 if !path_stats.missing_entries.is_empty() {
425 out.push_str("\nMissing PATH entries:\n");
426 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
427 out.push_str(&format!("- {}\n", entry));
428 }
429 if path_stats.missing_entries.len() > max_entries.min(5) {
430 out.push_str(&format!(
431 "- ... {} more missing entries omitted\n",
432 path_stats.missing_entries.len() - max_entries.min(5)
433 ));
434 }
435 }
436
437 if !findings.is_empty() {
438 out.push_str("\nFindings:\n");
439 for finding in findings.iter().take(max_entries.max(5)) {
440 out.push_str(&format!("- {}\n", finding));
441 }
442 if findings.len() > max_entries.max(5) {
443 out.push_str(&format!(
444 "- ... {} more findings omitted\n",
445 findings.len() - max_entries.max(5)
446 ));
447 }
448 } else {
449 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
450 }
451
452 out.push_str(
453 "\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.",
454 );
455
456 Ok(out.trim_end().to_string())
457}
458
459#[derive(Clone, Copy, Debug, Eq, PartialEq)]
460enum FixPlanKind {
461 EnvPath,
462 PortConflict,
463 LmStudio,
464 DriverInstall,
465 GroupPolicy,
466 FirewallRule,
467 SshKey,
468 WslSetup,
469 ServiceConfig,
470 WindowsActivation,
471 RegistryEdit,
472 ScheduledTaskCreate,
473 DiskCleanup,
474 DnsResolution,
475 Generic,
476}
477
478async fn inspect_fix_plan(
479 issue: Option<String>,
480 port_filter: Option<u16>,
481 max_entries: usize,
482) -> Result<String, String> {
483 let issue = issue.unwrap_or_else(|| {
484 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
485 .to_string()
486 });
487 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
488 match plan_kind {
489 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
490 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
491 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
492 FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
493 FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
494 FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
495 FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
496 FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
497 FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
498 FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
499 FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
500 FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
501 FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
502 FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
503 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
504 }
505}
506
507fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
508 let lower = issue.to_ascii_lowercase();
509 if lower.contains("firewall rule")
512 || lower.contains("inbound rule")
513 || lower.contains("outbound rule")
514 || (lower.contains("firewall")
515 && (lower.contains("allow")
516 || lower.contains("block")
517 || lower.contains("create")
518 || lower.contains("open")))
519 {
520 FixPlanKind::FirewallRule
521 } else if port_filter.is_some()
522 || lower.contains("port ")
523 || lower.contains("address already in use")
524 || lower.contains("already in use")
525 || lower.contains("what owns port")
526 || lower.contains("listening on port")
527 {
528 FixPlanKind::PortConflict
529 } else if lower.contains("lm studio")
530 || lower.contains("localhost:1234")
531 || lower.contains("/v1/models")
532 || lower.contains("no coding model loaded")
533 || lower.contains("embedding model")
534 || lower.contains("server on port 1234")
535 || lower.contains("runtime refresh")
536 {
537 FixPlanKind::LmStudio
538 } else if lower.contains("driver")
539 || lower.contains("gpu driver")
540 || lower.contains("nvidia driver")
541 || lower.contains("amd driver")
542 || lower.contains("install driver")
543 || lower.contains("update driver")
544 {
545 FixPlanKind::DriverInstall
546 } else if lower.contains("group policy")
547 || lower.contains("gpedit")
548 || lower.contains("local policy")
549 || lower.contains("secpol")
550 || lower.contains("administrative template")
551 {
552 FixPlanKind::GroupPolicy
553 } else if lower.contains("ssh key")
554 || lower.contains("ssh-keygen")
555 || lower.contains("generate ssh")
556 || lower.contains("authorized_keys")
557 || lower.contains("id_rsa")
558 || lower.contains("id_ed25519")
559 {
560 FixPlanKind::SshKey
561 } else if lower.contains("wsl")
562 || lower.contains("windows subsystem for linux")
563 || lower.contains("install ubuntu")
564 || lower.contains("install linux on windows")
565 || lower.contains("wsl2")
566 {
567 FixPlanKind::WslSetup
568 } else if lower.contains("service")
569 && (lower.contains("start ")
570 || lower.contains("stop ")
571 || lower.contains("restart ")
572 || lower.contains("enable ")
573 || lower.contains("disable ")
574 || lower.contains("configure service"))
575 {
576 FixPlanKind::ServiceConfig
577 } else if lower.contains("activate windows")
578 || lower.contains("windows activation")
579 || lower.contains("product key")
580 || lower.contains("kms")
581 || lower.contains("not activated")
582 {
583 FixPlanKind::WindowsActivation
584 } else if lower.contains("registry")
585 || lower.contains("regedit")
586 || lower.contains("hklm")
587 || lower.contains("hkcu")
588 || lower.contains("reg add")
589 || lower.contains("reg delete")
590 || lower.contains("registry key")
591 {
592 FixPlanKind::RegistryEdit
593 } else if lower.contains("scheduled task")
594 || lower.contains("task scheduler")
595 || lower.contains("schtasks")
596 || lower.contains("create task")
597 || lower.contains("run on startup")
598 || lower.contains("run on schedule")
599 || lower.contains("cron")
600 {
601 FixPlanKind::ScheduledTaskCreate
602 } else if lower.contains("disk cleanup")
603 || lower.contains("free up disk")
604 || lower.contains("free up space")
605 || lower.contains("clear cache")
606 || lower.contains("disk full")
607 || lower.contains("low disk space")
608 || lower.contains("reclaim space")
609 {
610 FixPlanKind::DiskCleanup
611 } else if lower.contains("cargo")
612 || lower.contains("rustc")
613 || lower.contains("path")
614 || lower.contains("package manager")
615 || lower.contains("package managers")
616 || lower.contains("toolchain")
617 || lower.contains("winget")
618 || lower.contains("choco")
619 || lower.contains("scoop")
620 || lower.contains("python")
621 || lower.contains("node")
622 {
623 FixPlanKind::EnvPath
624 } else if lower.contains("dns ")
625 || lower.contains("nameserver")
626 || lower.contains("cannot resolve")
627 || lower.contains("nslookup")
628 || lower.contains("flushdns")
629 {
630 FixPlanKind::DnsResolution
631 } else {
632 FixPlanKind::Generic
633 }
634}
635
636fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
637 let path_stats = analyze_path_env();
638 let toolchains = collect_toolchains();
639 let package_managers = collect_package_managers();
640 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
641 let found_tools = toolchains
642 .found
643 .iter()
644 .map(|(label, _)| label.as_str())
645 .collect::<HashSet<_>>();
646 let found_managers = package_managers
647 .found
648 .iter()
649 .map(|(label, _)| label.as_str())
650 .collect::<HashSet<_>>();
651
652 let mut out = String::from("Host inspection: fix_plan\n\n");
653 out.push_str(&format!("- Requested issue: {}\n", issue));
654 out.push_str("- Fix-plan type: environment/path\n");
655 out.push_str(&format!(
656 "- PATH health: {} duplicates, {} missing entries\n",
657 path_stats.duplicate_entries.len(),
658 path_stats.missing_entries.len()
659 ));
660 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
661 out.push_str(&format!(
662 "- Package managers found: {}\n",
663 package_managers.found.len()
664 ));
665
666 out.push_str("\nLikely causes:\n");
667 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
668 out.push_str(
669 "- 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",
670 );
671 }
672 if path_stats.duplicate_entries.is_empty()
673 && path_stats.missing_entries.is_empty()
674 && !findings.is_empty()
675 {
676 for finding in findings.iter().take(max_entries.max(4)) {
677 out.push_str(&format!("- {}\n", finding));
678 }
679 } else {
680 if !path_stats.duplicate_entries.is_empty() {
681 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
682 }
683 if !path_stats.missing_entries.is_empty() {
684 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
685 }
686 }
687 if found_tools.contains("node")
688 && !found_managers.contains("npm")
689 && !found_managers.contains("pnpm")
690 {
691 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
692 }
693 if found_tools.contains("python")
694 && !found_managers.contains("pip")
695 && !found_managers.contains("uv")
696 && !found_managers.contains("pipx")
697 {
698 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
699 }
700
701 out.push_str("\nFix plan:\n");
702 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");
703 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
704 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");
705 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
706 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");
707 }
708 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
709 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
710 }
711 if found_tools.contains("node")
712 && !found_managers.contains("npm")
713 && !found_managers.contains("pnpm")
714 {
715 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");
716 }
717 if found_tools.contains("python")
718 && !found_managers.contains("pip")
719 && !found_managers.contains("uv")
720 && !found_managers.contains("pipx")
721 {
722 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");
723 }
724
725 if !path_stats.duplicate_entries.is_empty() {
726 out.push_str("\nExample duplicate PATH rows:\n");
727 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
728 out.push_str(&format!("- {}\n", entry));
729 }
730 }
731 if !path_stats.missing_entries.is_empty() {
732 out.push_str("\nExample missing PATH rows:\n");
733 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
734 out.push_str(&format!("- {}\n", entry));
735 }
736 }
737
738 out.push_str(
739 "\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.",
740 );
741 Ok(out.trim_end().to_string())
742}
743
744fn inspect_port_fix_plan(
745 issue: &str,
746 port_filter: Option<u16>,
747 max_entries: usize,
748) -> Result<String, String> {
749 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
750 let listeners = collect_listening_ports().unwrap_or_default();
751 let mut matching = listeners;
752 if let Some(port) = requested_port {
753 matching.retain(|entry| entry.port == port);
754 }
755 let processes = collect_processes().unwrap_or_default();
756
757 let mut out = String::from("Host inspection: fix_plan\n\n");
758 out.push_str(&format!("- Requested issue: {}\n", issue));
759 out.push_str("- Fix-plan type: port_conflict\n");
760 if let Some(port) = requested_port {
761 out.push_str(&format!("- Requested port: {}\n", port));
762 } else {
763 out.push_str("- Requested port: not parsed from the issue text\n");
764 }
765 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
766
767 if !matching.is_empty() {
768 out.push_str("\nCurrent listeners:\n");
769 for entry in matching.iter().take(max_entries.min(5)) {
770 let process_name = entry
771 .pid
772 .as_deref()
773 .and_then(|pid| pid.parse::<u32>().ok())
774 .and_then(|pid| {
775 processes
776 .iter()
777 .find(|process| process.pid == pid)
778 .map(|process| process.name.as_str())
779 })
780 .unwrap_or("unknown");
781 let pid = entry.pid.as_deref().unwrap_or("unknown");
782 out.push_str(&format!(
783 "- {} {} ({}) pid {} process {}\n",
784 entry.protocol, entry.local, entry.state, pid, process_name
785 ));
786 }
787 }
788
789 out.push_str("\nFix plan:\n");
790 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");
791 if !matching.is_empty() {
792 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");
793 } else {
794 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");
795 }
796 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
797 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");
798 out.push_str(
799 "\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.",
800 );
801 Ok(out.trim_end().to_string())
802}
803
804async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
805 let config = crate::agent::config::load_config();
806 let configured_api = config
807 .api_url
808 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
809 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
810 let reachability = probe_http_endpoint(&models_url).await;
811 let embed_model = detect_loaded_embed_model(&configured_api).await;
812
813 let mut out = String::from("Host inspection: fix_plan\n\n");
814 out.push_str(&format!("- Requested issue: {}\n", issue));
815 out.push_str("- Fix-plan type: lm_studio\n");
816 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
817 out.push_str(&format!("- Probe URL: {}\n", models_url));
818 match &reachability {
819 EndpointProbe::Reachable(status) => {
820 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
821 }
822 EndpointProbe::Unreachable(detail) => {
823 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
824 }
825 }
826 out.push_str(&format!(
827 "- Embedding model loaded: {}\n",
828 embed_model.as_deref().unwrap_or("none detected")
829 ));
830
831 out.push_str("\nFix plan:\n");
832 match reachability {
833 EndpointProbe::Reachable(_) => {
834 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");
835 }
836 EndpointProbe::Unreachable(_) => {
837 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");
838 }
839 }
840 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");
841 out.push_str("- If chat works but semantic search does not, load the embedding model as a second resident model in LM Studio. Hematite expects a `nomic-embed` style model there.\n");
842 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");
843 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");
844 if let Some(model) = embed_model {
845 out.push_str(&format!(
846 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
847 model
848 ));
849 }
850 if max_entries > 0 {
851 out.push_str(
852 "\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.",
853 );
854 }
855 Ok(out.trim_end().to_string())
856}
857
858fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
859 #[cfg(target_os = "windows")]
861 let gpu_info = {
862 let out = Command::new("powershell")
863 .args([
864 "-NoProfile",
865 "-NonInteractive",
866 "-Command",
867 "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
868 ])
869 .output()
870 .ok()
871 .and_then(|o| String::from_utf8(o.stdout).ok())
872 .unwrap_or_default();
873 out.trim().to_string()
874 };
875 #[cfg(not(target_os = "windows"))]
876 let gpu_info = String::from("(GPU detection not available on this platform)");
877
878 let mut out = String::from("Host inspection: fix_plan\n\n");
879 out.push_str(&format!("- Requested issue: {}\n", issue));
880 out.push_str("- Fix-plan type: driver_install\n");
881 if !gpu_info.is_empty() {
882 out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
883 }
884 out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
885 out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
886 out.push_str(
887 "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
888 );
889 out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
890 out.push_str("4. Download the latest driver directly from the manufacturer:\n");
891 out.push_str(" - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
892 out.push_str(" - AMD: amd.com/support (use Auto-Detect tool)\n");
893 out.push_str(" - Intel: intel.com/content/www/us/en/download-center/home.html\n");
894 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");
895 out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
896 out.push_str("\nVerification:\n");
897 out.push_str("- After reboot, run in PowerShell:\n Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
898 out.push_str("- The DriverVersion should match what you installed.\n");
899 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.");
900 Ok(out.trim_end().to_string())
901}
902
903fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
904 #[cfg(target_os = "windows")]
906 let edition = {
907 Command::new("powershell")
908 .args([
909 "-NoProfile",
910 "-NonInteractive",
911 "-Command",
912 "(Get-CimInstance Win32_OperatingSystem).Caption",
913 ])
914 .output()
915 .ok()
916 .and_then(|o| String::from_utf8(o.stdout).ok())
917 .unwrap_or_default()
918 .trim()
919 .to_string()
920 };
921 #[cfg(not(target_os = "windows"))]
922 let edition = String::from("(Windows edition detection not available)");
923
924 let is_home = edition.to_lowercase().contains("home");
925
926 let mut out = String::from("Host inspection: fix_plan\n\n");
927 out.push_str(&format!("- Requested issue: {}\n", issue));
928 out.push_str("- Fix-plan type: group_policy\n");
929 out.push_str(&format!(
930 "- Windows edition detected: {}\n",
931 if edition.is_empty() {
932 "unknown".to_string()
933 } else {
934 edition.clone()
935 }
936 ));
937
938 if is_home {
939 out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
940 out.push_str("Options on Home edition:\n");
941 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");
942 out.push_str(
943 "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
944 );
945 out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
946 } else {
947 out.push_str("\nFix plan — Editing Local Group Policy:\n");
948 out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
949 out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
950 out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
951 out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
952 out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
953 out.push_str("6. To force immediate application, run in an elevated PowerShell:\n gpupdate /force\n");
954 }
955 out.push_str("\nVerification:\n");
956 out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
957 out.push_str(
958 "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
959 );
960 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.");
961 Ok(out.trim_end().to_string())
962}
963
964fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
965 #[cfg(target_os = "windows")]
966 let profile_state = {
967 Command::new("powershell")
968 .args([
969 "-NoProfile",
970 "-NonInteractive",
971 "-Command",
972 "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
973 ])
974 .output()
975 .ok()
976 .and_then(|o| String::from_utf8(o.stdout).ok())
977 .unwrap_or_default()
978 .trim()
979 .to_string()
980 };
981 #[cfg(not(target_os = "windows"))]
982 let profile_state = String::new();
983
984 let mut out = String::from("Host inspection: fix_plan\n\n");
985 out.push_str(&format!("- Requested issue: {}\n", issue));
986 out.push_str("- Fix-plan type: firewall_rule\n");
987 if !profile_state.is_empty() {
988 out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
989 }
990 out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
991 out.push_str("\nTo ALLOW inbound traffic on a port:\n");
992 out.push_str(" New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
993 out.push_str("\nTo BLOCK outbound traffic to an address:\n");
994 out.push_str(" New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
995 out.push_str("\nTo ALLOW an application through the firewall:\n");
996 out.push_str(" New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
997 out.push_str("\nTo REMOVE a rule you created:\n");
998 out.push_str(" Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
999 out.push_str("\nTo see existing custom rules:\n");
1000 out.push_str(" Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1001 out.push_str("\nVerification:\n");
1002 out.push_str("- After creating the rule, test reachability from another machine or use:\n Test-NetConnection -ComputerName localhost -Port 8080\n");
1003 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.");
1004 Ok(out.trim_end().to_string())
1005}
1006
1007fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1008 let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1009 let ssh_dir = home.join(".ssh");
1010 let has_ssh_dir = ssh_dir.exists();
1011 let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1012 let has_rsa = ssh_dir.join("id_rsa").exists();
1013 let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1014
1015 let mut out = String::from("Host inspection: fix_plan\n\n");
1016 out.push_str(&format!("- Requested issue: {}\n", issue));
1017 out.push_str("- Fix-plan type: ssh_key\n");
1018 out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1019 out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1020 out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1021 out.push_str(&format!(
1022 "- authorized_keys found: {}\n",
1023 has_authorized_keys
1024 ));
1025
1026 if has_ed25519 {
1027 out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1028 }
1029
1030 out.push_str("\nFix plan — Generating an SSH key pair:\n");
1031 out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1032 out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1033 out.push_str(" ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1034 out.push_str(
1035 " - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1036 );
1037 out.push_str(" - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1038 out.push_str("3. Start the SSH agent and add your key:\n");
1039 out.push_str(" # Windows (PowerShell, run as Admin once to enable the service):\n");
1040 out.push_str(" Set-Service -Name ssh-agent -StartupType Automatic\n");
1041 out.push_str(" Start-Service ssh-agent\n");
1042 out.push_str(" # Then add the key (normal PowerShell):\n");
1043 out.push_str(" ssh-add ~/.ssh/id_ed25519\n");
1044 out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1045 out.push_str(" # Print your public key:\n");
1046 out.push_str(" cat ~/.ssh/id_ed25519.pub\n");
1047 out.push_str(" # On the target server, append it:\n");
1048 out.push_str(" echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1049 out.push_str(" chmod 600 ~/.ssh/authorized_keys\n");
1050 out.push_str("5. Test the connection:\n");
1051 out.push_str(" ssh user@server-address\n");
1052 out.push_str("\nFor GitHub/GitLab:\n");
1053 out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1054 out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1055 out.push_str("- Test: ssh -T git@github.com\n");
1056 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.");
1057 Ok(out.trim_end().to_string())
1058}
1059
1060fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1061 #[cfg(target_os = "windows")]
1062 let wsl_status = {
1063 let out = Command::new("wsl")
1064 .args(["--status"])
1065 .output()
1066 .ok()
1067 .and_then(|o| {
1068 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1069 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1070 Some(format!("{}{}", stdout, stderr))
1071 })
1072 .unwrap_or_default();
1073 out.trim().to_string()
1074 };
1075 #[cfg(not(target_os = "windows"))]
1076 let wsl_status = String::new();
1077
1078 let wsl_installed =
1079 !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1080
1081 let mut out = String::from("Host inspection: fix_plan\n\n");
1082 out.push_str(&format!("- Requested issue: {}\n", issue));
1083 out.push_str("- Fix-plan type: wsl_setup\n");
1084 out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1085 if !wsl_status.is_empty() {
1086 out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1087 }
1088
1089 if wsl_installed {
1090 out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1091 out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1092 out.push_str(" Available distros: wsl --list --online\n");
1093 out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1094 out.push_str("3. Create your Linux username and password when prompted.\n");
1095 } else {
1096 out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1097 out.push_str("1. Open PowerShell as Administrator.\n");
1098 out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1099 out.push_str(" wsl --install\n");
1100 out.push_str(" (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1101 out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1102 out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1103 out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1104 out.push_str(" wsl --set-default-version 2\n");
1105 out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1106 out.push_str(" wsl --install -d Debian\n");
1107 out.push_str(" wsl --list --online # to see all available distros\n");
1108 }
1109 out.push_str("\nVerification:\n");
1110 out.push_str("- Run: wsl --list --verbose\n");
1111 out.push_str("- You should see your distro with State: Running and Version: 2\n");
1112 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.");
1113 Ok(out.trim_end().to_string())
1114}
1115
1116fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1117 let lower = issue.to_ascii_lowercase();
1118 let service_hint = if lower.contains("ssh") {
1120 Some("sshd")
1121 } else if lower.contains("mysql") {
1122 Some("MySQL80")
1123 } else if lower.contains("postgres") || lower.contains("postgresql") {
1124 Some("postgresql")
1125 } else if lower.contains("redis") {
1126 Some("Redis")
1127 } else if lower.contains("nginx") {
1128 Some("nginx")
1129 } else if lower.contains("apache") {
1130 Some("Apache2.4")
1131 } else {
1132 None
1133 };
1134
1135 #[cfg(target_os = "windows")]
1136 let service_state = if let Some(svc) = service_hint {
1137 Command::new("powershell")
1138 .args([
1139 "-NoProfile",
1140 "-NonInteractive",
1141 "-Command",
1142 &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1143 ])
1144 .output()
1145 .ok()
1146 .and_then(|o| String::from_utf8(o.stdout).ok())
1147 .unwrap_or_default()
1148 .trim()
1149 .to_string()
1150 } else {
1151 String::new()
1152 };
1153 #[cfg(not(target_os = "windows"))]
1154 let service_state = String::new();
1155
1156 let mut out = String::from("Host inspection: fix_plan\n\n");
1157 out.push_str(&format!("- Requested issue: {}\n", issue));
1158 out.push_str("- Fix-plan type: service_config\n");
1159 if let Some(svc) = service_hint {
1160 out.push_str(&format!("- Service detected in request: {}\n", svc));
1161 }
1162 if !service_state.is_empty() {
1163 out.push_str(&format!("- Current state: {}\n", service_state));
1164 }
1165
1166 out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1167 out.push_str("\nStart a service:\n");
1168 out.push_str(" Start-Service -Name \"ServiceName\"\n");
1169 out.push_str("\nStop a service:\n");
1170 out.push_str(" Stop-Service -Name \"ServiceName\"\n");
1171 out.push_str("\nRestart a service:\n");
1172 out.push_str(" Restart-Service -Name \"ServiceName\"\n");
1173 out.push_str("\nEnable a service to start automatically:\n");
1174 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1175 out.push_str("\nDisable a service (stops it from auto-starting):\n");
1176 out.push_str(" Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1177 out.push_str("\nFind the exact service name:\n");
1178 out.push_str(" Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1179 out.push_str("\nVerification:\n");
1180 out.push_str(" Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1181 if let Some(svc) = service_hint {
1182 out.push_str(&format!(
1183 "\nFor your detected service ({}):\n Get-Service -Name '{}'\n",
1184 svc, svc
1185 ));
1186 }
1187 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.");
1188 Ok(out.trim_end().to_string())
1189}
1190
1191fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1192 #[cfg(target_os = "windows")]
1193 let activation_status = {
1194 Command::new("powershell")
1195 .args([
1196 "-NoProfile",
1197 "-NonInteractive",
1198 "-Command",
1199 "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 + ')' })\" }",
1200 ])
1201 .output()
1202 .ok()
1203 .and_then(|o| String::from_utf8(o.stdout).ok())
1204 .unwrap_or_default()
1205 .trim()
1206 .to_string()
1207 };
1208 #[cfg(not(target_os = "windows"))]
1209 let activation_status = String::new();
1210
1211 let is_licensed = activation_status.to_lowercase().contains("licensed")
1212 && !activation_status.to_lowercase().contains("not licensed");
1213
1214 let mut out = String::from("Host inspection: fix_plan\n\n");
1215 out.push_str(&format!("- Requested issue: {}\n", issue));
1216 out.push_str("- Fix-plan type: windows_activation\n");
1217 if !activation_status.is_empty() {
1218 out.push_str(&format!(
1219 "- Current activation state:\n{}\n",
1220 activation_status
1221 ));
1222 }
1223
1224 if is_licensed {
1225 out.push_str(
1226 "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1227 );
1228 out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1229 out.push_str(" (Forces an online activation attempt)\n");
1230 out.push_str("2. Check activation details: slmgr /dli\n");
1231 } else {
1232 out.push_str("\nFix plan — Activating Windows:\n");
1233 out.push_str("1. Check your current status first:\n");
1234 out.push_str(" slmgr /dli (basic info)\n");
1235 out.push_str(" slmgr /dlv (detailed — shows remaining rearms, grace period)\n");
1236 out.push_str("\n2. If you have a retail product key:\n");
1237 out.push_str(" slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX (install key)\n");
1238 out.push_str(" slmgr /ato (activate online)\n");
1239 out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1240 out.push_str(" - Go to Settings → System → Activation\n");
1241 out.push_str(" - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1242 out.push_str(" - Sign in with the Microsoft account that holds the license\n");
1243 out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1244 out.push_str(" - Contact your IT department for the KMS server address\n");
1245 out.push_str(" - Set KMS host: slmgr /skms kms.yourorg.com\n");
1246 out.push_str(" - Activate: slmgr /ato\n");
1247 }
1248 out.push_str("\nVerification:\n");
1249 out.push_str(" slmgr /dli — should show 'License Status: Licensed'\n");
1250 out.push_str(" Or: Settings → System → Activation → 'Windows is activated'\n");
1251 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.");
1252 Ok(out.trim_end().to_string())
1253}
1254
1255fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1256 let mut out = String::from("Host inspection: fix_plan\n\n");
1257 out.push_str(&format!("- Requested issue: {}\n", issue));
1258 out.push_str("- Fix-plan type: registry_edit\n");
1259 out.push_str(
1260 "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1261 );
1262 out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1263 out.push_str("\n1. Back up before you touch anything:\n");
1264 out.push_str(" # Export the key you're about to change (PowerShell):\n");
1265 out.push_str(" reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1266 out.push_str(" # Or export the whole registry (takes a while):\n");
1267 out.push_str(" reg export HKLM C:\\backup\\HKLM_full.reg\n");
1268 out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1269 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1270 out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1271 out.push_str(
1272 " Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1273 );
1274 out.push_str("\n4. Create a new key:\n");
1275 out.push_str(" New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1276 out.push_str("\n5. Delete a value:\n");
1277 out.push_str(" Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1278 out.push_str("\n6. Restore from backup if something breaks:\n");
1279 out.push_str(" reg import C:\\backup\\MyKey_backup.reg\n");
1280 out.push_str("\nCommon registry hives:\n");
1281 out.push_str(" HKLM = HKEY_LOCAL_MACHINE (machine-wide, requires Admin)\n");
1282 out.push_str(" HKCU = HKEY_CURRENT_USER (current user, no elevation needed)\n");
1283 out.push_str(" HKCR = HKEY_CLASSES_ROOT (file associations)\n");
1284 out.push_str("\nVerification:\n");
1285 out.push_str(" Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1286 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.");
1287 Ok(out.trim_end().to_string())
1288}
1289
1290fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1291 let mut out = String::from("Host inspection: fix_plan\n\n");
1292 out.push_str(&format!("- Requested issue: {}\n", issue));
1293 out.push_str("- Fix-plan type: scheduled_task_create\n");
1294 out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1295 out.push_str("\nExample: Run a script at 9 AM every day\n");
1296 out.push_str(" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1297 out.push_str(" $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1298 out.push_str(" Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1299 out.push_str("\nExample: Run at Windows startup\n");
1300 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1301 out.push_str(" Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1302 out.push_str("\nExample: Run at user logon\n");
1303 out.push_str(" $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1304 out.push_str(
1305 " Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1306 );
1307 out.push_str("\nExample: Run every 30 minutes\n");
1308 out.push_str(" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1309 out.push_str("\nView all tasks:\n");
1310 out.push_str(" Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1311 out.push_str("\nDelete a task:\n");
1312 out.push_str(" Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1313 out.push_str("\nRun a task immediately:\n");
1314 out.push_str(" Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1315 out.push_str("\nVerification:\n");
1316 out.push_str(" Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1317 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.");
1318 Ok(out.trim_end().to_string())
1319}
1320
1321fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1322 #[cfg(target_os = "windows")]
1323 let disk_info = {
1324 Command::new("powershell")
1325 .args([
1326 "-NoProfile",
1327 "-NonInteractive",
1328 "-Command",
1329 "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\" }",
1330 ])
1331 .output()
1332 .ok()
1333 .and_then(|o| String::from_utf8(o.stdout).ok())
1334 .unwrap_or_default()
1335 .trim()
1336 .to_string()
1337 };
1338 #[cfg(not(target_os = "windows"))]
1339 let disk_info = String::new();
1340
1341 let mut out = String::from("Host inspection: fix_plan\n\n");
1342 out.push_str(&format!("- Requested issue: {}\n", issue));
1343 out.push_str("- Fix-plan type: disk_cleanup\n");
1344 if !disk_info.is_empty() {
1345 out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1346 }
1347 out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1348 out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1349 out.push_str(" cleanmgr /sageset:1 (configure what to clean)\n");
1350 out.push_str(" cleanmgr /sagerun:1 (run the cleanup)\n");
1351 out.push_str(" Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1352 out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1353 out.push_str(" Stop-Service wuauserv\n");
1354 out.push_str(" Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1355 out.push_str(" Start-Service wuauserv\n");
1356 out.push_str("\n3. Clear Windows Temp folder:\n");
1357 out.push_str(" Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1358 out.push_str(
1359 " Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1360 );
1361 out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1362 out.push_str(" - Rust build artifacts: cargo clean (inside each project)\n");
1363 out.push_str(" - npm cache: npm cache clean --force\n");
1364 out.push_str(" - pip cache: pip cache purge\n");
1365 out.push_str(
1366 " - Docker: docker system prune -a (removes all unused images/containers)\n",
1367 );
1368 out.push_str(" - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force (will redownload on next build)\n");
1369 out.push_str("\n5. Check for large files:\n");
1370 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");
1371 out.push_str("\nVerification:\n");
1372 out.push_str(
1373 " Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1374 );
1375 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.");
1376 Ok(out.trim_end().to_string())
1377}
1378
1379fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1380 let mut out = String::from("Host inspection: fix_plan\n\n");
1381 out.push_str(&format!("- Requested issue: {}\n", issue));
1382 out.push_str("- Fix-plan type: generic\n");
1383 out.push_str(
1384 "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1385 Structured lanes available:\n\
1386 - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1387 - Port conflict (address already in use, what owns port)\n\
1388 - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1389 - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1390 - Group Policy (gpedit, local policy, administrative template)\n\
1391 - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1392 - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1393 - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1394 - Service config (start/stop/restart/enable/disable a service)\n\
1395 - Windows activation (product key, not activated, kms)\n\
1396 - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1397 - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1398 - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1399 - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1400 );
1401 Ok(out.trim_end().to_string())
1402}
1403
1404fn inspect_resource_load() -> Result<String, String> {
1405 #[cfg(target_os = "windows")]
1406 {
1407 let output = Command::new("powershell")
1408 .args([
1409 "-NoProfile",
1410 "-Command",
1411 "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1412 ])
1413 .output()
1414 .map_err(|e| format!("Failed to run powershell: {e}"))?;
1415
1416 let text = String::from_utf8_lossy(&output.stdout);
1417 let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1418
1419 let cpu_load = lines
1420 .next()
1421 .and_then(|l| l.parse::<u32>().ok())
1422 .unwrap_or(0);
1423 let mem_json = lines.collect::<Vec<_>>().join("");
1424 let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1425
1426 let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1427 let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1428 let used_kb = total_kb.saturating_sub(free_kb);
1429 let mem_percent = if total_kb > 0 {
1430 (used_kb * 100) / total_kb
1431 } else {
1432 0
1433 };
1434
1435 let mut out = String::from("Host inspection: resource_load\n\n");
1436 out.push_str("**System Performance Summary:**\n");
1437 out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1438 out.push_str(&format!(
1439 "- Memory Usage: {} / {} ({}%)\n",
1440 human_bytes(used_kb * 1024),
1441 human_bytes(total_kb * 1024),
1442 mem_percent
1443 ));
1444
1445 if cpu_load > 85 {
1446 out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1447 }
1448 if mem_percent > 90 {
1449 out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1450 }
1451
1452 Ok(out)
1453 }
1454 #[cfg(not(target_os = "windows"))]
1455 {
1456 Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1457 }
1458}
1459
1460#[derive(Debug)]
1461enum EndpointProbe {
1462 Reachable(u16),
1463 Unreachable(String),
1464}
1465
1466async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1467 let client = match reqwest::Client::builder()
1468 .timeout(std::time::Duration::from_secs(3))
1469 .build()
1470 {
1471 Ok(client) => client,
1472 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1473 };
1474
1475 match client.get(url).send().await {
1476 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1477 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1478 }
1479}
1480
1481async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1482 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1483 let url = format!("{}/api/v0/models", base);
1484 let client = reqwest::Client::builder()
1485 .timeout(std::time::Duration::from_secs(3))
1486 .build()
1487 .ok()?;
1488
1489 #[derive(serde::Deserialize)]
1490 struct ModelList {
1491 data: Vec<ModelEntry>,
1492 }
1493 #[derive(serde::Deserialize)]
1494 struct ModelEntry {
1495 id: String,
1496 #[serde(rename = "type", default)]
1497 model_type: String,
1498 #[serde(default)]
1499 state: String,
1500 }
1501
1502 let response = client.get(url).send().await.ok()?;
1503 let models = response.json::<ModelList>().await.ok()?;
1504 models
1505 .data
1506 .into_iter()
1507 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1508 .map(|model| model.id)
1509}
1510
1511fn first_port_in_text(text: &str) -> Option<u16> {
1512 text.split(|c: char| !c.is_ascii_digit())
1513 .find(|fragment| !fragment.is_empty())
1514 .and_then(|fragment| fragment.parse::<u16>().ok())
1515}
1516
1517fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1518 let mut processes = collect_processes()?;
1519 if let Some(filter) = name_filter.as_deref() {
1520 let lowered = filter.to_ascii_lowercase();
1521 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1522 }
1523 processes.sort_by(|a, b| {
1524 let a_cpu = a.cpu_percent.unwrap_or(0.0);
1525 let b_cpu = b.cpu_percent.unwrap_or(0.0);
1526 b_cpu
1527 .partial_cmp(&a_cpu)
1528 .unwrap_or(std::cmp::Ordering::Equal)
1529 .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1530 .then_with(|| a.name.cmp(&b.name))
1531 .then_with(|| a.pid.cmp(&b.pid))
1532 });
1533
1534 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1535
1536 let mut out = String::from("Host inspection: processes\n\n");
1537 if let Some(filter) = name_filter.as_deref() {
1538 out.push_str(&format!("- Filter name: {}\n", filter));
1539 }
1540 out.push_str(&format!("- Processes found: {}\n", processes.len()));
1541 out.push_str(&format!(
1542 "- Total reported working set: {}\n",
1543 human_bytes(total_memory)
1544 ));
1545
1546 if processes.is_empty() {
1547 out.push_str("\nNo running processes matched.");
1548 return Ok(out);
1549 }
1550
1551 out.push_str("\nTop processes by resource usage:\n");
1552 for entry in processes.iter().take(max_entries) {
1553 let cpu_str = entry
1554 .cpu_percent
1555 .map(|p| format!(" [CPU: {:.1}%]", p))
1556 .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1557 .unwrap_or_default();
1558 let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1559 format!(" [I/O R:{}/W:{}]", r, w)
1560 } else {
1561 " [I/O unknown]".to_string()
1562 };
1563 out.push_str(&format!(
1564 "- {} (pid {}) - {}{}{}{}\n",
1565 entry.name,
1566 entry.pid,
1567 human_bytes(entry.memory_bytes),
1568 cpu_str,
1569 io_str,
1570 entry
1571 .detail
1572 .as_deref()
1573 .map(|detail| format!(" [{}]", detail))
1574 .unwrap_or_default()
1575 ));
1576 }
1577 if processes.len() > max_entries {
1578 out.push_str(&format!(
1579 "- ... {} more processes omitted\n",
1580 processes.len() - max_entries
1581 ));
1582 }
1583
1584 Ok(out.trim_end().to_string())
1585}
1586
1587fn inspect_network(max_entries: usize) -> Result<String, String> {
1588 let adapters = collect_network_adapters()?;
1589 let active_count = adapters
1590 .iter()
1591 .filter(|adapter| adapter.is_active())
1592 .count();
1593 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1594
1595 let mut out = String::from("Host inspection: network\n\n");
1596 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1597 out.push_str(&format!("- Active adapters: {}\n", active_count));
1598 out.push_str(&format!(
1599 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1600 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1601 ));
1602
1603 if adapters.is_empty() {
1604 out.push_str("\nNo adapter details were detected.");
1605 return Ok(out);
1606 }
1607
1608 out.push_str("\nAdapter summary:\n");
1609 for adapter in adapters.iter().take(max_entries) {
1610 let status = if adapter.is_active() {
1611 "active"
1612 } else if adapter.disconnected {
1613 "disconnected"
1614 } else {
1615 "idle"
1616 };
1617 let mut details = vec![status.to_string()];
1618 if !adapter.ipv4.is_empty() {
1619 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1620 }
1621 if !adapter.ipv6.is_empty() {
1622 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1623 }
1624 if !adapter.gateways.is_empty() {
1625 details.push(format!("gateway {}", adapter.gateways.join(", ")));
1626 }
1627 if !adapter.dns_servers.is_empty() {
1628 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1629 }
1630 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1631 }
1632 if adapters.len() > max_entries {
1633 out.push_str(&format!(
1634 "- ... {} more adapters omitted\n",
1635 adapters.len() - max_entries
1636 ));
1637 }
1638
1639 Ok(out.trim_end().to_string())
1640}
1641
1642fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1643 let mut out = String::from("Host inspection: lan_discovery\n\n");
1644
1645 #[cfg(target_os = "windows")]
1646 {
1647 let n = max_entries.clamp(5, 20);
1648 let adapters = collect_network_adapters()?;
1649 let services = collect_services().unwrap_or_default();
1650 let active_adapters: Vec<&NetworkAdapter> = adapters
1651 .iter()
1652 .filter(|adapter| adapter.is_active())
1653 .collect();
1654 let gateways: Vec<String> = active_adapters
1655 .iter()
1656 .flat_map(|adapter| adapter.gateways.clone())
1657 .collect::<HashSet<_>>()
1658 .into_iter()
1659 .collect();
1660
1661 let neighbor_script = r#"
1662$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1663 Where-Object {
1664 $_.IPAddress -notlike '127.*' -and
1665 $_.IPAddress -notlike '169.254*' -and
1666 $_.State -notin @('Unreachable','Invalid')
1667 } |
1668 Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1669$neighbors | ConvertTo-Json -Compress
1670"#;
1671 let neighbor_text = Command::new("powershell")
1672 .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1673 .output()
1674 .ok()
1675 .and_then(|o| String::from_utf8(o.stdout).ok())
1676 .unwrap_or_default();
1677 let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1678 .into_iter()
1679 .take(n)
1680 .collect();
1681
1682 let listener_script = r#"
1683Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1684 Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1685 Select-Object LocalAddress, LocalPort, OwningProcess |
1686 ForEach-Object {
1687 $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1688 "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1689 }
1690"#;
1691 let listener_text = Command::new("powershell")
1692 .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1693 .output()
1694 .ok()
1695 .and_then(|o| String::from_utf8(o.stdout).ok())
1696 .unwrap_or_default();
1697 let listeners: Vec<(String, u16, String, String)> = listener_text
1698 .lines()
1699 .filter_map(|line| {
1700 let parts: Vec<&str> = line.trim().split('|').collect();
1701 if parts.len() < 4 {
1702 return None;
1703 }
1704 Some((
1705 parts[0].to_string(),
1706 parts[1].parse::<u16>().ok()?,
1707 parts[2].to_string(),
1708 parts[3].to_string(),
1709 ))
1710 })
1711 .take(n)
1712 .collect();
1713
1714 let smb_mapping_script = r#"
1715Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1716 ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1717"#;
1718 let smb_mappings: Vec<String> = Command::new("powershell")
1719 .args([
1720 "-NoProfile",
1721 "-NonInteractive",
1722 "-Command",
1723 smb_mapping_script,
1724 ])
1725 .output()
1726 .ok()
1727 .and_then(|o| String::from_utf8(o.stdout).ok())
1728 .unwrap_or_default()
1729 .lines()
1730 .take(n)
1731 .map(|line| line.trim().to_string())
1732 .filter(|line| !line.is_empty())
1733 .collect();
1734
1735 let smb_connections_script = r#"
1736Get-SmbConnection -ErrorAction SilentlyContinue |
1737 Select-Object ServerName, ShareName, NumOpens |
1738 ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1739"#;
1740 let smb_connections: Vec<String> = Command::new("powershell")
1741 .args([
1742 "-NoProfile",
1743 "-NonInteractive",
1744 "-Command",
1745 smb_connections_script,
1746 ])
1747 .output()
1748 .ok()
1749 .and_then(|o| String::from_utf8(o.stdout).ok())
1750 .unwrap_or_default()
1751 .lines()
1752 .take(n)
1753 .map(|line| line.trim().to_string())
1754 .filter(|line| !line.is_empty())
1755 .collect();
1756
1757 let discovery_service_names = [
1758 "FDResPub",
1759 "fdPHost",
1760 "SSDPSRV",
1761 "upnphost",
1762 "LanmanServer",
1763 "LanmanWorkstation",
1764 "lmhosts",
1765 ];
1766 let discovery_services: Vec<&ServiceEntry> = services
1767 .iter()
1768 .filter(|entry| {
1769 discovery_service_names
1770 .iter()
1771 .any(|name| entry.name.eq_ignore_ascii_case(name))
1772 })
1773 .collect();
1774
1775 let mut findings = Vec::new();
1776 if active_adapters.is_empty() {
1777 findings.push(AuditFinding {
1778 finding: "No active LAN adapters were detected.".to_string(),
1779 impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1780 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(),
1781 });
1782 }
1783
1784 let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
1785 .iter()
1786 .copied()
1787 .filter(|entry| {
1788 !entry.status.eq_ignore_ascii_case("running")
1789 && !entry.status.eq_ignore_ascii_case("active")
1790 })
1791 .collect();
1792 if !stopped_discovery_services.is_empty() {
1793 let names = stopped_discovery_services
1794 .iter()
1795 .map(|entry| entry.name.as_str())
1796 .collect::<Vec<_>>()
1797 .join(", ");
1798 findings.push(AuditFinding {
1799 finding: format!("Discovery-related services are not running: {names}"),
1800 impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
1801 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(),
1802 });
1803 }
1804
1805 if listeners.is_empty() {
1806 findings.push(AuditFinding {
1807 finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
1808 impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
1809 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(),
1810 });
1811 }
1812
1813 if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
1814 findings.push(AuditFinding {
1815 finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
1816 impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
1817 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(),
1818 });
1819 }
1820
1821 out.push_str("=== Findings ===\n");
1822 if findings.is_empty() {
1823 out.push_str(
1824 "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
1825 );
1826 out.push_str(" Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
1827 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");
1828 } else {
1829 for finding in &findings {
1830 out.push_str(&format!("- Finding: {}\n", finding.finding));
1831 out.push_str(&format!(" Impact: {}\n", finding.impact));
1832 out.push_str(&format!(" Fix: {}\n", finding.fix));
1833 }
1834 }
1835
1836 out.push_str("\n=== Active adapter and gateway summary ===\n");
1837 if active_adapters.is_empty() {
1838 out.push_str("- No active adapters detected.\n");
1839 } else {
1840 for adapter in active_adapters.iter().take(n) {
1841 let ipv4 = if adapter.ipv4.is_empty() {
1842 "no IPv4".to_string()
1843 } else {
1844 adapter.ipv4.join(", ")
1845 };
1846 let gateway = if adapter.gateways.is_empty() {
1847 "no gateway".to_string()
1848 } else {
1849 adapter.gateways.join(", ")
1850 };
1851 out.push_str(&format!(
1852 "- {} | IPv4: {} | Gateway: {}\n",
1853 adapter.name, ipv4, gateway
1854 ));
1855 }
1856 }
1857
1858 out.push_str("\n=== Neighborhood evidence ===\n");
1859 out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
1860 out.push_str(&format!(
1861 "- Neighbor entries observed: {}\n",
1862 neighbors.len()
1863 ));
1864 if neighbors.is_empty() {
1865 out.push_str("- No ARP/neighbor evidence retrieved.\n");
1866 } else {
1867 for (ip, mac, state, iface) in neighbors.iter().take(n) {
1868 out.push_str(&format!(
1869 "- {} on {} | MAC: {} | State: {}\n",
1870 ip, iface, mac, state
1871 ));
1872 }
1873 }
1874
1875 out.push_str("\n=== Discovery services ===\n");
1876 if discovery_services.is_empty() {
1877 out.push_str("- Discovery service status unavailable.\n");
1878 } else {
1879 for entry in discovery_services.iter().take(n) {
1880 let startup = entry.startup.as_deref().unwrap_or("unknown");
1881 out.push_str(&format!(
1882 "- {} | Status: {} | Startup: {}\n",
1883 entry.name, entry.status, startup
1884 ));
1885 }
1886 }
1887
1888 out.push_str("\n=== Discovery listener surface ===\n");
1889 if listeners.is_empty() {
1890 out.push_str("- No discovery-oriented UDP listeners detected.\n");
1891 } else {
1892 for (addr, port, pid, proc_name) in listeners.iter().take(n) {
1893 let label = match *port {
1894 137 => "NetBIOS Name Service",
1895 138 => "NetBIOS Datagram",
1896 1900 => "SSDP/UPnP",
1897 5353 => "mDNS",
1898 5355 => "LLMNR",
1899 _ => "Discovery",
1900 };
1901 let proc_label = if proc_name.is_empty() {
1902 "unknown".to_string()
1903 } else {
1904 proc_name.clone()
1905 };
1906 out.push_str(&format!(
1907 "- {}:{} | {} | PID {} ({})\n",
1908 addr, port, label, pid, proc_label
1909 ));
1910 }
1911 }
1912
1913 out.push_str("\n=== SMB and neighborhood visibility ===\n");
1914 if smb_mappings.is_empty() && smb_connections.is_empty() {
1915 out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
1916 } else {
1917 if !smb_mappings.is_empty() {
1918 out.push_str("- Mapped drives:\n");
1919 for mapping in smb_mappings.iter().take(n) {
1920 let parts: Vec<&str> = mapping.split('|').collect();
1921 if parts.len() >= 2 {
1922 out.push_str(&format!(" - {} -> {}\n", parts[0], parts[1]));
1923 }
1924 }
1925 }
1926 if !smb_connections.is_empty() {
1927 out.push_str("- Active SMB connections:\n");
1928 for connection in smb_connections.iter().take(n) {
1929 let parts: Vec<&str> = connection.split('|').collect();
1930 if parts.len() >= 3 {
1931 out.push_str(&format!(
1932 " - {}\\{} | Opens: {}\n",
1933 parts[0], parts[1], parts[2]
1934 ));
1935 }
1936 }
1937 }
1938 }
1939 }
1940
1941 #[cfg(not(target_os = "windows"))]
1942 {
1943 let n = max_entries.clamp(5, 20);
1944 let adapters = collect_network_adapters()?;
1945 let arp_output = Command::new("ip")
1946 .args(["neigh"])
1947 .output()
1948 .ok()
1949 .and_then(|o| String::from_utf8(o.stdout).ok())
1950 .unwrap_or_default();
1951 let neighbors: Vec<&str> = arp_output
1952 .lines()
1953 .filter(|line| !line.trim().is_empty())
1954 .take(n)
1955 .collect();
1956
1957 out.push_str("=== Findings ===\n");
1958 if adapters.iter().any(|adapter| adapter.is_active()) {
1959 out.push_str(
1960 "- Finding: LAN discovery support is partially available on this platform.\n",
1961 );
1962 out.push_str(" Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
1963 out.push_str(" Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
1964 } else {
1965 out.push_str("- Finding: No active LAN adapters were detected.\n");
1966 out.push_str(
1967 " Impact: Neighborhood discovery cannot work without an active interface.\n",
1968 );
1969 out.push_str(" Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
1970 }
1971
1972 out.push_str("\n=== Active adapter and gateway summary ===\n");
1973 if adapters.is_empty() {
1974 out.push_str("- No adapters detected.\n");
1975 } else {
1976 for adapter in adapters.iter().take(n) {
1977 let ipv4 = if adapter.ipv4.is_empty() {
1978 "no IPv4".to_string()
1979 } else {
1980 adapter.ipv4.join(", ")
1981 };
1982 let gateway = if adapter.gateways.is_empty() {
1983 "no gateway".to_string()
1984 } else {
1985 adapter.gateways.join(", ")
1986 };
1987 out.push_str(&format!(
1988 "- {} | IPv4: {} | Gateway: {}\n",
1989 adapter.name, ipv4, gateway
1990 ));
1991 }
1992 }
1993
1994 out.push_str("\n=== Neighborhood evidence ===\n");
1995 if neighbors.is_empty() {
1996 out.push_str("- No neighbor entries detected.\n");
1997 } else {
1998 for line in neighbors {
1999 out.push_str(&format!("- {}\n", line.trim()));
2000 }
2001 }
2002 }
2003
2004 Ok(out.trim_end().to_string())
2005}
2006
2007fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2008 let mut services = collect_services()?;
2009 if let Some(filter) = name_filter.as_deref() {
2010 let lowered = filter.to_ascii_lowercase();
2011 services.retain(|entry| {
2012 entry.name.to_ascii_lowercase().contains(&lowered)
2013 || entry
2014 .display_name
2015 .as_deref()
2016 .map(|d| d.to_ascii_lowercase().contains(&lowered))
2017 .unwrap_or(false)
2018 });
2019 }
2020
2021 services.sort_by(|a, b| {
2022 let a_running =
2023 a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2024 let b_running =
2025 b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2026 b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2027 });
2028
2029 let running = services
2030 .iter()
2031 .filter(|entry| {
2032 entry.status.eq_ignore_ascii_case("running")
2033 || entry.status.eq_ignore_ascii_case("active")
2034 })
2035 .count();
2036 let failed = services
2037 .iter()
2038 .filter(|entry| {
2039 entry.status.eq_ignore_ascii_case("failed")
2040 || entry.status.eq_ignore_ascii_case("error")
2041 || entry.status.eq_ignore_ascii_case("stopped")
2042 })
2043 .count();
2044
2045 let mut out = String::from("Host inspection: services\n\n");
2046 if let Some(filter) = name_filter.as_deref() {
2047 out.push_str(&format!("- Filter name: {}\n", filter));
2048 }
2049 out.push_str(&format!("- Services found: {}\n", services.len()));
2050 out.push_str(&format!("- Running/active: {}\n", running));
2051 out.push_str(&format!("- Failed/stopped: {}\n", failed));
2052
2053 if services.is_empty() {
2054 out.push_str("\nNo services matched.");
2055 return Ok(out);
2056 }
2057
2058 let per_section = (max_entries / 2).max(5);
2060
2061 let running_services: Vec<_> = services
2062 .iter()
2063 .filter(|e| {
2064 e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2065 })
2066 .collect();
2067 let stopped_services: Vec<_> = services
2068 .iter()
2069 .filter(|e| {
2070 e.status.eq_ignore_ascii_case("stopped")
2071 || e.status.eq_ignore_ascii_case("failed")
2072 || e.status.eq_ignore_ascii_case("error")
2073 })
2074 .collect();
2075
2076 let fmt_entry = |entry: &&ServiceEntry| {
2077 let startup = entry
2078 .startup
2079 .as_deref()
2080 .map(|v| format!(" | startup {}", v))
2081 .unwrap_or_default();
2082 let logon = entry
2083 .start_name
2084 .as_deref()
2085 .map(|v| format!(" | LogOn: {}", v))
2086 .unwrap_or_default();
2087 let display = entry
2088 .display_name
2089 .as_deref()
2090 .filter(|v| *v != &entry.name)
2091 .map(|v| format!(" [{}]", v))
2092 .unwrap_or_default();
2093 format!(
2094 "- {}{} - {}{}{}\n",
2095 entry.name, display, entry.status, startup, logon
2096 )
2097 };
2098
2099 out.push_str(&format!(
2100 "\nRunning services ({} total, showing up to {}):\n",
2101 running_services.len(),
2102 per_section
2103 ));
2104 for entry in running_services.iter().take(per_section) {
2105 out.push_str(&fmt_entry(entry));
2106 }
2107 if running_services.len() > per_section {
2108 out.push_str(&format!(
2109 "- ... {} more running services omitted\n",
2110 running_services.len() - per_section
2111 ));
2112 }
2113
2114 out.push_str(&format!(
2115 "\nStopped/failed services ({} total, showing up to {}):\n",
2116 stopped_services.len(),
2117 per_section
2118 ));
2119 for entry in stopped_services.iter().take(per_section) {
2120 out.push_str(&fmt_entry(entry));
2121 }
2122 if stopped_services.len() > per_section {
2123 out.push_str(&format!(
2124 "- ... {} more stopped services omitted\n",
2125 stopped_services.len() - per_section
2126 ));
2127 }
2128
2129 Ok(out.trim_end().to_string())
2130}
2131
2132async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2133 inspect_directory("Disk", path, max_entries).await
2134}
2135
2136fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2137 let mut listeners = collect_listening_ports()?;
2138 if let Some(port) = port_filter {
2139 listeners.retain(|entry| entry.port == port);
2140 }
2141 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2142
2143 let mut out = String::from("Host inspection: ports\n\n");
2144 if let Some(port) = port_filter {
2145 out.push_str(&format!("- Filter port: {}\n", port));
2146 }
2147 out.push_str(&format!(
2148 "- Listening endpoints found: {}\n",
2149 listeners.len()
2150 ));
2151
2152 if listeners.is_empty() {
2153 out.push_str("\nNo listening endpoints matched.");
2154 return Ok(out);
2155 }
2156
2157 out.push_str("\nListening endpoints:\n");
2158 for entry in listeners.iter().take(max_entries) {
2159 let pid_str = entry
2160 .pid
2161 .as_deref()
2162 .map(|p| format!(" pid {}", p))
2163 .unwrap_or_default();
2164 let name_str = entry
2165 .process_name
2166 .as_deref()
2167 .map(|n| format!(" [{}]", n))
2168 .unwrap_or_default();
2169 out.push_str(&format!(
2170 "- {} {} ({}){}{}\n",
2171 entry.protocol, entry.local, entry.state, pid_str, name_str
2172 ));
2173 }
2174 if listeners.len() > max_entries {
2175 out.push_str(&format!(
2176 "- ... {} more listening endpoints omitted\n",
2177 listeners.len() - max_entries
2178 ));
2179 }
2180
2181 Ok(out.trim_end().to_string())
2182}
2183
2184fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2185 if !path.exists() {
2186 return Err(format!("Path does not exist: {}", path.display()));
2187 }
2188 if !path.is_dir() {
2189 return Err(format!("Path is not a directory: {}", path.display()));
2190 }
2191
2192 let markers = collect_project_markers(&path);
2193 let hematite_state = collect_hematite_state(&path);
2194 let git_state = inspect_git_state(&path);
2195 let release_state = inspect_release_artifacts(&path);
2196
2197 let mut out = String::from("Host inspection: repo_doctor\n\n");
2198 out.push_str(&format!("- Path: {}\n", path.display()));
2199 out.push_str(&format!(
2200 "- Workspace mode: {}\n",
2201 workspace_mode_for_path(&path)
2202 ));
2203
2204 if markers.is_empty() {
2205 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");
2206 } else {
2207 out.push_str("- Project markers:\n");
2208 for marker in markers.iter().take(max_entries) {
2209 out.push_str(&format!(" - {}\n", marker));
2210 }
2211 }
2212
2213 match git_state {
2214 Some(git) => {
2215 out.push_str(&format!("- Git root: {}\n", git.root.display()));
2216 out.push_str(&format!("- Git branch: {}\n", git.branch));
2217 out.push_str(&format!("- Git status: {}\n", git.status_label()));
2218 }
2219 None => out.push_str("- Git: not inside a detected work tree\n"),
2220 }
2221
2222 out.push_str(&format!(
2223 "- Hematite docs/imports/reports: {}/{}/{}\n",
2224 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2225 ));
2226 if hematite_state.workspace_profile {
2227 out.push_str("- Workspace profile: present\n");
2228 } else {
2229 out.push_str("- Workspace profile: absent\n");
2230 }
2231
2232 if let Some(release) = release_state {
2233 out.push_str(&format!("- Cargo version: {}\n", release.version));
2234 out.push_str(&format!(
2235 "- Windows artifacts for current version: {}/{}/{}\n",
2236 bool_label(release.portable_dir),
2237 bool_label(release.portable_zip),
2238 bool_label(release.setup_exe)
2239 ));
2240 }
2241
2242 Ok(out.trim_end().to_string())
2243}
2244
2245async fn inspect_known_directory(
2246 label: &str,
2247 path: Option<PathBuf>,
2248 max_entries: usize,
2249) -> Result<String, String> {
2250 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2251 inspect_directory(label, path, max_entries).await
2252}
2253
2254async fn inspect_directory(
2255 label: &str,
2256 path: PathBuf,
2257 max_entries: usize,
2258) -> Result<String, String> {
2259 let label = label.to_string();
2260 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2261 .await
2262 .map_err(|e| format!("inspect_host task failed: {e}"))?
2263}
2264
2265fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2266 if !path.exists() {
2267 return Err(format!("Path does not exist: {}", path.display()));
2268 }
2269 if !path.is_dir() {
2270 return Err(format!("Path is not a directory: {}", path.display()));
2271 }
2272
2273 let mut top_level_entries = Vec::new();
2274 for entry in fs::read_dir(path)
2275 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2276 {
2277 match entry {
2278 Ok(entry) => top_level_entries.push(entry),
2279 Err(_) => continue,
2280 }
2281 }
2282 top_level_entries.sort_by_key(|entry| entry.file_name());
2283
2284 let top_level_count = top_level_entries.len();
2285 let mut sample_names = Vec::new();
2286 let mut largest_entries = Vec::new();
2287 let mut aggregate = PathAggregate::default();
2288 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2289
2290 for entry in top_level_entries {
2291 let name = entry.file_name().to_string_lossy().to_string();
2292 if sample_names.len() < max_entries {
2293 sample_names.push(name.clone());
2294 }
2295 let kind = match entry.file_type() {
2296 Ok(ft) if ft.is_dir() => "dir",
2297 Ok(ft) if ft.is_symlink() => "symlink",
2298 _ => "file",
2299 };
2300 let stats = measure_path(&entry.path(), &mut budget);
2301 aggregate.merge(&stats);
2302 largest_entries.push(LargestEntry {
2303 name,
2304 kind,
2305 bytes: stats.total_bytes,
2306 });
2307 }
2308
2309 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2310
2311 let mut out = format!("Directory inspection: {}\n\n", label);
2312 out.push_str(&format!("- Path: {}\n", path.display()));
2313 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2314 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2315 out.push_str(&format!(
2316 "- Recursive directories: {}\n",
2317 aggregate.dir_count
2318 ));
2319 out.push_str(&format!(
2320 "- Total size: {}{}\n",
2321 human_bytes(aggregate.total_bytes),
2322 if aggregate.partial {
2323 " (partial scan)"
2324 } else {
2325 ""
2326 }
2327 ));
2328 if aggregate.skipped_entries > 0 {
2329 out.push_str(&format!(
2330 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2331 aggregate.skipped_entries
2332 ));
2333 }
2334
2335 if !largest_entries.is_empty() {
2336 out.push_str("\nLargest top-level entries:\n");
2337 for entry in largest_entries.iter().take(max_entries) {
2338 out.push_str(&format!(
2339 "- {} [{}] - {}\n",
2340 entry.name,
2341 entry.kind,
2342 human_bytes(entry.bytes)
2343 ));
2344 }
2345 }
2346
2347 if !sample_names.is_empty() {
2348 out.push_str("\nSample names:\n");
2349 for name in sample_names {
2350 out.push_str(&format!("- {}\n", name));
2351 }
2352 }
2353
2354 Ok(out.trim_end().to_string())
2355}
2356
2357fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2358 let trimmed = raw.trim();
2359 if trimmed.is_empty() {
2360 return Err("Path must not be empty.".to_string());
2361 }
2362
2363 if let Some(rest) = trimmed
2364 .strip_prefix("~/")
2365 .or_else(|| trimmed.strip_prefix("~\\"))
2366 {
2367 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2368 return Ok(home.join(rest));
2369 }
2370
2371 let path = PathBuf::from(trimmed);
2372 if path.is_absolute() {
2373 Ok(path)
2374 } else {
2375 let cwd =
2376 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2377 let full_path = cwd.join(&path);
2378
2379 if !full_path.exists()
2382 && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2383 {
2384 if let Some(home) = home::home_dir() {
2385 let home_path = home.join(trimmed);
2386 if home_path.exists() {
2387 return Ok(home_path);
2388 }
2389 }
2390 }
2391
2392 Ok(full_path)
2393 }
2394}
2395
2396fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2397 workspace_mode_for_path(workspace_root)
2398}
2399
2400fn workspace_mode_for_path(path: &Path) -> &'static str {
2401 if is_project_marker_path(path) {
2402 "project"
2403 } else if path.join(".hematite").join("docs").exists()
2404 || path.join(".hematite").join("imports").exists()
2405 || path.join(".hematite").join("reports").exists()
2406 {
2407 "docs-only"
2408 } else {
2409 "general directory"
2410 }
2411}
2412
2413fn is_project_marker_path(path: &Path) -> bool {
2414 [
2415 "Cargo.toml",
2416 "package.json",
2417 "pyproject.toml",
2418 "go.mod",
2419 "composer.json",
2420 "requirements.txt",
2421 "Makefile",
2422 "justfile",
2423 ]
2424 .iter()
2425 .any(|name| path.join(name).exists())
2426 || path.join(".git").exists()
2427}
2428
2429fn preferred_shell_label() -> &'static str {
2430 #[cfg(target_os = "windows")]
2431 {
2432 "PowerShell"
2433 }
2434 #[cfg(not(target_os = "windows"))]
2435 {
2436 "sh"
2437 }
2438}
2439
2440fn desktop_dir() -> Option<PathBuf> {
2441 home::home_dir().map(|home| home.join("Desktop"))
2442}
2443
2444fn downloads_dir() -> Option<PathBuf> {
2445 home::home_dir().map(|home| home.join("Downloads"))
2446}
2447
2448fn count_top_level_items(path: &Path) -> Result<usize, String> {
2449 let mut count = 0usize;
2450 for entry in
2451 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2452 {
2453 if entry.is_ok() {
2454 count += 1;
2455 }
2456 }
2457 Ok(count)
2458}
2459
2460#[derive(Default)]
2461struct PathAggregate {
2462 total_bytes: u64,
2463 file_count: u64,
2464 dir_count: u64,
2465 skipped_entries: u64,
2466 partial: bool,
2467}
2468
2469impl PathAggregate {
2470 fn merge(&mut self, other: &PathAggregate) {
2471 self.total_bytes += other.total_bytes;
2472 self.file_count += other.file_count;
2473 self.dir_count += other.dir_count;
2474 self.skipped_entries += other.skipped_entries;
2475 self.partial |= other.partial;
2476 }
2477}
2478
2479struct LargestEntry {
2480 name: String,
2481 kind: &'static str,
2482 bytes: u64,
2483}
2484
2485fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2486 if *budget == 0 {
2487 return PathAggregate {
2488 partial: true,
2489 skipped_entries: 1,
2490 ..PathAggregate::default()
2491 };
2492 }
2493 *budget -= 1;
2494
2495 let metadata = match fs::symlink_metadata(path) {
2496 Ok(metadata) => metadata,
2497 Err(_) => {
2498 return PathAggregate {
2499 skipped_entries: 1,
2500 ..PathAggregate::default()
2501 }
2502 }
2503 };
2504
2505 let file_type = metadata.file_type();
2506 if file_type.is_symlink() {
2507 return PathAggregate {
2508 skipped_entries: 1,
2509 ..PathAggregate::default()
2510 };
2511 }
2512
2513 if metadata.is_file() {
2514 return PathAggregate {
2515 total_bytes: metadata.len(),
2516 file_count: 1,
2517 ..PathAggregate::default()
2518 };
2519 }
2520
2521 if !metadata.is_dir() {
2522 return PathAggregate::default();
2523 }
2524
2525 let mut aggregate = PathAggregate {
2526 dir_count: 1,
2527 ..PathAggregate::default()
2528 };
2529
2530 let read_dir = match fs::read_dir(path) {
2531 Ok(read_dir) => read_dir,
2532 Err(_) => {
2533 aggregate.skipped_entries += 1;
2534 return aggregate;
2535 }
2536 };
2537
2538 for child in read_dir {
2539 match child {
2540 Ok(child) => {
2541 let child_stats = measure_path(&child.path(), budget);
2542 aggregate.merge(&child_stats);
2543 }
2544 Err(_) => aggregate.skipped_entries += 1,
2545 }
2546 }
2547
2548 aggregate
2549}
2550
2551struct PathAnalysis {
2552 total_entries: usize,
2553 unique_entries: usize,
2554 entries: Vec<String>,
2555 duplicate_entries: Vec<String>,
2556 missing_entries: Vec<String>,
2557}
2558
2559fn analyze_path_env() -> PathAnalysis {
2560 let mut entries = Vec::new();
2561 let mut duplicate_entries = Vec::new();
2562 let mut missing_entries = Vec::new();
2563 let mut seen = HashSet::new();
2564
2565 let raw_path = std::env::var_os("PATH").unwrap_or_default();
2566 for path in std::env::split_paths(&raw_path) {
2567 let display = path.display().to_string();
2568 if display.trim().is_empty() {
2569 continue;
2570 }
2571
2572 let normalized = normalize_path_entry(&display);
2573 if !seen.insert(normalized) {
2574 duplicate_entries.push(display.clone());
2575 }
2576 if !path.exists() {
2577 missing_entries.push(display.clone());
2578 }
2579 entries.push(display);
2580 }
2581
2582 let total_entries = entries.len();
2583 let unique_entries = seen.len();
2584
2585 PathAnalysis {
2586 total_entries,
2587 unique_entries,
2588 entries,
2589 duplicate_entries,
2590 missing_entries,
2591 }
2592}
2593
2594fn normalize_path_entry(value: &str) -> String {
2595 #[cfg(target_os = "windows")]
2596 {
2597 value
2598 .replace('/', "\\")
2599 .trim_end_matches(['\\', '/'])
2600 .to_ascii_lowercase()
2601 }
2602 #[cfg(not(target_os = "windows"))]
2603 {
2604 value.trim_end_matches('/').to_string()
2605 }
2606}
2607
2608struct ToolchainReport {
2609 found: Vec<(String, String)>,
2610 missing: Vec<String>,
2611}
2612
2613struct PackageManagerReport {
2614 found: Vec<(String, String)>,
2615}
2616
2617#[derive(Debug, Clone)]
2618struct ProcessEntry {
2619 name: String,
2620 pid: u32,
2621 memory_bytes: u64,
2622 cpu_seconds: Option<f64>,
2623 cpu_percent: Option<f64>,
2624 read_ops: Option<u64>,
2625 write_ops: Option<u64>,
2626 detail: Option<String>,
2627}
2628
2629#[derive(Debug, Clone)]
2630struct ServiceEntry {
2631 name: String,
2632 status: String,
2633 startup: Option<String>,
2634 display_name: Option<String>,
2635 start_name: Option<String>,
2636}
2637
2638#[derive(Debug, Clone, Default)]
2639struct NetworkAdapter {
2640 name: String,
2641 ipv4: Vec<String>,
2642 ipv6: Vec<String>,
2643 gateways: Vec<String>,
2644 dns_servers: Vec<String>,
2645 disconnected: bool,
2646}
2647
2648impl NetworkAdapter {
2649 fn is_active(&self) -> bool {
2650 !self.disconnected
2651 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2652 }
2653}
2654
2655#[derive(Debug, Clone, Copy, Default)]
2656struct ListenerExposureSummary {
2657 loopback_only: usize,
2658 wildcard_public: usize,
2659 specific_bind: usize,
2660}
2661
2662#[derive(Debug, Clone)]
2663struct ListeningPort {
2664 protocol: String,
2665 local: String,
2666 port: u16,
2667 state: String,
2668 pid: Option<String>,
2669 process_name: Option<String>,
2670}
2671
2672fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2673 #[cfg(target_os = "windows")]
2674 {
2675 collect_windows_listening_ports()
2676 }
2677 #[cfg(not(target_os = "windows"))]
2678 {
2679 collect_unix_listening_ports()
2680 }
2681}
2682
2683fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2684 #[cfg(target_os = "windows")]
2685 {
2686 collect_windows_network_adapters()
2687 }
2688 #[cfg(not(target_os = "windows"))]
2689 {
2690 collect_unix_network_adapters()
2691 }
2692}
2693
2694fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2695 #[cfg(target_os = "windows")]
2696 {
2697 collect_windows_services()
2698 }
2699 #[cfg(not(target_os = "windows"))]
2700 {
2701 collect_unix_services()
2702 }
2703}
2704
2705#[cfg(target_os = "windows")]
2706fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2707 let output = Command::new("netstat")
2708 .args(["-ano", "-p", "tcp"])
2709 .output()
2710 .map_err(|e| format!("Failed to run netstat: {e}"))?;
2711 if !output.status.success() {
2712 return Err("netstat returned a non-success status.".to_string());
2713 }
2714
2715 let text = String::from_utf8_lossy(&output.stdout);
2716 let mut listeners = Vec::new();
2717 for line in text.lines() {
2718 let trimmed = line.trim();
2719 if !trimmed.starts_with("TCP") {
2720 continue;
2721 }
2722 let cols: Vec<&str> = trimmed.split_whitespace().collect();
2723 if cols.len() < 5 || cols[3] != "LISTENING" {
2724 continue;
2725 }
2726 let Some(port) = extract_port_from_socket(cols[1]) else {
2727 continue;
2728 };
2729 listeners.push(ListeningPort {
2730 protocol: cols[0].to_string(),
2731 local: cols[1].to_string(),
2732 port,
2733 state: cols[3].to_string(),
2734 pid: Some(cols[4].to_string()),
2735 process_name: None,
2736 });
2737 }
2738
2739 let unique_pids: Vec<String> = listeners
2742 .iter()
2743 .filter_map(|l| l.pid.clone())
2744 .collect::<HashSet<_>>()
2745 .into_iter()
2746 .collect();
2747
2748 if !unique_pids.is_empty() {
2749 let pid_list = unique_pids.join(",");
2750 let ps_cmd = format!(
2751 "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2752 pid_list
2753 );
2754 if let Ok(ps_out) = Command::new("powershell")
2755 .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2756 .output()
2757 {
2758 let mut pid_map = std::collections::HashMap::<String, String>::new();
2759 let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2760 for line in ps_text.lines() {
2761 let parts: Vec<&str> = line.split_whitespace().collect();
2762 if parts.len() >= 2 {
2763 pid_map.insert(parts[0].to_string(), parts[1].to_string());
2764 }
2765 }
2766 for listener in &mut listeners {
2767 if let Some(pid) = &listener.pid {
2768 listener.process_name = pid_map.get(pid).cloned();
2769 }
2770 }
2771 }
2772 }
2773
2774 Ok(listeners)
2775}
2776
2777#[cfg(not(target_os = "windows"))]
2778fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2779 let output = Command::new("ss")
2780 .args(["-ltn"])
2781 .output()
2782 .map_err(|e| format!("Failed to run ss: {e}"))?;
2783 if !output.status.success() {
2784 return Err("ss returned a non-success status.".to_string());
2785 }
2786
2787 let text = String::from_utf8_lossy(&output.stdout);
2788 let mut listeners = Vec::new();
2789 for line in text.lines().skip(1) {
2790 let cols: Vec<&str> = line.split_whitespace().collect();
2791 if cols.len() < 4 {
2792 continue;
2793 }
2794 let Some(port) = extract_port_from_socket(cols[3]) else {
2795 continue;
2796 };
2797 listeners.push(ListeningPort {
2798 protocol: "tcp".to_string(),
2799 local: cols[3].to_string(),
2800 port,
2801 state: cols[0].to_string(),
2802 pid: None,
2803 process_name: None,
2804 });
2805 }
2806
2807 Ok(listeners)
2808}
2809
2810fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2811 #[cfg(target_os = "windows")]
2812 {
2813 collect_windows_processes()
2814 }
2815 #[cfg(not(target_os = "windows"))]
2816 {
2817 collect_unix_processes()
2818 }
2819}
2820
2821#[cfg(target_os = "windows")]
2822fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2823 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2824 let output = Command::new("powershell")
2825 .args(["-NoProfile", "-Command", command])
2826 .output()
2827 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2828 if !output.status.success() {
2829 return Err("PowerShell service inspection returned a non-success status.".to_string());
2830 }
2831
2832 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2833}
2834
2835#[cfg(not(target_os = "windows"))]
2836fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2837 let status_output = Command::new("systemctl")
2838 .args([
2839 "list-units",
2840 "--type=service",
2841 "--all",
2842 "--no-pager",
2843 "--no-legend",
2844 "--plain",
2845 ])
2846 .output()
2847 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2848 if !status_output.status.success() {
2849 return Err("systemctl list-units returned a non-success status.".to_string());
2850 }
2851
2852 let startup_output = Command::new("systemctl")
2853 .args([
2854 "list-unit-files",
2855 "--type=service",
2856 "--no-legend",
2857 "--no-pager",
2858 "--plain",
2859 ])
2860 .output()
2861 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2862 if !startup_output.status.success() {
2863 return Err("systemctl list-unit-files returned a non-success status.".to_string());
2864 }
2865
2866 Ok(parse_unix_services(
2867 &String::from_utf8_lossy(&status_output.stdout),
2868 &String::from_utf8_lossy(&startup_output.stdout),
2869 ))
2870}
2871
2872#[cfg(target_os = "windows")]
2873fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2874 let output = Command::new("ipconfig")
2875 .args(["/all"])
2876 .output()
2877 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2878 if !output.status.success() {
2879 return Err("ipconfig returned a non-success status.".to_string());
2880 }
2881
2882 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2883 &output.stdout,
2884 )))
2885}
2886
2887#[cfg(not(target_os = "windows"))]
2888fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2889 let addr_output = Command::new("ip")
2890 .args(["-o", "addr", "show", "up"])
2891 .output()
2892 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2893 if !addr_output.status.success() {
2894 return Err("ip addr returned a non-success status.".to_string());
2895 }
2896
2897 let route_output = Command::new("ip")
2898 .args(["route", "show", "default"])
2899 .output()
2900 .map_err(|e| format!("Failed to run ip route: {e}"))?;
2901 if !route_output.status.success() {
2902 return Err("ip route returned a non-success status.".to_string());
2903 }
2904
2905 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2906 apply_unix_default_routes(
2907 &mut adapters,
2908 &String::from_utf8_lossy(&route_output.stdout),
2909 );
2910 apply_unix_dns_servers(&mut adapters);
2911 Ok(adapters)
2912}
2913
2914#[cfg(target_os = "windows")]
2915fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2916 let script = r#"
2918 $s1 = Get-Process | Select-Object Id, CPU
2919 Start-Sleep -Milliseconds 250
2920 $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
2921 $s2 | ForEach-Object {
2922 $p2 = $_
2923 $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
2924 $pct = 0.0
2925 if ($p1 -and $p2.CPU -gt $p1.CPU) {
2926 # (Delta CPU seconds / interval) * 100 / LogicalProcessors
2927 # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
2928 # Standard Task Manager style is (delta / interval) * 100.
2929 $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
2930 }
2931 "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
2932 }
2933 "#;
2934
2935 let output = Command::new("powershell")
2936 .args(["-NoProfile", "-Command", script])
2937 .output()
2938 .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2939
2940 let text = String::from_utf8_lossy(&output.stdout);
2941 let mut out = Vec::new();
2942 for line in text.lines() {
2943 let parts: Vec<&str> = line.trim().split('|').collect();
2944 if parts.len() < 5 {
2945 continue;
2946 }
2947 let mut entry = ProcessEntry {
2948 name: "unknown".to_string(),
2949 pid: 0,
2950 memory_bytes: 0,
2951 cpu_seconds: None,
2952 cpu_percent: None,
2953 read_ops: None,
2954 write_ops: None,
2955 detail: None,
2956 };
2957 for p in parts {
2958 if let Some((k, v)) = p.split_once(':') {
2959 match k {
2960 "PID" => entry.pid = v.parse().unwrap_or(0),
2961 "NAME" => entry.name = v.to_string(),
2962 "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
2963 "CPU_S" => entry.cpu_seconds = v.parse().ok(),
2964 "CPU_P" => entry.cpu_percent = v.parse().ok(),
2965 "READ" => entry.read_ops = v.parse().ok(),
2966 "WRITE" => entry.write_ops = v.parse().ok(),
2967 _ => {}
2968 }
2969 }
2970 }
2971 out.push(entry);
2972 }
2973 Ok(out)
2974}
2975
2976#[cfg(not(target_os = "windows"))]
2977fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2978 let output = Command::new("ps")
2979 .args(["-eo", "pid=,rss=,comm="])
2980 .output()
2981 .map_err(|e| format!("Failed to run ps: {e}"))?;
2982 if !output.status.success() {
2983 return Err("ps returned a non-success status.".to_string());
2984 }
2985
2986 let text = String::from_utf8_lossy(&output.stdout);
2987 let mut processes = Vec::new();
2988 for line in text.lines() {
2989 let cols: Vec<&str> = line.split_whitespace().collect();
2990 if cols.len() < 3 {
2991 continue;
2992 }
2993 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2994 else {
2995 continue;
2996 };
2997 processes.push(ProcessEntry {
2998 name: cols[2..].join(" "),
2999 pid,
3000 memory_bytes: rss_kib * 1024,
3001 cpu_seconds: None,
3002 cpu_percent: None,
3003 read_ops: None,
3004 write_ops: None,
3005 detail: None,
3006 });
3007 }
3008
3009 Ok(processes)
3010}
3011
3012fn extract_port_from_socket(value: &str) -> Option<u16> {
3013 let cleaned = value.trim().trim_matches(['[', ']']);
3014 let port_str = cleaned.rsplit(':').next()?;
3015 port_str.parse::<u16>().ok()
3016}
3017
3018fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3019 let mut summary = ListenerExposureSummary::default();
3020 for entry in listeners {
3021 let local = entry.local.to_ascii_lowercase();
3022 if is_loopback_listener(&local) {
3023 summary.loopback_only += 1;
3024 } else if is_wildcard_listener(&local) {
3025 summary.wildcard_public += 1;
3026 } else {
3027 summary.specific_bind += 1;
3028 }
3029 }
3030 summary
3031}
3032
3033fn is_loopback_listener(local: &str) -> bool {
3034 local.starts_with("127.")
3035 || local.starts_with("[::1]")
3036 || local.starts_with("::1")
3037 || local.starts_with("localhost:")
3038}
3039
3040fn is_wildcard_listener(local: &str) -> bool {
3041 local.starts_with("0.0.0.0:")
3042 || local.starts_with("[::]:")
3043 || local.starts_with(":::")
3044 || local == "*:*"
3045}
3046
3047struct GitState {
3048 root: PathBuf,
3049 branch: String,
3050 dirty_entries: usize,
3051}
3052
3053impl GitState {
3054 fn status_label(&self) -> String {
3055 if self.dirty_entries == 0 {
3056 "clean".to_string()
3057 } else {
3058 format!("dirty ({} changed path(s))", self.dirty_entries)
3059 }
3060 }
3061}
3062
3063fn inspect_git_state(path: &Path) -> Option<GitState> {
3064 let root = capture_first_line(
3065 "git",
3066 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3067 )?;
3068 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3069 .unwrap_or_else(|| "detached".to_string());
3070 let output = Command::new("git")
3071 .args(["-C", path.to_str()?, "status", "--short"])
3072 .output()
3073 .ok()?;
3074 if !output.status.success() {
3075 return None;
3076 }
3077 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3078 Some(GitState {
3079 root: PathBuf::from(root),
3080 branch,
3081 dirty_entries,
3082 })
3083}
3084
3085struct HematiteState {
3086 docs_count: usize,
3087 import_count: usize,
3088 report_count: usize,
3089 workspace_profile: bool,
3090}
3091
3092fn collect_hematite_state(path: &Path) -> HematiteState {
3093 let root = path.join(".hematite");
3094 HematiteState {
3095 docs_count: count_entries_if_exists(&root.join("docs")),
3096 import_count: count_entries_if_exists(&root.join("imports")),
3097 report_count: count_entries_if_exists(&root.join("reports")),
3098 workspace_profile: root.join("workspace_profile.json").exists(),
3099 }
3100}
3101
3102fn count_entries_if_exists(path: &Path) -> usize {
3103 if !path.exists() || !path.is_dir() {
3104 return 0;
3105 }
3106 fs::read_dir(path)
3107 .ok()
3108 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3109 .unwrap_or(0)
3110}
3111
3112fn collect_project_markers(path: &Path) -> Vec<String> {
3113 [
3114 "Cargo.toml",
3115 "package.json",
3116 "pyproject.toml",
3117 "go.mod",
3118 "justfile",
3119 "Makefile",
3120 ".git",
3121 ]
3122 .iter()
3123 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3124 .collect()
3125}
3126
3127struct ReleaseArtifactState {
3128 version: String,
3129 portable_dir: bool,
3130 portable_zip: bool,
3131 setup_exe: bool,
3132}
3133
3134fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3135 let cargo_toml = path.join("Cargo.toml");
3136 if !cargo_toml.exists() {
3137 return None;
3138 }
3139 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3140 let version = [regex_line_capture(
3141 &cargo_text,
3142 r#"(?m)^version\s*=\s*"([^"]+)""#,
3143 )?]
3144 .concat();
3145 let dist_windows = path.join("dist").join("windows");
3146 let prefix = format!("Hematite-{}", version);
3147 Some(ReleaseArtifactState {
3148 version,
3149 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3150 portable_zip: dist_windows
3151 .join(format!("{}-portable.zip", prefix))
3152 .exists(),
3153 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3154 })
3155}
3156
3157fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3158 let regex = regex::Regex::new(pattern).ok()?;
3159 let captures = regex.captures(text)?;
3160 captures.get(1).map(|m| m.as_str().to_string())
3161}
3162
3163fn bool_label(value: bool) -> &'static str {
3164 if value {
3165 "yes"
3166 } else {
3167 "no"
3168 }
3169}
3170
3171fn collect_toolchains() -> ToolchainReport {
3172 let checks = [
3173 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3174 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3175 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3176 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3177 ToolCheck::new(
3178 "npm",
3179 &[
3180 CommandProbe::new("npm", &["--version"]),
3181 CommandProbe::new("npm.cmd", &["--version"]),
3182 ],
3183 ),
3184 ToolCheck::new(
3185 "pnpm",
3186 &[
3187 CommandProbe::new("pnpm", &["--version"]),
3188 CommandProbe::new("pnpm.cmd", &["--version"]),
3189 ],
3190 ),
3191 ToolCheck::new(
3192 "python",
3193 &[
3194 CommandProbe::new("python", &["--version"]),
3195 CommandProbe::new("python3", &["--version"]),
3196 CommandProbe::new("py", &["-3", "--version"]),
3197 CommandProbe::new("py", &["--version"]),
3198 ],
3199 ),
3200 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3201 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3202 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3203 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3204 ];
3205
3206 let mut found = Vec::new();
3207 let mut missing = Vec::new();
3208
3209 for check in checks {
3210 match check.detect() {
3211 Some(version) => found.push((check.label.to_string(), version)),
3212 None => missing.push(check.label.to_string()),
3213 }
3214 }
3215
3216 ToolchainReport { found, missing }
3217}
3218
3219fn collect_package_managers() -> PackageManagerReport {
3220 let checks = [
3221 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3222 ToolCheck::new(
3223 "npm",
3224 &[
3225 CommandProbe::new("npm", &["--version"]),
3226 CommandProbe::new("npm.cmd", &["--version"]),
3227 ],
3228 ),
3229 ToolCheck::new(
3230 "pnpm",
3231 &[
3232 CommandProbe::new("pnpm", &["--version"]),
3233 CommandProbe::new("pnpm.cmd", &["--version"]),
3234 ],
3235 ),
3236 ToolCheck::new(
3237 "pip",
3238 &[
3239 CommandProbe::new("python", &["-m", "pip", "--version"]),
3240 CommandProbe::new("python3", &["-m", "pip", "--version"]),
3241 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3242 CommandProbe::new("py", &["-m", "pip", "--version"]),
3243 CommandProbe::new("pip", &["--version"]),
3244 ],
3245 ),
3246 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3247 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3248 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3249 ToolCheck::new(
3250 "choco",
3251 &[
3252 CommandProbe::new("choco", &["--version"]),
3253 CommandProbe::new("choco.exe", &["--version"]),
3254 ],
3255 ),
3256 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3257 ];
3258
3259 let mut found = Vec::new();
3260 for check in checks {
3261 match check.detect() {
3262 Some(version) => found.push((check.label.to_string(), version)),
3263 None => {}
3264 }
3265 }
3266
3267 PackageManagerReport { found }
3268}
3269
3270#[derive(Clone)]
3271struct ToolCheck {
3272 label: &'static str,
3273 probes: Vec<CommandProbe>,
3274}
3275
3276impl ToolCheck {
3277 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3278 Self {
3279 label,
3280 probes: probes.to_vec(),
3281 }
3282 }
3283
3284 fn detect(&self) -> Option<String> {
3285 for probe in &self.probes {
3286 if let Some(output) = capture_first_line(probe.program, probe.args) {
3287 return Some(output);
3288 }
3289 }
3290 None
3291 }
3292}
3293
3294#[derive(Clone, Copy)]
3295struct CommandProbe {
3296 program: &'static str,
3297 args: &'static [&'static str],
3298}
3299
3300impl CommandProbe {
3301 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3302 Self { program, args }
3303 }
3304}
3305
3306fn build_env_doctor_findings(
3307 toolchains: &ToolchainReport,
3308 package_managers: &PackageManagerReport,
3309 path_stats: &PathAnalysis,
3310) -> Vec<String> {
3311 let found_tools = toolchains
3312 .found
3313 .iter()
3314 .map(|(label, _)| label.as_str())
3315 .collect::<HashSet<_>>();
3316 let found_managers = package_managers
3317 .found
3318 .iter()
3319 .map(|(label, _)| label.as_str())
3320 .collect::<HashSet<_>>();
3321
3322 let mut findings = Vec::new();
3323
3324 if path_stats.duplicate_entries.len() > 0 {
3325 findings.push(format!(
3326 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3327 path_stats.duplicate_entries.len()
3328 ));
3329 }
3330 if path_stats.missing_entries.len() > 0 {
3331 findings.push(format!(
3332 "PATH contains {} entries that do not exist on disk.",
3333 path_stats.missing_entries.len()
3334 ));
3335 }
3336 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3337 findings.push(
3338 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3339 .to_string(),
3340 );
3341 }
3342 if found_tools.contains("node")
3343 && !found_managers.contains("npm")
3344 && !found_managers.contains("pnpm")
3345 {
3346 findings.push(
3347 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3348 .to_string(),
3349 );
3350 }
3351 if found_tools.contains("python")
3352 && !found_managers.contains("pip")
3353 && !found_managers.contains("uv")
3354 && !found_managers.contains("pipx")
3355 {
3356 findings.push(
3357 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3358 .to_string(),
3359 );
3360 }
3361 let windows_manager_count = ["winget", "choco", "scoop"]
3362 .iter()
3363 .filter(|label| found_managers.contains(**label))
3364 .count();
3365 if windows_manager_count > 1 {
3366 findings.push(
3367 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3368 .to_string(),
3369 );
3370 }
3371 if findings.is_empty() && !found_managers.is_empty() {
3372 findings.push(
3373 "Core package-manager coverage looks healthy for a normal developer workstation."
3374 .to_string(),
3375 );
3376 }
3377
3378 findings
3379}
3380
3381fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3382 let output = std::process::Command::new(program)
3383 .args(args)
3384 .output()
3385 .ok()?;
3386 if !output.status.success() {
3387 return None;
3388 }
3389
3390 let stdout = if output.stdout.is_empty() {
3391 String::from_utf8_lossy(&output.stderr).into_owned()
3392 } else {
3393 String::from_utf8_lossy(&output.stdout).into_owned()
3394 };
3395
3396 stdout
3397 .lines()
3398 .map(str::trim)
3399 .find(|line| !line.is_empty())
3400 .map(|line| line.to_string())
3401}
3402
3403fn human_bytes(bytes: u64) -> String {
3404 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3405 let mut value = bytes as f64;
3406 let mut unit_index = 0usize;
3407
3408 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3409 value /= 1024.0;
3410 unit_index += 1;
3411 }
3412
3413 if unit_index == 0 {
3414 format!("{} {}", bytes, UNITS[unit_index])
3415 } else {
3416 format!("{value:.1} {}", UNITS[unit_index])
3417 }
3418}
3419
3420#[cfg(target_os = "windows")]
3421fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3422 let mut adapters = Vec::new();
3423 let mut current: Option<NetworkAdapter> = None;
3424 let mut pending_dns = false;
3425
3426 for raw_line in text.lines() {
3427 let line = raw_line.trim_end();
3428 let trimmed = line.trim();
3429 if trimmed.is_empty() {
3430 pending_dns = false;
3431 continue;
3432 }
3433
3434 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3435 if let Some(adapter) = current.take() {
3436 adapters.push(adapter);
3437 }
3438 current = Some(NetworkAdapter {
3439 name: trimmed.trim_end_matches(':').to_string(),
3440 ..NetworkAdapter::default()
3441 });
3442 pending_dns = false;
3443 continue;
3444 }
3445
3446 let Some(adapter) = current.as_mut() else {
3447 continue;
3448 };
3449
3450 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3451 adapter.disconnected = true;
3452 }
3453
3454 if let Some(value) = value_after_colon(trimmed) {
3455 let normalized = normalize_ipconfig_value(value);
3456 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3457 adapter.ipv4.push(normalized);
3458 pending_dns = false;
3459 } else if trimmed.starts_with("IPv6 Address")
3460 || trimmed.starts_with("Temporary IPv6 Address")
3461 || trimmed.starts_with("Link-local IPv6 Address")
3462 {
3463 if !normalized.is_empty() {
3464 adapter.ipv6.push(normalized);
3465 }
3466 pending_dns = false;
3467 } else if trimmed.starts_with("Default Gateway") {
3468 if !normalized.is_empty() {
3469 adapter.gateways.push(normalized);
3470 }
3471 pending_dns = false;
3472 } else if trimmed.starts_with("DNS Servers") {
3473 if !normalized.is_empty() {
3474 adapter.dns_servers.push(normalized);
3475 }
3476 pending_dns = true;
3477 } else {
3478 pending_dns = false;
3479 }
3480 } else if pending_dns {
3481 let normalized = normalize_ipconfig_value(trimmed);
3482 if !normalized.is_empty() {
3483 adapter.dns_servers.push(normalized);
3484 }
3485 }
3486 }
3487
3488 if let Some(adapter) = current.take() {
3489 adapters.push(adapter);
3490 }
3491
3492 for adapter in &mut adapters {
3493 dedup_vec(&mut adapter.ipv4);
3494 dedup_vec(&mut adapter.ipv6);
3495 dedup_vec(&mut adapter.gateways);
3496 dedup_vec(&mut adapter.dns_servers);
3497 }
3498
3499 adapters
3500}
3501
3502#[cfg(not(target_os = "windows"))]
3503fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3504 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3505
3506 for line in text.lines() {
3507 let cols: Vec<&str> = line.split_whitespace().collect();
3508 if cols.len() < 4 {
3509 continue;
3510 }
3511 let name = cols[1].trim_end_matches(':').to_string();
3512 let family = cols[2];
3513 let addr = cols[3].split('/').next().unwrap_or("").to_string();
3514 let entry = adapters
3515 .entry(name.clone())
3516 .or_insert_with(|| NetworkAdapter {
3517 name,
3518 ..NetworkAdapter::default()
3519 });
3520 match family {
3521 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3522 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3523 _ => {}
3524 }
3525 }
3526
3527 adapters.into_values().collect()
3528}
3529
3530#[cfg(not(target_os = "windows"))]
3531fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3532 for line in text.lines() {
3533 let cols: Vec<&str> = line.split_whitespace().collect();
3534 if cols.len() < 5 {
3535 continue;
3536 }
3537 let gateway = cols
3538 .windows(2)
3539 .find(|pair| pair[0] == "via")
3540 .map(|pair| pair[1].to_string());
3541 let dev = cols
3542 .windows(2)
3543 .find(|pair| pair[0] == "dev")
3544 .map(|pair| pair[1]);
3545 if let (Some(gateway), Some(dev)) = (gateway, dev) {
3546 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3547 adapter.gateways.push(gateway);
3548 }
3549 }
3550 }
3551
3552 for adapter in adapters {
3553 dedup_vec(&mut adapter.gateways);
3554 }
3555}
3556
3557#[cfg(not(target_os = "windows"))]
3558fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3559 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3560 return;
3561 };
3562 let mut dns_servers = text
3563 .lines()
3564 .filter_map(|line| line.strip_prefix("nameserver "))
3565 .map(str::trim)
3566 .filter(|value| !value.is_empty())
3567 .map(|value| value.to_string())
3568 .collect::<Vec<_>>();
3569 dedup_vec(&mut dns_servers);
3570 if dns_servers.is_empty() {
3571 return;
3572 }
3573 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3574 adapter.dns_servers = dns_servers.clone();
3575 }
3576}
3577
3578#[cfg(target_os = "windows")]
3579fn value_after_colon(line: &str) -> Option<&str> {
3580 line.split_once(':').map(|(_, value)| value.trim())
3581}
3582
3583#[cfg(target_os = "windows")]
3584fn normalize_ipconfig_value(value: &str) -> String {
3585 value
3586 .trim()
3587 .trim_end_matches("(Preferred)")
3588 .trim_end_matches("(Deprecated)")
3589 .trim()
3590 .trim_matches(['(', ')'])
3591 .trim()
3592 .to_string()
3593}
3594
3595#[cfg(target_os = "windows")]
3596fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3597 let mac_upper = mac.to_ascii_uppercase();
3598 if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3599 return true;
3600 }
3601
3602 ip == "255.255.255.255"
3603 || ip.starts_with("224.")
3604 || ip.starts_with("225.")
3605 || ip.starts_with("226.")
3606 || ip.starts_with("227.")
3607 || ip.starts_with("228.")
3608 || ip.starts_with("229.")
3609 || ip.starts_with("230.")
3610 || ip.starts_with("231.")
3611 || ip.starts_with("232.")
3612 || ip.starts_with("233.")
3613 || ip.starts_with("234.")
3614 || ip.starts_with("235.")
3615 || ip.starts_with("236.")
3616 || ip.starts_with("237.")
3617 || ip.starts_with("238.")
3618 || ip.starts_with("239.")
3619}
3620
3621fn dedup_vec(values: &mut Vec<String>) {
3622 let mut seen = HashSet::new();
3623 values.retain(|value| seen.insert(value.clone()));
3624}
3625
3626#[cfg(target_os = "windows")]
3627fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3628 let trimmed = text.trim();
3629 if trimmed.is_empty() {
3630 return Vec::new();
3631 }
3632
3633 let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3634 return Vec::new();
3635 };
3636 let entries = match value {
3637 Value::Array(items) => items,
3638 other => vec![other],
3639 };
3640
3641 let mut neighbors = Vec::new();
3642 for entry in entries {
3643 let ip = entry
3644 .get("IPAddress")
3645 .and_then(|v| v.as_str())
3646 .unwrap_or("")
3647 .to_string();
3648 if ip.is_empty() {
3649 continue;
3650 }
3651 let mac = entry
3652 .get("LinkLayerAddress")
3653 .and_then(|v| v.as_str())
3654 .unwrap_or("unknown")
3655 .to_string();
3656 let state = entry
3657 .get("State")
3658 .and_then(|v| v.as_str())
3659 .unwrap_or("unknown")
3660 .to_string();
3661 let iface = entry
3662 .get("InterfaceAlias")
3663 .and_then(|v| v.as_str())
3664 .unwrap_or("unknown")
3665 .to_string();
3666 if is_noise_lan_neighbor(&ip, &mac) {
3667 continue;
3668 }
3669 neighbors.push((ip, mac, state, iface));
3670 }
3671
3672 neighbors
3673}
3674
3675#[cfg(target_os = "windows")]
3676fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3677 let trimmed = text.trim();
3678 if trimmed.is_empty() {
3679 return Ok(Vec::new());
3680 }
3681
3682 let value: Value = serde_json::from_str(trimmed)
3683 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3684 let entries = match value {
3685 Value::Array(items) => items,
3686 other => vec![other],
3687 };
3688
3689 let mut services = Vec::new();
3690 for entry in entries {
3691 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3692 continue;
3693 };
3694 services.push(ServiceEntry {
3695 name: name.to_string(),
3696 status: entry
3697 .get("State")
3698 .and_then(|v| v.as_str())
3699 .unwrap_or("unknown")
3700 .to_string(),
3701 startup: entry
3702 .get("StartMode")
3703 .and_then(|v| v.as_str())
3704 .map(|v| v.to_string()),
3705 display_name: entry
3706 .get("DisplayName")
3707 .and_then(|v| v.as_str())
3708 .map(|v| v.to_string()),
3709 start_name: entry
3710 .get("StartName")
3711 .and_then(|v| v.as_str())
3712 .map(|v| v.to_string()),
3713 });
3714 }
3715
3716 Ok(services)
3717}
3718
3719#[cfg(target_os = "windows")]
3720fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3721 match node.cloned() {
3722 Some(Value::Array(items)) => items,
3723 Some(other) => vec![other],
3724 None => Vec::new(),
3725 }
3726}
3727
3728#[cfg(target_os = "windows")]
3729fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3730 windows_json_entries(node)
3731 .into_iter()
3732 .filter_map(|entry| {
3733 let name = entry
3734 .get("FriendlyName")
3735 .and_then(|v| v.as_str())
3736 .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3737 .unwrap_or("")
3738 .trim()
3739 .to_string();
3740 if name.is_empty() {
3741 return None;
3742 }
3743 Some(WindowsPnpDevice {
3744 name,
3745 status: entry
3746 .get("Status")
3747 .and_then(|v| v.as_str())
3748 .unwrap_or("Unknown")
3749 .trim()
3750 .to_string(),
3751 problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3752 entry
3753 .get("Problem")
3754 .and_then(|v| v.as_i64())
3755 .map(|v| v as u64)
3756 }),
3757 class_name: entry
3758 .get("Class")
3759 .and_then(|v| v.as_str())
3760 .map(|v| v.trim().to_string()),
3761 instance_id: entry
3762 .get("InstanceId")
3763 .and_then(|v| v.as_str())
3764 .map(|v| v.trim().to_string()),
3765 })
3766 })
3767 .collect()
3768}
3769
3770#[cfg(target_os = "windows")]
3771fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3772 windows_json_entries(node)
3773 .into_iter()
3774 .filter_map(|entry| {
3775 let name = entry
3776 .get("Name")
3777 .and_then(|v| v.as_str())
3778 .unwrap_or("")
3779 .trim()
3780 .to_string();
3781 if name.is_empty() {
3782 return None;
3783 }
3784 Some(WindowsSoundDevice {
3785 name,
3786 status: entry
3787 .get("Status")
3788 .and_then(|v| v.as_str())
3789 .unwrap_or("Unknown")
3790 .trim()
3791 .to_string(),
3792 manufacturer: entry
3793 .get("Manufacturer")
3794 .and_then(|v| v.as_str())
3795 .map(|v| v.trim().to_string()),
3796 })
3797 })
3798 .collect()
3799}
3800
3801#[cfg(target_os = "windows")]
3802fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
3803 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3804 || device.problem.unwrap_or(0) != 0
3805}
3806
3807#[cfg(target_os = "windows")]
3808fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
3809 !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3810}
3811
3812#[cfg(target_os = "windows")]
3813fn is_microphone_like_name(name: &str) -> bool {
3814 let lower = name.to_ascii_lowercase();
3815 lower.contains("microphone")
3816 || lower.contains("mic")
3817 || lower.contains("input")
3818 || lower.contains("array")
3819 || lower.contains("capture")
3820 || lower.contains("record")
3821}
3822
3823#[cfg(target_os = "windows")]
3824fn is_bluetooth_like_name(name: &str) -> bool {
3825 let lower = name.to_ascii_lowercase();
3826 lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
3827}
3828
3829#[cfg(target_os = "windows")]
3830fn service_is_running(service: &ServiceEntry) -> bool {
3831 service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
3832}
3833
3834#[cfg(not(target_os = "windows"))]
3835fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3836 let mut startup_modes = std::collections::HashMap::<String, String>::new();
3837 for line in startup_text.lines() {
3838 let cols: Vec<&str> = line.split_whitespace().collect();
3839 if cols.len() < 2 {
3840 continue;
3841 }
3842 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3843 }
3844
3845 let mut services = Vec::new();
3846 for line in status_text.lines() {
3847 let cols: Vec<&str> = line.split_whitespace().collect();
3848 if cols.len() < 4 {
3849 continue;
3850 }
3851 let unit = cols[0];
3852 let load = cols[1];
3853 let active = cols[2];
3854 let sub = cols[3];
3855 let description = if cols.len() > 4 {
3856 Some(cols[4..].join(" "))
3857 } else {
3858 None
3859 };
3860 services.push(ServiceEntry {
3861 name: unit.to_string(),
3862 status: format!("{}/{}", active, sub),
3863 startup: startup_modes
3864 .get(unit)
3865 .cloned()
3866 .or_else(|| Some(load.to_string())),
3867 display_name: description,
3868 start_name: None,
3869 });
3870 }
3871
3872 services
3873}
3874
3875fn inspect_health_report() -> Result<String, String> {
3881 let mut needs_fix: Vec<String> = Vec::new();
3882 let mut watch: Vec<String> = Vec::new();
3883 let mut good: Vec<String> = Vec::new();
3884 let mut tips: Vec<String> = Vec::new();
3885
3886 health_check_disk(&mut needs_fix, &mut watch, &mut good);
3887 health_check_memory(&mut watch, &mut good);
3888 health_check_tools(&mut watch, &mut good, &mut tips);
3889 health_check_recent_errors(&mut watch, &mut tips);
3890
3891 let overall = if !needs_fix.is_empty() {
3892 "ACTION REQUIRED"
3893 } else if !watch.is_empty() {
3894 "WORTH A LOOK"
3895 } else {
3896 "ALL GOOD"
3897 };
3898
3899 let mut out = format!("System Health Report — {overall}\n\n");
3900
3901 if !needs_fix.is_empty() {
3902 out.push_str("Needs fixing:\n");
3903 for item in &needs_fix {
3904 out.push_str(&format!(" [!] {item}\n"));
3905 }
3906 out.push('\n');
3907 }
3908 if !watch.is_empty() {
3909 out.push_str("Worth watching:\n");
3910 for item in &watch {
3911 out.push_str(&format!(" [-] {item}\n"));
3912 }
3913 out.push('\n');
3914 }
3915 if !good.is_empty() {
3916 out.push_str("Looking good:\n");
3917 for item in &good {
3918 out.push_str(&format!(" [+] {item}\n"));
3919 }
3920 out.push('\n');
3921 }
3922 if !tips.is_empty() {
3923 out.push_str("To dig deeper:\n");
3924 for tip in &tips {
3925 out.push_str(&format!(" {tip}\n"));
3926 }
3927 }
3928
3929 Ok(out.trim_end().to_string())
3930}
3931
3932fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3933 #[cfg(target_os = "windows")]
3934 {
3935 let script = r#"try {
3936 $d = Get-PSDrive C -ErrorAction Stop
3937 "$($d.Free)|$($d.Used)"
3938} catch { "ERR" }"#;
3939 if let Ok(out) = Command::new("powershell")
3940 .args(["-NoProfile", "-Command", script])
3941 .output()
3942 {
3943 let text = String::from_utf8_lossy(&out.stdout);
3944 let text = text.trim();
3945 if !text.starts_with("ERR") {
3946 let parts: Vec<&str> = text.split('|').collect();
3947 if parts.len() == 2 {
3948 let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3949 let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3950 let total = free_bytes + used_bytes;
3951 let free_gb = free_bytes / 1_073_741_824;
3952 let pct_free = if total > 0 {
3953 (free_bytes as f64 / total as f64 * 100.0) as u64
3954 } else {
3955 0
3956 };
3957 let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3958 if free_gb < 5 {
3959 needs_fix.push(format!(
3960 "{msg} — very low. Free up space or your system may slow down or stop working."
3961 ));
3962 } else if free_gb < 15 {
3963 watch.push(format!("{msg} — getting low, consider cleaning up."));
3964 } else {
3965 good.push(msg);
3966 }
3967 return;
3968 }
3969 }
3970 }
3971 watch.push("Disk: could not read free space from C: drive.".to_string());
3972 }
3973
3974 #[cfg(not(target_os = "windows"))]
3975 {
3976 if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3977 let text = String::from_utf8_lossy(&out.stdout);
3978 for line in text.lines().skip(1) {
3979 let cols: Vec<&str> = line.split_whitespace().collect();
3980 if cols.len() >= 5 {
3981 let avail_str = cols[3].trim_end_matches('G');
3982 let use_pct = cols[4].trim_end_matches('%');
3983 let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3984 let used_pct: u64 = use_pct.parse().unwrap_or(0);
3985 let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3986 if avail_gb < 5 {
3987 needs_fix.push(format!(
3988 "{msg} — very low. Free up space to prevent system issues."
3989 ));
3990 } else if avail_gb < 15 {
3991 watch.push(format!("{msg} — getting low."));
3992 } else {
3993 good.push(msg);
3994 }
3995 return;
3996 }
3997 }
3998 }
3999 watch.push("Disk: could not determine free space.".to_string());
4000 }
4001}
4002
4003fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4004 #[cfg(target_os = "windows")]
4005 {
4006 let script = r#"try {
4007 $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4008 "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4009} catch { "ERR" }"#;
4010 if let Ok(out) = Command::new("powershell")
4011 .args(["-NoProfile", "-Command", script])
4012 .output()
4013 {
4014 let text = String::from_utf8_lossy(&out.stdout);
4015 let text = text.trim();
4016 if !text.starts_with("ERR") {
4017 let parts: Vec<&str> = text.split('|').collect();
4018 if parts.len() == 2 {
4019 let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4020 let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4021 if total_kb > 0 {
4022 let free_gb = free_kb / 1_048_576;
4023 let total_gb = total_kb / 1_048_576;
4024 let free_pct = free_kb * 100 / total_kb;
4025 let msg = format!(
4026 "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4027 );
4028 if free_pct < 10 {
4029 watch.push(format!(
4030 "{msg} — very low. Close unused apps to free up memory."
4031 ));
4032 } else if free_pct < 25 {
4033 watch.push(format!("{msg} — running a bit low."));
4034 } else {
4035 good.push(msg);
4036 }
4037 return;
4038 }
4039 }
4040 }
4041 }
4042 }
4043
4044 #[cfg(not(target_os = "windows"))]
4045 {
4046 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4047 let mut total_kb = 0u64;
4048 let mut avail_kb = 0u64;
4049 for line in content.lines() {
4050 if line.starts_with("MemTotal:") {
4051 total_kb = line
4052 .split_whitespace()
4053 .nth(1)
4054 .and_then(|v| v.parse().ok())
4055 .unwrap_or(0);
4056 } else if line.starts_with("MemAvailable:") {
4057 avail_kb = line
4058 .split_whitespace()
4059 .nth(1)
4060 .and_then(|v| v.parse().ok())
4061 .unwrap_or(0);
4062 }
4063 }
4064 if total_kb > 0 {
4065 let free_gb = avail_kb / 1_048_576;
4066 let total_gb = total_kb / 1_048_576;
4067 let free_pct = avail_kb * 100 / total_kb;
4068 let msg =
4069 format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4070 if free_pct < 10 {
4071 watch.push(format!("{msg} — very low. Close unused apps."));
4072 } else if free_pct < 25 {
4073 watch.push(format!("{msg} — running a bit low."));
4074 } else {
4075 good.push(msg);
4076 }
4077 }
4078 }
4079 }
4080}
4081
4082fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4083 let tool_checks: &[(&str, &str, &str)] = &[
4084 ("git", "--version", "Git"),
4085 ("cargo", "--version", "Rust / Cargo"),
4086 ("node", "--version", "Node.js"),
4087 ("python", "--version", "Python"),
4088 ("python3", "--version", "Python 3"),
4089 ("npm", "--version", "npm"),
4090 ];
4091
4092 let mut found: Vec<String> = Vec::new();
4093 let mut missing: Vec<String> = Vec::new();
4094 let mut python_found = false;
4095
4096 for (cmd, arg, label) in tool_checks {
4097 if cmd.starts_with("python") && python_found {
4098 continue;
4099 }
4100 let ok = Command::new(cmd)
4101 .arg(arg)
4102 .stdout(std::process::Stdio::null())
4103 .stderr(std::process::Stdio::null())
4104 .status()
4105 .map(|s| s.success())
4106 .unwrap_or(false);
4107 if ok {
4108 found.push((*label).to_string());
4109 if cmd.starts_with("python") {
4110 python_found = true;
4111 }
4112 } else if !cmd.starts_with("python") || !python_found {
4113 missing.push((*label).to_string());
4114 }
4115 }
4116
4117 if !found.is_empty() {
4118 good.push(format!("Dev tools found: {}", found.join(", ")));
4119 }
4120 if !missing.is_empty() {
4121 watch.push(format!(
4122 "Not installed (or not on PATH): {} — only matters if you need them",
4123 missing.join(", ")
4124 ));
4125 tips.push(
4126 "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4127 .to_string(),
4128 );
4129 }
4130}
4131
4132fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4133 #[cfg(target_os = "windows")]
4134 {
4135 let script = r#"try {
4136 $cutoff = (Get-Date).AddHours(-24)
4137 $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4138 $count
4139} catch { "0" }"#;
4140 if let Ok(out) = Command::new("powershell")
4141 .args(["-NoProfile", "-Command", script])
4142 .output()
4143 {
4144 let text = String::from_utf8_lossy(&out.stdout);
4145 let count: u64 = text.trim().parse().unwrap_or(0);
4146 if count > 0 {
4147 watch.push(format!(
4148 "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4149 if count == 1 { "" } else { "s" }
4150 ));
4151 tips.push(
4152 "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4153 .to_string(),
4154 );
4155 }
4156 }
4157 }
4158
4159 #[cfg(not(target_os = "windows"))]
4160 {
4161 if let Ok(out) = Command::new("journalctl")
4162 .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4163 .output()
4164 {
4165 let text = String::from_utf8_lossy(&out.stdout);
4166 if !text.trim().is_empty() {
4167 watch.push("Critical/error entries found in the system journal.".to_string());
4168 tips.push(
4169 "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4170 );
4171 }
4172 }
4173 }
4174}
4175
4176fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4179 let mut out = String::from("Host inspection: log_check\n\n");
4180
4181 #[cfg(target_os = "windows")]
4182 {
4183 let hours = lookback_hours.unwrap_or(24);
4185 out.push_str(&format!(
4186 "Checking System/Application logs from the last {} hours...\n\n",
4187 hours
4188 ));
4189
4190 let n = max_entries.clamp(1, 50);
4191 let script = format!(
4192 r#"try {{
4193 $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4194 if (-not $events) {{ "NO_EVENTS"; exit }}
4195 $events | Select-Object -First {n} | ForEach-Object {{
4196 $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4197 $line
4198 }}
4199}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4200 hours = hours,
4201 n = n
4202 );
4203 let output = Command::new("powershell")
4204 .args(["-NoProfile", "-Command", &script])
4205 .output()
4206 .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4207
4208 let raw = String::from_utf8_lossy(&output.stdout);
4209 let text = raw.trim();
4210
4211 if text.is_empty() || text == "NO_EVENTS" {
4212 out.push_str("No critical or error events found in Application/System logs.\n");
4213 return Ok(out.trim_end().to_string());
4214 }
4215 if text.starts_with("ERROR:") {
4216 out.push_str(&format!("Warning: event log query returned: {text}\n"));
4217 return Ok(out.trim_end().to_string());
4218 }
4219
4220 let mut count = 0usize;
4221 for line in text.lines() {
4222 let parts: Vec<&str> = line.splitn(4, '|').collect();
4223 if parts.len() == 4 {
4224 let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4225 out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4226 count += 1;
4227 }
4228 }
4229 out.push_str(&format!(
4230 "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4231 ));
4232 }
4233
4234 #[cfg(not(target_os = "windows"))]
4235 {
4236 let _ = lookback_hours;
4237 let n = max_entries.clamp(1, 50).to_string();
4239 let output = Command::new("journalctl")
4240 .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4241 .output();
4242
4243 match output {
4244 Ok(o) if o.status.success() => {
4245 let text = String::from_utf8_lossy(&o.stdout);
4246 let trimmed = text.trim();
4247 if trimmed.is_empty() || trimmed.contains("No entries") {
4248 out.push_str("No critical or error entries found in the system journal.\n");
4249 } else {
4250 out.push_str(trimmed);
4251 out.push('\n');
4252 out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4253 }
4254 }
4255 _ => {
4256 let log_paths = ["/var/log/syslog", "/var/log/messages"];
4258 let mut found = false;
4259 for log_path in &log_paths {
4260 if let Ok(content) = std::fs::read_to_string(log_path) {
4261 let lines: Vec<&str> = content.lines().collect();
4262 let tail: Vec<&str> = lines
4263 .iter()
4264 .rev()
4265 .filter(|l| {
4266 let l_lower = l.to_ascii_lowercase();
4267 l_lower.contains("error") || l_lower.contains("crit")
4268 })
4269 .take(max_entries)
4270 .copied()
4271 .collect::<Vec<_>>()
4272 .into_iter()
4273 .rev()
4274 .collect();
4275 if !tail.is_empty() {
4276 out.push_str(&format!("Source: {log_path}\n"));
4277 for l in &tail {
4278 out.push_str(l);
4279 out.push('\n');
4280 }
4281 found = true;
4282 break;
4283 }
4284 }
4285 }
4286 if !found {
4287 out.push_str(
4288 "journalctl not found and no readable syslog detected on this system.\n",
4289 );
4290 }
4291 }
4292 }
4293 }
4294
4295 Ok(out.trim_end().to_string())
4296}
4297
4298fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4301 let mut out = String::from("Host inspection: startup_items\n\n");
4302
4303 #[cfg(target_os = "windows")]
4304 {
4305 let script = r#"
4307$hives = @(
4308 @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4309 @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4310 @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4311)
4312foreach ($h in $hives) {
4313 try {
4314 $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4315 $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4316 "$($h.Hive)|$($_.Name)|$($_.Value)"
4317 }
4318 } catch {}
4319}
4320"#;
4321 let output = Command::new("powershell")
4322 .args(["-NoProfile", "-Command", script])
4323 .output()
4324 .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4325
4326 let raw = String::from_utf8_lossy(&output.stdout);
4327 let text = raw.trim();
4328
4329 let entries: Vec<(String, String, String)> = text
4330 .lines()
4331 .filter_map(|l| {
4332 let parts: Vec<&str> = l.splitn(3, '|').collect();
4333 if parts.len() == 3 {
4334 Some((
4335 parts[0].to_string(),
4336 parts[1].to_string(),
4337 parts[2].to_string(),
4338 ))
4339 } else {
4340 None
4341 }
4342 })
4343 .take(max_entries)
4344 .collect();
4345
4346 if entries.is_empty() {
4347 out.push_str("No startup entries found in the Windows Run registry keys.\n");
4348 } else {
4349 out.push_str("Registry run keys (programs that start with Windows):\n\n");
4350 let mut last_hive = String::new();
4351 for (hive, name, value) in &entries {
4352 if *hive != last_hive {
4353 out.push_str(&format!("[{}]\n", hive));
4354 last_hive = hive.clone();
4355 }
4356 let display = if value.len() > 100 {
4358 format!("{}…", &value[..100])
4359 } else {
4360 value.clone()
4361 };
4362 out.push_str(&format!(" {name}: {display}\n"));
4363 }
4364 out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4365 }
4366
4367 let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { " $($_.Name): $($_.Command) ($($_.Location))" }"#;
4369 if let Ok(unified_out) = Command::new("powershell")
4370 .args(["-NoProfile", "-Command", unified_script])
4371 .output()
4372 {
4373 let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4374 let trimmed = unified_text.trim();
4375 if !trimmed.is_empty() {
4376 out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4377 out.push_str(trimmed);
4378 out.push('\n');
4379 }
4380 }
4381 }
4382
4383 #[cfg(not(target_os = "windows"))]
4384 {
4385 let output = Command::new("systemctl")
4387 .args([
4388 "list-unit-files",
4389 "--type=service",
4390 "--state=enabled",
4391 "--no-legend",
4392 "--no-pager",
4393 "--plain",
4394 ])
4395 .output();
4396
4397 match output {
4398 Ok(o) if o.status.success() => {
4399 let text = String::from_utf8_lossy(&o.stdout);
4400 let services: Vec<&str> = text
4401 .lines()
4402 .filter(|l| !l.trim().is_empty())
4403 .take(max_entries)
4404 .collect();
4405 if services.is_empty() {
4406 out.push_str("No enabled systemd services found.\n");
4407 } else {
4408 out.push_str("Enabled systemd services (run at boot):\n\n");
4409 for s in &services {
4410 out.push_str(&format!(" {s}\n"));
4411 }
4412 out.push_str(&format!(
4413 "\nShowing {} of enabled services.\n",
4414 services.len()
4415 ));
4416 }
4417 }
4418 _ => {
4419 out.push_str(
4420 "systemctl not found on this system. Cannot enumerate startup services.\n",
4421 );
4422 }
4423 }
4424
4425 if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4427 let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4428 let reboot_entries: Vec<&str> = cron_text
4429 .lines()
4430 .filter(|l| l.trim_start().starts_with("@reboot"))
4431 .collect();
4432 if !reboot_entries.is_empty() {
4433 out.push_str("\nCron @reboot entries:\n");
4434 for e in reboot_entries {
4435 out.push_str(&format!(" {e}\n"));
4436 }
4437 }
4438 }
4439 }
4440
4441 Ok(out.trim_end().to_string())
4442}
4443
4444fn inspect_os_config() -> Result<String, String> {
4445 let mut out = String::from("Host inspection: OS Configuration\n\n");
4446
4447 #[cfg(target_os = "windows")]
4448 {
4449 if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4451 let power_str = String::from_utf8_lossy(&power_out.stdout);
4452 out.push_str("=== Power Plan ===\n");
4453 out.push_str(power_str.trim());
4454 out.push_str("\n\n");
4455 }
4456
4457 let fw_script =
4459 "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4460 if let Ok(fw_out) = Command::new("powershell")
4461 .args(["-NoProfile", "-Command", fw_script])
4462 .output()
4463 {
4464 let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4465 out.push_str("=== Firewall Profiles ===\n");
4466 out.push_str(fw_str.trim());
4467 out.push_str("\n\n");
4468 }
4469
4470 let uptime_script =
4472 "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4473 if let Ok(uptime_out) = Command::new("powershell")
4474 .args(["-NoProfile", "-Command", uptime_script])
4475 .output()
4476 {
4477 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4478 out.push_str("=== System Uptime (Last Boot) ===\n");
4479 out.push_str(uptime_str.trim());
4480 out.push_str("\n\n");
4481 }
4482 }
4483
4484 #[cfg(not(target_os = "windows"))]
4485 {
4486 if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4488 let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4489 out.push_str("=== System Uptime ===\n");
4490 out.push_str(uptime_str.trim());
4491 out.push_str("\n\n");
4492 }
4493
4494 if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4496 let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4497 if !ufw_str.trim().is_empty() {
4498 out.push_str("=== Firewall (UFW) ===\n");
4499 out.push_str(ufw_str.trim());
4500 out.push_str("\n\n");
4501 }
4502 }
4503 }
4504 Ok(out.trim_end().to_string())
4505}
4506
4507pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4508 let action = args
4509 .get("action")
4510 .and_then(|v| v.as_str())
4511 .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4512
4513 let target = args
4514 .get("target")
4515 .and_then(|v| v.as_str())
4516 .unwrap_or("")
4517 .trim();
4518
4519 if target.is_empty() && action != "clear_temp" {
4520 return Err("Missing required argument: 'target' for this action".to_string());
4521 }
4522
4523 match action {
4524 "install_package" => {
4525 #[cfg(target_os = "windows")]
4526 {
4527 let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
4528 match Command::new("powershell")
4529 .args(["-NoProfile", "-Command", &cmd])
4530 .output()
4531 {
4532 Ok(out) => Ok(format!(
4533 "Executed remediation (winget install):\n{}",
4534 String::from_utf8_lossy(&out.stdout)
4535 )),
4536 Err(e) => Err(format!("Failed to run winget: {}", e)),
4537 }
4538 }
4539 #[cfg(not(target_os = "windows"))]
4540 {
4541 Err(
4542 "install_package via wrapper is only supported on Windows currently (winget)"
4543 .to_string(),
4544 )
4545 }
4546 }
4547 "restart_service" => {
4548 #[cfg(target_os = "windows")]
4549 {
4550 let cmd = format!("Restart-Service -Name {} -Force", target);
4551 match Command::new("powershell")
4552 .args(["-NoProfile", "-Command", &cmd])
4553 .output()
4554 {
4555 Ok(out) => {
4556 let err_str = String::from_utf8_lossy(&out.stderr);
4557 if !err_str.is_empty() {
4558 return Err(format!("Error restarting service:\n{}", err_str));
4559 }
4560 Ok(format!("Successfully restarted service: {}", target))
4561 }
4562 Err(e) => Err(format!("Failed to restart service: {}", e)),
4563 }
4564 }
4565 #[cfg(not(target_os = "windows"))]
4566 {
4567 Err(
4568 "restart_service via wrapper is only supported on Windows currently"
4569 .to_string(),
4570 )
4571 }
4572 }
4573 "clear_temp" => {
4574 #[cfg(target_os = "windows")]
4575 {
4576 let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4577 match Command::new("powershell")
4578 .args(["-NoProfile", "-Command", cmd])
4579 .output()
4580 {
4581 Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4582 Err(e) => Err(format!("Failed to clear temp: {}", e)),
4583 }
4584 }
4585 #[cfg(not(target_os = "windows"))]
4586 {
4587 Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4588 }
4589 }
4590 other => Err(format!("Unknown remediation action: {}", other)),
4591 }
4592}
4593
4594fn inspect_storage(max_entries: usize) -> Result<String, String> {
4597 let mut out = String::from("Host inspection: storage\n\n");
4598 let _ = max_entries; out.push_str("Drives:\n");
4602
4603 #[cfg(target_os = "windows")]
4604 {
4605 let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4606 $free = $_.Free
4607 $used = $_.Used
4608 if ($free -eq $null) { $free = 0 }
4609 if ($used -eq $null) { $used = 0 }
4610 $total = $free + $used
4611 "$($_.Name)|$free|$used|$total"
4612}"#;
4613 match Command::new("powershell")
4614 .args(["-NoProfile", "-Command", script])
4615 .output()
4616 {
4617 Ok(o) => {
4618 let text = String::from_utf8_lossy(&o.stdout);
4619 let mut drive_count = 0usize;
4620 for line in text.lines() {
4621 let parts: Vec<&str> = line.trim().split('|').collect();
4622 if parts.len() == 4 {
4623 let name = parts[0];
4624 let free: u64 = parts[1].parse().unwrap_or(0);
4625 let total: u64 = parts[3].parse().unwrap_or(0);
4626 if total == 0 {
4627 continue;
4628 }
4629 let free_gb = free / 1_073_741_824;
4630 let total_gb = total / 1_073_741_824;
4631 let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4632 let bar_len = 20usize;
4633 let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4634 let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4635 let warn = if free_gb < 5 {
4636 " [!] CRITICALLY LOW"
4637 } else if free_gb < 15 {
4638 " [-] LOW"
4639 } else {
4640 ""
4641 };
4642 out.push_str(&format!(
4643 " {name}: [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4644 ));
4645 drive_count += 1;
4646 }
4647 }
4648 if drive_count == 0 {
4649 out.push_str(" (could not enumerate drives)\n");
4650 }
4651 }
4652 Err(e) => out.push_str(&format!(" (drive scan failed: {e})\n")),
4653 }
4654
4655 let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4657 match Command::new("powershell")
4658 .args(["-NoProfile", "-Command", latency_script])
4659 .output()
4660 {
4661 Ok(o) => {
4662 out.push_str("\nReal-time Disk Intensity:\n");
4663 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4664 if !text.is_empty() {
4665 out.push_str(&format!(" Average Disk Queue Length: {text}\n"));
4666 if let Ok(q) = text.parse::<f64>() {
4667 if q > 2.0 {
4668 out.push_str(
4669 " [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4670 );
4671 } else {
4672 out.push_str(" [~] Disk latency is within healthy bounds.\n");
4673 }
4674 }
4675 } else {
4676 out.push_str(" Average Disk Queue Length: unavailable\n");
4677 }
4678 }
4679 Err(_) => {
4680 out.push_str("\nReal-time Disk Intensity:\n");
4681 out.push_str(" Average Disk Queue Length: unavailable\n");
4682 }
4683 }
4684 }
4685
4686 #[cfg(not(target_os = "windows"))]
4687 {
4688 match Command::new("df")
4689 .args(["-h", "--output=target,size,avail,pcent"])
4690 .output()
4691 {
4692 Ok(o) => {
4693 let text = String::from_utf8_lossy(&o.stdout);
4694 let mut count = 0usize;
4695 for line in text.lines().skip(1) {
4696 let cols: Vec<&str> = line.split_whitespace().collect();
4697 if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4698 out.push_str(&format!(
4699 " {} size: {} avail: {} used: {}\n",
4700 cols[0], cols[1], cols[2], cols[3]
4701 ));
4702 count += 1;
4703 if count >= max_entries {
4704 break;
4705 }
4706 }
4707 }
4708 }
4709 Err(e) => out.push_str(&format!(" (df failed: {e})\n")),
4710 }
4711 }
4712
4713 out.push_str("\nLarge developer cache directories (if present):\n");
4715
4716 #[cfg(target_os = "windows")]
4717 {
4718 let home = std::env::var("USERPROFILE").unwrap_or_default();
4719 let check_dirs: &[(&str, &str)] = &[
4720 ("Temp", r"AppData\Local\Temp"),
4721 ("npm cache", r"AppData\Roaming\npm-cache"),
4722 ("Cargo registry", r".cargo\registry"),
4723 ("Cargo git", r".cargo\git"),
4724 ("pip cache", r"AppData\Local\pip\cache"),
4725 ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4726 (".rustup toolchains", r".rustup\toolchains"),
4727 ("node_modules (home)", r"node_modules"),
4728 ];
4729
4730 let mut found_any = false;
4731 for (label, rel) in check_dirs {
4732 let full = format!(r"{}\{}", home, rel);
4733 let path = std::path::Path::new(&full);
4734 if path.exists() {
4735 let size_script = format!(
4737 r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4738 full.replace('\'', "''")
4739 );
4740 let size_mb = Command::new("powershell")
4741 .args(["-NoProfile", "-Command", &size_script])
4742 .output()
4743 .ok()
4744 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4745 .unwrap_or_else(|| "?".to_string());
4746 out.push_str(&format!(" {label}: {size_mb} MB ({full})\n"));
4747 found_any = true;
4748 }
4749 }
4750 if !found_any {
4751 out.push_str(" (none of the common cache directories found)\n");
4752 }
4753
4754 out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4755 }
4756
4757 #[cfg(not(target_os = "windows"))]
4758 {
4759 let home = std::env::var("HOME").unwrap_or_default();
4760 let check_dirs: &[(&str, &str)] = &[
4761 ("npm cache", ".npm"),
4762 ("Cargo registry", ".cargo/registry"),
4763 ("pip cache", ".cache/pip"),
4764 (".rustup toolchains", ".rustup/toolchains"),
4765 ("Yarn cache", ".cache/yarn"),
4766 ];
4767 let mut found_any = false;
4768 for (label, rel) in check_dirs {
4769 let full = format!("{}/{}", home, rel);
4770 if std::path::Path::new(&full).exists() {
4771 let size = Command::new("du")
4772 .args(["-sh", &full])
4773 .output()
4774 .ok()
4775 .map(|o| {
4776 let s = String::from_utf8_lossy(&o.stdout);
4777 s.split_whitespace().next().unwrap_or("?").to_string()
4778 })
4779 .unwrap_or_else(|| "?".to_string());
4780 out.push_str(&format!(" {label}: {size} ({full})\n"));
4781 found_any = true;
4782 }
4783 }
4784 if !found_any {
4785 out.push_str(" (none of the common cache directories found)\n");
4786 }
4787 }
4788
4789 Ok(out.trim_end().to_string())
4790}
4791
4792fn inspect_hardware() -> Result<String, String> {
4795 let mut out = String::from("Host inspection: hardware\n\n");
4796
4797 #[cfg(target_os = "windows")]
4798 {
4799 let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4801 "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4802} | Select-Object -First 1"#;
4803 if let Ok(o) = Command::new("powershell")
4804 .args(["-NoProfile", "-Command", cpu_script])
4805 .output()
4806 {
4807 let text = String::from_utf8_lossy(&o.stdout);
4808 let text = text.trim();
4809 let parts: Vec<&str> = text.split('|').collect();
4810 if parts.len() == 4 {
4811 out.push_str(&format!(
4812 "CPU: {}\n {} physical cores, {} logical processors, {:.1} GHz\n\n",
4813 parts[0],
4814 parts[1],
4815 parts[2],
4816 parts[3].parse::<f32>().unwrap_or(0.0)
4817 ));
4818 } else {
4819 out.push_str(&format!("CPU: {text}\n\n"));
4820 }
4821 }
4822
4823 let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4825$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4826$speed = ($sticks | Select-Object -First 1).Speed
4827"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4828 if let Ok(o) = Command::new("powershell")
4829 .args(["-NoProfile", "-Command", ram_script])
4830 .output()
4831 {
4832 let text = String::from_utf8_lossy(&o.stdout);
4833 out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4834 }
4835
4836 let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4838 "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4839}"#;
4840 if let Ok(o) = Command::new("powershell")
4841 .args(["-NoProfile", "-Command", gpu_script])
4842 .output()
4843 {
4844 let text = String::from_utf8_lossy(&o.stdout);
4845 let lines: Vec<&str> = text.lines().collect();
4846 if !lines.is_empty() {
4847 out.push_str("GPU(s):\n");
4848 for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4849 let parts: Vec<&str> = line.trim().split('|').collect();
4850 if parts.len() == 3 {
4851 let res = if parts[2] == "x" || parts[2].starts_with('0') {
4852 String::new()
4853 } else {
4854 format!(" — {}@display", parts[2])
4855 };
4856 out.push_str(&format!(
4857 " {}\n Driver: {}{}\n",
4858 parts[0], parts[1], res
4859 ));
4860 } else {
4861 out.push_str(&format!(" {}\n", line.trim()));
4862 }
4863 }
4864 out.push('\n');
4865 }
4866 }
4867
4868 let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4870$bios = Get-CimInstance Win32_BIOS
4871$cs = Get-CimInstance Win32_ComputerSystem
4872$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4873$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4874"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4875 if let Ok(o) = Command::new("powershell")
4876 .args(["-NoProfile", "-Command", mb_script])
4877 .output()
4878 {
4879 let text = String::from_utf8_lossy(&o.stdout);
4880 let text = text.trim().trim_matches('"');
4881 let parts: Vec<&str> = text.split('|').collect();
4882 if parts.len() == 4 {
4883 out.push_str(&format!(
4884 "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4885 parts[0].trim(),
4886 parts[1].trim(),
4887 parts[2].trim(),
4888 parts[3].trim()
4889 ));
4890 }
4891 }
4892
4893 let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4895 "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4896}"#;
4897 if let Ok(o) = Command::new("powershell")
4898 .args(["-NoProfile", "-Command", disp_script])
4899 .output()
4900 {
4901 let text = String::from_utf8_lossy(&o.stdout);
4902 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4903 if !lines.is_empty() {
4904 out.push_str("Display(s):\n");
4905 for line in &lines {
4906 let parts: Vec<&str> = line.trim().split('|').collect();
4907 if parts.len() == 2 {
4908 out.push_str(&format!(" {} — {}\n", parts[0].trim(), parts[1]));
4909 }
4910 }
4911 }
4912 }
4913 }
4914
4915 #[cfg(not(target_os = "windows"))]
4916 {
4917 if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4919 let model = content
4920 .lines()
4921 .find(|l| l.starts_with("model name"))
4922 .and_then(|l| l.split(':').nth(1))
4923 .map(str::trim)
4924 .unwrap_or("unknown");
4925 let cores = content
4926 .lines()
4927 .filter(|l| l.starts_with("processor"))
4928 .count();
4929 out.push_str(&format!("CPU: {model}\n {cores} logical processors\n\n"));
4930 }
4931
4932 if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4934 let total_kb: u64 = content
4935 .lines()
4936 .find(|l| l.starts_with("MemTotal:"))
4937 .and_then(|l| l.split_whitespace().nth(1))
4938 .and_then(|v| v.parse().ok())
4939 .unwrap_or(0);
4940 let total_gb = total_kb / 1_048_576;
4941 out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4942 }
4943
4944 if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4946 let text = String::from_utf8_lossy(&o.stdout);
4947 let gpu_lines: Vec<&str> = text
4948 .lines()
4949 .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4950 .collect();
4951 if !gpu_lines.is_empty() {
4952 out.push_str("GPU(s):\n");
4953 for l in gpu_lines {
4954 out.push_str(&format!(" {l}\n"));
4955 }
4956 out.push('\n');
4957 }
4958 }
4959
4960 if let Ok(o) = Command::new("dmidecode")
4962 .args(["-t", "baseboard", "-t", "bios"])
4963 .output()
4964 {
4965 let text = String::from_utf8_lossy(&o.stdout);
4966 out.push_str("Motherboard/BIOS:\n");
4967 for line in text
4968 .lines()
4969 .filter(|l| {
4970 l.contains("Manufacturer:")
4971 || l.contains("Product Name:")
4972 || l.contains("Version:")
4973 })
4974 .take(6)
4975 {
4976 out.push_str(&format!(" {}\n", line.trim()));
4977 }
4978 }
4979 }
4980
4981 Ok(out.trim_end().to_string())
4982}
4983
4984fn inspect_updates() -> Result<String, String> {
4987 let mut out = String::from("Host inspection: updates\n\n");
4988
4989 #[cfg(target_os = "windows")]
4990 {
4991 let script = r#"
4993try {
4994 $sess = New-Object -ComObject Microsoft.Update.Session
4995 $searcher = $sess.CreateUpdateSearcher()
4996 $count = $searcher.GetTotalHistoryCount()
4997 if ($count -gt 0) {
4998 $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4999 $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5000 } else { "NONE|LAST_INSTALL" }
5001} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5002"#;
5003 if let Ok(o) = Command::new("powershell")
5004 .args(["-NoProfile", "-Command", script])
5005 .output()
5006 {
5007 let raw = String::from_utf8_lossy(&o.stdout);
5008 let text = raw.trim();
5009 if text.starts_with("ERROR:") {
5010 out.push_str("Last update install: (unable to query)\n");
5011 } else if text.contains("NONE") {
5012 out.push_str("Last update install: No update history found\n");
5013 } else {
5014 let date = text.replace("|LAST_INSTALL", "");
5015 out.push_str(&format!("Last update install: {date}\n"));
5016 }
5017 }
5018
5019 let pending_script = r#"
5021try {
5022 $sess = New-Object -ComObject Microsoft.Update.Session
5023 $searcher = $sess.CreateUpdateSearcher()
5024 $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5025 $results.Updates.Count.ToString() + "|PENDING"
5026} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5027"#;
5028 if let Ok(o) = Command::new("powershell")
5029 .args(["-NoProfile", "-Command", pending_script])
5030 .output()
5031 {
5032 let raw = String::from_utf8_lossy(&o.stdout);
5033 let text = raw.trim();
5034 if text.starts_with("ERROR:") {
5035 out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5036 } else {
5037 let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5038 if count == 0 {
5039 out.push_str("Pending updates: Up to date — no updates waiting\n");
5040 } else if count > 0 {
5041 out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5042 out.push_str(
5043 " → Open Windows Update (Settings > Windows Update) to install\n",
5044 );
5045 }
5046 }
5047 }
5048
5049 let svc_script = r#"
5051$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5052if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5053"#;
5054 if let Ok(o) = Command::new("powershell")
5055 .args(["-NoProfile", "-Command", svc_script])
5056 .output()
5057 {
5058 let raw = String::from_utf8_lossy(&o.stdout);
5059 let status = raw.trim();
5060 out.push_str(&format!("Windows Update service: {status}\n"));
5061 }
5062 }
5063
5064 #[cfg(not(target_os = "windows"))]
5065 {
5066 let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5067 let mut found = false;
5068 if let Ok(o) = apt_out {
5069 let text = String::from_utf8_lossy(&o.stdout);
5070 let lines: Vec<&str> = text
5071 .lines()
5072 .filter(|l| l.contains('/') && !l.contains("Listing"))
5073 .collect();
5074 if !lines.is_empty() {
5075 out.push_str(&format!(
5076 "{} package(s) can be upgraded (apt)\n",
5077 lines.len()
5078 ));
5079 out.push_str(" → Run: sudo apt upgrade\n");
5080 found = true;
5081 }
5082 }
5083 if !found {
5084 if let Ok(o) = Command::new("dnf")
5085 .args(["check-update", "--quiet"])
5086 .output()
5087 {
5088 let text = String::from_utf8_lossy(&o.stdout);
5089 let count = text
5090 .lines()
5091 .filter(|l| !l.is_empty() && !l.starts_with('!'))
5092 .count();
5093 if count > 0 {
5094 out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5095 out.push_str(" → Run: sudo dnf upgrade\n");
5096 } else {
5097 out.push_str("System is up to date.\n");
5098 }
5099 } else {
5100 out.push_str("Could not query package manager for updates.\n");
5101 }
5102 }
5103 }
5104
5105 Ok(out.trim_end().to_string())
5106}
5107
5108fn inspect_security() -> Result<String, String> {
5111 let mut out = String::from("Host inspection: security\n\n");
5112
5113 #[cfg(target_os = "windows")]
5114 {
5115 let defender_script = r#"
5117try {
5118 $status = Get-MpComputerStatus -ErrorAction Stop
5119 "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5120} catch { "ERROR:" + $_.Exception.Message }
5121"#;
5122 if let Ok(o) = Command::new("powershell")
5123 .args(["-NoProfile", "-Command", defender_script])
5124 .output()
5125 {
5126 let raw = String::from_utf8_lossy(&o.stdout);
5127 let text = raw.trim();
5128 if text.starts_with("ERROR:") {
5129 out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5130 } else {
5131 let get = |key: &str| -> String {
5132 text.split('|')
5133 .find(|s| s.starts_with(key))
5134 .and_then(|s| s.splitn(2, ':').nth(1))
5135 .unwrap_or("unknown")
5136 .to_string()
5137 };
5138 let rtp = get("RTP");
5139 let last_scan = {
5140 text.split('|')
5142 .find(|s| s.starts_with("SCAN:"))
5143 .and_then(|s| s.get(5..))
5144 .unwrap_or("unknown")
5145 .to_string()
5146 };
5147 let def_ver = get("VER");
5148 let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5149
5150 let rtp_label = if rtp == "True" {
5151 "ENABLED"
5152 } else {
5153 "DISABLED [!]"
5154 };
5155 out.push_str(&format!(
5156 "Windows Defender real-time protection: {rtp_label}\n"
5157 ));
5158 out.push_str(&format!("Last quick scan: {last_scan}\n"));
5159 out.push_str(&format!("Signature version: {def_ver}\n"));
5160 if age_days >= 0 {
5161 let freshness = if age_days == 0 {
5162 "up to date".to_string()
5163 } else if age_days <= 3 {
5164 format!("{age_days} day(s) old — OK")
5165 } else if age_days <= 7 {
5166 format!("{age_days} day(s) old — consider updating")
5167 } else {
5168 format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5169 };
5170 out.push_str(&format!("Signature age: {freshness}\n"));
5171 }
5172 if rtp != "True" {
5173 out.push_str(
5174 "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5175 );
5176 out.push_str(
5177 " → Open Windows Security > Virus & threat protection to re-enable.\n",
5178 );
5179 }
5180 }
5181 }
5182
5183 out.push('\n');
5184
5185 let fw_script = r#"
5187try {
5188 Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5189} catch { "ERROR:" + $_.Exception.Message }
5190"#;
5191 if let Ok(o) = Command::new("powershell")
5192 .args(["-NoProfile", "-Command", fw_script])
5193 .output()
5194 {
5195 let raw = String::from_utf8_lossy(&o.stdout);
5196 let text = raw.trim();
5197 if !text.starts_with("ERROR:") && !text.is_empty() {
5198 out.push_str("Windows Firewall:\n");
5199 for line in text.lines() {
5200 if let Some((name, enabled)) = line.split_once(':') {
5201 let state = if enabled.trim() == "True" {
5202 "ON"
5203 } else {
5204 "OFF [!]"
5205 };
5206 out.push_str(&format!(" {name}: {state}\n"));
5207 }
5208 }
5209 out.push('\n');
5210 }
5211 }
5212
5213 let act_script = r#"
5215try {
5216 $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5217 if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5218} catch { "UNKNOWN" }
5219"#;
5220 if let Ok(o) = Command::new("powershell")
5221 .args(["-NoProfile", "-Command", act_script])
5222 .output()
5223 {
5224 let raw = String::from_utf8_lossy(&o.stdout);
5225 match raw.trim() {
5226 "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5227 "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5228 _ => out.push_str("Windows activation: Unable to determine\n"),
5229 }
5230 }
5231
5232 let uac_script = r#"
5234$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5235if ($val -eq 1) { "ON" } else { "OFF" }
5236"#;
5237 if let Ok(o) = Command::new("powershell")
5238 .args(["-NoProfile", "-Command", uac_script])
5239 .output()
5240 {
5241 let raw = String::from_utf8_lossy(&o.stdout);
5242 let state = raw.trim();
5243 let label = if state == "ON" {
5244 "Enabled"
5245 } else {
5246 "DISABLED [!] — recommended to re-enable via secpol.msc"
5247 };
5248 out.push_str(&format!("UAC (User Account Control): {label}\n"));
5249 }
5250 }
5251
5252 #[cfg(not(target_os = "windows"))]
5253 {
5254 if let Ok(o) = Command::new("ufw").arg("status").output() {
5255 let text = String::from_utf8_lossy(&o.stdout);
5256 out.push_str(&format!(
5257 "UFW: {}\n",
5258 text.lines().next().unwrap_or("unknown")
5259 ));
5260 }
5261 if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5262 if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5263 out.push_str(&format!("{line}\n"));
5264 }
5265 }
5266 }
5267
5268 Ok(out.trim_end().to_string())
5269}
5270
5271fn inspect_pending_reboot() -> Result<String, String> {
5274 let mut out = String::from("Host inspection: pending_reboot\n\n");
5275
5276 #[cfg(target_os = "windows")]
5277 {
5278 let script = r#"
5279$reasons = @()
5280if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5281 $reasons += "Windows Update requires a restart"
5282}
5283if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5284 $reasons += "Windows component install/update requires a restart"
5285}
5286$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5287if ($pfro -and $pfro.PendingFileRenameOperations) {
5288 $reasons += "Pending file rename operations (driver or system file replacement)"
5289}
5290if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5291"#;
5292 let output = Command::new("powershell")
5293 .args(["-NoProfile", "-Command", script])
5294 .output()
5295 .map_err(|e| format!("pending_reboot: {e}"))?;
5296
5297 let raw = String::from_utf8_lossy(&output.stdout);
5298 let text = raw.trim();
5299
5300 if text == "NO_REBOOT_NEEDED" {
5301 out.push_str("No restart required — system is up to date and stable.\n");
5302 } else if text.is_empty() {
5303 out.push_str("Could not determine reboot status.\n");
5304 } else {
5305 out.push_str("[!] A system restart is pending:\n\n");
5306 for reason in text.split("|REASON|") {
5307 out.push_str(&format!(" • {}\n", reason.trim()));
5308 }
5309 out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5310 }
5311 }
5312
5313 #[cfg(not(target_os = "windows"))]
5314 {
5315 if std::path::Path::new("/var/run/reboot-required").exists() {
5316 out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5317 if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5318 out.push_str("Packages requiring restart:\n");
5319 for p in pkgs.lines().take(10) {
5320 out.push_str(&format!(" • {p}\n"));
5321 }
5322 }
5323 } else {
5324 out.push_str("No restart required.\n");
5325 }
5326 }
5327
5328 Ok(out.trim_end().to_string())
5329}
5330
5331fn inspect_disk_health() -> Result<String, String> {
5334 let mut out = String::from("Host inspection: disk_health\n\n");
5335
5336 #[cfg(target_os = "windows")]
5337 {
5338 let script = r#"
5339try {
5340 $disks = Get-PhysicalDisk -ErrorAction Stop
5341 foreach ($d in $disks) {
5342 $size_gb = [math]::Round($d.Size / 1GB, 0)
5343 $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5344 }
5345} catch { "ERROR:" + $_.Exception.Message }
5346"#;
5347 let output = Command::new("powershell")
5348 .args(["-NoProfile", "-Command", script])
5349 .output()
5350 .map_err(|e| format!("disk_health: {e}"))?;
5351
5352 let raw = String::from_utf8_lossy(&output.stdout);
5353 let text = raw.trim();
5354
5355 if text.starts_with("ERROR:") {
5356 out.push_str(&format!("Unable to query disk health: {text}\n"));
5357 out.push_str("This may require running as administrator.\n");
5358 } else if text.is_empty() {
5359 out.push_str("No physical disks found.\n");
5360 } else {
5361 out.push_str("Physical Drive Health:\n\n");
5362 for line in text.lines() {
5363 let parts: Vec<&str> = line.splitn(5, '|').collect();
5364 if parts.len() >= 4 {
5365 let name = parts[0];
5366 let media = parts[1];
5367 let size = parts[2];
5368 let health = parts[3];
5369 let op_status = parts.get(4).unwrap_or(&"");
5370 let health_label = match health.trim() {
5371 "Healthy" => "OK",
5372 "Warning" => "[!] WARNING",
5373 "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5374 other => other,
5375 };
5376 out.push_str(&format!(" {name}\n"));
5377 out.push_str(&format!(" Type: {media} | Size: {size}\n"));
5378 out.push_str(&format!(" Health: {health_label}\n"));
5379 if !op_status.is_empty() {
5380 out.push_str(&format!(" Status: {op_status}\n"));
5381 }
5382 out.push('\n');
5383 }
5384 }
5385 }
5386
5387 let smart_script = r#"
5389try {
5390 Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5391 ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5392} catch { "" }
5393"#;
5394 if let Ok(o) = Command::new("powershell")
5395 .args(["-NoProfile", "-Command", smart_script])
5396 .output()
5397 {
5398 let raw2 = String::from_utf8_lossy(&o.stdout);
5399 let text2 = raw2.trim();
5400 if !text2.is_empty() {
5401 let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5402 if failures.is_empty() {
5403 out.push_str("SMART failure prediction: No failures predicted\n");
5404 } else {
5405 out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5406 for f in failures {
5407 let name = f.split('|').next().unwrap_or(f);
5408 out.push_str(&format!(" • {name}\n"));
5409 }
5410 out.push_str(
5411 "\nBack up your data immediately and replace the failing drive.\n",
5412 );
5413 }
5414 }
5415 }
5416 }
5417
5418 #[cfg(not(target_os = "windows"))]
5419 {
5420 if let Ok(o) = Command::new("lsblk")
5421 .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5422 .output()
5423 {
5424 let text = String::from_utf8_lossy(&o.stdout);
5425 out.push_str("Block devices:\n");
5426 out.push_str(text.trim());
5427 out.push('\n');
5428 }
5429 if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5430 let devices = String::from_utf8_lossy(&scan.stdout);
5431 for dev_line in devices.lines().take(4) {
5432 let dev = dev_line.split_whitespace().next().unwrap_or("");
5433 if dev.is_empty() {
5434 continue;
5435 }
5436 if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5437 let health = String::from_utf8_lossy(&o.stdout);
5438 if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5439 {
5440 out.push_str(&format!("{dev}: {}\n", line.trim()));
5441 }
5442 }
5443 }
5444 } else {
5445 out.push_str("(install smartmontools for SMART health data)\n");
5446 }
5447 }
5448
5449 Ok(out.trim_end().to_string())
5450}
5451
5452fn inspect_battery() -> Result<String, String> {
5455 let mut out = String::from("Host inspection: battery\n\n");
5456
5457 #[cfg(target_os = "windows")]
5458 {
5459 let script = r#"
5460try {
5461 $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5462 if (-not $bats) { "NO_BATTERY"; exit }
5463
5464 # Modern Battery Health (Cycle count + Capacity health)
5465 $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5466 $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue
5467 $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5468
5469 foreach ($b in $bats) {
5470 $state = switch ($b.BatteryStatus) {
5471 1 { "Discharging" }
5472 2 { "AC Power (Fully Charged)" }
5473 3 { "AC Power (Charging)" }
5474 default { "Status $($b.BatteryStatus)" }
5475 }
5476
5477 $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5478 $health = if ($static -and $full) {
5479 [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5480 } else { "unknown" }
5481
5482 $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5483 }
5484} catch { "ERROR:" + $_.Exception.Message }
5485"#;
5486 let output = Command::new("powershell")
5487 .args(["-NoProfile", "-Command", script])
5488 .output()
5489 .map_err(|e| format!("battery: {e}"))?;
5490
5491 let raw = String::from_utf8_lossy(&output.stdout);
5492 let text = raw.trim();
5493
5494 if text == "NO_BATTERY" {
5495 out.push_str("No battery detected — desktop or AC-only system.\n");
5496 return Ok(out.trim_end().to_string());
5497 }
5498 if text.starts_with("ERROR:") {
5499 out.push_str(&format!("Unable to query battery: {text}\n"));
5500 return Ok(out.trim_end().to_string());
5501 }
5502
5503 for line in text.lines() {
5504 let parts: Vec<&str> = line.split('|').collect();
5505 if parts.len() == 5 {
5506 let name = parts[0];
5507 let charge: i64 = parts[1].parse().unwrap_or(-1);
5508 let state = parts[2];
5509 let cycles = parts[3];
5510 let health = parts[4];
5511
5512 out.push_str(&format!("Battery: {name}\n"));
5513 if charge >= 0 {
5514 let bar_filled = (charge as usize * 20) / 100;
5515 out.push_str(&format!(
5516 " Charge: [{}{}] {}%\n",
5517 "#".repeat(bar_filled),
5518 ".".repeat(20 - bar_filled),
5519 charge
5520 ));
5521 }
5522 out.push_str(&format!(" Status: {state}\n"));
5523 out.push_str(&format!(" Cycles: {cycles}\n"));
5524 out.push_str(&format!(
5525 " Health: {health}% (Actual vs Design Capacity)\n\n"
5526 ));
5527 }
5528 }
5529 }
5530
5531 #[cfg(not(target_os = "windows"))]
5532 {
5533 let power_path = std::path::Path::new("/sys/class/power_supply");
5534 let mut found = false;
5535 if power_path.exists() {
5536 if let Ok(entries) = std::fs::read_dir(power_path) {
5537 for entry in entries.flatten() {
5538 let p = entry.path();
5539 if let Ok(t) = std::fs::read_to_string(p.join("type")) {
5540 if t.trim() == "Battery" {
5541 found = true;
5542 let name = p
5543 .file_name()
5544 .unwrap_or_default()
5545 .to_string_lossy()
5546 .to_string();
5547 out.push_str(&format!("Battery: {name}\n"));
5548 let read = |f: &str| {
5549 std::fs::read_to_string(p.join(f))
5550 .ok()
5551 .map(|s| s.trim().to_string())
5552 };
5553 if let Some(cap) = read("capacity") {
5554 out.push_str(&format!(" Charge: {cap}%\n"));
5555 }
5556 if let Some(status) = read("status") {
5557 out.push_str(&format!(" Status: {status}\n"));
5558 }
5559 if let (Some(full), Some(design)) =
5560 (read("energy_full"), read("energy_full_design"))
5561 {
5562 if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
5563 {
5564 if d > 0.0 {
5565 out.push_str(&format!(
5566 " Wear level: {:.1}% of design capacity\n",
5567 (f / d) * 100.0
5568 ));
5569 }
5570 }
5571 }
5572 }
5573 }
5574 }
5575 }
5576 }
5577 if !found {
5578 out.push_str("No battery found.\n");
5579 }
5580 }
5581
5582 Ok(out.trim_end().to_string())
5583}
5584
5585fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5588 let mut out = String::from("Host inspection: recent_crashes\n\n");
5589 let n = max_entries.clamp(1, 30);
5590
5591 #[cfg(target_os = "windows")]
5592 {
5593 let bsod_script = format!(
5595 r#"
5596try {{
5597 $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5598 if ($events) {{
5599 $events | ForEach-Object {{
5600 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5601 }}
5602 }} else {{ "NO_BSOD" }}
5603}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5604 );
5605
5606 if let Ok(o) = Command::new("powershell")
5607 .args(["-NoProfile", "-Command", &bsod_script])
5608 .output()
5609 {
5610 let raw = String::from_utf8_lossy(&o.stdout);
5611 let text = raw.trim();
5612 if text == "NO_BSOD" {
5613 out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5614 } else if text.starts_with("ERROR:") {
5615 out.push_str("System crashes: unable to query\n");
5616 } else {
5617 out.push_str("System crashes / unexpected shutdowns:\n");
5618 for line in text.lines() {
5619 let parts: Vec<&str> = line.splitn(3, '|').collect();
5620 if parts.len() >= 3 {
5621 let time = parts[0];
5622 let id = parts[1];
5623 let msg = parts[2];
5624 let label = if id == "41" {
5625 "Unexpected shutdown"
5626 } else {
5627 "BSOD (BugCheck)"
5628 };
5629 out.push_str(&format!(" [{time}] {label}: {msg}\n"));
5630 }
5631 }
5632 out.push('\n');
5633 }
5634 }
5635
5636 let app_script = format!(
5638 r#"
5639try {{
5640 $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5641 if ($crashes) {{
5642 $crashes | ForEach-Object {{
5643 $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5644 }}
5645 }} else {{ "NO_CRASHES" }}
5646}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5647 );
5648
5649 if let Ok(o) = Command::new("powershell")
5650 .args(["-NoProfile", "-Command", &app_script])
5651 .output()
5652 {
5653 let raw = String::from_utf8_lossy(&o.stdout);
5654 let text = raw.trim();
5655 if text == "NO_CRASHES" {
5656 out.push_str("Application crashes: None in recent history\n");
5657 } else if text.starts_with("ERROR_APP:") {
5658 out.push_str("Application crashes: unable to query\n");
5659 } else {
5660 out.push_str("Application crashes:\n");
5661 for line in text.lines().take(n) {
5662 let parts: Vec<&str> = line.splitn(2, '|').collect();
5663 if parts.len() >= 2 {
5664 out.push_str(&format!(" [{}] {}\n", parts[0], parts[1]));
5665 }
5666 }
5667 }
5668 }
5669 }
5670
5671 #[cfg(not(target_os = "windows"))]
5672 {
5673 let n_str = n.to_string();
5674 if let Ok(o) = Command::new("journalctl")
5675 .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5676 .output()
5677 {
5678 let text = String::from_utf8_lossy(&o.stdout);
5679 let trimmed = text.trim();
5680 if trimmed.is_empty() || trimmed.contains("No entries") {
5681 out.push_str("No kernel panics or critical crashes found.\n");
5682 } else {
5683 out.push_str("Kernel critical events:\n");
5684 out.push_str(trimmed);
5685 out.push('\n');
5686 }
5687 }
5688 if let Ok(o) = Command::new("coredumpctl")
5689 .args(["list", "--no-pager"])
5690 .output()
5691 {
5692 let text = String::from_utf8_lossy(&o.stdout);
5693 let count = text
5694 .lines()
5695 .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5696 .count();
5697 if count > 0 {
5698 out.push_str(&format!(
5699 "\nCore dumps on file: {count}\n → Run: coredumpctl list\n"
5700 ));
5701 }
5702 }
5703 }
5704
5705 Ok(out.trim_end().to_string())
5706}
5707
5708fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5711 let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5712 let n = max_entries.clamp(1, 30);
5713
5714 #[cfg(target_os = "windows")]
5715 {
5716 let script = format!(
5717 r#"
5718try {{
5719 $tasks = Get-ScheduledTask -ErrorAction Stop |
5720 Where-Object {{ $_.State -ne 'Disabled' }} |
5721 ForEach-Object {{
5722 $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5723 $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5724 $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5725 }} else {{ "never" }}
5726 $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5727 $exec = ($_.Actions | Select-Object -First 1).Execute
5728 if (-not $exec) {{ $exec = "(no exec)" }}
5729 $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5730 }}
5731 $tasks | Select-Object -First {n}
5732}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5733 );
5734
5735 let output = Command::new("powershell")
5736 .args(["-NoProfile", "-Command", &script])
5737 .output()
5738 .map_err(|e| format!("scheduled_tasks: {e}"))?;
5739
5740 let raw = String::from_utf8_lossy(&output.stdout);
5741 let text = raw.trim();
5742
5743 if text.starts_with("ERROR:") {
5744 out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5745 } else if text.is_empty() {
5746 out.push_str("No active scheduled tasks found.\n");
5747 } else {
5748 out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5749 for line in text.lines() {
5750 let parts: Vec<&str> = line.splitn(6, '|').collect();
5751 if parts.len() >= 5 {
5752 let name = parts[0];
5753 let path = parts[1];
5754 let state = parts[2];
5755 let last = parts[3];
5756 let res = parts[4];
5757 let exec = parts.get(5).unwrap_or(&"").trim();
5758 let display_path = path.trim_matches('\\');
5759 let display_path = if display_path.is_empty() {
5760 "Root"
5761 } else {
5762 display_path
5763 };
5764 out.push_str(&format!(" {name} [{display_path}]\n"));
5765 out.push_str(&format!(
5766 " State: {state} | Last run: {last} | Result: {res}\n"
5767 ));
5768 if !exec.is_empty() && exec != "(no exec)" {
5769 let short = if exec.len() > 80 { &exec[..80] } else { exec };
5770 out.push_str(&format!(" Runs: {short}\n"));
5771 }
5772 }
5773 }
5774 }
5775 }
5776
5777 #[cfg(not(target_os = "windows"))]
5778 {
5779 if let Ok(o) = Command::new("systemctl")
5780 .args(["list-timers", "--no-pager", "--all"])
5781 .output()
5782 {
5783 let text = String::from_utf8_lossy(&o.stdout);
5784 out.push_str("Systemd timers:\n");
5785 for l in text
5786 .lines()
5787 .filter(|l| {
5788 !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5789 })
5790 .take(n)
5791 {
5792 out.push_str(&format!(" {l}\n"));
5793 }
5794 out.push('\n');
5795 }
5796 if let Ok(o) = Command::new("crontab").arg("-l").output() {
5797 let text = String::from_utf8_lossy(&o.stdout);
5798 let jobs: Vec<&str> = text
5799 .lines()
5800 .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5801 .collect();
5802 if !jobs.is_empty() {
5803 out.push_str("User crontab:\n");
5804 for j in jobs.iter().take(n) {
5805 out.push_str(&format!(" {j}\n"));
5806 }
5807 }
5808 }
5809 }
5810
5811 Ok(out.trim_end().to_string())
5812}
5813
5814fn inspect_dev_conflicts() -> Result<String, String> {
5817 let mut out = String::from("Host inspection: dev_conflicts\n\n");
5818 let mut conflicts: Vec<String> = Vec::new();
5819 let mut notes: Vec<String> = Vec::new();
5820
5821 {
5823 let node_ver = Command::new("node")
5824 .arg("--version")
5825 .output()
5826 .ok()
5827 .and_then(|o| String::from_utf8(o.stdout).ok())
5828 .map(|s| s.trim().to_string());
5829 let nvm_active = Command::new("nvm")
5830 .arg("current")
5831 .output()
5832 .ok()
5833 .and_then(|o| String::from_utf8(o.stdout).ok())
5834 .map(|s| s.trim().to_string())
5835 .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5836 let fnm_active = Command::new("fnm")
5837 .arg("current")
5838 .output()
5839 .ok()
5840 .and_then(|o| String::from_utf8(o.stdout).ok())
5841 .map(|s| s.trim().to_string())
5842 .filter(|s| !s.is_empty() && !s.contains("none"));
5843 let volta_active = Command::new("volta")
5844 .args(["which", "node"])
5845 .output()
5846 .ok()
5847 .and_then(|o| String::from_utf8(o.stdout).ok())
5848 .map(|s| s.trim().to_string())
5849 .filter(|s| !s.is_empty());
5850
5851 out.push_str("Node.js:\n");
5852 if let Some(ref v) = node_ver {
5853 out.push_str(&format!(" Active: {v}\n"));
5854 } else {
5855 out.push_str(" Not installed\n");
5856 }
5857 let managers: Vec<&str> = [
5858 nvm_active.as_deref(),
5859 fnm_active.as_deref(),
5860 volta_active.as_deref(),
5861 ]
5862 .iter()
5863 .filter_map(|x| *x)
5864 .collect();
5865 if managers.len() > 1 {
5866 conflicts.push(format!(
5867 "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5868 ));
5869 } else if !managers.is_empty() {
5870 out.push_str(&format!(" Version manager: {}\n", managers[0]));
5871 }
5872 out.push('\n');
5873 }
5874
5875 {
5877 let py3 = Command::new("python3")
5878 .arg("--version")
5879 .output()
5880 .ok()
5881 .and_then(|o| {
5882 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5883 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5884 let v = if stdout.is_empty() { stderr } else { stdout };
5885 if v.is_empty() {
5886 None
5887 } else {
5888 Some(v)
5889 }
5890 });
5891 let py = Command::new("python")
5892 .arg("--version")
5893 .output()
5894 .ok()
5895 .and_then(|o| {
5896 let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5897 let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5898 let v = if stdout.is_empty() { stderr } else { stdout };
5899 if v.is_empty() {
5900 None
5901 } else {
5902 Some(v)
5903 }
5904 });
5905 let pyenv = Command::new("pyenv")
5906 .arg("version")
5907 .output()
5908 .ok()
5909 .and_then(|o| String::from_utf8(o.stdout).ok())
5910 .map(|s| s.trim().to_string())
5911 .filter(|s| !s.is_empty());
5912 let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5913
5914 out.push_str("Python:\n");
5915 match (&py3, &py) {
5916 (Some(v3), Some(v)) if v3 != v => {
5917 out.push_str(&format!(" python3: {v3}\n python: {v}\n"));
5918 if v.contains("2.") {
5919 conflicts.push(
5920 "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5921 );
5922 } else {
5923 notes.push(
5924 "python and python3 resolve to different minor versions.".to_string(),
5925 );
5926 }
5927 }
5928 (Some(v3), None) => out.push_str(&format!(" python3: {v3}\n")),
5929 (None, Some(v)) => out.push_str(&format!(" python: {v}\n")),
5930 (Some(v3), Some(_)) => out.push_str(&format!(" {v3}\n")),
5931 (None, None) => out.push_str(" Not installed\n"),
5932 }
5933 if let Some(ref pe) = pyenv {
5934 out.push_str(&format!(" pyenv: {pe}\n"));
5935 }
5936 if let Some(env) = conda_env {
5937 if env == "base" {
5938 notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5939 } else {
5940 out.push_str(&format!(" conda env: {env}\n"));
5941 }
5942 }
5943 out.push('\n');
5944 }
5945
5946 {
5948 let toolchain = Command::new("rustup")
5949 .args(["show", "active-toolchain"])
5950 .output()
5951 .ok()
5952 .and_then(|o| String::from_utf8(o.stdout).ok())
5953 .map(|s| s.trim().to_string())
5954 .filter(|s| !s.is_empty());
5955 let cargo_ver = Command::new("cargo")
5956 .arg("--version")
5957 .output()
5958 .ok()
5959 .and_then(|o| String::from_utf8(o.stdout).ok())
5960 .map(|s| s.trim().to_string());
5961 let rustc_ver = Command::new("rustc")
5962 .arg("--version")
5963 .output()
5964 .ok()
5965 .and_then(|o| String::from_utf8(o.stdout).ok())
5966 .map(|s| s.trim().to_string());
5967
5968 out.push_str("Rust:\n");
5969 if let Some(ref t) = toolchain {
5970 out.push_str(&format!(" Active toolchain: {t}\n"));
5971 }
5972 if let Some(ref c) = cargo_ver {
5973 out.push_str(&format!(" {c}\n"));
5974 }
5975 if let Some(ref r) = rustc_ver {
5976 out.push_str(&format!(" {r}\n"));
5977 }
5978 if cargo_ver.is_none() && rustc_ver.is_none() {
5979 out.push_str(" Not installed\n");
5980 }
5981
5982 #[cfg(not(target_os = "windows"))]
5984 if let Ok(o) = Command::new("which").arg("rustc").output() {
5985 let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5986 if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5987 conflicts.push(format!(
5988 "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5989 ));
5990 }
5991 }
5992 out.push('\n');
5993 }
5994
5995 {
5997 let git_ver = Command::new("git")
5998 .arg("--version")
5999 .output()
6000 .ok()
6001 .and_then(|o| String::from_utf8(o.stdout).ok())
6002 .map(|s| s.trim().to_string());
6003 out.push_str("Git:\n");
6004 if let Some(ref v) = git_ver {
6005 out.push_str(&format!(" {v}\n"));
6006 let email = Command::new("git")
6007 .args(["config", "--global", "user.email"])
6008 .output()
6009 .ok()
6010 .and_then(|o| String::from_utf8(o.stdout).ok())
6011 .map(|s| s.trim().to_string());
6012 if let Some(ref e) = email {
6013 if e.is_empty() {
6014 notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6015 } else {
6016 out.push_str(&format!(" user.email: {e}\n"));
6017 }
6018 }
6019 let gpg_sign = Command::new("git")
6020 .args(["config", "--global", "commit.gpgsign"])
6021 .output()
6022 .ok()
6023 .and_then(|o| String::from_utf8(o.stdout).ok())
6024 .map(|s| s.trim().to_string());
6025 if gpg_sign.as_deref() == Some("true") {
6026 let key = Command::new("git")
6027 .args(["config", "--global", "user.signingkey"])
6028 .output()
6029 .ok()
6030 .and_then(|o| String::from_utf8(o.stdout).ok())
6031 .map(|s| s.trim().to_string());
6032 if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6033 conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6034 }
6035 }
6036 } else {
6037 out.push_str(" Not installed\n");
6038 }
6039 out.push('\n');
6040 }
6041
6042 {
6044 let path_env = std::env::var("PATH").unwrap_or_default();
6045 let sep = if cfg!(windows) { ';' } else { ':' };
6046 let mut seen = HashSet::new();
6047 let mut dupes: Vec<String> = Vec::new();
6048 for p in path_env.split(sep) {
6049 let norm = p.trim().to_lowercase();
6050 if !norm.is_empty() && !seen.insert(norm) {
6051 dupes.push(p.to_string());
6052 }
6053 }
6054 if !dupes.is_empty() {
6055 let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6056 notes.push(format!(
6057 "Duplicate PATH entries: {} {}",
6058 shown.join(", "),
6059 if dupes.len() > 3 {
6060 format!("+{} more", dupes.len() - 3)
6061 } else {
6062 String::new()
6063 }
6064 ));
6065 }
6066 }
6067
6068 if conflicts.is_empty() && notes.is_empty() {
6070 out.push_str("No conflicts detected — dev environment looks clean.\n");
6071 } else {
6072 if !conflicts.is_empty() {
6073 out.push_str("CONFLICTS:\n");
6074 for c in &conflicts {
6075 out.push_str(&format!(" [!] {c}\n"));
6076 }
6077 out.push('\n');
6078 }
6079 if !notes.is_empty() {
6080 out.push_str("NOTES:\n");
6081 for n in ¬es {
6082 out.push_str(&format!(" [-] {n}\n"));
6083 }
6084 }
6085 }
6086
6087 Ok(out.trim_end().to_string())
6088}
6089
6090fn inspect_connectivity() -> Result<String, String> {
6093 let mut out = String::from("Host inspection: connectivity\n\n");
6094
6095 #[cfg(target_os = "windows")]
6096 {
6097 let inet_script = r#"
6098try {
6099 $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6100 if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6101} catch { "ERROR:" + $_.Exception.Message }
6102"#;
6103 if let Ok(o) = Command::new("powershell")
6104 .args(["-NoProfile", "-Command", inet_script])
6105 .output()
6106 {
6107 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6108 match text.as_str() {
6109 "REACHABLE" => out.push_str("Internet: reachable\n"),
6110 "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6111 _ => out.push_str(&format!(
6112 "Internet: {}\n",
6113 text.trim_start_matches("ERROR:").trim()
6114 )),
6115 }
6116 }
6117
6118 let dns_script = r#"
6119try {
6120 Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6121 "DNS:ok"
6122} catch { "DNS:fail:" + $_.Exception.Message }
6123"#;
6124 if let Ok(o) = Command::new("powershell")
6125 .args(["-NoProfile", "-Command", dns_script])
6126 .output()
6127 {
6128 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6129 if text == "DNS:ok" {
6130 out.push_str("DNS: resolving correctly\n");
6131 } else {
6132 let detail = text.trim_start_matches("DNS:fail:").trim();
6133 out.push_str(&format!("DNS: failed — {}\n", detail));
6134 }
6135 }
6136
6137 let gw_script = r#"
6138(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6139"#;
6140 if let Ok(o) = Command::new("powershell")
6141 .args(["-NoProfile", "-Command", gw_script])
6142 .output()
6143 {
6144 let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6145 if !gw.is_empty() && gw != "0.0.0.0" {
6146 out.push_str(&format!("Default gateway: {}\n", gw));
6147 }
6148 }
6149 }
6150
6151 #[cfg(not(target_os = "windows"))]
6152 {
6153 let reachable = Command::new("ping")
6154 .args(["-c", "1", "-W", "2", "8.8.8.8"])
6155 .output()
6156 .map(|o| o.status.success())
6157 .unwrap_or(false);
6158 out.push_str(if reachable {
6159 "Internet: reachable\n"
6160 } else {
6161 "Internet: unreachable\n"
6162 });
6163 let dns_ok = Command::new("getent")
6164 .args(["hosts", "dns.google"])
6165 .output()
6166 .map(|o| o.status.success())
6167 .unwrap_or(false);
6168 out.push_str(if dns_ok {
6169 "DNS: resolving correctly\n"
6170 } else {
6171 "DNS: failed\n"
6172 });
6173 if let Ok(o) = Command::new("ip")
6174 .args(["route", "show", "default"])
6175 .output()
6176 {
6177 let text = String::from_utf8_lossy(&o.stdout);
6178 if let Some(line) = text.lines().next() {
6179 out.push_str(&format!("Default gateway: {}\n", line.trim()));
6180 }
6181 }
6182 }
6183
6184 Ok(out.trim_end().to_string())
6185}
6186
6187fn inspect_wifi() -> Result<String, String> {
6190 let mut out = String::from("Host inspection: wifi\n\n");
6191
6192 #[cfg(target_os = "windows")]
6193 {
6194 let output = Command::new("netsh")
6195 .args(["wlan", "show", "interfaces"])
6196 .output()
6197 .map_err(|e| format!("wifi: {e}"))?;
6198 let text = String::from_utf8_lossy(&output.stdout).to_string();
6199
6200 if text.contains("There is no wireless interface") || text.trim().is_empty() {
6201 out.push_str("No wireless interface detected on this machine.\n");
6202 return Ok(out.trim_end().to_string());
6203 }
6204
6205 let fields = [
6206 ("SSID", "SSID"),
6207 ("State", "State"),
6208 ("Signal", "Signal"),
6209 ("Radio type", "Radio type"),
6210 ("Channel", "Channel"),
6211 ("Receive rate (Mbps)", "Download speed (Mbps)"),
6212 ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6213 ("Authentication", "Authentication"),
6214 ("Network type", "Network type"),
6215 ];
6216
6217 let mut any = false;
6218 for line in text.lines() {
6219 let trimmed = line.trim();
6220 for (key, label) in &fields {
6221 if trimmed.starts_with(key) && trimmed.contains(':') {
6222 let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6223 if !val.is_empty() {
6224 out.push_str(&format!(" {label}: {val}\n"));
6225 any = true;
6226 }
6227 }
6228 }
6229 }
6230 if !any {
6231 out.push_str(" (Wi-Fi adapter disconnected or no active connection)\n");
6232 }
6233 }
6234
6235 #[cfg(not(target_os = "windows"))]
6236 {
6237 if let Ok(o) = Command::new("nmcli")
6238 .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6239 .output()
6240 {
6241 let text = String::from_utf8_lossy(&o.stdout).to_string();
6242 let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6243 if lines.is_empty() {
6244 out.push_str("No Wi-Fi devices found.\n");
6245 } else {
6246 for l in lines {
6247 out.push_str(&format!(" {l}\n"));
6248 }
6249 }
6250 } else if let Ok(o) = Command::new("iwconfig").output() {
6251 let text = String::from_utf8_lossy(&o.stdout).to_string();
6252 if !text.trim().is_empty() {
6253 out.push_str(text.trim());
6254 out.push('\n');
6255 }
6256 } else {
6257 out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6258 }
6259 }
6260
6261 Ok(out.trim_end().to_string())
6262}
6263
6264fn inspect_connections(max_entries: usize) -> Result<String, String> {
6267 let mut out = String::from("Host inspection: connections\n\n");
6268 let n = max_entries.clamp(1, 25);
6269
6270 #[cfg(target_os = "windows")]
6271 {
6272 let script = format!(
6273 r#"
6274try {{
6275 $procs = @{{}}
6276 Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6277 $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6278 Sort-Object OwningProcess
6279 "TOTAL:" + $all.Count
6280 $all | Select-Object -First {n} | ForEach-Object {{
6281 $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6282 $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6283 }}
6284}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6285 );
6286
6287 let output = Command::new("powershell")
6288 .args(["-NoProfile", "-Command", &script])
6289 .output()
6290 .map_err(|e| format!("connections: {e}"))?;
6291
6292 let raw = String::from_utf8_lossy(&output.stdout);
6293 let text = raw.trim();
6294
6295 if text.starts_with("ERROR:") {
6296 out.push_str(&format!("Unable to query connections: {text}\n"));
6297 } else {
6298 let mut total = 0usize;
6299 let mut rows = Vec::new();
6300 for line in text.lines() {
6301 if let Some(rest) = line.strip_prefix("TOTAL:") {
6302 total = rest.trim().parse().unwrap_or(0);
6303 } else {
6304 rows.push(line);
6305 }
6306 }
6307 out.push_str(&format!("Established TCP connections: {total}\n\n"));
6308 for row in &rows {
6309 let parts: Vec<&str> = row.splitn(4, '|').collect();
6310 if parts.len() == 4 {
6311 out.push_str(&format!(
6312 " {:<15} (pid {:<5}) | {} → {}\n",
6313 parts[0], parts[1], parts[2], parts[3]
6314 ));
6315 }
6316 }
6317 if total > n {
6318 out.push_str(&format!(
6319 "\n ... {} more connections not shown\n",
6320 total.saturating_sub(n)
6321 ));
6322 }
6323 }
6324 }
6325
6326 #[cfg(not(target_os = "windows"))]
6327 {
6328 if let Ok(o) = Command::new("ss")
6329 .args(["-tnp", "state", "established"])
6330 .output()
6331 {
6332 let text = String::from_utf8_lossy(&o.stdout);
6333 let lines: Vec<&str> = text
6334 .lines()
6335 .skip(1)
6336 .filter(|l| !l.trim().is_empty())
6337 .collect();
6338 out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6339 for line in lines.iter().take(n) {
6340 out.push_str(&format!(" {}\n", line.trim()));
6341 }
6342 if lines.len() > n {
6343 out.push_str(&format!("\n ... {} more not shown\n", lines.len() - n));
6344 }
6345 } else {
6346 out.push_str("ss not available — install iproute2\n");
6347 }
6348 }
6349
6350 Ok(out.trim_end().to_string())
6351}
6352
6353fn inspect_vpn() -> Result<String, String> {
6356 let mut out = String::from("Host inspection: vpn\n\n");
6357
6358 #[cfg(target_os = "windows")]
6359 {
6360 let script = r#"
6361try {
6362 $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6363 $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6364 $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6365 }
6366 if ($vpn) {
6367 foreach ($a in $vpn) {
6368 $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6369 }
6370 } else { "NONE" }
6371} catch { "ERROR:" + $_.Exception.Message }
6372"#;
6373 let output = Command::new("powershell")
6374 .args(["-NoProfile", "-Command", script])
6375 .output()
6376 .map_err(|e| format!("vpn: {e}"))?;
6377
6378 let raw = String::from_utf8_lossy(&output.stdout);
6379 let text = raw.trim();
6380
6381 if text == "NONE" {
6382 out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6383 } else if text.starts_with("ERROR:") {
6384 out.push_str(&format!("Unable to query adapters: {text}\n"));
6385 } else {
6386 out.push_str("VPN adapters:\n\n");
6387 for line in text.lines() {
6388 let parts: Vec<&str> = line.splitn(4, '|').collect();
6389 if parts.len() >= 3 {
6390 let name = parts[0];
6391 let desc = parts[1];
6392 let status = parts[2];
6393 let media = parts.get(3).unwrap_or(&"unknown");
6394 let label = if status.trim() == "Up" {
6395 "CONNECTED"
6396 } else {
6397 "disconnected"
6398 };
6399 out.push_str(&format!(
6400 " {name} [{label}]\n {desc}\n Status: {status} | Media: {media}\n\n"
6401 ));
6402 }
6403 }
6404 }
6405
6406 let ras_script = r#"
6408try {
6409 $c = Get-VpnConnection -ErrorAction Stop
6410 if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6411 else { "NO_RAS" }
6412} catch { "NO_RAS" }
6413"#;
6414 if let Ok(o) = Command::new("powershell")
6415 .args(["-NoProfile", "-Command", ras_script])
6416 .output()
6417 {
6418 let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6419 if t != "NO_RAS" && !t.is_empty() {
6420 out.push_str("Windows VPN connections:\n");
6421 for line in t.lines() {
6422 let parts: Vec<&str> = line.splitn(3, '|').collect();
6423 if parts.len() >= 2 {
6424 let name = parts[0];
6425 let status = parts[1];
6426 let server = parts.get(2).unwrap_or(&"");
6427 out.push_str(&format!(" {name} → {server} [{status}]\n"));
6428 }
6429 }
6430 }
6431 }
6432 }
6433
6434 #[cfg(not(target_os = "windows"))]
6435 {
6436 if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6437 let text = String::from_utf8_lossy(&o.stdout);
6438 let vpn_ifaces: Vec<&str> = text
6439 .lines()
6440 .filter(|l| {
6441 l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6442 })
6443 .collect();
6444 if vpn_ifaces.is_empty() {
6445 out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6446 } else {
6447 out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6448 for l in vpn_ifaces {
6449 out.push_str(&format!(" {}\n", l.trim()));
6450 }
6451 }
6452 }
6453 }
6454
6455 Ok(out.trim_end().to_string())
6456}
6457
6458fn inspect_proxy() -> Result<String, String> {
6461 let mut out = String::from("Host inspection: proxy\n\n");
6462
6463 #[cfg(target_os = "windows")]
6464 {
6465 let script = r#"
6466$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6467if ($ie) {
6468 "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6469} else { "NONE" }
6470"#;
6471 if let Ok(o) = Command::new("powershell")
6472 .args(["-NoProfile", "-Command", script])
6473 .output()
6474 {
6475 let raw = String::from_utf8_lossy(&o.stdout);
6476 let text = raw.trim();
6477 if text != "NONE" && !text.is_empty() {
6478 let get = |key: &str| -> &str {
6479 text.split('|')
6480 .find(|s| s.starts_with(key))
6481 .and_then(|s| s.splitn(2, ':').nth(1))
6482 .unwrap_or("")
6483 };
6484 let enabled = get("ENABLE");
6485 let server = get("SERVER");
6486 let overrides = get("OVERRIDE");
6487 out.push_str("WinINET / IE proxy:\n");
6488 out.push_str(&format!(
6489 " Enabled: {}\n",
6490 if enabled == "1" { "yes" } else { "no" }
6491 ));
6492 if !server.is_empty() && server != "None" {
6493 out.push_str(&format!(" Proxy server: {server}\n"));
6494 }
6495 if !overrides.is_empty() && overrides != "None" {
6496 out.push_str(&format!(" Bypass list: {overrides}\n"));
6497 }
6498 out.push('\n');
6499 }
6500 }
6501
6502 if let Ok(o) = Command::new("netsh")
6503 .args(["winhttp", "show", "proxy"])
6504 .output()
6505 {
6506 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6507 out.push_str("WinHTTP proxy:\n");
6508 for line in text.lines() {
6509 let l = line.trim();
6510 if !l.is_empty() {
6511 out.push_str(&format!(" {l}\n"));
6512 }
6513 }
6514 out.push('\n');
6515 }
6516
6517 let mut env_found = false;
6518 for var in &[
6519 "http_proxy",
6520 "https_proxy",
6521 "HTTP_PROXY",
6522 "HTTPS_PROXY",
6523 "no_proxy",
6524 "NO_PROXY",
6525 ] {
6526 if let Ok(val) = std::env::var(var) {
6527 if !env_found {
6528 out.push_str("Environment proxy variables:\n");
6529 env_found = true;
6530 }
6531 out.push_str(&format!(" {var}: {val}\n"));
6532 }
6533 }
6534 if !env_found {
6535 out.push_str("No proxy environment variables set.\n");
6536 }
6537 }
6538
6539 #[cfg(not(target_os = "windows"))]
6540 {
6541 let mut found = false;
6542 for var in &[
6543 "http_proxy",
6544 "https_proxy",
6545 "HTTP_PROXY",
6546 "HTTPS_PROXY",
6547 "no_proxy",
6548 "NO_PROXY",
6549 "ALL_PROXY",
6550 "all_proxy",
6551 ] {
6552 if let Ok(val) = std::env::var(var) {
6553 if !found {
6554 out.push_str("Proxy environment variables:\n");
6555 found = true;
6556 }
6557 out.push_str(&format!(" {var}: {val}\n"));
6558 }
6559 }
6560 if !found {
6561 out.push_str("No proxy environment variables set.\n");
6562 }
6563 if let Ok(content) = std::fs::read_to_string("/etc/environment") {
6564 let proxy_lines: Vec<&str> = content
6565 .lines()
6566 .filter(|l| l.to_lowercase().contains("proxy"))
6567 .collect();
6568 if !proxy_lines.is_empty() {
6569 out.push_str("\nSystem proxy (/etc/environment):\n");
6570 for l in proxy_lines {
6571 out.push_str(&format!(" {l}\n"));
6572 }
6573 }
6574 }
6575 }
6576
6577 Ok(out.trim_end().to_string())
6578}
6579
6580fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6583 let mut out = String::from("Host inspection: firewall_rules\n\n");
6584 let n = max_entries.clamp(1, 20);
6585
6586 #[cfg(target_os = "windows")]
6587 {
6588 let script = format!(
6589 r#"
6590try {{
6591 $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6592 Where-Object {{
6593 $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6594 $_.Owner -eq $null
6595 }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6596 "TOTAL:" + $rules.Count
6597 $rules | ForEach-Object {{
6598 $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6599 $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6600 $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6601 }}
6602}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6603 );
6604
6605 let output = Command::new("powershell")
6606 .args(["-NoProfile", "-Command", &script])
6607 .output()
6608 .map_err(|e| format!("firewall_rules: {e}"))?;
6609
6610 let raw = String::from_utf8_lossy(&output.stdout);
6611 let text = raw.trim();
6612
6613 if text.starts_with("ERROR:") {
6614 out.push_str(&format!(
6615 "Unable to query firewall rules: {}\n",
6616 text.trim_start_matches("ERROR:").trim()
6617 ));
6618 out.push_str("This query may require running as administrator.\n");
6619 } else if text.is_empty() {
6620 out.push_str("No non-default enabled firewall rules found.\n");
6621 } else {
6622 let mut total = 0usize;
6623 for line in text.lines() {
6624 if let Some(rest) = line.strip_prefix("TOTAL:") {
6625 total = rest.trim().parse().unwrap_or(0);
6626 out.push_str(&format!(
6627 "Non-default enabled rules (showing up to {n}):\n\n"
6628 ));
6629 } else {
6630 let parts: Vec<&str> = line.splitn(4, '|').collect();
6631 if parts.len() >= 3 {
6632 let name = parts[0];
6633 let dir = parts[1];
6634 let action = parts[2];
6635 let profile = parts.get(3).unwrap_or(&"Any");
6636 let icon = if action == "Block" { "[!]" } else { " " };
6637 out.push_str(&format!(
6638 " {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6639 ));
6640 }
6641 }
6642 }
6643 if total == 0 {
6644 out.push_str("No non-default enabled rules found.\n");
6645 }
6646 }
6647 }
6648
6649 #[cfg(not(target_os = "windows"))]
6650 {
6651 if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6652 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6653 if !text.is_empty() {
6654 out.push_str(&text);
6655 out.push('\n');
6656 }
6657 } else if let Ok(o) = Command::new("iptables")
6658 .args(["-L", "-n", "--line-numbers"])
6659 .output()
6660 {
6661 let text = String::from_utf8_lossy(&o.stdout);
6662 for l in text.lines().take(n * 2) {
6663 out.push_str(&format!(" {l}\n"));
6664 }
6665 } else {
6666 out.push_str("ufw and iptables not available or insufficient permissions.\n");
6667 }
6668 }
6669
6670 Ok(out.trim_end().to_string())
6671}
6672
6673fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6676 let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6677 let hops = max_entries.clamp(5, 30);
6678
6679 #[cfg(target_os = "windows")]
6680 {
6681 let output = Command::new("tracert")
6682 .args(["-d", "-h", &hops.to_string(), host])
6683 .output()
6684 .map_err(|e| format!("tracert: {e}"))?;
6685 let raw = String::from_utf8_lossy(&output.stdout);
6686 let mut hop_count = 0usize;
6687 for line in raw.lines() {
6688 let trimmed = line.trim();
6689 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6690 hop_count += 1;
6691 out.push_str(&format!(" {trimmed}\n"));
6692 } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6693 out.push_str(&format!("{trimmed}\n"));
6694 }
6695 }
6696 if hop_count == 0 {
6697 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6698 }
6699 }
6700
6701 #[cfg(not(target_os = "windows"))]
6702 {
6703 let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6704 || std::path::Path::new("/usr/sbin/traceroute").exists()
6705 {
6706 "traceroute"
6707 } else {
6708 "tracepath"
6709 };
6710 let output = Command::new(cmd)
6711 .args(["-m", &hops.to_string(), "-n", host])
6712 .output()
6713 .map_err(|e| format!("{cmd}: {e}"))?;
6714 let raw = String::from_utf8_lossy(&output.stdout);
6715 let mut hop_count = 0usize;
6716 for line in raw.lines().take(hops + 2) {
6717 let trimmed = line.trim();
6718 if !trimmed.is_empty() {
6719 hop_count += 1;
6720 out.push_str(&format!(" {trimmed}\n"));
6721 }
6722 }
6723 if hop_count == 0 {
6724 out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6725 }
6726 }
6727
6728 Ok(out.trim_end().to_string())
6729}
6730
6731fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6734 let mut out = String::from("Host inspection: dns_cache\n\n");
6735 let n = max_entries.clamp(10, 100);
6736
6737 #[cfg(target_os = "windows")]
6738 {
6739 let output = Command::new("powershell")
6740 .args([
6741 "-NoProfile",
6742 "-Command",
6743 "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6744 ])
6745 .output()
6746 .map_err(|e| format!("dns_cache: {e}"))?;
6747
6748 let raw = String::from_utf8_lossy(&output.stdout);
6749 let lines: Vec<&str> = raw.lines().skip(1).collect();
6750 let total = lines.len();
6751
6752 if total == 0 {
6753 out.push_str("DNS cache is empty or could not be read.\n");
6754 } else {
6755 out.push_str(&format!(
6756 "DNS cache entries (showing up to {n} of {total}):\n\n"
6757 ));
6758 let mut shown = 0usize;
6759 for line in lines.iter().take(n) {
6760 let cols: Vec<&str> = line.splitn(4, ',').collect();
6761 if cols.len() >= 3 {
6762 let entry = cols[0].trim_matches('"');
6763 let rtype = cols[1].trim_matches('"');
6764 let data = cols[2].trim_matches('"');
6765 let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6766 out.push_str(&format!(" {entry:<45} {rtype:<6} {data} (TTL {ttl}s)\n"));
6767 shown += 1;
6768 }
6769 }
6770 if total > shown {
6771 out.push_str(&format!("\n ... and {} more entries\n", total - shown));
6772 }
6773 }
6774 }
6775
6776 #[cfg(not(target_os = "windows"))]
6777 {
6778 if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6779 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6780 if !text.is_empty() {
6781 out.push_str("systemd-resolved statistics:\n");
6782 for line in text.lines().take(n) {
6783 out.push_str(&format!(" {line}\n"));
6784 }
6785 out.push('\n');
6786 }
6787 }
6788 if let Ok(o) = Command::new("dscacheutil")
6789 .args(["-cachedump", "-entries", "Host"])
6790 .output()
6791 {
6792 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6793 if !text.is_empty() {
6794 out.push_str("DNS cache (macOS dscacheutil):\n");
6795 for line in text.lines().take(n) {
6796 out.push_str(&format!(" {line}\n"));
6797 }
6798 } else {
6799 out.push_str("DNS cache is empty or not accessible on this platform.\n");
6800 }
6801 } else {
6802 out.push_str(
6803 "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6804 );
6805 }
6806 }
6807
6808 Ok(out.trim_end().to_string())
6809}
6810
6811fn inspect_arp() -> Result<String, String> {
6814 let mut out = String::from("Host inspection: arp\n\n");
6815
6816 #[cfg(target_os = "windows")]
6817 {
6818 let output = Command::new("arp")
6819 .args(["-a"])
6820 .output()
6821 .map_err(|e| format!("arp: {e}"))?;
6822 let raw = String::from_utf8_lossy(&output.stdout);
6823 let mut count = 0usize;
6824 for line in raw.lines() {
6825 let t = line.trim();
6826 if t.is_empty() {
6827 continue;
6828 }
6829 out.push_str(&format!(" {t}\n"));
6830 if t.contains("dynamic") || t.contains("static") {
6831 count += 1;
6832 }
6833 }
6834 out.push_str(&format!("\nTotal entries: {count}\n"));
6835 }
6836
6837 #[cfg(not(target_os = "windows"))]
6838 {
6839 if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6840 let raw = String::from_utf8_lossy(&o.stdout);
6841 let mut count = 0usize;
6842 for line in raw.lines() {
6843 let t = line.trim();
6844 if !t.is_empty() {
6845 out.push_str(&format!(" {t}\n"));
6846 count += 1;
6847 }
6848 }
6849 out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6850 } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6851 let raw = String::from_utf8_lossy(&o.stdout);
6852 let mut count = 0usize;
6853 for line in raw.lines() {
6854 let t = line.trim();
6855 if !t.is_empty() {
6856 out.push_str(&format!(" {t}\n"));
6857 count += 1;
6858 }
6859 }
6860 out.push_str(&format!("\nTotal entries: {count}\n"));
6861 } else {
6862 out.push_str("arp and ip neigh not available.\n");
6863 }
6864 }
6865
6866 Ok(out.trim_end().to_string())
6867}
6868
6869fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6872 let mut out = String::from("Host inspection: route_table\n\n");
6873 let n = max_entries.clamp(10, 50);
6874
6875 #[cfg(target_os = "windows")]
6876 {
6877 let script = r#"
6878try {
6879 $routes = Get-NetRoute -ErrorAction Stop |
6880 Where-Object { $_.RouteMetric -lt 9000 } |
6881 Sort-Object RouteMetric |
6882 Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6883 "TOTAL:" + $routes.Count
6884 $routes | ForEach-Object {
6885 $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6886 }
6887} catch { "ERROR:" + $_.Exception.Message }
6888"#;
6889 let output = Command::new("powershell")
6890 .args(["-NoProfile", "-Command", script])
6891 .output()
6892 .map_err(|e| format!("route_table: {e}"))?;
6893 let raw = String::from_utf8_lossy(&output.stdout);
6894 let text = raw.trim();
6895
6896 if text.starts_with("ERROR:") {
6897 out.push_str(&format!(
6898 "Unable to read route table: {}\n",
6899 text.trim_start_matches("ERROR:").trim()
6900 ));
6901 } else {
6902 let mut shown = 0usize;
6903 for line in text.lines() {
6904 if let Some(rest) = line.strip_prefix("TOTAL:") {
6905 let total: usize = rest.trim().parse().unwrap_or(0);
6906 out.push_str(&format!(
6907 "Routing table (showing up to {n} of {total} routes):\n\n"
6908 ));
6909 out.push_str(&format!(
6910 " {:<22} {:<18} {:>8} Interface\n",
6911 "Destination", "Next Hop", "Metric"
6912 ));
6913 out.push_str(&format!(" {}\n", "-".repeat(70)));
6914 } else if shown < n {
6915 let parts: Vec<&str> = line.splitn(4, '|').collect();
6916 if parts.len() == 4 {
6917 let dest = parts[0];
6918 let hop =
6919 if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6920 "on-link"
6921 } else {
6922 parts[1]
6923 };
6924 let metric = parts[2];
6925 let iface = parts[3];
6926 out.push_str(&format!(" {dest:<22} {hop:<18} {metric:>8} {iface}\n"));
6927 shown += 1;
6928 }
6929 }
6930 }
6931 }
6932 }
6933
6934 #[cfg(not(target_os = "windows"))]
6935 {
6936 if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6937 let raw = String::from_utf8_lossy(&o.stdout);
6938 let lines: Vec<&str> = raw.lines().collect();
6939 let total = lines.len();
6940 out.push_str(&format!(
6941 "Routing table (showing up to {n} of {total} routes):\n\n"
6942 ));
6943 for line in lines.iter().take(n) {
6944 out.push_str(&format!(" {line}\n"));
6945 }
6946 if total > n {
6947 out.push_str(&format!("\n ... and {} more routes\n", total - n));
6948 }
6949 } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6950 let raw = String::from_utf8_lossy(&o.stdout);
6951 for line in raw.lines().take(n) {
6952 out.push_str(&format!(" {line}\n"));
6953 }
6954 } else {
6955 out.push_str("ip route and netstat not available.\n");
6956 }
6957 }
6958
6959 Ok(out.trim_end().to_string())
6960}
6961
6962fn inspect_env(max_entries: usize) -> Result<String, String> {
6965 let mut out = String::from("Host inspection: env\n\n");
6966 let n = max_entries.clamp(10, 50);
6967
6968 fn looks_like_secret(name: &str) -> bool {
6969 let n = name.to_uppercase();
6970 n.contains("KEY")
6971 || n.contains("SECRET")
6972 || n.contains("TOKEN")
6973 || n.contains("PASSWORD")
6974 || n.contains("PASSWD")
6975 || n.contains("CREDENTIAL")
6976 || n.contains("AUTH")
6977 || n.contains("CERT")
6978 || n.contains("PRIVATE")
6979 }
6980
6981 let known_dev_vars: &[&str] = &[
6982 "CARGO_HOME",
6983 "RUSTUP_HOME",
6984 "GOPATH",
6985 "GOROOT",
6986 "GOBIN",
6987 "JAVA_HOME",
6988 "ANDROID_HOME",
6989 "ANDROID_SDK_ROOT",
6990 "PYTHONPATH",
6991 "PYTHONHOME",
6992 "VIRTUAL_ENV",
6993 "CONDA_DEFAULT_ENV",
6994 "CONDA_PREFIX",
6995 "NODE_PATH",
6996 "NVM_DIR",
6997 "NVM_BIN",
6998 "PNPM_HOME",
6999 "DENO_INSTALL",
7000 "DENO_DIR",
7001 "DOTNET_ROOT",
7002 "NUGET_PACKAGES",
7003 "CMAKE_HOME",
7004 "VCPKG_ROOT",
7005 "AWS_PROFILE",
7006 "AWS_REGION",
7007 "AWS_DEFAULT_REGION",
7008 "GCP_PROJECT",
7009 "GOOGLE_CLOUD_PROJECT",
7010 "GOOGLE_APPLICATION_CREDENTIALS",
7011 "AZURE_SUBSCRIPTION_ID",
7012 "DATABASE_URL",
7013 "REDIS_URL",
7014 "MONGO_URI",
7015 "EDITOR",
7016 "VISUAL",
7017 "SHELL",
7018 "TERM",
7019 "XDG_CONFIG_HOME",
7020 "XDG_DATA_HOME",
7021 "XDG_CACHE_HOME",
7022 "HOME",
7023 "USERPROFILE",
7024 "APPDATA",
7025 "LOCALAPPDATA",
7026 "TEMP",
7027 "TMP",
7028 "COMPUTERNAME",
7029 "USERNAME",
7030 "USERDOMAIN",
7031 "PROCESSOR_ARCHITECTURE",
7032 "NUMBER_OF_PROCESSORS",
7033 "OS",
7034 "HOMEDRIVE",
7035 "HOMEPATH",
7036 "HTTP_PROXY",
7037 "HTTPS_PROXY",
7038 "NO_PROXY",
7039 "ALL_PROXY",
7040 "http_proxy",
7041 "https_proxy",
7042 "no_proxy",
7043 "DOCKER_HOST",
7044 "DOCKER_BUILDKIT",
7045 "COMPOSE_PROJECT_NAME",
7046 "KUBECONFIG",
7047 "KUBE_CONTEXT",
7048 "CI",
7049 "GITHUB_ACTIONS",
7050 "GITLAB_CI",
7051 "LMSTUDIO_HOME",
7052 "HEMATITE_URL",
7053 ];
7054
7055 let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7056 all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7057 let total = all_vars.len();
7058
7059 let mut dev_found: Vec<String> = Vec::new();
7060 let mut secret_found: Vec<String> = Vec::new();
7061
7062 for (k, v) in &all_vars {
7063 if k == "PATH" {
7064 continue;
7065 }
7066 if looks_like_secret(k) {
7067 secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7068 } else {
7069 let k_upper = k.to_uppercase();
7070 let is_known = known_dev_vars
7071 .iter()
7072 .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7073 if is_known {
7074 let display = if v.len() > 120 {
7075 format!("{k} = {}…", &v[..117])
7076 } else {
7077 format!("{k} = {v}")
7078 };
7079 dev_found.push(display);
7080 }
7081 }
7082 }
7083
7084 out.push_str(&format!("Total environment variables: {total}\n\n"));
7085
7086 if let Ok(p) = std::env::var("PATH") {
7087 let sep = if cfg!(target_os = "windows") {
7088 ';'
7089 } else {
7090 ':'
7091 };
7092 let count = p.split(sep).count();
7093 out.push_str(&format!(
7094 "PATH: {count} entries (use topic=path for full audit)\n\n"
7095 ));
7096 }
7097
7098 if !secret_found.is_empty() {
7099 out.push_str(&format!(
7100 "=== Secret/credential variables ({} detected, values hidden) ===\n",
7101 secret_found.len()
7102 ));
7103 for s in secret_found.iter().take(n) {
7104 out.push_str(&format!(" {s}\n"));
7105 }
7106 out.push('\n');
7107 }
7108
7109 if !dev_found.is_empty() {
7110 out.push_str(&format!(
7111 "=== Developer & tool variables ({}) ===\n",
7112 dev_found.len()
7113 ));
7114 for d in dev_found.iter().take(n) {
7115 out.push_str(&format!(" {d}\n"));
7116 }
7117 out.push('\n');
7118 }
7119
7120 let other_count = all_vars
7121 .iter()
7122 .filter(|(k, _)| {
7123 k != "PATH"
7124 && !looks_like_secret(k)
7125 && !known_dev_vars
7126 .iter()
7127 .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7128 })
7129 .count();
7130 if other_count > 0 {
7131 out.push_str(&format!(
7132 "Other variables: {other_count} (use 'env' in shell to see all)\n"
7133 ));
7134 }
7135
7136 Ok(out.trim_end().to_string())
7137}
7138
7139fn inspect_hosts_file() -> Result<String, String> {
7142 let mut out = String::from("Host inspection: hosts_file\n\n");
7143
7144 let hosts_path = if cfg!(target_os = "windows") {
7145 std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7146 } else {
7147 std::path::PathBuf::from("/etc/hosts")
7148 };
7149
7150 out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7151
7152 match fs::read_to_string(&hosts_path) {
7153 Ok(content) => {
7154 let mut active_entries: Vec<String> = Vec::new();
7155 let mut comment_lines = 0usize;
7156 let mut blank_lines = 0usize;
7157
7158 for line in content.lines() {
7159 let t = line.trim();
7160 if t.is_empty() {
7161 blank_lines += 1;
7162 } else if t.starts_with('#') {
7163 comment_lines += 1;
7164 } else {
7165 active_entries.push(line.to_string());
7166 }
7167 }
7168
7169 out.push_str(&format!(
7170 "Active entries: {} | Comment lines: {} | Blank lines: {}\n\n",
7171 active_entries.len(),
7172 comment_lines,
7173 blank_lines
7174 ));
7175
7176 if active_entries.is_empty() {
7177 out.push_str(
7178 "No active host entries (file contains only comments/blanks — standard default state).\n",
7179 );
7180 } else {
7181 out.push_str("=== Active entries ===\n");
7182 for entry in &active_entries {
7183 out.push_str(&format!(" {entry}\n"));
7184 }
7185 out.push('\n');
7186
7187 let custom: Vec<&String> = active_entries
7188 .iter()
7189 .filter(|e| {
7190 let t = e.trim_start();
7191 !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7192 })
7193 .collect();
7194 if !custom.is_empty() {
7195 out.push_str(&format!(
7196 "[!] Custom (non-loopback) entries: {}\n",
7197 custom.len()
7198 ));
7199 for e in &custom {
7200 out.push_str(&format!(" {e}\n"));
7201 }
7202 } else {
7203 out.push_str("All active entries are standard loopback or block entries.\n");
7204 }
7205 }
7206
7207 out.push_str("\n=== Full file ===\n");
7208 for line in content.lines() {
7209 out.push_str(&format!(" {line}\n"));
7210 }
7211 }
7212 Err(e) => {
7213 out.push_str(&format!("Could not read hosts file: {e}\n"));
7214 if cfg!(target_os = "windows") {
7215 out.push_str(
7216 "On Windows, run Hematite as Administrator if permission is denied.\n",
7217 );
7218 }
7219 }
7220 }
7221
7222 Ok(out.trim_end().to_string())
7223}
7224
7225struct AuditFinding {
7228 finding: String,
7229 impact: String,
7230 fix: String,
7231}
7232
7233#[cfg(target_os = "windows")]
7234#[derive(Debug, Clone)]
7235struct WindowsPnpDevice {
7236 name: String,
7237 status: String,
7238 problem: Option<u64>,
7239 class_name: Option<String>,
7240 instance_id: Option<String>,
7241}
7242
7243#[cfg(target_os = "windows")]
7244#[derive(Debug, Clone)]
7245struct WindowsSoundDevice {
7246 name: String,
7247 status: String,
7248 manufacturer: Option<String>,
7249}
7250
7251struct DockerMountAudit {
7252 mount_type: String,
7253 source: Option<String>,
7254 destination: String,
7255 name: Option<String>,
7256 read_write: Option<bool>,
7257 driver: Option<String>,
7258 exists_on_host: Option<bool>,
7259}
7260
7261struct DockerContainerAudit {
7262 name: String,
7263 image: String,
7264 status: String,
7265 mounts: Vec<DockerMountAudit>,
7266}
7267
7268struct DockerVolumeAudit {
7269 name: String,
7270 driver: String,
7271 mountpoint: Option<String>,
7272 scope: Option<String>,
7273}
7274
7275#[cfg(target_os = "windows")]
7276struct WslDistroAudit {
7277 name: String,
7278 state: String,
7279 version: String,
7280}
7281
7282#[cfg(target_os = "windows")]
7283struct WslRootUsage {
7284 total_kb: u64,
7285 used_kb: u64,
7286 avail_kb: u64,
7287 use_percent: String,
7288 mnt_c_present: Option<bool>,
7289}
7290
7291fn docker_engine_version() -> Result<String, String> {
7292 let version_output = Command::new("docker")
7293 .args(["version", "--format", "{{.Server.Version}}"])
7294 .output();
7295
7296 match version_output {
7297 Err(_) => Err(
7298 "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7299 ),
7300 Ok(o) if !o.status.success() => {
7301 let stderr = String::from_utf8_lossy(&o.stderr);
7302 if stderr.contains("cannot connect")
7303 || stderr.contains("Is the docker daemon running")
7304 || stderr.contains("pipe")
7305 || stderr.contains("socket")
7306 {
7307 Err(
7308 "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7309 )
7310 } else {
7311 Err(format!("Docker: error - {}", stderr.trim()))
7312 }
7313 }
7314 Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7315 }
7316}
7317
7318fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7319 let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7320 return Vec::new();
7321 };
7322 let Value::Array(entries) = value else {
7323 return Vec::new();
7324 };
7325
7326 let mut mounts = Vec::new();
7327 for entry in entries {
7328 let mount_type = entry
7329 .get("Type")
7330 .and_then(|v| v.as_str())
7331 .unwrap_or("unknown")
7332 .to_string();
7333 let source = entry
7334 .get("Source")
7335 .and_then(|v| v.as_str())
7336 .map(|v| v.to_string());
7337 let destination = entry
7338 .get("Destination")
7339 .and_then(|v| v.as_str())
7340 .unwrap_or("?")
7341 .to_string();
7342 let name = entry
7343 .get("Name")
7344 .and_then(|v| v.as_str())
7345 .map(|v| v.to_string());
7346 let read_write = entry.get("RW").and_then(|v| v.as_bool());
7347 let driver = entry
7348 .get("Driver")
7349 .and_then(|v| v.as_str())
7350 .map(|v| v.to_string());
7351 let exists_on_host = if mount_type == "bind" {
7352 source.as_deref().map(|path| Path::new(path).exists())
7353 } else {
7354 None
7355 };
7356 mounts.push(DockerMountAudit {
7357 mount_type,
7358 source,
7359 destination,
7360 name,
7361 read_write,
7362 driver,
7363 exists_on_host,
7364 });
7365 }
7366
7367 mounts
7368}
7369
7370fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7371 let mut audit = DockerVolumeAudit {
7372 name: name.to_string(),
7373 driver: "unknown".to_string(),
7374 mountpoint: None,
7375 scope: None,
7376 };
7377
7378 if let Ok(output) = Command::new("docker")
7379 .args(["volume", "inspect", name, "--format", "{{json .}}"])
7380 .output()
7381 {
7382 if output.status.success() {
7383 if let Ok(value) =
7384 serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7385 {
7386 audit.driver = value
7387 .get("Driver")
7388 .and_then(|v| v.as_str())
7389 .unwrap_or("unknown")
7390 .to_string();
7391 audit.mountpoint = value
7392 .get("Mountpoint")
7393 .and_then(|v| v.as_str())
7394 .map(|v| v.to_string());
7395 audit.scope = value
7396 .get("Scope")
7397 .and_then(|v| v.as_str())
7398 .map(|v| v.to_string());
7399 }
7400 }
7401 }
7402
7403 audit
7404}
7405
7406#[cfg(target_os = "windows")]
7407fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7408 let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7409 for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7410 let path = local_app_data
7411 .join("Docker")
7412 .join("wsl")
7413 .join("disk")
7414 .join(file_name);
7415 if let Ok(metadata) = fs::metadata(&path) {
7416 return Some((path, metadata.len()));
7417 }
7418 }
7419 None
7420}
7421
7422#[cfg(target_os = "windows")]
7423fn clean_wsl_text(raw: &[u8]) -> String {
7424 String::from_utf8_lossy(raw)
7425 .chars()
7426 .filter(|c| *c != '\0')
7427 .collect()
7428}
7429
7430#[cfg(target_os = "windows")]
7431fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7432 let mut distros = Vec::new();
7433 for line in raw.lines() {
7434 let trimmed = line.trim();
7435 if trimmed.is_empty()
7436 || trimmed.to_uppercase().starts_with("NAME")
7437 || trimmed.starts_with("---")
7438 {
7439 continue;
7440 }
7441 let normalized = trimmed.trim_start_matches('*').trim();
7442 let cols: Vec<&str> = normalized.split_whitespace().collect();
7443 if cols.len() < 3 {
7444 continue;
7445 }
7446 let version = cols[cols.len() - 1].to_string();
7447 let state = cols[cols.len() - 2].to_string();
7448 let name = cols[..cols.len() - 2].join(" ");
7449 if !name.is_empty() {
7450 distros.push(WslDistroAudit {
7451 name,
7452 state,
7453 version,
7454 });
7455 }
7456 }
7457 distros
7458}
7459
7460#[cfg(target_os = "windows")]
7461fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7462 let output = Command::new("wsl")
7463 .args([
7464 "-d",
7465 distro_name,
7466 "--",
7467 "sh",
7468 "-lc",
7469 "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7470 ])
7471 .output()
7472 .ok()?;
7473 if !output.status.success() {
7474 return None;
7475 }
7476
7477 let text = clean_wsl_text(&output.stdout);
7478 let mut total_kb = 0;
7479 let mut used_kb = 0;
7480 let mut avail_kb = 0;
7481 let mut use_percent = String::from("unknown");
7482 let mut mnt_c_present = None;
7483
7484 for line in text.lines() {
7485 let trimmed = line.trim();
7486 if trimmed.starts_with("__MNTC__:") {
7487 mnt_c_present = Some(trimmed.ends_with("ok"));
7488 continue;
7489 }
7490 let cols: Vec<&str> = trimmed.split_whitespace().collect();
7491 if cols.len() >= 6 {
7492 total_kb = cols[1].parse::<u64>().unwrap_or(0);
7493 used_kb = cols[2].parse::<u64>().unwrap_or(0);
7494 avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7495 use_percent = cols[4].to_string();
7496 }
7497 }
7498
7499 Some(WslRootUsage {
7500 total_kb,
7501 used_kb,
7502 avail_kb,
7503 use_percent,
7504 mnt_c_present,
7505 })
7506}
7507
7508#[cfg(target_os = "windows")]
7509fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7510 let mut vhds = Vec::new();
7511 let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7512 return vhds;
7513 };
7514 let packages_dir = local_app_data.join("Packages");
7515 let Ok(entries) = fs::read_dir(packages_dir) else {
7516 return vhds;
7517 };
7518
7519 for entry in entries.flatten() {
7520 let path = entry.path().join("LocalState").join("ext4.vhdx");
7521 if let Ok(metadata) = fs::metadata(&path) {
7522 vhds.push((path, metadata.len()));
7523 }
7524 }
7525 vhds.sort_by(|a, b| b.1.cmp(&a.1));
7526 vhds
7527}
7528
7529fn inspect_docker(max_entries: usize) -> Result<String, String> {
7530 let mut out = String::from("Host inspection: docker\n\n");
7531 let n = max_entries.clamp(5, 25);
7532
7533 let version_output = Command::new("docker")
7534 .args(["version", "--format", "{{.Server.Version}}"])
7535 .output();
7536
7537 match version_output {
7538 Err(_) => {
7539 out.push_str("Docker: not found on PATH.\n");
7540 out.push_str(
7541 "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
7542 );
7543 return Ok(out.trim_end().to_string());
7544 }
7545 Ok(o) if !o.status.success() => {
7546 let stderr = String::from_utf8_lossy(&o.stderr);
7547 if stderr.contains("cannot connect")
7548 || stderr.contains("Is the docker daemon running")
7549 || stderr.contains("pipe")
7550 || stderr.contains("socket")
7551 {
7552 out.push_str("Docker: installed but daemon is NOT running.\n");
7553 out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
7554 } else {
7555 out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
7556 }
7557 return Ok(out.trim_end().to_string());
7558 }
7559 Ok(o) => {
7560 let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
7561 out.push_str(&format!("Docker Engine: {version}\n"));
7562 }
7563 }
7564
7565 if let Ok(o) = Command::new("docker")
7566 .args([
7567 "info",
7568 "--format",
7569 "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
7570 ])
7571 .output()
7572 {
7573 let info = String::from_utf8_lossy(&o.stdout);
7574 for line in info.lines() {
7575 let t = line.trim();
7576 if !t.is_empty() {
7577 out.push_str(&format!(" {t}\n"));
7578 }
7579 }
7580 out.push('\n');
7581 }
7582
7583 if let Ok(o) = Command::new("docker")
7584 .args([
7585 "ps",
7586 "--format",
7587 "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
7588 ])
7589 .output()
7590 {
7591 let raw = String::from_utf8_lossy(&o.stdout);
7592 let lines: Vec<&str> = raw.lines().collect();
7593 if lines.len() <= 1 {
7594 out.push_str("Running containers: none\n\n");
7595 } else {
7596 out.push_str(&format!(
7597 "=== Running containers ({}) ===\n",
7598 lines.len().saturating_sub(1)
7599 ));
7600 for line in lines.iter().take(n + 1) {
7601 out.push_str(&format!(" {line}\n"));
7602 }
7603 if lines.len() > n + 1 {
7604 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
7605 }
7606 out.push('\n');
7607 }
7608 }
7609
7610 if let Ok(o) = Command::new("docker")
7611 .args([
7612 "images",
7613 "--format",
7614 "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
7615 ])
7616 .output()
7617 {
7618 let raw = String::from_utf8_lossy(&o.stdout);
7619 let lines: Vec<&str> = raw.lines().collect();
7620 if lines.len() > 1 {
7621 out.push_str(&format!(
7622 "=== Local images ({}) ===\n",
7623 lines.len().saturating_sub(1)
7624 ));
7625 for line in lines.iter().take(n + 1) {
7626 out.push_str(&format!(" {line}\n"));
7627 }
7628 if lines.len() > n + 1 {
7629 out.push_str(&format!(" ... and {} more\n", lines.len() - n - 1));
7630 }
7631 out.push('\n');
7632 }
7633 }
7634
7635 if let Ok(o) = Command::new("docker")
7636 .args([
7637 "compose",
7638 "ls",
7639 "--format",
7640 "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
7641 ])
7642 .output()
7643 {
7644 let raw = String::from_utf8_lossy(&o.stdout);
7645 let lines: Vec<&str> = raw.lines().collect();
7646 if lines.len() > 1 {
7647 out.push_str(&format!(
7648 "=== Compose projects ({}) ===\n",
7649 lines.len().saturating_sub(1)
7650 ));
7651 for line in lines.iter().take(n + 1) {
7652 out.push_str(&format!(" {line}\n"));
7653 }
7654 out.push('\n');
7655 }
7656 }
7657
7658 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7659 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7660 if !ctx.is_empty() {
7661 out.push_str(&format!("Active context: {ctx}\n"));
7662 }
7663 }
7664
7665 Ok(out.trim_end().to_string())
7666}
7667
7668fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
7671 let mut out = String::from("Host inspection: docker_filesystems\n\n");
7672 let n = max_entries.clamp(3, 12);
7673
7674 match docker_engine_version() {
7675 Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
7676 Err(message) => {
7677 out.push_str(&message);
7678 return Ok(out.trim_end().to_string());
7679 }
7680 }
7681
7682 if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7683 let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7684 if !ctx.is_empty() {
7685 out.push_str(&format!("Active context: {ctx}\n"));
7686 }
7687 }
7688 out.push('\n');
7689
7690 let mut containers = Vec::new();
7691 if let Ok(o) = Command::new("docker")
7692 .args([
7693 "ps",
7694 "-a",
7695 "--format",
7696 "{{.Names}}\t{{.Image}}\t{{.Status}}",
7697 ])
7698 .output()
7699 {
7700 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7701 let cols: Vec<&str> = line.split('\t').collect();
7702 if cols.len() < 3 {
7703 continue;
7704 }
7705 let name = cols[0].trim().to_string();
7706 if name.is_empty() {
7707 continue;
7708 }
7709 let inspect_output = Command::new("docker")
7710 .args(["inspect", &name, "--format", "{{json .Mounts}}"])
7711 .output();
7712 let mounts = match inspect_output {
7713 Ok(result) if result.status.success() => {
7714 parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
7715 }
7716 _ => Vec::new(),
7717 };
7718 containers.push(DockerContainerAudit {
7719 name,
7720 image: cols[1].trim().to_string(),
7721 status: cols[2].trim().to_string(),
7722 mounts,
7723 });
7724 }
7725 }
7726
7727 let mut volumes = Vec::new();
7728 if let Ok(o) = Command::new("docker")
7729 .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
7730 .output()
7731 {
7732 for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7733 let cols: Vec<&str> = line.split('\t').collect();
7734 let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
7735 continue;
7736 };
7737 let mut audit = inspect_docker_volume(name);
7738 if audit.driver == "unknown" {
7739 audit.driver = cols
7740 .get(1)
7741 .map(|v| v.trim())
7742 .filter(|v| !v.is_empty())
7743 .unwrap_or("unknown")
7744 .to_string();
7745 }
7746 volumes.push(audit);
7747 }
7748 }
7749
7750 let mut findings = Vec::new();
7751 for container in &containers {
7752 for mount in &container.mounts {
7753 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7754 let source = mount.source.as_deref().unwrap_or("<unknown>");
7755 findings.push(AuditFinding {
7756 finding: format!(
7757 "Container '{}' has a bind mount whose host source is missing: {} -> {}",
7758 container.name, source, mount.destination
7759 ),
7760 impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
7761 fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
7762 });
7763 }
7764 }
7765 }
7766
7767 #[cfg(target_os = "windows")]
7768 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7769 if size_bytes >= 20 * 1024 * 1024 * 1024 {
7770 findings.push(AuditFinding {
7771 finding: format!(
7772 "Docker Desktop disk image is large: {} at {}",
7773 human_bytes(size_bytes),
7774 path.display()
7775 ),
7776 impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
7777 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(),
7778 });
7779 }
7780 }
7781
7782 out.push_str("=== Findings ===\n");
7783 if findings.is_empty() {
7784 out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
7785 out.push_str(" Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
7786 out.push_str(" Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
7787 } else {
7788 for finding in &findings {
7789 out.push_str(&format!("- Finding: {}\n", finding.finding));
7790 out.push_str(&format!(" Impact: {}\n", finding.impact));
7791 out.push_str(&format!(" Fix: {}\n", finding.fix));
7792 }
7793 }
7794
7795 out.push_str("\n=== Container mount summary ===\n");
7796 if containers.is_empty() {
7797 out.push_str("- No containers found.\n");
7798 } else {
7799 for container in &containers {
7800 out.push_str(&format!(
7801 "- {} ({}) [{}]\n",
7802 container.name, container.image, container.status
7803 ));
7804 if container.mounts.is_empty() {
7805 out.push_str(" - no mounts reported\n");
7806 continue;
7807 }
7808 for mount in &container.mounts {
7809 let mut source = mount
7810 .name
7811 .clone()
7812 .or_else(|| mount.source.clone())
7813 .unwrap_or_else(|| "<unknown>".to_string());
7814 if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7815 source.push_str(" [missing]");
7816 }
7817 let mut extras = Vec::new();
7818 if let Some(rw) = mount.read_write {
7819 extras.push(if rw { "rw" } else { "ro" }.to_string());
7820 }
7821 if let Some(driver) = &mount.driver {
7822 extras.push(format!("driver={driver}"));
7823 }
7824 let extra_suffix = if extras.is_empty() {
7825 String::new()
7826 } else {
7827 format!(" ({})", extras.join(", "))
7828 };
7829 out.push_str(&format!(
7830 " - {}: {} -> {}{}\n",
7831 mount.mount_type, source, mount.destination, extra_suffix
7832 ));
7833 }
7834 }
7835 }
7836
7837 out.push_str("\n=== Named volumes ===\n");
7838 if volumes.is_empty() {
7839 out.push_str("- No named volumes found.\n");
7840 } else {
7841 for volume in &volumes {
7842 let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
7843 if let Some(scope) = &volume.scope {
7844 detail.push_str(&format!(", scope: {scope}"));
7845 }
7846 if let Some(mountpoint) = &volume.mountpoint {
7847 detail.push_str(&format!(", mountpoint: {mountpoint}"));
7848 }
7849 out.push_str(&format!("{detail}\n"));
7850 }
7851 }
7852
7853 #[cfg(target_os = "windows")]
7854 if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7855 out.push_str("\n=== Docker Desktop disk ===\n");
7856 out.push_str(&format!(
7857 "- {} at {}\n",
7858 human_bytes(size_bytes),
7859 path.display()
7860 ));
7861 }
7862
7863 Ok(out.trim_end().to_string())
7864}
7865
7866fn inspect_wsl() -> Result<String, String> {
7867 let mut out = String::from("Host inspection: wsl\n\n");
7868
7869 #[cfg(target_os = "windows")]
7870 {
7871 if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
7872 let raw = String::from_utf8_lossy(&o.stdout);
7873 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
7874 for line in cleaned.lines().take(4) {
7875 let t = line.trim();
7876 if !t.is_empty() {
7877 out.push_str(&format!(" {t}\n"));
7878 }
7879 }
7880 out.push('\n');
7881 }
7882
7883 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
7884 match list_output {
7885 Err(e) => {
7886 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
7887 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
7888 }
7889 Ok(o) if !o.status.success() => {
7890 let stderr = String::from_utf8_lossy(&o.stderr);
7891 let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
7892 out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
7893 out.push_str("Run: wsl --install\n");
7894 }
7895 Ok(o) => {
7896 let raw = String::from_utf8_lossy(&o.stdout);
7897 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
7898 let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
7899 let distro_lines: Vec<&str> = lines
7900 .iter()
7901 .filter(|l| {
7902 let t = l.trim();
7903 !t.is_empty()
7904 && !t.to_uppercase().starts_with("NAME")
7905 && !t.starts_with("---")
7906 })
7907 .copied()
7908 .collect();
7909
7910 if distro_lines.is_empty() {
7911 out.push_str("WSL: installed but no distributions found.\n");
7912 out.push_str("Install a distro: wsl --install -d Ubuntu\n");
7913 } else {
7914 out.push_str("=== WSL Distributions ===\n");
7915 for line in &lines {
7916 out.push_str(&format!(" {}\n", line.trim()));
7917 }
7918 out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
7919 }
7920 }
7921 }
7922
7923 if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
7924 let raw = String::from_utf8_lossy(&o.stdout);
7925 let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
7926 let status_lines: Vec<&str> = cleaned
7927 .lines()
7928 .filter(|l| !l.trim().is_empty())
7929 .take(8)
7930 .collect();
7931 if !status_lines.is_empty() {
7932 out.push_str("\n=== WSL status ===\n");
7933 for line in status_lines {
7934 out.push_str(&format!(" {}\n", line.trim()));
7935 }
7936 }
7937 }
7938 }
7939
7940 #[cfg(not(target_os = "windows"))]
7941 {
7942 out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
7943 out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
7944 }
7945
7946 Ok(out.trim_end().to_string())
7947}
7948
7949fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
7952 let mut out = String::from("Host inspection: wsl_filesystems\n\n");
7953
7954 #[cfg(target_os = "windows")]
7955 {
7956 let n = max_entries.clamp(3, 12);
7957 let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
7958 let distros = match list_output {
7959 Err(e) => {
7960 out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
7961 out.push_str("WSL may not be installed. Enable with: wsl --install\n");
7962 return Ok(out.trim_end().to_string());
7963 }
7964 Ok(o) if !o.status.success() => {
7965 let cleaned = clean_wsl_text(&o.stderr);
7966 out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
7967 out.push_str("Run: wsl --install\n");
7968 return Ok(out.trim_end().to_string());
7969 }
7970 Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
7971 };
7972
7973 out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
7974
7975 let vhdx_files = collect_wsl_vhdx_files();
7976 let mut findings = Vec::new();
7977 let mut live_usage = Vec::new();
7978
7979 for distro in distros.iter().take(n) {
7980 if distro.state.eq_ignore_ascii_case("Running") {
7981 if let Some(usage) = wsl_root_usage(&distro.name) {
7982 if let Some(false) = usage.mnt_c_present {
7983 findings.push(AuditFinding {
7984 finding: format!(
7985 "Distro '{}' is running without /mnt/c available",
7986 distro.name
7987 ),
7988 impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
7989 fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
7990 });
7991 }
7992
7993 let percent_num = usage
7994 .use_percent
7995 .trim_end_matches('%')
7996 .parse::<u32>()
7997 .unwrap_or(0);
7998 if percent_num >= 85 {
7999 findings.push(AuditFinding {
8000 finding: format!(
8001 "Distro '{}' root filesystem is {} full",
8002 distro.name, usage.use_percent
8003 ),
8004 impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8005 fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8006 });
8007 }
8008 live_usage.push((distro.name.clone(), usage));
8009 }
8010 }
8011 }
8012
8013 for (path, size_bytes) in vhdx_files.iter().take(n) {
8014 if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8015 findings.push(AuditFinding {
8016 finding: format!(
8017 "Host-side WSL disk image is large: {} at {}",
8018 human_bytes(*size_bytes),
8019 path.display()
8020 ),
8021 impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8022 fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8023 });
8024 }
8025 }
8026
8027 out.push_str("=== Findings ===\n");
8028 if findings.is_empty() {
8029 out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8030 out.push_str(" Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8031 out.push_str(" Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8032 } else {
8033 for finding in &findings {
8034 out.push_str(&format!("- Finding: {}\n", finding.finding));
8035 out.push_str(&format!(" Impact: {}\n", finding.impact));
8036 out.push_str(&format!(" Fix: {}\n", finding.fix));
8037 }
8038 }
8039
8040 out.push_str("\n=== Distro bridge and root usage ===\n");
8041 if distros.is_empty() {
8042 out.push_str("- No WSL distributions found.\n");
8043 } else {
8044 for distro in distros.iter().take(n) {
8045 out.push_str(&format!(
8046 "- {} [state: {}, version: {}]\n",
8047 distro.name, distro.state, distro.version
8048 ));
8049 if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8050 out.push_str(&format!(
8051 " - rootfs: {} used / {} total ({}), free: {}\n",
8052 human_bytes(usage.used_kb * 1024),
8053 human_bytes(usage.total_kb * 1024),
8054 usage.use_percent,
8055 human_bytes(usage.avail_kb * 1024)
8056 ));
8057 match usage.mnt_c_present {
8058 Some(true) => out.push_str(" - /mnt/c bridge: present\n"),
8059 Some(false) => out.push_str(" - /mnt/c bridge: missing\n"),
8060 None => out.push_str(" - /mnt/c bridge: unknown\n"),
8061 }
8062 } else if distro.state.eq_ignore_ascii_case("Running") {
8063 out.push_str(" - live rootfs check: unavailable\n");
8064 } else {
8065 out.push_str(
8066 " - live rootfs check: skipped to avoid starting a stopped distro\n",
8067 );
8068 }
8069 }
8070 }
8071
8072 out.push_str("\n=== Host-side VHDX files ===\n");
8073 if vhdx_files.is_empty() {
8074 out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8075 } else {
8076 for (path, size_bytes) in vhdx_files.iter().take(n) {
8077 out.push_str(&format!(
8078 "- {} at {}\n",
8079 human_bytes(*size_bytes),
8080 path.display()
8081 ));
8082 }
8083 }
8084 }
8085
8086 #[cfg(not(target_os = "windows"))]
8087 {
8088 let _ = max_entries;
8089 out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8090 out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8091 }
8092
8093 Ok(out.trim_end().to_string())
8094}
8095
8096fn dirs_home() -> Option<PathBuf> {
8097 std::env::var("HOME")
8098 .ok()
8099 .map(PathBuf::from)
8100 .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8101}
8102
8103fn inspect_ssh() -> Result<String, String> {
8104 let mut out = String::from("Host inspection: ssh\n\n");
8105
8106 if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8107 let ver = if o.stdout.is_empty() {
8108 String::from_utf8_lossy(&o.stderr).trim().to_string()
8109 } else {
8110 String::from_utf8_lossy(&o.stdout).trim().to_string()
8111 };
8112 if !ver.is_empty() {
8113 out.push_str(&format!("SSH client: {ver}\n"));
8114 }
8115 } else {
8116 out.push_str("SSH client: not found on PATH.\n");
8117 }
8118
8119 #[cfg(target_os = "windows")]
8120 {
8121 let script = r#"
8122$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8123if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8124else { "SSHD:not_installed" }
8125"#;
8126 if let Ok(o) = Command::new("powershell")
8127 .args(["-NoProfile", "-Command", script])
8128 .output()
8129 {
8130 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8131 if text.contains("not_installed") {
8132 out.push_str("SSH server (sshd): not installed\n");
8133 } else {
8134 out.push_str(&format!(
8135 "SSH server (sshd): {}\n",
8136 text.trim_start_matches("SSHD:")
8137 ));
8138 }
8139 }
8140 }
8141
8142 #[cfg(not(target_os = "windows"))]
8143 {
8144 if let Ok(o) = Command::new("systemctl")
8145 .args(["is-active", "sshd"])
8146 .output()
8147 {
8148 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8149 out.push_str(&format!("SSH server (sshd): {status}\n"));
8150 } else if let Ok(o) = Command::new("systemctl")
8151 .args(["is-active", "ssh"])
8152 .output()
8153 {
8154 let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8155 out.push_str(&format!("SSH server (ssh): {status}\n"));
8156 }
8157 }
8158
8159 out.push('\n');
8160
8161 if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8162 if ssh_dir.exists() {
8163 out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8164
8165 let kh = ssh_dir.join("known_hosts");
8166 if kh.exists() {
8167 let count = fs::read_to_string(&kh)
8168 .map(|c| {
8169 c.lines()
8170 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8171 .count()
8172 })
8173 .unwrap_or(0);
8174 out.push_str(&format!(" known_hosts: {count} entries\n"));
8175 } else {
8176 out.push_str(" known_hosts: not present\n");
8177 }
8178
8179 let ak = ssh_dir.join("authorized_keys");
8180 if ak.exists() {
8181 let count = fs::read_to_string(&ak)
8182 .map(|c| {
8183 c.lines()
8184 .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8185 .count()
8186 })
8187 .unwrap_or(0);
8188 out.push_str(&format!(" authorized_keys: {count} public keys\n"));
8189 } else {
8190 out.push_str(" authorized_keys: not present\n");
8191 }
8192
8193 let key_names = [
8194 "id_rsa",
8195 "id_ed25519",
8196 "id_ecdsa",
8197 "id_dsa",
8198 "id_ecdsa_sk",
8199 "id_ed25519_sk",
8200 ];
8201 let found_keys: Vec<&str> = key_names
8202 .iter()
8203 .filter(|k| ssh_dir.join(k).exists())
8204 .copied()
8205 .collect();
8206 if !found_keys.is_empty() {
8207 out.push_str(&format!(" Private keys: {}\n", found_keys.join(", ")));
8208 } else {
8209 out.push_str(" Private keys: none found\n");
8210 }
8211
8212 let config_path = ssh_dir.join("config");
8213 if config_path.exists() {
8214 out.push_str("\n=== SSH config hosts ===\n");
8215 match fs::read_to_string(&config_path) {
8216 Ok(content) => {
8217 let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8218 let mut current: Option<(String, Vec<String>)> = None;
8219 for line in content.lines() {
8220 let t = line.trim();
8221 if t.is_empty() || t.starts_with('#') {
8222 continue;
8223 }
8224 if let Some(host) = t.strip_prefix("Host ") {
8225 if let Some(prev) = current.take() {
8226 hosts.push(prev);
8227 }
8228 current = Some((host.trim().to_string(), Vec::new()));
8229 } else if let Some((_, ref mut details)) = current {
8230 let tu = t.to_uppercase();
8231 if tu.starts_with("HOSTNAME ")
8232 || tu.starts_with("USER ")
8233 || tu.starts_with("PORT ")
8234 || tu.starts_with("IDENTITYFILE ")
8235 {
8236 details.push(t.to_string());
8237 }
8238 }
8239 }
8240 if let Some(prev) = current {
8241 hosts.push(prev);
8242 }
8243
8244 if hosts.is_empty() {
8245 out.push_str(" No Host entries found.\n");
8246 } else {
8247 for (h, details) in &hosts {
8248 if details.is_empty() {
8249 out.push_str(&format!(" Host {h}\n"));
8250 } else {
8251 out.push_str(&format!(
8252 " Host {h} [{}]\n",
8253 details.join(", ")
8254 ));
8255 }
8256 }
8257 out.push_str(&format!("\n Total configured hosts: {}\n", hosts.len()));
8258 }
8259 }
8260 Err(e) => out.push_str(&format!(" Could not read config: {e}\n")),
8261 }
8262 } else {
8263 out.push_str(" SSH config: not present\n");
8264 }
8265 } else {
8266 out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8267 }
8268 }
8269
8270 Ok(out.trim_end().to_string())
8271}
8272
8273fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8276 let mut out = String::from("Host inspection: installed_software\n\n");
8277 let n = max_entries.clamp(10, 50);
8278
8279 #[cfg(target_os = "windows")]
8280 {
8281 let winget_out = Command::new("winget")
8282 .args(["list", "--accept-source-agreements"])
8283 .output();
8284
8285 if let Ok(o) = winget_out {
8286 if o.status.success() {
8287 let raw = String::from_utf8_lossy(&o.stdout);
8288 let mut header_done = false;
8289 let mut packages: Vec<&str> = Vec::new();
8290 for line in raw.lines() {
8291 let t = line.trim();
8292 if t.starts_with("---") {
8293 header_done = true;
8294 continue;
8295 }
8296 if header_done && !t.is_empty() {
8297 packages.push(line);
8298 }
8299 }
8300 let total = packages.len();
8301 out.push_str(&format!(
8302 "=== Installed software via winget ({total} packages) ===\n\n"
8303 ));
8304 for line in packages.iter().take(n) {
8305 out.push_str(&format!(" {line}\n"));
8306 }
8307 if total > n {
8308 out.push_str(&format!("\n ... and {} more packages\n", total - n));
8309 }
8310 out.push_str("\nFor full list: winget list\n");
8311 return Ok(out.trim_end().to_string());
8312 }
8313 }
8314
8315 let script = format!(
8317 r#"
8318$apps = @()
8319$reg_paths = @(
8320 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8321 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8322 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8323)
8324foreach ($p in $reg_paths) {{
8325 try {{
8326 $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8327 Where-Object {{ $_.DisplayName }} |
8328 Select-Object DisplayName, DisplayVersion, Publisher
8329 }} catch {{}}
8330}}
8331$sorted = $apps | Sort-Object DisplayName -Unique
8332"TOTAL:" + $sorted.Count
8333$sorted | Select-Object -First {n} | ForEach-Object {{
8334 $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8335}}
8336"#
8337 );
8338 if let Ok(o) = Command::new("powershell")
8339 .args(["-NoProfile", "-Command", &script])
8340 .output()
8341 {
8342 let raw = String::from_utf8_lossy(&o.stdout);
8343 out.push_str("=== Installed software (registry scan) ===\n");
8344 out.push_str(&format!(" {:<50} {:<18} Publisher\n", "Name", "Version"));
8345 out.push_str(&format!(" {}\n", "-".repeat(90)));
8346 for line in raw.lines() {
8347 if let Some(rest) = line.strip_prefix("TOTAL:") {
8348 let total: usize = rest.trim().parse().unwrap_or(0);
8349 out.push_str(&format!(" (Total: {total}, showing first {n})\n\n"));
8350 } else if !line.trim().is_empty() {
8351 let parts: Vec<&str> = line.splitn(3, '|').collect();
8352 let name = parts.first().map(|s| s.trim()).unwrap_or("");
8353 let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8354 let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8355 out.push_str(&format!(" {:<50} {:<18} {pub_}\n", name, ver));
8356 }
8357 }
8358 } else {
8359 out.push_str(
8360 "Could not query installed software (winget and registry scan both failed).\n",
8361 );
8362 }
8363 }
8364
8365 #[cfg(target_os = "linux")]
8366 {
8367 let mut found = false;
8368 if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8369 if o.status.success() {
8370 let raw = String::from_utf8_lossy(&o.stdout);
8371 let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8372 let total = installed.len();
8373 out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8374 for line in installed.iter().take(n) {
8375 out.push_str(&format!(" {}\n", line.trim()));
8376 }
8377 if total > n {
8378 out.push_str(&format!(" ... and {} more\n", total - n));
8379 }
8380 out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8381 found = true;
8382 }
8383 }
8384 if !found {
8385 if let Ok(o) = Command::new("rpm")
8386 .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8387 .output()
8388 {
8389 if o.status.success() {
8390 let raw = String::from_utf8_lossy(&o.stdout);
8391 let lines: Vec<&str> = raw.lines().collect();
8392 let total = lines.len();
8393 out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8394 for line in lines.iter().take(n) {
8395 out.push_str(&format!(" {line}\n"));
8396 }
8397 if total > n {
8398 out.push_str(&format!(" ... and {} more\n", total - n));
8399 }
8400 found = true;
8401 }
8402 }
8403 }
8404 if !found {
8405 if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8406 if o.status.success() {
8407 let raw = String::from_utf8_lossy(&o.stdout);
8408 let lines: Vec<&str> = raw.lines().collect();
8409 let total = lines.len();
8410 out.push_str(&format!(
8411 "=== Installed packages via pacman ({total}) ===\n"
8412 ));
8413 for line in lines.iter().take(n) {
8414 out.push_str(&format!(" {line}\n"));
8415 }
8416 if total > n {
8417 out.push_str(&format!(" ... and {} more\n", total - n));
8418 }
8419 found = true;
8420 }
8421 }
8422 }
8423 if !found {
8424 out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8425 }
8426 }
8427
8428 #[cfg(target_os = "macos")]
8429 {
8430 if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8431 if o.status.success() {
8432 let raw = String::from_utf8_lossy(&o.stdout);
8433 let lines: Vec<&str> = raw.lines().collect();
8434 let total = lines.len();
8435 out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8436 for line in lines.iter().take(n) {
8437 out.push_str(&format!(" {line}\n"));
8438 }
8439 if total > n {
8440 out.push_str(&format!(" ... and {} more\n", total - n));
8441 }
8442 out.push_str("\nFor full list: brew list --versions\n");
8443 }
8444 } else {
8445 out.push_str("Homebrew not found.\n");
8446 }
8447 if let Ok(o) = Command::new("mas").args(["list"]).output() {
8448 if o.status.success() {
8449 let raw = String::from_utf8_lossy(&o.stdout);
8450 let lines: Vec<&str> = raw.lines().collect();
8451 out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8452 for line in lines.iter().take(n) {
8453 out.push_str(&format!(" {line}\n"));
8454 }
8455 }
8456 }
8457 }
8458
8459 Ok(out.trim_end().to_string())
8460}
8461
8462fn inspect_git_config() -> Result<String, String> {
8465 let mut out = String::from("Host inspection: git_config\n\n");
8466
8467 if let Ok(o) = Command::new("git").args(["--version"]).output() {
8468 let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8469 out.push_str(&format!("Git: {ver}\n\n"));
8470 } else {
8471 out.push_str("Git: not found on PATH.\n");
8472 return Ok(out.trim_end().to_string());
8473 }
8474
8475 if let Ok(o) = Command::new("git")
8476 .args(["config", "--global", "--list"])
8477 .output()
8478 {
8479 if o.status.success() {
8480 let raw = String::from_utf8_lossy(&o.stdout);
8481 let mut pairs: Vec<(String, String)> = raw
8482 .lines()
8483 .filter_map(|l| {
8484 let mut parts = l.splitn(2, '=');
8485 let k = parts.next()?.trim().to_string();
8486 let v = parts.next().unwrap_or("").trim().to_string();
8487 Some((k, v))
8488 })
8489 .collect();
8490 pairs.sort_by(|a, b| a.0.cmp(&b.0));
8491
8492 out.push_str("=== Global git config ===\n");
8493
8494 let sections: &[(&str, &[&str])] = &[
8495 ("Identity", &["user.name", "user.email", "user.signingkey"]),
8496 (
8497 "Core",
8498 &[
8499 "core.editor",
8500 "core.autocrlf",
8501 "core.eol",
8502 "core.ignorecase",
8503 "core.filemode",
8504 ],
8505 ),
8506 (
8507 "Commit/Signing",
8508 &[
8509 "commit.gpgsign",
8510 "tag.gpgsign",
8511 "gpg.format",
8512 "gpg.ssh.allowedsignersfile",
8513 ],
8514 ),
8515 (
8516 "Push/Pull",
8517 &[
8518 "push.default",
8519 "push.autosetupremote",
8520 "pull.rebase",
8521 "pull.ff",
8522 ],
8523 ),
8524 ("Credential", &["credential.helper"]),
8525 ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
8526 ];
8527
8528 let mut shown_keys: HashSet<String> = HashSet::new();
8529 for (section, keys) in sections {
8530 let mut section_lines: Vec<String> = Vec::new();
8531 for key in *keys {
8532 if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
8533 section_lines.push(format!(" {k} = {v}"));
8534 shown_keys.insert(k.clone());
8535 }
8536 }
8537 if !section_lines.is_empty() {
8538 out.push_str(&format!("\n[{section}]\n"));
8539 for line in section_lines {
8540 out.push_str(&format!("{line}\n"));
8541 }
8542 }
8543 }
8544
8545 let other: Vec<&(String, String)> = pairs
8546 .iter()
8547 .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
8548 .collect();
8549 if !other.is_empty() {
8550 out.push_str("\n[Other]\n");
8551 for (k, v) in other.iter().take(20) {
8552 out.push_str(&format!(" {k} = {v}\n"));
8553 }
8554 if other.len() > 20 {
8555 out.push_str(&format!(" ... and {} more\n", other.len() - 20));
8556 }
8557 }
8558
8559 out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
8560 } else {
8561 out.push_str("No global git config found.\n");
8562 out.push_str("Set up with:\n");
8563 out.push_str(" git config --global user.name \"Your Name\"\n");
8564 out.push_str(" git config --global user.email \"you@example.com\"\n");
8565 }
8566 }
8567
8568 if let Ok(o) = Command::new("git")
8569 .args(["config", "--local", "--list"])
8570 .output()
8571 {
8572 if o.status.success() {
8573 let raw = String::from_utf8_lossy(&o.stdout);
8574 let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8575 if !lines.is_empty() {
8576 out.push_str(&format!(
8577 "\n=== Local repo config ({} keys) ===\n",
8578 lines.len()
8579 ));
8580 for line in lines.iter().take(15) {
8581 out.push_str(&format!(" {line}\n"));
8582 }
8583 if lines.len() > 15 {
8584 out.push_str(&format!(" ... and {} more\n", lines.len() - 15));
8585 }
8586 }
8587 }
8588 }
8589
8590 if let Ok(o) = Command::new("git")
8591 .args(["config", "--global", "--get-regexp", r"alias\."])
8592 .output()
8593 {
8594 if o.status.success() {
8595 let raw = String::from_utf8_lossy(&o.stdout);
8596 let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8597 if !aliases.is_empty() {
8598 out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
8599 for a in aliases.iter().take(20) {
8600 out.push_str(&format!(" {a}\n"));
8601 }
8602 if aliases.len() > 20 {
8603 out.push_str(&format!(" ... and {} more\n", aliases.len() - 20));
8604 }
8605 }
8606 }
8607 }
8608
8609 Ok(out.trim_end().to_string())
8610}
8611
8612fn inspect_databases() -> Result<String, String> {
8615 let mut out = String::from("Host inspection: databases\n\n");
8616 out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
8617
8618 struct DbEngine {
8619 name: &'static str,
8620 service_names: &'static [&'static str],
8621 default_port: u16,
8622 cli_name: &'static str,
8623 cli_version_args: &'static [&'static str],
8624 }
8625
8626 let engines: &[DbEngine] = &[
8627 DbEngine {
8628 name: "PostgreSQL",
8629 service_names: &[
8630 "postgresql",
8631 "postgresql-x64-14",
8632 "postgresql-x64-15",
8633 "postgresql-x64-16",
8634 "postgresql-x64-17",
8635 ],
8636
8637 default_port: 5432,
8638 cli_name: "psql",
8639 cli_version_args: &["--version"],
8640 },
8641 DbEngine {
8642 name: "MySQL",
8643 service_names: &["mysql", "mysql80", "mysql57"],
8644
8645 default_port: 3306,
8646 cli_name: "mysql",
8647 cli_version_args: &["--version"],
8648 },
8649 DbEngine {
8650 name: "MariaDB",
8651 service_names: &["mariadb", "mariadb.exe"],
8652
8653 default_port: 3306,
8654 cli_name: "mariadb",
8655 cli_version_args: &["--version"],
8656 },
8657 DbEngine {
8658 name: "MongoDB",
8659 service_names: &["mongodb", "mongod"],
8660
8661 default_port: 27017,
8662 cli_name: "mongod",
8663 cli_version_args: &["--version"],
8664 },
8665 DbEngine {
8666 name: "Redis",
8667 service_names: &["redis", "redis-server"],
8668
8669 default_port: 6379,
8670 cli_name: "redis-server",
8671 cli_version_args: &["--version"],
8672 },
8673 DbEngine {
8674 name: "SQL Server",
8675 service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
8676
8677 default_port: 1433,
8678 cli_name: "sqlcmd",
8679 cli_version_args: &["-?"],
8680 },
8681 DbEngine {
8682 name: "SQLite",
8683 service_names: &[], default_port: 0, cli_name: "sqlite3",
8687 cli_version_args: &["--version"],
8688 },
8689 DbEngine {
8690 name: "CouchDB",
8691 service_names: &["couchdb", "apache-couchdb"],
8692
8693 default_port: 5984,
8694 cli_name: "couchdb",
8695 cli_version_args: &["--version"],
8696 },
8697 DbEngine {
8698 name: "Cassandra",
8699 service_names: &["cassandra"],
8700
8701 default_port: 9042,
8702 cli_name: "cqlsh",
8703 cli_version_args: &["--version"],
8704 },
8705 DbEngine {
8706 name: "Elasticsearch",
8707 service_names: &["elasticsearch-service-x64", "elasticsearch"],
8708
8709 default_port: 9200,
8710 cli_name: "elasticsearch",
8711 cli_version_args: &["--version"],
8712 },
8713 ];
8714
8715 fn port_listening(port: u16) -> bool {
8717 if port == 0 {
8718 return false;
8719 }
8720 std::net::TcpStream::connect_timeout(
8722 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8723 std::time::Duration::from_millis(150),
8724 )
8725 .is_ok()
8726 }
8727
8728 let mut found_any = false;
8729
8730 for engine in engines {
8731 let mut status_parts: Vec<String> = Vec::new();
8732 let mut detected = false;
8733
8734 let version = Command::new(engine.cli_name)
8736 .args(engine.cli_version_args)
8737 .output()
8738 .ok()
8739 .and_then(|o| {
8740 let combined = if o.stdout.is_empty() {
8741 String::from_utf8_lossy(&o.stderr).trim().to_string()
8742 } else {
8743 String::from_utf8_lossy(&o.stdout).trim().to_string()
8744 };
8745 combined.lines().next().map(|l| l.trim().to_string())
8747 });
8748
8749 if let Some(ref ver) = version {
8750 if !ver.is_empty() {
8751 status_parts.push(format!("version: {ver}"));
8752 detected = true;
8753 }
8754 }
8755
8756 if engine.default_port > 0 && port_listening(engine.default_port) {
8758 status_parts.push(format!("listening on :{}", engine.default_port));
8759 detected = true;
8760 } else if engine.default_port > 0 && detected {
8761 status_parts.push(format!("not listening on :{}", engine.default_port));
8762 }
8763
8764 #[cfg(target_os = "windows")]
8766 {
8767 if !engine.service_names.is_empty() {
8768 let service_list = engine.service_names.join("','");
8769 let script = format!(
8770 r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
8771 service_list
8772 );
8773 if let Ok(o) = Command::new("powershell")
8774 .args(["-NoProfile", "-Command", &script])
8775 .output()
8776 {
8777 let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8778 if !text.is_empty() {
8779 let parts: Vec<&str> = text.splitn(2, ':').collect();
8780 let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
8781 let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
8782 status_parts.push(format!("service '{svc_name}': {svc_state}"));
8783 detected = true;
8784 }
8785 }
8786 }
8787 }
8788
8789 #[cfg(not(target_os = "windows"))]
8791 {
8792 for svc in engine.service_names {
8793 if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
8794 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
8795 if !state.is_empty() && state != "inactive" {
8796 status_parts.push(format!("systemd '{svc}': {state}"));
8797 detected = true;
8798 break;
8799 }
8800 }
8801 }
8802 }
8803
8804 if detected {
8805 found_any = true;
8806 let label = if engine.default_port > 0 {
8807 format!("{} (default port: {})", engine.name, engine.default_port)
8808 } else {
8809 format!("{} (file-based, no port)", engine.name)
8810 };
8811 out.push_str(&format!("[FOUND] {label}\n"));
8812 for part in &status_parts {
8813 out.push_str(&format!(" {part}\n"));
8814 }
8815 out.push('\n');
8816 }
8817 }
8818
8819 if !found_any {
8820 out.push_str("No local database engines detected.\n");
8821 out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
8822 out.push_str(
8823 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8824 );
8825 } else {
8826 out.push_str("---\n");
8827 out.push_str(
8828 "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8829 );
8830 out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
8831 }
8832
8833 Ok(out.trim_end().to_string())
8834}
8835
8836fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
8839 let mut out = String::from("Host inspection: user_accounts\n\n");
8840
8841 #[cfg(target_os = "windows")]
8842 {
8843 let users_out = Command::new("powershell")
8844 .args([
8845 "-NoProfile", "-NonInteractive", "-Command",
8846 "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \" $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
8847 ])
8848 .output()
8849 .ok()
8850 .and_then(|o| String::from_utf8(o.stdout).ok())
8851 .unwrap_or_default();
8852
8853 out.push_str("=== Local User Accounts ===\n");
8854 if users_out.trim().is_empty() {
8855 out.push_str(" (requires elevation or Get-LocalUser unavailable)\n");
8856 } else {
8857 for line in users_out.lines().take(max_entries) {
8858 if !line.trim().is_empty() {
8859 out.push_str(line);
8860 out.push('\n');
8861 }
8862 }
8863 }
8864
8865 let admins_out = Command::new("powershell")
8866 .args([
8867 "-NoProfile", "-NonInteractive", "-Command",
8868 "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \" $($_.ObjectClass): $($_.Name)\" }",
8869 ])
8870 .output()
8871 .ok()
8872 .and_then(|o| String::from_utf8(o.stdout).ok())
8873 .unwrap_or_default();
8874
8875 out.push_str("\n=== Administrators Group Members ===\n");
8876 if admins_out.trim().is_empty() {
8877 out.push_str(" (unable to retrieve)\n");
8878 } else {
8879 out.push_str(admins_out.trim());
8880 out.push('\n');
8881 }
8882
8883 let sessions_out = Command::new("powershell")
8884 .args([
8885 "-NoProfile",
8886 "-NonInteractive",
8887 "-Command",
8888 "query user 2>$null",
8889 ])
8890 .output()
8891 .ok()
8892 .and_then(|o| String::from_utf8(o.stdout).ok())
8893 .unwrap_or_default();
8894
8895 out.push_str("\n=== Active Logon Sessions ===\n");
8896 if sessions_out.trim().is_empty() {
8897 out.push_str(" (none or requires elevation)\n");
8898 } else {
8899 for line in sessions_out.lines().take(max_entries) {
8900 if !line.trim().is_empty() {
8901 out.push_str(&format!(" {}\n", line));
8902 }
8903 }
8904 }
8905
8906 let is_admin = Command::new("powershell")
8907 .args([
8908 "-NoProfile", "-NonInteractive", "-Command",
8909 "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
8910 ])
8911 .output()
8912 .ok()
8913 .and_then(|o| String::from_utf8(o.stdout).ok())
8914 .map(|s| s.trim().to_lowercase())
8915 .unwrap_or_default();
8916
8917 out.push_str("\n=== Current Session Elevation ===\n");
8918 out.push_str(&format!(
8919 " Running as Administrator: {}\n",
8920 if is_admin.contains("true") {
8921 "YES"
8922 } else {
8923 "no"
8924 }
8925 ));
8926 }
8927
8928 #[cfg(not(target_os = "windows"))]
8929 {
8930 let who_out = Command::new("who")
8931 .output()
8932 .ok()
8933 .and_then(|o| String::from_utf8(o.stdout).ok())
8934 .unwrap_or_default();
8935 out.push_str("=== Active Sessions ===\n");
8936 if who_out.trim().is_empty() {
8937 out.push_str(" (none)\n");
8938 } else {
8939 for line in who_out.lines().take(max_entries) {
8940 out.push_str(&format!(" {}\n", line));
8941 }
8942 }
8943 let id_out = Command::new("id")
8944 .output()
8945 .ok()
8946 .and_then(|o| String::from_utf8(o.stdout).ok())
8947 .unwrap_or_default();
8948 out.push_str(&format!("\n=== Current User ===\n {}\n", id_out.trim()));
8949 }
8950
8951 Ok(out.trim_end().to_string())
8952}
8953
8954fn inspect_audit_policy() -> Result<String, String> {
8957 let mut out = String::from("Host inspection: audit_policy\n\n");
8958
8959 #[cfg(target_os = "windows")]
8960 {
8961 let auditpol_out = Command::new("auditpol")
8962 .args(["/get", "/category:*"])
8963 .output()
8964 .ok()
8965 .and_then(|o| String::from_utf8(o.stdout).ok())
8966 .unwrap_or_default();
8967
8968 if auditpol_out.trim().is_empty()
8969 || auditpol_out.to_lowercase().contains("access is denied")
8970 {
8971 out.push_str("Audit policy requires Administrator elevation to read.\n");
8972 out.push_str(
8973 "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
8974 );
8975 } else {
8976 out.push_str("=== Windows Audit Policy ===\n");
8977 let mut any_enabled = false;
8978 for line in auditpol_out.lines() {
8979 let trimmed = line.trim();
8980 if trimmed.is_empty() {
8981 continue;
8982 }
8983 if trimmed.contains("Success") || trimmed.contains("Failure") {
8984 out.push_str(&format!(" [ENABLED] {}\n", trimmed));
8985 any_enabled = true;
8986 } else {
8987 out.push_str(&format!(" {}\n", trimmed));
8988 }
8989 }
8990 if !any_enabled {
8991 out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
8992 out.push_str(
8993 "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
8994 );
8995 }
8996 }
8997
8998 let evtlog = Command::new("powershell")
8999 .args([
9000 "-NoProfile", "-NonInteractive", "-Command",
9001 "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9002 ])
9003 .output()
9004 .ok()
9005 .and_then(|o| String::from_utf8(o.stdout).ok())
9006 .map(|s| s.trim().to_string())
9007 .unwrap_or_default();
9008
9009 out.push_str(&format!(
9010 "\n=== Windows Event Log Service ===\n Status: {}\n",
9011 if evtlog.is_empty() {
9012 "unknown".to_string()
9013 } else {
9014 evtlog
9015 }
9016 ));
9017 }
9018
9019 #[cfg(not(target_os = "windows"))]
9020 {
9021 let auditd_status = Command::new("systemctl")
9022 .args(["is-active", "auditd"])
9023 .output()
9024 .ok()
9025 .and_then(|o| String::from_utf8(o.stdout).ok())
9026 .map(|s| s.trim().to_string())
9027 .unwrap_or_else(|| "not found".to_string());
9028
9029 out.push_str(&format!(
9030 "=== auditd service ===\n Status: {}\n",
9031 auditd_status
9032 ));
9033
9034 if auditd_status == "active" {
9035 let rules = Command::new("auditctl")
9036 .args(["-l"])
9037 .output()
9038 .ok()
9039 .and_then(|o| String::from_utf8(o.stdout).ok())
9040 .unwrap_or_default();
9041 out.push_str("\n=== Active Audit Rules ===\n");
9042 if rules.trim().is_empty() || rules.contains("No rules") {
9043 out.push_str(" No rules configured.\n");
9044 } else {
9045 for line in rules.lines() {
9046 out.push_str(&format!(" {}\n", line));
9047 }
9048 }
9049 }
9050 }
9051
9052 Ok(out.trim_end().to_string())
9053}
9054
9055fn inspect_shares(max_entries: usize) -> Result<String, String> {
9058 let mut out = String::from("Host inspection: shares\n\n");
9059
9060 #[cfg(target_os = "windows")]
9061 {
9062 let smb_out = Command::new("powershell")
9063 .args([
9064 "-NoProfile", "-NonInteractive", "-Command",
9065 "Get-SmbShare | ForEach-Object { \" $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9066 ])
9067 .output()
9068 .ok()
9069 .and_then(|o| String::from_utf8(o.stdout).ok())
9070 .unwrap_or_default();
9071
9072 out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9073 let smb_lines: Vec<&str> = smb_out
9074 .lines()
9075 .filter(|l| !l.trim().is_empty())
9076 .take(max_entries)
9077 .collect();
9078 if smb_lines.is_empty() {
9079 out.push_str(" No SMB shares or unable to retrieve.\n");
9080 } else {
9081 for line in &smb_lines {
9082 let name = line.trim().split('|').next().unwrap_or("").trim();
9083 if name.ends_with('$') {
9084 out.push_str(&format!(" {}\n", line.trim()));
9085 } else {
9086 out.push_str(&format!(" [CUSTOM] {}\n", line.trim()));
9087 }
9088 }
9089 }
9090
9091 let smb_security = Command::new("powershell")
9092 .args([
9093 "-NoProfile", "-NonInteractive", "-Command",
9094 "Get-SmbServerConfiguration | ForEach-Object { \" SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9095 ])
9096 .output()
9097 .ok()
9098 .and_then(|o| String::from_utf8(o.stdout).ok())
9099 .unwrap_or_default();
9100
9101 out.push_str("\n=== SMB Server Security Settings ===\n");
9102 if smb_security.trim().is_empty() {
9103 out.push_str(" (unable to retrieve)\n");
9104 } else {
9105 out.push_str(smb_security.trim());
9106 out.push('\n');
9107 if smb_security.to_lowercase().contains("smb1: true") {
9108 out.push_str(" [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9109 }
9110 }
9111
9112 let drives_out = Command::new("powershell")
9113 .args([
9114 "-NoProfile", "-NonInteractive", "-Command",
9115 "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \" $($_.Name): -> $($_.DisplayRoot)\" }",
9116 ])
9117 .output()
9118 .ok()
9119 .and_then(|o| String::from_utf8(o.stdout).ok())
9120 .unwrap_or_default();
9121
9122 out.push_str("\n=== Mapped Network Drives ===\n");
9123 if drives_out.trim().is_empty() {
9124 out.push_str(" None.\n");
9125 } else {
9126 for line in drives_out.lines().take(max_entries) {
9127 if !line.trim().is_empty() {
9128 out.push_str(line);
9129 out.push('\n');
9130 }
9131 }
9132 }
9133 }
9134
9135 #[cfg(not(target_os = "windows"))]
9136 {
9137 let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9138 out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9139 if smb_conf.is_empty() {
9140 out.push_str(" Not found or Samba not installed.\n");
9141 } else {
9142 for line in smb_conf.lines().take(max_entries) {
9143 out.push_str(&format!(" {}\n", line));
9144 }
9145 }
9146 let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9147 out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9148 if nfs_exports.is_empty() {
9149 out.push_str(" Not configured.\n");
9150 } else {
9151 for line in nfs_exports.lines().take(max_entries) {
9152 out.push_str(&format!(" {}\n", line));
9153 }
9154 }
9155 }
9156
9157 Ok(out.trim_end().to_string())
9158}
9159
9160fn inspect_dns_servers() -> Result<String, String> {
9163 let mut out = String::from("Host inspection: dns_servers\n\n");
9164
9165 #[cfg(target_os = "windows")]
9166 {
9167 let dns_out = Command::new("powershell")
9168 .args([
9169 "-NoProfile", "-NonInteractive", "-Command",
9170 "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \" $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9171 ])
9172 .output()
9173 .ok()
9174 .and_then(|o| String::from_utf8(o.stdout).ok())
9175 .unwrap_or_default();
9176
9177 out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9178 if dns_out.trim().is_empty() {
9179 out.push_str(" (unable to retrieve)\n");
9180 } else {
9181 for line in dns_out.lines() {
9182 if line.trim().is_empty() {
9183 continue;
9184 }
9185 let mut annotation = "";
9186 if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9187 annotation = " <- Google Public DNS";
9188 } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9189 annotation = " <- Cloudflare DNS";
9190 } else if line.contains("9.9.9.9") {
9191 annotation = " <- Quad9";
9192 } else if line.contains("208.67.222") || line.contains("208.67.220") {
9193 annotation = " <- OpenDNS";
9194 }
9195 out.push_str(line);
9196 out.push_str(annotation);
9197 out.push('\n');
9198 }
9199 }
9200
9201 let doh_out = Command::new("powershell")
9202 .args([
9203 "-NoProfile", "-NonInteractive", "-Command",
9204 "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \" $($_.ServerAddress): $($_.DohTemplate)\" }",
9205 ])
9206 .output()
9207 .ok()
9208 .and_then(|o| String::from_utf8(o.stdout).ok())
9209 .unwrap_or_default();
9210
9211 out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9212 if doh_out.trim().is_empty() {
9213 out.push_str(" Not configured (plain DNS).\n");
9214 } else {
9215 out.push_str(doh_out.trim());
9216 out.push('\n');
9217 }
9218
9219 let suffixes = Command::new("powershell")
9220 .args([
9221 "-NoProfile", "-NonInteractive", "-Command",
9222 "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \" $_\" }",
9223 ])
9224 .output()
9225 .ok()
9226 .and_then(|o| String::from_utf8(o.stdout).ok())
9227 .unwrap_or_default();
9228
9229 if !suffixes.trim().is_empty() {
9230 out.push_str("\n=== DNS Search Suffix List ===\n");
9231 out.push_str(suffixes.trim());
9232 out.push('\n');
9233 }
9234 }
9235
9236 #[cfg(not(target_os = "windows"))]
9237 {
9238 let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9239 out.push_str("=== /etc/resolv.conf ===\n");
9240 if resolv.is_empty() {
9241 out.push_str(" Not found.\n");
9242 } else {
9243 for line in resolv.lines() {
9244 if !line.trim().is_empty() && !line.starts_with('#') {
9245 out.push_str(&format!(" {}\n", line));
9246 }
9247 }
9248 }
9249 let resolved_out = Command::new("resolvectl")
9250 .args(["status", "--no-pager"])
9251 .output()
9252 .ok()
9253 .and_then(|o| String::from_utf8(o.stdout).ok())
9254 .unwrap_or_default();
9255 if !resolved_out.is_empty() {
9256 out.push_str("\n=== systemd-resolved ===\n");
9257 for line in resolved_out.lines().take(30) {
9258 out.push_str(&format!(" {}\n", line));
9259 }
9260 }
9261 }
9262
9263 Ok(out.trim_end().to_string())
9264}
9265
9266fn inspect_bitlocker() -> Result<String, String> {
9267 let mut out = String::from("Host inspection: bitlocker\n\n");
9268
9269 #[cfg(target_os = "windows")]
9270 {
9271 let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9272 let output = Command::new("powershell")
9273 .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9274 .output()
9275 .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9276
9277 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9278 let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9279
9280 if !stdout.trim().is_empty() {
9281 out.push_str("=== BitLocker Volumes ===\n");
9282 for line in stdout.lines() {
9283 out.push_str(&format!(" {}\n", line));
9284 }
9285 } else if !stderr.trim().is_empty() {
9286 if stderr.contains("Access is denied") {
9287 out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9288 } else {
9289 out.push_str(&format!(
9290 "Error retrieving BitLocker info: {}\n",
9291 stderr.trim()
9292 ));
9293 }
9294 } else {
9295 out.push_str("No BitLocker volumes detected or access denied.\n");
9296 }
9297 }
9298
9299 #[cfg(not(target_os = "windows"))]
9300 {
9301 out.push_str(
9302 "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9303 );
9304 let lsblk = Command::new("lsblk")
9305 .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9306 .output()
9307 .ok()
9308 .and_then(|o| String::from_utf8(o.stdout).ok())
9309 .unwrap_or_default();
9310 if lsblk.contains("crypto_LUKS") {
9311 out.push_str("=== LUKS Encrypted Volumes ===\n");
9312 for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9313 out.push_str(&format!(" {}\n", line));
9314 }
9315 } else {
9316 out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9317 }
9318 }
9319
9320 Ok(out.trim_end().to_string())
9321}
9322
9323fn inspect_rdp() -> Result<String, String> {
9324 let mut out = String::from("Host inspection: rdp\n\n");
9325
9326 #[cfg(target_os = "windows")]
9327 {
9328 let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9329 let f_deny = Command::new("powershell")
9330 .args([
9331 "-NoProfile",
9332 "-Command",
9333 &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9334 ])
9335 .output()
9336 .ok()
9337 .and_then(|o| String::from_utf8(o.stdout).ok())
9338 .unwrap_or_default()
9339 .trim()
9340 .to_string();
9341
9342 let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9343 out.push_str(&format!("=== RDP Status: {} ===\n", status));
9344
9345 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"])
9346 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9347 out.push_str(&format!(
9348 " Port: {}\n",
9349 if port.is_empty() {
9350 "3389 (default)"
9351 } else {
9352 &port
9353 }
9354 ));
9355
9356 let nla = Command::new("powershell")
9357 .args([
9358 "-NoProfile",
9359 "-Command",
9360 &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9361 ])
9362 .output()
9363 .ok()
9364 .and_then(|o| String::from_utf8(o.stdout).ok())
9365 .unwrap_or_default()
9366 .trim()
9367 .to_string();
9368 out.push_str(&format!(
9369 " NLA Required: {}\n",
9370 if nla == "1" { "Yes" } else { "No" }
9371 ));
9372
9373 out.push_str("\n=== Active Sessions ===\n");
9374 let qwinsta = Command::new("qwinsta")
9375 .output()
9376 .ok()
9377 .and_then(|o| String::from_utf8(o.stdout).ok())
9378 .unwrap_or_default();
9379 if qwinsta.trim().is_empty() {
9380 out.push_str(" No active sessions listed.\n");
9381 } else {
9382 for line in qwinsta.lines() {
9383 out.push_str(&format!(" {}\n", line));
9384 }
9385 }
9386
9387 out.push_str("\n=== Firewall Rule Check ===\n");
9388 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))\" }"])
9389 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9390 if fw.trim().is_empty() {
9391 out.push_str(" No enabled RDP firewall rules found.\n");
9392 } else {
9393 out.push_str(fw.trim_end());
9394 out.push('\n');
9395 }
9396 }
9397
9398 #[cfg(not(target_os = "windows"))]
9399 {
9400 out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9401 let ss = Command::new("ss")
9402 .args(["-tlnp"])
9403 .output()
9404 .ok()
9405 .and_then(|o| String::from_utf8(o.stdout).ok())
9406 .unwrap_or_default();
9407 let matches: Vec<&str> = ss
9408 .lines()
9409 .filter(|l| l.contains(":3389") || l.contains(":590"))
9410 .collect();
9411 if matches.is_empty() {
9412 out.push_str(" No RDP/VNC listeners detected via 'ss'.\n");
9413 } else {
9414 for m in matches {
9415 out.push_str(&format!(" {}\n", m));
9416 }
9417 }
9418 }
9419
9420 Ok(out.trim_end().to_string())
9421}
9422
9423fn inspect_shadow_copies() -> Result<String, String> {
9424 let mut out = String::from("Host inspection: shadow_copies\n\n");
9425
9426 #[cfg(target_os = "windows")]
9427 {
9428 let output = Command::new("vssadmin")
9429 .args(["list", "shadows"])
9430 .output()
9431 .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9432 let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9433
9434 if stdout.contains("No items found") || stdout.trim().is_empty() {
9435 out.push_str("No Volume Shadow Copies found.\n");
9436 } else {
9437 out.push_str("=== Volume Shadow Copies ===\n");
9438 for line in stdout.lines().take(50) {
9439 if line.contains("Creation Time:")
9440 || line.contains("Contents:")
9441 || line.contains("Volume Name:")
9442 {
9443 out.push_str(&format!(" {}\n", line.trim()));
9444 }
9445 }
9446 }
9447
9448 out.push_str("\n=== Shadow Copy Storage ===\n");
9449 let storage_out = Command::new("vssadmin")
9450 .args(["list", "shadowstorage"])
9451 .output()
9452 .ok();
9453 if let Some(o) = storage_out {
9454 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9455 for line in stdout.lines() {
9456 if line.contains("Used Shadow Copy Storage space:")
9457 || line.contains("Max Shadow Copy Storage space:")
9458 {
9459 out.push_str(&format!(" {}\n", line.trim()));
9460 }
9461 }
9462 }
9463 }
9464
9465 #[cfg(not(target_os = "windows"))]
9466 {
9467 out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
9468 let lvs = Command::new("lvs")
9469 .output()
9470 .ok()
9471 .and_then(|o| String::from_utf8(o.stdout).ok())
9472 .unwrap_or_default();
9473 if !lvs.is_empty() {
9474 out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
9475 out.push_str(&lvs);
9476 } else {
9477 out.push_str("No LVM volumes detected.\n");
9478 }
9479 }
9480
9481 Ok(out.trim_end().to_string())
9482}
9483
9484fn inspect_pagefile() -> Result<String, String> {
9485 let mut out = String::from("Host inspection: pagefile\n\n");
9486
9487 #[cfg(target_os = "windows")]
9488 {
9489 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)\" }";
9490 let output = Command::new("powershell")
9491 .args(["-NoProfile", "-Command", ps_cmd])
9492 .output()
9493 .ok()
9494 .and_then(|o| String::from_utf8(o.stdout).ok())
9495 .unwrap_or_default();
9496
9497 if output.trim().is_empty() {
9498 out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
9499 let managed = Command::new("powershell")
9500 .args([
9501 "-NoProfile",
9502 "-Command",
9503 "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
9504 ])
9505 .output()
9506 .ok()
9507 .and_then(|o| String::from_utf8(o.stdout).ok())
9508 .unwrap_or_default()
9509 .trim()
9510 .to_string();
9511 out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
9512 } else {
9513 out.push_str("=== Page File Usage ===\n");
9514 out.push_str(&output);
9515 }
9516 }
9517
9518 #[cfg(not(target_os = "windows"))]
9519 {
9520 out.push_str("=== Swap Usage (Linux/macOS) ===\n");
9521 let swap = Command::new("swapon")
9522 .args(["--show"])
9523 .output()
9524 .ok()
9525 .and_then(|o| String::from_utf8(o.stdout).ok())
9526 .unwrap_or_default();
9527 if swap.is_empty() {
9528 let free = Command::new("free")
9529 .args(["-h"])
9530 .output()
9531 .ok()
9532 .and_then(|o| String::from_utf8(o.stdout).ok())
9533 .unwrap_or_default();
9534 out.push_str(&free);
9535 } else {
9536 out.push_str(&swap);
9537 }
9538 }
9539
9540 Ok(out.trim_end().to_string())
9541}
9542
9543fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
9544 let mut out = String::from("Host inspection: windows_features\n\n");
9545
9546 #[cfg(target_os = "windows")]
9547 {
9548 out.push_str("=== Quick Check: Notable Features ===\n");
9549 let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
9550 let output = Command::new("powershell")
9551 .args(["-NoProfile", "-Command", quick_ps])
9552 .output()
9553 .ok();
9554
9555 if let Some(o) = output {
9556 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9557 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
9558
9559 if !stdout.trim().is_empty() {
9560 for f in stdout.lines() {
9561 out.push_str(&format!(" [ENABLED] {}\n", f));
9562 }
9563 } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
9564 out.push_str(" Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
9565 } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
9566 out.push_str(
9567 " No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
9568 );
9569 }
9570 }
9571
9572 out.push_str(&format!(
9573 "\n=== All Enabled Features (capped at {}) ===\n",
9574 max_entries
9575 ));
9576 let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
9577 let all_out = Command::new("powershell")
9578 .args(["-NoProfile", "-Command", &all_ps])
9579 .output()
9580 .ok();
9581 if let Some(o) = all_out {
9582 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9583 if !stdout.trim().is_empty() {
9584 out.push_str(&stdout);
9585 }
9586 }
9587 }
9588
9589 #[cfg(not(target_os = "windows"))]
9590 {
9591 let _ = max_entries;
9592 out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
9593 }
9594
9595 Ok(out.trim_end().to_string())
9596}
9597
9598fn inspect_audio(max_entries: usize) -> Result<String, String> {
9599 let mut out = String::from("Host inspection: audio\n\n");
9600
9601 #[cfg(target_os = "windows")]
9602 {
9603 let n = max_entries.clamp(5, 20);
9604 let services = collect_services().unwrap_or_default();
9605 let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
9606 let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
9607
9608 let core_services: Vec<&ServiceEntry> = services
9609 .iter()
9610 .filter(|entry| {
9611 core_service_names
9612 .iter()
9613 .any(|name| entry.name.eq_ignore_ascii_case(name))
9614 })
9615 .collect();
9616 let bluetooth_audio_services: Vec<&ServiceEntry> = services
9617 .iter()
9618 .filter(|entry| {
9619 bluetooth_audio_service_names
9620 .iter()
9621 .any(|name| entry.name.eq_ignore_ascii_case(name))
9622 })
9623 .collect();
9624
9625 let probe_script = r#"
9626$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
9627 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9628$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9629 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9630$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
9631 Select-Object Name, Status, Manufacturer, PNPDeviceID)
9632[pscustomobject]@{
9633 Media = $media
9634 Endpoints = $endpoints
9635 SoundDevices = $sound
9636} | ConvertTo-Json -Compress -Depth 4
9637"#;
9638 let probe_raw = Command::new("powershell")
9639 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9640 .output()
9641 .ok()
9642 .and_then(|o| String::from_utf8(o.stdout).ok())
9643 .unwrap_or_default();
9644 let probe_loaded = !probe_raw.trim().is_empty();
9645 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9646
9647 let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
9648 let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
9649 let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
9650
9651 let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
9652 .iter()
9653 .filter(|device| !is_microphone_like_name(&device.name))
9654 .collect();
9655 let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
9656 .iter()
9657 .filter(|device| is_microphone_like_name(&device.name))
9658 .collect();
9659 let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
9660 .iter()
9661 .filter(|device| is_bluetooth_like_name(&device.name))
9662 .collect();
9663 let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
9664 .iter()
9665 .filter(|device| windows_device_has_issue(device))
9666 .collect();
9667 let media_problems: Vec<&WindowsPnpDevice> = media_devices
9668 .iter()
9669 .filter(|device| windows_device_has_issue(device))
9670 .collect();
9671 let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
9672 .iter()
9673 .filter(|device| windows_sound_device_has_issue(device))
9674 .collect();
9675
9676 let mut findings = Vec::new();
9677
9678 let stopped_core_services: Vec<&ServiceEntry> = core_services
9679 .iter()
9680 .copied()
9681 .filter(|service| !service_is_running(service))
9682 .collect();
9683 if !stopped_core_services.is_empty() {
9684 let names = stopped_core_services
9685 .iter()
9686 .map(|service| service.name.as_str())
9687 .collect::<Vec<_>>()
9688 .join(", ");
9689 findings.push(AuditFinding {
9690 finding: format!("Core audio services are not running: {names}"),
9691 impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
9692 fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
9693 });
9694 }
9695
9696 if probe_loaded
9697 && endpoints.is_empty()
9698 && media_devices.is_empty()
9699 && sound_devices.is_empty()
9700 {
9701 findings.push(AuditFinding {
9702 finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
9703 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(),
9704 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(),
9705 });
9706 }
9707
9708 if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
9709 {
9710 let mut problem_labels = Vec::new();
9711 problem_labels.extend(
9712 endpoint_problems
9713 .iter()
9714 .take(3)
9715 .map(|device| device.name.clone()),
9716 );
9717 problem_labels.extend(
9718 media_problems
9719 .iter()
9720 .take(3)
9721 .map(|device| device.name.clone()),
9722 );
9723 problem_labels.extend(
9724 sound_problems
9725 .iter()
9726 .take(3)
9727 .map(|device| device.name.clone()),
9728 );
9729 findings.push(AuditFinding {
9730 finding: format!(
9731 "Windows reports audio device issues for: {}",
9732 problem_labels.join(", ")
9733 ),
9734 impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
9735 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(),
9736 });
9737 }
9738
9739 let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
9740 .iter()
9741 .copied()
9742 .filter(|service| !service_is_running(service))
9743 .collect();
9744 if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
9745 let names = stopped_bt_audio_services
9746 .iter()
9747 .map(|service| service.name.as_str())
9748 .collect::<Vec<_>>()
9749 .join(", ");
9750 findings.push(AuditFinding {
9751 finding: format!(
9752 "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
9753 ),
9754 impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
9755 fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
9756 });
9757 }
9758
9759 out.push_str("=== Findings ===\n");
9760 if findings.is_empty() {
9761 out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
9762 out.push_str(" Impact: Playback and recording look structurally present from this inspection pass.\n");
9763 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");
9764 } else {
9765 for finding in &findings {
9766 out.push_str(&format!("- Finding: {}\n", finding.finding));
9767 out.push_str(&format!(" Impact: {}\n", finding.impact));
9768 out.push_str(&format!(" Fix: {}\n", finding.fix));
9769 }
9770 }
9771
9772 out.push_str("\n=== Audio services ===\n");
9773 if core_services.is_empty() && bluetooth_audio_services.is_empty() {
9774 out.push_str(
9775 "- No Windows audio services were retrieved from the service inventory.\n",
9776 );
9777 } else {
9778 for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
9779 out.push_str(&format!(
9780 "- {} | Status: {} | Startup: {}\n",
9781 service.name,
9782 service.status,
9783 service.startup.as_deref().unwrap_or("Unknown")
9784 ));
9785 }
9786 }
9787
9788 out.push_str("\n=== Playback and recording endpoints ===\n");
9789 if !probe_loaded {
9790 out.push_str("- Windows endpoint inventory probe returned no data.\n");
9791 } else if endpoints.is_empty() {
9792 out.push_str("- No audio endpoints detected.\n");
9793 } else {
9794 out.push_str(&format!(
9795 "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
9796 playback_endpoints.len(),
9797 recording_endpoints.len()
9798 ));
9799 for device in playback_endpoints.iter().take(n) {
9800 out.push_str(&format!(
9801 "- [PLAYBACK] {} | Status: {}{}\n",
9802 device.name,
9803 device.status,
9804 device
9805 .problem
9806 .filter(|problem| *problem != 0)
9807 .map(|problem| format!(" | ProblemCode: {problem}"))
9808 .unwrap_or_default()
9809 ));
9810 }
9811 for device in recording_endpoints.iter().take(n) {
9812 out.push_str(&format!(
9813 "- [MIC] {} | Status: {}{}\n",
9814 device.name,
9815 device.status,
9816 device
9817 .problem
9818 .filter(|problem| *problem != 0)
9819 .map(|problem| format!(" | ProblemCode: {problem}"))
9820 .unwrap_or_default()
9821 ));
9822 }
9823 }
9824
9825 out.push_str("\n=== Sound hardware devices ===\n");
9826 if sound_devices.is_empty() {
9827 out.push_str("- No Win32_SoundDevice entries were returned.\n");
9828 } else {
9829 for device in sound_devices.iter().take(n) {
9830 out.push_str(&format!(
9831 "- {} | Status: {}{}\n",
9832 device.name,
9833 device.status,
9834 device
9835 .manufacturer
9836 .as_deref()
9837 .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
9838 .unwrap_or_default()
9839 ));
9840 }
9841 }
9842
9843 out.push_str("\n=== Media-class device inventory ===\n");
9844 if media_devices.is_empty() {
9845 out.push_str("- No media-class PnP devices were returned.\n");
9846 } else {
9847 for device in media_devices.iter().take(n) {
9848 out.push_str(&format!(
9849 "- {} | Status: {}{}\n",
9850 device.name,
9851 device.status,
9852 device
9853 .class_name
9854 .as_deref()
9855 .map(|class_name| format!(" | Class: {class_name}"))
9856 .unwrap_or_default()
9857 ));
9858 }
9859 }
9860 }
9861
9862 #[cfg(not(target_os = "windows"))]
9863 {
9864 let _ = max_entries;
9865 out.push_str(
9866 "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
9867 );
9868 out.push_str(
9869 "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
9870 );
9871 }
9872
9873 Ok(out.trim_end().to_string())
9874}
9875
9876fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
9877 let mut out = String::from("Host inspection: bluetooth\n\n");
9878
9879 #[cfg(target_os = "windows")]
9880 {
9881 let n = max_entries.clamp(5, 20);
9882 let services = collect_services().unwrap_or_default();
9883 let bluetooth_services: Vec<&ServiceEntry> = services
9884 .iter()
9885 .filter(|entry| {
9886 entry.name.eq_ignore_ascii_case("bthserv")
9887 || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
9888 || entry.name.eq_ignore_ascii_case("BTAGService")
9889 || entry.name.starts_with("BluetoothUserService")
9890 || entry
9891 .display_name
9892 .as_deref()
9893 .unwrap_or("")
9894 .to_ascii_lowercase()
9895 .contains("bluetooth")
9896 })
9897 .collect();
9898
9899 let probe_script = r#"
9900$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
9901 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9902$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
9903 Where-Object {
9904 $_.Class -eq 'Bluetooth' -or
9905 $_.FriendlyName -match 'Bluetooth' -or
9906 $_.InstanceId -like 'BTH*'
9907 } |
9908 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9909$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9910 Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
9911 Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9912[pscustomobject]@{
9913 Radios = $radios
9914 Devices = $devices
9915 AudioEndpoints = $audio
9916} | ConvertTo-Json -Compress -Depth 4
9917"#;
9918 let probe_raw = Command::new("powershell")
9919 .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9920 .output()
9921 .ok()
9922 .and_then(|o| String::from_utf8(o.stdout).ok())
9923 .unwrap_or_default();
9924 let probe_loaded = !probe_raw.trim().is_empty();
9925 let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9926
9927 let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
9928 let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
9929 let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
9930 let radio_problems: Vec<&WindowsPnpDevice> = radios
9931 .iter()
9932 .filter(|device| windows_device_has_issue(device))
9933 .collect();
9934 let device_problems: Vec<&WindowsPnpDevice> = devices
9935 .iter()
9936 .filter(|device| windows_device_has_issue(device))
9937 .collect();
9938
9939 let mut findings = Vec::new();
9940
9941 if probe_loaded && radios.is_empty() {
9942 findings.push(AuditFinding {
9943 finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
9944 impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
9945 fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
9946 });
9947 }
9948
9949 let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
9950 .iter()
9951 .copied()
9952 .filter(|service| !service_is_running(service))
9953 .collect();
9954 if !stopped_bluetooth_services.is_empty() {
9955 let names = stopped_bluetooth_services
9956 .iter()
9957 .map(|service| service.name.as_str())
9958 .collect::<Vec<_>>()
9959 .join(", ");
9960 findings.push(AuditFinding {
9961 finding: format!("Bluetooth-related services are not fully running: {names}"),
9962 impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
9963 fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
9964 });
9965 }
9966
9967 if !radio_problems.is_empty() || !device_problems.is_empty() {
9968 let problem_labels = radio_problems
9969 .iter()
9970 .chain(device_problems.iter())
9971 .take(5)
9972 .map(|device| device.name.as_str())
9973 .collect::<Vec<_>>()
9974 .join(", ");
9975 findings.push(AuditFinding {
9976 finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
9977 impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
9978 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(),
9979 });
9980 }
9981
9982 if !audio_endpoints.is_empty()
9983 && bluetooth_services
9984 .iter()
9985 .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
9986 && bluetooth_services
9987 .iter()
9988 .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
9989 .any(|service| !service_is_running(service))
9990 {
9991 findings.push(AuditFinding {
9992 finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
9993 impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
9994 fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
9995 });
9996 }
9997
9998 out.push_str("=== Findings ===\n");
9999 if findings.is_empty() {
10000 out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10001 out.push_str(" Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10002 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");
10003 } else {
10004 for finding in &findings {
10005 out.push_str(&format!("- Finding: {}\n", finding.finding));
10006 out.push_str(&format!(" Impact: {}\n", finding.impact));
10007 out.push_str(&format!(" Fix: {}\n", finding.fix));
10008 }
10009 }
10010
10011 out.push_str("\n=== Bluetooth services ===\n");
10012 if bluetooth_services.is_empty() {
10013 out.push_str(
10014 "- No Bluetooth-related services were retrieved from the service inventory.\n",
10015 );
10016 } else {
10017 for service in bluetooth_services.iter().take(n) {
10018 out.push_str(&format!(
10019 "- {} | Status: {} | Startup: {}\n",
10020 service.name,
10021 service.status,
10022 service.startup.as_deref().unwrap_or("Unknown")
10023 ));
10024 }
10025 }
10026
10027 out.push_str("\n=== Bluetooth radios and adapters ===\n");
10028 if !probe_loaded {
10029 out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10030 } else if radios.is_empty() {
10031 out.push_str("- No Bluetooth radios detected.\n");
10032 } else {
10033 for device in radios.iter().take(n) {
10034 out.push_str(&format!(
10035 "- {} | Status: {}{}\n",
10036 device.name,
10037 device.status,
10038 device
10039 .problem
10040 .filter(|problem| *problem != 0)
10041 .map(|problem| format!(" | ProblemCode: {problem}"))
10042 .unwrap_or_default()
10043 ));
10044 }
10045 }
10046
10047 out.push_str("\n=== Bluetooth-associated devices ===\n");
10048 if devices.is_empty() {
10049 out.push_str("- No Bluetooth-associated device nodes detected.\n");
10050 } else {
10051 for device in devices.iter().take(n) {
10052 out.push_str(&format!(
10053 "- {} | Status: {}{}\n",
10054 device.name,
10055 device.status,
10056 device
10057 .class_name
10058 .as_deref()
10059 .map(|class_name| format!(" | Class: {class_name}"))
10060 .unwrap_or_default()
10061 ));
10062 }
10063 }
10064
10065 out.push_str("\n=== Bluetooth audio endpoints ===\n");
10066 if audio_endpoints.is_empty() {
10067 out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10068 } else {
10069 for device in audio_endpoints.iter().take(n) {
10070 out.push_str(&format!(
10071 "- {} | Status: {}{}\n",
10072 device.name,
10073 device.status,
10074 device
10075 .instance_id
10076 .as_deref()
10077 .map(|instance_id| format!(" | Instance: {instance_id}"))
10078 .unwrap_or_default()
10079 ));
10080 }
10081 }
10082 }
10083
10084 #[cfg(not(target_os = "windows"))]
10085 {
10086 let _ = max_entries;
10087 out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10088 out.push_str(
10089 "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10090 );
10091 }
10092
10093 Ok(out.trim_end().to_string())
10094}
10095
10096fn inspect_printers(max_entries: usize) -> Result<String, String> {
10097 let mut out = String::from("Host inspection: printers\n\n");
10098
10099 #[cfg(target_os = "windows")]
10100 {
10101 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)])
10102 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10103 if list.trim().is_empty() {
10104 out.push_str("No printers detected.\n");
10105 } else {
10106 out.push_str("=== Installed Printers ===\n");
10107 out.push_str(&list);
10108 }
10109
10110 let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \" [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10111 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10112 if !jobs.trim().is_empty() {
10113 out.push_str("\n=== Active Print Jobs ===\n");
10114 out.push_str(&jobs);
10115 }
10116 }
10117
10118 #[cfg(not(target_os = "windows"))]
10119 {
10120 let _ = max_entries;
10121 out.push_str("Checking LPSTAT for printers...\n");
10122 let lpstat = Command::new("lpstat")
10123 .args(["-p", "-d"])
10124 .output()
10125 .ok()
10126 .and_then(|o| String::from_utf8(o.stdout).ok())
10127 .unwrap_or_default();
10128 if lpstat.is_empty() {
10129 out.push_str(" No CUPS/LP printers found.\n");
10130 } else {
10131 out.push_str(&lpstat);
10132 }
10133 }
10134
10135 Ok(out.trim_end().to_string())
10136}
10137
10138fn inspect_winrm() -> Result<String, String> {
10139 let mut out = String::from("Host inspection: winrm\n\n");
10140
10141 #[cfg(target_os = "windows")]
10142 {
10143 let svc = Command::new("powershell")
10144 .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10145 .output()
10146 .ok()
10147 .and_then(|o| String::from_utf8(o.stdout).ok())
10148 .unwrap_or_default()
10149 .trim()
10150 .to_string();
10151 out.push_str(&format!(
10152 "WinRM Service Status: {}\n\n",
10153 if svc.is_empty() { "NOT_FOUND" } else { &svc }
10154 ));
10155
10156 out.push_str("=== WinRM Listeners ===\n");
10157 let output = Command::new("powershell")
10158 .args([
10159 "-NoProfile",
10160 "-Command",
10161 "winrm enumerate winrm/config/listener 2>$null",
10162 ])
10163 .output()
10164 .ok();
10165 if let Some(o) = output {
10166 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10167 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10168
10169 if !stdout.trim().is_empty() {
10170 for line in stdout.lines() {
10171 if line.contains("Address =")
10172 || line.contains("Transport =")
10173 || line.contains("Port =")
10174 {
10175 out.push_str(&format!(" {}\n", line.trim()));
10176 }
10177 }
10178 } else if stderr.contains("Access is denied") {
10179 out.push_str(" Error: Access denied to WinRM configuration.\n");
10180 } else {
10181 out.push_str(" No listeners configured.\n");
10182 }
10183 }
10184
10185 out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10186 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))\" }"])
10187 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10188 if test_out.trim().is_empty() {
10189 out.push_str(" WinRM not responding to local WS-Man requests.\n");
10190 } else {
10191 out.push_str(&test_out);
10192 }
10193 }
10194
10195 #[cfg(not(target_os = "windows"))]
10196 {
10197 out.push_str(
10198 "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10199 );
10200 let ss = Command::new("ss")
10201 .args(["-tln"])
10202 .output()
10203 .ok()
10204 .and_then(|o| String::from_utf8(o.stdout).ok())
10205 .unwrap_or_default();
10206 if ss.contains(":5985") || ss.contains(":5986") {
10207 out.push_str(" WinRM ports (5985/5986) are listening.\n");
10208 } else {
10209 out.push_str(" WinRM ports not detected.\n");
10210 }
10211 }
10212
10213 Ok(out.trim_end().to_string())
10214}
10215
10216fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10217 let mut out = String::from("Host inspection: network_stats\n\n");
10218
10219 #[cfg(target_os = "windows")]
10220 {
10221 let ps_cmd = format!(
10222 "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10223 Start-Sleep -Milliseconds 250; \
10224 $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10225 $s2 | ForEach-Object {{ \
10226 $name = $_.Name; \
10227 $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10228 if ($prev) {{ \
10229 $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10230 $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10231 $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10232 $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10233 $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10234 $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10235 \" $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10236 }} \
10237 }}",
10238 max_entries
10239 );
10240 let output = Command::new("powershell")
10241 .args(["-NoProfile", "-Command", &ps_cmd])
10242 .output()
10243 .ok()
10244 .and_then(|o| String::from_utf8(o.stdout).ok())
10245 .unwrap_or_default();
10246 if output.trim().is_empty() {
10247 out.push_str("No network adapter statistics available.\n");
10248 } else {
10249 out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10250 out.push_str(&output);
10251 }
10252
10253 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)\" } }"])
10254 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10255 if !discards.trim().is_empty() {
10256 out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10257 out.push_str(&discards);
10258 }
10259 }
10260
10261 #[cfg(not(target_os = "windows"))]
10262 {
10263 let _ = max_entries;
10264 out.push_str("=== Network Stats (ip -s link) ===\n");
10265 let ip_s = Command::new("ip")
10266 .args(["-s", "link"])
10267 .output()
10268 .ok()
10269 .and_then(|o| String::from_utf8(o.stdout).ok())
10270 .unwrap_or_default();
10271 if ip_s.is_empty() {
10272 let netstat = Command::new("netstat")
10273 .args(["-i"])
10274 .output()
10275 .ok()
10276 .and_then(|o| String::from_utf8(o.stdout).ok())
10277 .unwrap_or_default();
10278 out.push_str(&netstat);
10279 } else {
10280 out.push_str(&ip_s);
10281 }
10282 }
10283
10284 Ok(out.trim_end().to_string())
10285}
10286
10287fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10288 let mut out = String::from("Host inspection: udp_ports\n\n");
10289
10290 #[cfg(target_os = "windows")]
10291 {
10292 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);
10293 let output = Command::new("powershell")
10294 .args(["-NoProfile", "-Command", &ps_cmd])
10295 .output()
10296 .ok();
10297
10298 if let Some(o) = output {
10299 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10300 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10301
10302 if !stdout.trim().is_empty() {
10303 out.push_str("=== UDP Listeners (Local:Port) ===\n");
10304 for line in stdout.lines() {
10305 let mut note = "";
10306 if line.contains(":53 ") {
10307 note = " [DNS]";
10308 } else if line.contains(":67 ") || line.contains(":68 ") {
10309 note = " [DHCP]";
10310 } else if line.contains(":123 ") {
10311 note = " [NTP]";
10312 } else if line.contains(":161 ") {
10313 note = " [SNMP]";
10314 } else if line.contains(":1900 ") {
10315 note = " [SSDP/UPnP]";
10316 } else if line.contains(":5353 ") {
10317 note = " [mDNS]";
10318 }
10319
10320 out.push_str(&format!("{}{}\n", line, note));
10321 }
10322 } else if stderr.contains("Access is denied") {
10323 out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10324 } else {
10325 out.push_str("No UDP listeners detected.\n");
10326 }
10327 }
10328 }
10329
10330 #[cfg(not(target_os = "windows"))]
10331 {
10332 let ss_out = Command::new("ss")
10333 .args(["-ulnp"])
10334 .output()
10335 .ok()
10336 .and_then(|o| String::from_utf8(o.stdout).ok())
10337 .unwrap_or_default();
10338 out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10339 if ss_out.is_empty() {
10340 let netstat_out = Command::new("netstat")
10341 .args(["-ulnp"])
10342 .output()
10343 .ok()
10344 .and_then(|o| String::from_utf8(o.stdout).ok())
10345 .unwrap_or_default();
10346 if netstat_out.is_empty() {
10347 out.push_str(" Neither 'ss' nor 'netstat' available.\n");
10348 } else {
10349 for line in netstat_out.lines().take(max_entries) {
10350 out.push_str(&format!(" {}\n", line));
10351 }
10352 }
10353 } else {
10354 for line in ss_out.lines().take(max_entries) {
10355 out.push_str(&format!(" {}\n", line));
10356 }
10357 }
10358 }
10359
10360 Ok(out.trim_end().to_string())
10361}
10362
10363fn inspect_gpo() -> Result<String, String> {
10364 let mut out = String::from("Host inspection: gpo\n\n");
10365
10366 #[cfg(target_os = "windows")]
10367 {
10368 let output = Command::new("gpresult")
10369 .args(["/r", "/scope", "computer"])
10370 .output()
10371 .ok();
10372
10373 if let Some(o) = output {
10374 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10375 let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10376
10377 if stdout.contains("Applied Group Policy Objects") {
10378 out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10379 let mut capture = false;
10380 for line in stdout.lines() {
10381 if line.contains("Applied Group Policy Objects") {
10382 capture = true;
10383 } else if capture && line.contains("The following GPOs were not applied") {
10384 break;
10385 }
10386 if capture && !line.trim().is_empty() {
10387 out.push_str(&format!(" {}\n", line.trim()));
10388 }
10389 }
10390 } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10391 out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10392 } else {
10393 out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10394 }
10395 }
10396 }
10397
10398 #[cfg(not(target_os = "windows"))]
10399 {
10400 out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10401 }
10402
10403 Ok(out.trim_end().to_string())
10404}
10405
10406fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10407 let mut out = String::from("Host inspection: certificates\n\n");
10408
10409 #[cfg(target_os = "windows")]
10410 {
10411 let ps_cmd = format!(
10412 "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10413 $days = ($_.NotAfter - (Get-Date)).Days; \
10414 $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10415 \" $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10416 }}",
10417 max_entries
10418 );
10419 let output = Command::new("powershell")
10420 .args(["-NoProfile", "-Command", &ps_cmd])
10421 .output()
10422 .ok();
10423
10424 if let Some(o) = output {
10425 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10426 if !stdout.trim().is_empty() {
10427 out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10428 out.push_str(&stdout);
10429 } else {
10430 out.push_str("No certificates found in the Local Machine Personal store.\n");
10431 }
10432 }
10433 }
10434
10435 #[cfg(not(target_os = "windows"))]
10436 {
10437 let _ = max_entries;
10438 out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10439 for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10441 if Path::new(path).exists() {
10442 out.push_str(&format!(" Cert directory found: {}\n", path));
10443 }
10444 }
10445 }
10446
10447 Ok(out.trim_end().to_string())
10448}
10449
10450fn inspect_integrity() -> Result<String, String> {
10451 let mut out = String::from("Host inspection: integrity\n\n");
10452
10453 #[cfg(target_os = "windows")]
10454 {
10455 let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
10456 let output = Command::new("powershell")
10457 .args(["-NoProfile", "-Command", &ps_cmd])
10458 .output()
10459 .ok();
10460
10461 if let Some(o) = output {
10462 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10463 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10464 out.push_str("=== Windows Component Store Health (CBS) ===\n");
10465 let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
10466 let repair = val
10467 .get("AutoRepairNeeded")
10468 .and_then(|v| v.as_u64())
10469 .unwrap_or(0);
10470
10471 out.push_str(&format!(
10472 " Corruption Detected: {}\n",
10473 if corrupt != 0 {
10474 "YES (SFC/DISM recommended)"
10475 } else {
10476 "No"
10477 }
10478 ));
10479 out.push_str(&format!(
10480 " Auto-Repair Needed: {}\n",
10481 if repair != 0 { "YES" } else { "No" }
10482 ));
10483
10484 if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
10485 out.push_str(&format!(" Last Repair Attempt: (Raw code: {})\n", last));
10486 }
10487 } else {
10488 out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
10489 }
10490 }
10491
10492 if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
10493 out.push_str(
10494 "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
10495 );
10496 }
10497 }
10498
10499 #[cfg(not(target_os = "windows"))]
10500 {
10501 out.push_str("System integrity check (Linux)\n\n");
10502 let pkg_check = Command::new("rpm")
10503 .args(["-Va"])
10504 .output()
10505 .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
10506 .ok();
10507 if let Some(o) = pkg_check {
10508 out.push_str(" Package verification system active.\n");
10509 if o.status.success() {
10510 out.push_str(" No major package integrity issues detected.\n");
10511 }
10512 }
10513 }
10514
10515 Ok(out.trim_end().to_string())
10516}
10517
10518fn inspect_domain() -> Result<String, String> {
10519 let mut out = String::from("Host inspection: domain\n\n");
10520
10521 #[cfg(target_os = "windows")]
10522 {
10523 out.push_str("=== Windows Domain / Workgroup Identity ===\n");
10524 let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
10525 let output = Command::new("powershell")
10526 .args(["-NoProfile", "-Command", &ps_cmd])
10527 .output()
10528 .ok();
10529
10530 if let Some(o) = output {
10531 let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10532 if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10533 let part_of_domain = val
10534 .get("PartOfDomain")
10535 .and_then(|v| v.as_bool())
10536 .unwrap_or(false);
10537 let domain = val
10538 .get("Domain")
10539 .and_then(|v| v.as_str())
10540 .unwrap_or("Unknown");
10541 let workgroup = val
10542 .get("Workgroup")
10543 .and_then(|v| v.as_str())
10544 .unwrap_or("Unknown");
10545
10546 out.push_str(&format!(
10547 " Join Status: {}\n",
10548 if part_of_domain {
10549 "DOMAIN JOINED"
10550 } else {
10551 "WORKGROUP"
10552 }
10553 ));
10554 if part_of_domain {
10555 out.push_str(&format!(" Active Directory Domain: {}\n", domain));
10556 } else {
10557 out.push_str(&format!(" Workgroup Name: {}\n", workgroup));
10558 }
10559
10560 if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
10561 out.push_str(&format!(" NetBIOS Name: {}\n", name));
10562 }
10563 } else {
10564 out.push_str(" Domain identity data unavailable from WMI.\n");
10565 }
10566 } else {
10567 out.push_str(" Domain identity data unavailable from WMI.\n");
10568 }
10569 }
10570
10571 #[cfg(not(target_os = "windows"))]
10572 {
10573 let domainname = Command::new("domainname")
10574 .output()
10575 .ok()
10576 .and_then(|o| String::from_utf8(o.stdout).ok())
10577 .unwrap_or_default();
10578 out.push_str("=== Linux Domain Identity ===\n");
10579 if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
10580 out.push_str(&format!(" NIS/YP Domain: {}\n", domainname.trim()));
10581 } else {
10582 out.push_str(" No NIS domain configured.\n");
10583 }
10584 }
10585
10586 Ok(out.trim_end().to_string())
10587}
10588
10589fn inspect_device_health() -> Result<String, String> {
10590 let mut out = String::from("Host inspection: device_health\n\n");
10591
10592 #[cfg(target_os = "windows")]
10593 {
10594 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)\" }";
10595 let output = Command::new("powershell")
10596 .args(["-NoProfile", "-Command", ps_cmd])
10597 .output()
10598 .ok()
10599 .and_then(|o| String::from_utf8(o.stdout).ok())
10600 .unwrap_or_default();
10601
10602 if output.trim().is_empty() {
10603 out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
10604 } else {
10605 out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
10606 out.push_str(&output);
10607 out.push_str(
10608 "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
10609 );
10610 }
10611 }
10612
10613 #[cfg(not(target_os = "windows"))]
10614 {
10615 out.push_str("Checking dmesg for hardware errors...\n");
10616 let dmesg = Command::new("dmesg")
10617 .args(["--level=err,crit,alert"])
10618 .output()
10619 .ok()
10620 .and_then(|o| String::from_utf8(o.stdout).ok())
10621 .unwrap_or_default();
10622 if dmesg.is_empty() {
10623 out.push_str(" No critical hardware errors found in dmesg.\n");
10624 } else {
10625 out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
10626 }
10627 }
10628
10629 Ok(out.trim_end().to_string())
10630}
10631
10632fn inspect_drivers(max_entries: usize) -> Result<String, String> {
10633 let mut out = String::from("Host inspection: drivers\n\n");
10634
10635 #[cfg(target_os = "windows")]
10636 {
10637 out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
10638 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);
10639 let output = Command::new("powershell")
10640 .args(["-NoProfile", "-Command", &ps_cmd])
10641 .output()
10642 .ok()
10643 .and_then(|o| String::from_utf8(o.stdout).ok())
10644 .unwrap_or_default();
10645
10646 if output.trim().is_empty() {
10647 out.push_str(" No drivers retrieved via WMI.\n");
10648 } else {
10649 out.push_str(&output);
10650 }
10651 }
10652
10653 #[cfg(not(target_os = "windows"))]
10654 {
10655 out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
10656 let lsmod = Command::new("lsmod")
10657 .output()
10658 .ok()
10659 .and_then(|o| String::from_utf8(o.stdout).ok())
10660 .unwrap_or_default();
10661 out.push_str(
10662 &lsmod
10663 .lines()
10664 .take(max_entries)
10665 .collect::<Vec<_>>()
10666 .join("\n"),
10667 );
10668 }
10669
10670 Ok(out.trim_end().to_string())
10671}
10672
10673fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
10674 let mut out = String::from("Host inspection: peripherals\n\n");
10675
10676 #[cfg(target_os = "windows")]
10677 {
10678 let _ = max_entries;
10679 out.push_str("=== USB Controllers & Hubs ===\n");
10680 let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \" $($_.Name) ($($_.Status))\" }"])
10681 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10682 out.push_str(if usb.is_empty() {
10683 " None detected.\n"
10684 } else {
10685 &usb
10686 });
10687
10688 out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
10689 let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \" [KB] $($_.Name) ($($_.Status))\" }"])
10690 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10691 let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \" [PTR] $($_.Name) ($($_.Status))\" }"])
10692 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10693 out.push_str(&kb);
10694 out.push_str(&mouse);
10695
10696 out.push_str("\n=== Connected Monitors (WMI) ===\n");
10697 let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \" Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
10698 .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10699 out.push_str(if mon.is_empty() {
10700 " No active monitors identified via WMI.\n"
10701 } else {
10702 &mon
10703 });
10704 }
10705
10706 #[cfg(not(target_os = "windows"))]
10707 {
10708 out.push_str("=== Connected USB Devices (lsusb) ===\n");
10709 let lsusb = Command::new("lsusb")
10710 .output()
10711 .ok()
10712 .and_then(|o| String::from_utf8(o.stdout).ok())
10713 .unwrap_or_default();
10714 out.push_str(
10715 &lsusb
10716 .lines()
10717 .take(max_entries)
10718 .collect::<Vec<_>>()
10719 .join("\n"),
10720 );
10721 }
10722
10723 Ok(out.trim_end().to_string())
10724}
10725
10726fn inspect_sessions(max_entries: usize) -> Result<String, String> {
10727 let mut out = String::from("Host inspection: sessions\n\n");
10728
10729 #[cfg(target_os = "windows")]
10730 {
10731 out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
10732 let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
10733 "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
10734}"#;
10735 if let Ok(o) = Command::new("powershell")
10736 .args(["-NoProfile", "-Command", script])
10737 .output()
10738 {
10739 let text = String::from_utf8_lossy(&o.stdout);
10740 let lines: Vec<&str> = text.lines().collect();
10741 if lines.is_empty() {
10742 out.push_str(" No active logon sessions enumerated via WMI.\n");
10743 } else {
10744 for line in lines
10745 .iter()
10746 .take(max_entries)
10747 .filter(|l| !l.trim().is_empty())
10748 {
10749 let parts: Vec<&str> = line.trim().split('|').collect();
10750 if parts.len() == 4 {
10751 let logon_type = match parts[2] {
10752 "2" => "Interactive",
10753 "3" => "Network",
10754 "4" => "Batch",
10755 "5" => "Service",
10756 "7" => "Unlock",
10757 "8" => "NetworkCleartext",
10758 "9" => "NewCredentials",
10759 "10" => "RemoteInteractive",
10760 "11" => "CachedInteractive",
10761 _ => "Other",
10762 };
10763 out.push_str(&format!(
10764 "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
10765 parts[0], logon_type, parts[1], parts[3]
10766 ));
10767 }
10768 }
10769 }
10770 } else {
10771 out.push_str(" Active logon session data unavailable from WMI.\n");
10772 }
10773 }
10774
10775 #[cfg(not(target_os = "windows"))]
10776 {
10777 out.push_str("=== Logged-in Users (who) ===\n");
10778 let who = Command::new("who")
10779 .output()
10780 .ok()
10781 .and_then(|o| String::from_utf8(o.stdout).ok())
10782 .unwrap_or_default();
10783 out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
10784 }
10785
10786 Ok(out.trim_end().to_string())
10787}
10788
10789async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
10790 let mut out = String::from("Host inspection: disk_benchmark\n\n");
10791 let mut final_path = path;
10792
10793 if !final_path.exists() {
10794 if let Ok(current_exe) = std::env::current_exe() {
10795 out.push_str(&format!(
10796 "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
10797 final_path.display()
10798 ));
10799 final_path = current_exe;
10800 } else {
10801 return Err(format!("Target not found: {}", final_path.display()));
10802 }
10803 }
10804
10805 let target = if final_path.is_dir() {
10806 let mut target_file = final_path.join("Cargo.toml");
10808 if !target_file.exists() {
10809 target_file = final_path.join("README.md");
10810 }
10811 if !target_file.exists() {
10812 return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
10813 }
10814 target_file
10815 } else {
10816 final_path
10817 };
10818
10819 out.push_str(&format!("Target: {}\n", target.display()));
10820 out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
10821
10822 #[cfg(target_os = "windows")]
10823 {
10824 let script = format!(
10825 r#"
10826$target = "{}"
10827if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
10828
10829$diskQueue = @()
10830$readStats = @()
10831$startTime = Get-Date
10832$duration = 5
10833
10834# Background reader job
10835$job = Start-Job -ScriptBlock {{
10836 param($t, $d)
10837 $stop = (Get-Date).AddSeconds($d)
10838 while ((Get-Date) -lt $stop) {{
10839 try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
10840 }}
10841}} -ArgumentList $target, $duration
10842
10843# Metrics collector loop
10844$stopTime = (Get-Date).AddSeconds($duration)
10845while ((Get-Date) -lt $stopTime) {{
10846 $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
10847 if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
10848
10849 $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
10850 if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
10851
10852 Start-Sleep -Milliseconds 250
10853}}
10854
10855Stop-Job $job
10856Receive-Job $job | Out-Null
10857Remove-Job $job
10858
10859$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
10860$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
10861$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
10862
10863"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
10864"#,
10865 target.display()
10866 );
10867
10868 let output = Command::new("powershell")
10869 .args(["-NoProfile", "-Command", &script])
10870 .output()
10871 .map_err(|e| format!("Benchmark failed: {e}"))?;
10872
10873 let raw = String::from_utf8_lossy(&output.stdout);
10874 let text = raw.trim();
10875
10876 if text.starts_with("ERROR") {
10877 return Err(text.to_string());
10878 }
10879
10880 let mut lines = text.lines();
10881 if let Some(metrics_line) = lines.next() {
10882 let parts: Vec<&str> = metrics_line.split('|').collect();
10883 let mut avg_q = "unknown".to_string();
10884 let mut max_q = "unknown".to_string();
10885 let mut avg_r = "unknown".to_string();
10886
10887 for p in parts {
10888 if let Some((k, v)) = p.split_once(':') {
10889 match k {
10890 "AVG_Q" => avg_q = v.to_string(),
10891 "MAX_Q" => max_q = v.to_string(),
10892 "AVG_R" => avg_r = v.to_string(),
10893 _ => {}
10894 }
10895 }
10896 }
10897
10898 out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
10899 out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
10900 out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
10901 out.push_str(&format!("- Disk Throughput (Avg): {} reads/sec\n", avg_r));
10902 out.push_str("\nVerdict: ");
10903 let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
10904 if q_num > 1.0 {
10905 out.push_str(
10906 "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
10907 );
10908 } else if q_num > 0.1 {
10909 out.push_str("MODERATE LOAD — significant I/O pressure detected.");
10910 } else {
10911 out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
10912 }
10913 }
10914 }
10915
10916 #[cfg(not(target_os = "windows"))]
10917 {
10918 out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
10919 out.push_str("Generic disk load simulated.\n");
10920 }
10921
10922 Ok(out)
10923}
10924
10925fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
10926 let mut out = String::from("Host inspection: permissions\n\n");
10927 out.push_str(&format!(
10928 "Auditing access control for: {}\n\n",
10929 path.display()
10930 ));
10931
10932 #[cfg(target_os = "windows")]
10933 {
10934 let script = format!(
10935 "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
10936 path.display()
10937 );
10938 let output = Command::new("powershell")
10939 .args(["-NoProfile", "-Command", &script])
10940 .output()
10941 .map_err(|e| format!("ACL check failed: {e}"))?;
10942
10943 let text = String::from_utf8_lossy(&output.stdout);
10944 if text.trim().is_empty() {
10945 out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
10946 } else {
10947 out.push_str("=== Windows NTFS Permissions ===\n");
10948 out.push_str(&text);
10949 }
10950 }
10951
10952 #[cfg(not(target_os = "windows"))]
10953 {
10954 let output = Command::new("ls")
10955 .args(["-ld", &path.to_string_lossy()])
10956 .output()
10957 .map_err(|e| format!("ls check failed: {e}"))?;
10958 out.push_str("=== Unix File Permissions ===\n");
10959 out.push_str(&String::from_utf8_lossy(&output.stdout));
10960 }
10961
10962 Ok(out.trim_end().to_string())
10963}
10964
10965fn inspect_login_history(max_entries: usize) -> Result<String, String> {
10966 let mut out = String::from("Host inspection: login_history\n\n");
10967
10968 #[cfg(target_os = "windows")]
10969 {
10970 out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
10971 out.push_str("Note: This typically requires Administrator elevation.\n\n");
10972
10973 let n = max_entries.clamp(1, 50);
10974 let script = format!(
10975 r#"try {{
10976 $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
10977 $events | ForEach-Object {{
10978 $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
10979 # Extract target user name from the XML/Properties if possible
10980 $user = $_.Properties[5].Value
10981 $type = $_.Properties[8].Value
10982 "[$time] User: $user | Type: $type"
10983 }}
10984}} catch {{ "ERROR:" + $_.Exception.Message }}"#
10985 );
10986
10987 let output = Command::new("powershell")
10988 .args(["-NoProfile", "-Command", &script])
10989 .output()
10990 .map_err(|e| format!("Login history query failed: {e}"))?;
10991
10992 let text = String::from_utf8_lossy(&output.stdout);
10993 if text.starts_with("ERROR:") {
10994 out.push_str(&format!("Unable to query Security Log: {}\n", text));
10995 } else if text.trim().is_empty() {
10996 out.push_str("No recent logon events found or access denied.\n");
10997 } else {
10998 out.push_str("=== Recent Logons (Event ID 4624) ===\n");
10999 out.push_str(&text);
11000 }
11001 }
11002
11003 #[cfg(not(target_os = "windows"))]
11004 {
11005 let output = Command::new("last")
11006 .args(["-n", &max_entries.to_string()])
11007 .output()
11008 .map_err(|e| format!("last command failed: {e}"))?;
11009 out.push_str("=== Unix Login History (last) ===\n");
11010 out.push_str(&String::from_utf8_lossy(&output.stdout));
11011 }
11012
11013 Ok(out.trim_end().to_string())
11014}
11015
11016fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11017 let mut out = String::from("Host inspection: share_access\n\n");
11018 out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11019
11020 #[cfg(target_os = "windows")]
11021 {
11022 let script = format!(
11023 r#"
11024$p = '{}'
11025$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11026if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11027 $res.Reachable = $true
11028 try {{
11029 $null = Get-ChildItem -Path $p -ErrorAction Stop
11030 $res.Readable = $true
11031 }} catch {{
11032 $res.Error = $_.Exception.Message
11033 }}
11034}} else {{
11035 $res.Error = "Server unreachable (Ping failed)"
11036}}
11037"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11038 path.display()
11039 );
11040
11041 let output = Command::new("powershell")
11042 .args(["-NoProfile", "-Command", &script])
11043 .output()
11044 .map_err(|e| format!("Share test failed: {e}"))?;
11045
11046 let text = String::from_utf8_lossy(&output.stdout);
11047 out.push_str("=== Share Triage Results ===\n");
11048 out.push_str(&text);
11049 }
11050
11051 #[cfg(not(target_os = "windows"))]
11052 {
11053 out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11054 }
11055
11056 Ok(out.trim_end().to_string())
11057}
11058
11059fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11060 let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11061 out.push_str(&format!("Issue: {}\n\n", issue));
11062 out.push_str("Proposed Remediation Steps:\n");
11063 out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11064 out.push_str(" `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11065 out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11066 out.push_str(" `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11067 out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11068 out.push_str(" `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11069 out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11070 out.push_str(
11071 " `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11072 );
11073
11074 Ok(out)
11075}
11076
11077fn inspect_registry_audit() -> Result<String, String> {
11078 let mut out = String::from("Host inspection: registry_audit\n\n");
11079 out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11080
11081 #[cfg(target_os = "windows")]
11082 {
11083 let script = r#"
11084$findings = @()
11085
11086# 1. Image File Execution Options (Debugger Hijacking)
11087$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11088if (Test-Path $ifeo) {
11089 Get-ChildItem $ifeo | ForEach-Object {
11090 $p = Get-ItemProperty $_.PSPath
11091 if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11092 }
11093}
11094
11095# 2. Winlogon Shell Integrity
11096$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11097$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11098if ($shell -and $shell -ne "explorer.exe") {
11099 $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11100}
11101
11102# 3. Session Manager BootExecute
11103$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11104$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11105if ($boot -and $boot -notcontains "autocheck autochk *") {
11106 $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11107}
11108
11109if ($findings.Count -eq 0) {
11110 "PASS: No common registry hijacking or shell overrides detected."
11111} else {
11112 $findings -join "`n"
11113}
11114"#;
11115 let output = Command::new("powershell")
11116 .args(["-NoProfile", "-Command", &script])
11117 .output()
11118 .map_err(|e| format!("Registry audit failed: {e}"))?;
11119
11120 let text = String::from_utf8_lossy(&output.stdout);
11121 out.push_str("=== Persistence & Integrity Check ===\n");
11122 out.push_str(&text);
11123 }
11124
11125 #[cfg(not(target_os = "windows"))]
11126 {
11127 out.push_str("Registry auditing is specific to Windows environments.\n");
11128 }
11129
11130 Ok(out.trim_end().to_string())
11131}
11132
11133fn inspect_thermal() -> Result<String, String> {
11134 let mut out = String::from("Host inspection: thermal\n\n");
11135 out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11136
11137 #[cfg(target_os = "windows")]
11138 {
11139 let script = r#"
11140$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11141if ($thermal) {
11142 $thermal | ForEach-Object {
11143 $temp = [math]::Round(($_.Temperature - 273.15), 1)
11144 "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11145 }
11146} else {
11147 "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11148 $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11149 "Current CPU Load: $throttling%"
11150}
11151"#;
11152 let output = Command::new("powershell")
11153 .args(["-NoProfile", "-Command", script])
11154 .output()
11155 .map_err(|e| format!("Thermal check failed: {e}"))?;
11156 out.push_str("=== Windows Thermal State ===\n");
11157 out.push_str(&String::from_utf8_lossy(&output.stdout));
11158 }
11159
11160 #[cfg(not(target_os = "windows"))]
11161 {
11162 out.push_str(
11163 "Thermal inspection is currently optimized for Windows performance counters.\n",
11164 );
11165 }
11166
11167 Ok(out.trim_end().to_string())
11168}
11169
11170fn inspect_activation() -> Result<String, String> {
11171 let mut out = String::from("Host inspection: activation\n\n");
11172 out.push_str("Auditing Windows activation and license state...\n\n");
11173
11174 #[cfg(target_os = "windows")]
11175 {
11176 let script = r#"
11177$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11178$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11179"Status: $($xpr.Trim())"
11180"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11181"#;
11182 let output = Command::new("powershell")
11183 .args(["-NoProfile", "-Command", script])
11184 .output()
11185 .map_err(|e| format!("Activation check failed: {e}"))?;
11186 out.push_str("=== Windows License Report ===\n");
11187 out.push_str(&String::from_utf8_lossy(&output.stdout));
11188 }
11189
11190 #[cfg(not(target_os = "windows"))]
11191 {
11192 out.push_str("Windows activation check is specific to the Windows platform.\n");
11193 }
11194
11195 Ok(out.trim_end().to_string())
11196}
11197
11198fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11199 let mut out = String::from("Host inspection: patch_history\n\n");
11200 out.push_str(&format!(
11201 "Listing the last {} installed Windows updates (KBs)...\n\n",
11202 max_entries
11203 ));
11204
11205 #[cfg(target_os = "windows")]
11206 {
11207 let n = max_entries.clamp(1, 50);
11208 let script = format!(
11209 "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11210 n
11211 );
11212 let output = Command::new("powershell")
11213 .args(["-NoProfile", "-Command", &script])
11214 .output()
11215 .map_err(|e| format!("Patch history query failed: {e}"))?;
11216 out.push_str("=== Recent HotFixes (KBs) ===\n");
11217 out.push_str(&String::from_utf8_lossy(&output.stdout));
11218 }
11219
11220 #[cfg(not(target_os = "windows"))]
11221 {
11222 out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11223 }
11224
11225 Ok(out.trim_end().to_string())
11226}
11227
11228fn inspect_ad_user(identity: &str) -> Result<String, String> {
11231 let mut out = String::from("Host inspection: ad_user\n\n");
11232 let ident = identity.trim();
11233 if ident.is_empty() {
11234 out.push_str("Status: No identity specified. Performing self-discovery...\n");
11235 #[cfg(target_os = "windows")]
11236 {
11237 let script = r#"
11238$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11239"USER: " + $u.Name
11240"SID: " + $u.User.Value
11241"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11242"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11243"#;
11244 let output = Command::new("powershell")
11245 .args(["-NoProfile", "-Command", script])
11246 .output()
11247 .ok();
11248 if let Some(o) = output {
11249 out.push_str(&String::from_utf8_lossy(&o.stdout));
11250 }
11251 }
11252 return Ok(out);
11253 }
11254
11255 #[cfg(target_os = "windows")]
11256 {
11257 let script = format!(
11258 r#"
11259try {{
11260 $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11261 "NAME: " + $u.Name
11262 "SID: " + $u.SID
11263 "ENABLED: " + $u.Enabled
11264 "EXPIRED: " + $u.PasswordExpired
11265 "LOGON: " + $u.LastLogonDate
11266 "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11267}} catch {{
11268 # Fallback to net user if AD module is missing or fails
11269 $net = net user "{ident}" /domain 2>&1
11270 if ($LASTEXITCODE -eq 0) {{
11271 $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11272 }} else {{
11273 "ERROR: " + $_.Exception.Message
11274 }}
11275}}"#
11276 );
11277
11278 let output = Command::new("powershell")
11279 .args(["-NoProfile", "-Command", &script])
11280 .output()
11281 .ok();
11282
11283 if let Some(o) = output {
11284 let stdout = String::from_utf8_lossy(&o.stdout);
11285 if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11286 out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11287 }
11288 out.push_str(&stdout);
11289 }
11290 }
11291
11292 #[cfg(not(target_os = "windows"))]
11293 {
11294 let _ = ident;
11295 out.push_str("(AD User lookup only available on Windows nodes)\n");
11296 }
11297
11298 Ok(out.trim_end().to_string())
11299}
11300
11301fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11304 let mut out = String::from("Host inspection: dns_lookup\n\n");
11305 let target = name.trim();
11306 if target.is_empty() {
11307 return Err("Missing required target name for dns_lookup.".to_string());
11308 }
11309
11310 #[cfg(target_os = "windows")]
11311 {
11312 let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11313 let output = Command::new("powershell")
11314 .args(["-NoProfile", "-Command", &script])
11315 .output()
11316 .ok();
11317 if let Some(o) = output {
11318 let stdout = String::from_utf8_lossy(&o.stdout);
11319 if stdout.trim().is_empty() {
11320 out.push_str(&format!("No {record_type} records found for {target}.\n"));
11321 } else {
11322 out.push_str(&stdout);
11323 }
11324 }
11325 }
11326
11327 #[cfg(not(target_os = "windows"))]
11328 {
11329 let output = Command::new("dig")
11330 .args([target, record_type, "+short"])
11331 .output()
11332 .ok();
11333 if let Some(o) = output {
11334 out.push_str(&String::from_utf8_lossy(&o.stdout));
11335 }
11336 }
11337
11338 Ok(out.trim_end().to_string())
11339}
11340
11341fn inspect_hyperv() -> Result<String, String> {
11344 let mut out = String::from("Host inspection: hyperv\n\n");
11345
11346 #[cfg(target_os = "windows")]
11347 {
11348 let script = "Get-VM -ErrorAction SilentlyContinue | Select-Object Name, State, Uptime, Status, CPUUsage, MemoryAssigned | Format-Table -AutoSize";
11349 let output = Command::new("powershell")
11350 .args(["-NoProfile", "-Command", script])
11351 .output()
11352 .ok();
11353 if let Some(o) = output {
11354 let stdout = String::from_utf8_lossy(&o.stdout);
11355 if stdout.trim().is_empty() {
11356 out.push_str(
11357 "No Hyper-V Virtual Machines found or Hyper-V module not installed.\n",
11358 );
11359 } else {
11360 out.push_str(&stdout);
11361 }
11362 }
11363 }
11364
11365 #[cfg(not(target_os = "windows"))]
11366 {
11367 out.push_str("(Hyper-V lookup only available on Windows hosts)\n");
11368 }
11369
11370 Ok(out.trim_end().to_string())
11371}
11372
11373fn inspect_ip_config() -> Result<String, String> {
11376 let mut out = String::from("Host inspection: ip_config\n\n");
11377
11378 #[cfg(target_os = "windows")]
11379 {
11380 let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
11381 $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
11382 '\\n Status: ' + $_.NetAdapter.Status + \
11383 '\\n Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
11384 '\\n DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
11385 '\\n DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11386 '\\n IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11387 '\\n DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
11388 }";
11389 let output = Command::new("powershell")
11390 .args(["-NoProfile", "-Command", script])
11391 .output()
11392 .ok();
11393 if let Some(o) = output {
11394 out.push_str(&String::from_utf8_lossy(&o.stdout));
11395 }
11396 }
11397
11398 #[cfg(not(target_os = "windows"))]
11399 {
11400 let output = Command::new("ip").args(["addr", "show"]).output().ok();
11401 if let Some(o) = output {
11402 out.push_str(&String::from_utf8_lossy(&o.stdout));
11403 }
11404 }
11405
11406 Ok(out.trim_end().to_string())
11407}
11408
11409#[cfg(target_os = "windows")]
11410fn gpu_voltage_telemetry_note() -> String {
11411 let output = Command::new("nvidia-smi")
11412 .args(["--help-query-gpu"])
11413 .output();
11414
11415 match output {
11416 Ok(o) => {
11417 let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
11418 if text.contains("\"voltage\"") || text.contains("voltage.") {
11419 "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
11420 } else {
11421 "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()
11422 }
11423 }
11424 Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
11425 }
11426}
11427
11428#[cfg(target_os = "windows")]
11429fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
11430 if raw == 0 {
11431 return None;
11432 }
11433 if raw & 0x80 != 0 {
11434 let tenths = raw & 0x7f;
11435 return Some(format!(
11436 "{:.1} V (firmware-reported WMI current voltage)",
11437 tenths as f64 / 10.0
11438 ));
11439 }
11440
11441 let legacy = match raw {
11442 1 => Some("5.0 V"),
11443 2 => Some("3.3 V"),
11444 4 => Some("2.9 V"),
11445 _ => None,
11446 }?;
11447 Some(format!(
11448 "{} (legacy WMI voltage capability flag, not live telemetry)",
11449 legacy
11450 ))
11451}
11452
11453async fn inspect_overclocker() -> Result<String, String> {
11454 let mut out = String::from("Host inspection: overclocker\n\n");
11455
11456 #[cfg(target_os = "windows")]
11457 {
11458 out.push_str(
11459 "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
11460 );
11461
11462 let nvidia = Command::new("nvidia-smi")
11464 .args([
11465 "--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",
11466 "--format=csv,noheader,nounits",
11467 ])
11468 .output();
11469
11470 if let Ok(o) = nvidia {
11471 let stdout = String::from_utf8_lossy(&o.stdout);
11472 if !stdout.trim().is_empty() {
11473 out.push_str("=== GPU SENSE (NVIDIA) ===\n");
11474 let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
11475 if parts.len() >= 10 {
11476 out.push_str(&format!("- Model: {}\n", parts[0]));
11477 out.push_str(&format!("- Graphics: {} MHz\n", parts[1]));
11478 out.push_str(&format!("- Memory: {} MHz\n", parts[2]));
11479 out.push_str(&format!("- Fan Speed: {}%\n", parts[3]));
11480 out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
11481 if !parts[6].eq_ignore_ascii_case("[N/A]") {
11482 out.push_str(&format!("- Power Avg: {} W\n", parts[6]));
11483 }
11484 if !parts[7].eq_ignore_ascii_case("[N/A]") {
11485 out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
11486 }
11487 if !parts[8].eq_ignore_ascii_case("[N/A]") {
11488 out.push_str(&format!("- Power Cap: {} W requested\n", parts[8]));
11489 }
11490 if !parts[9].eq_ignore_ascii_case("[N/A]") {
11491 out.push_str(&format!("- Power Enf: {} W enforced\n", parts[9]));
11492 }
11493 out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
11494
11495 if parts.len() > 10 {
11496 let throttle_hex = parts[10];
11497 let reasons = decode_nvidia_throttle_reasons(throttle_hex);
11498 if !reasons.is_empty() {
11499 out.push_str(&format!("- Throttling: YES [Reason: {}]\n", reasons));
11500 } else {
11501 out.push_str("- Throttling: None (Performance State: Max)\n");
11502 }
11503 }
11504 }
11505 out.push_str("\n");
11506 }
11507 }
11508
11509 out.push_str("=== VOLTAGE TELEMETRY ===\n");
11510 out.push_str(&format!(
11511 "- GPU Voltage: {}\n\n",
11512 gpu_voltage_telemetry_note()
11513 ));
11514
11515 let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
11517 let history = gpu_state.history.lock().unwrap();
11518 if history.len() >= 2 {
11519 out.push_str("=== SILICON TRENDS (Session) ===\n");
11520 let first = history.front().unwrap();
11521 let last = history.back().unwrap();
11522
11523 let temp_diff = last.temperature as i32 - first.temperature as i32;
11524 let clock_diff = last.core_clock as i32 - first.core_clock as i32;
11525
11526 let temp_trend = if temp_diff > 1 {
11527 "Rising"
11528 } else if temp_diff < -1 {
11529 "Falling"
11530 } else {
11531 "Stable"
11532 };
11533 let clock_trend = if clock_diff > 10 {
11534 "Increasing"
11535 } else if clock_diff < -10 {
11536 "Decreasing"
11537 } else {
11538 "Stable"
11539 };
11540
11541 out.push_str(&format!(
11542 "- Temperature: {} ({}°C anomaly)\n",
11543 temp_trend, temp_diff
11544 ));
11545 out.push_str(&format!(
11546 "- Core Clock: {} ({} MHz delta)\n",
11547 clock_trend, clock_diff
11548 ));
11549 out.push_str("\n");
11550 }
11551
11552 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))\" }";
11554 let cpu_stats = Command::new("powershell")
11555 .args(["-NoProfile", "-Command", ps_cmd])
11556 .output();
11557
11558 if let Ok(o) = cpu_stats {
11559 let stdout = String::from_utf8_lossy(&o.stdout);
11560 if !stdout.trim().is_empty() {
11561 out.push_str("=== SILICON CORE (CPU) ===\n");
11562 for line in stdout.lines() {
11563 if let Some((path, val)) = line.split_once(':') {
11564 if path.to_lowercase().contains("processor frequency") {
11565 out.push_str(&format!("- Current Freq: {} MHz (2s Avg)\n", val));
11566 } else if path.to_lowercase().contains("% of maximum frequency") {
11567 out.push_str(&format!("- Throttling: {}% of Max Capacity\n", val));
11568 let throttle_num = val.parse::<f64>().unwrap_or(100.0);
11569 if throttle_num < 95.0 {
11570 out.push_str(
11571 " [WARNING] Active downclocking or power-saving detected.\n",
11572 );
11573 }
11574 }
11575 }
11576 }
11577 }
11578 }
11579
11580 let thermal = Command::new("powershell")
11582 .args([
11583 "-NoProfile",
11584 "-Command",
11585 "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
11586 ])
11587 .output();
11588 if let Ok(o) = thermal {
11589 let stdout = String::from_utf8_lossy(&o.stdout);
11590 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
11591 let temp = if v.is_array() {
11592 v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
11593 } else {
11594 v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
11595 };
11596 if temp > 1.0 {
11597 out.push_str(&format!("- CPU Package: {}°C (ACPI Zone)\n", temp));
11598 }
11599 }
11600 }
11601
11602 let wmi = Command::new("powershell")
11604 .args([
11605 "-NoProfile",
11606 "-Command",
11607 "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
11608 ])
11609 .output();
11610
11611 if let Ok(o) = wmi {
11612 let stdout = String::from_utf8_lossy(&o.stdout);
11613 if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
11614 out.push_str("\n=== HARDWARE DNA ===\n");
11615 out.push_str(&format!(
11616 "- Rated Max: {} MHz\n",
11617 v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
11618 ));
11619 match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
11620 Some(raw) => {
11621 if let Some(decoded) = decode_wmi_processor_voltage(raw) {
11622 out.push_str(&format!("- CPU Voltage: {}\n", decoded));
11623 } else {
11624 out.push_str(
11625 "- CPU Voltage: Unavailable or non-telemetry WMI value on this firmware path\n",
11626 );
11627 }
11628 }
11629 None => out.push_str("- CPU Voltage: Unavailable on this WMI path\n"),
11630 }
11631 }
11632 }
11633 }
11634
11635 #[cfg(not(target_os = "windows"))]
11636 {
11637 out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
11638 }
11639
11640 Ok(out.trim_end().to_string())
11641}
11642
11643#[cfg(target_os = "windows")]
11645fn decode_nvidia_throttle_reasons(hex: &str) -> String {
11646 let hex = hex.trim().trim_start_matches("0x");
11647 let val = match u64::from_str_radix(hex, 16) {
11648 Ok(v) => v,
11649 Err(_) => return String::new(),
11650 };
11651
11652 if val == 0 {
11653 return String::new();
11654 }
11655
11656 let mut reasons = Vec::new();
11657 if val & 0x01 != 0 {
11658 reasons.push("GPU Idle");
11659 }
11660 if val & 0x02 != 0 {
11661 reasons.push("Applications Clocks Setting");
11662 }
11663 if val & 0x04 != 0 {
11664 reasons.push("SW Power Cap (PL1/PL2)");
11665 }
11666 if val & 0x08 != 0 {
11667 reasons.push("HW Slowdown (Thermal/Power)");
11668 }
11669 if val & 0x10 != 0 {
11670 reasons.push("Sync Boost");
11671 }
11672 if val & 0x20 != 0 {
11673 reasons.push("SW Thermal Slowdown");
11674 }
11675 if val & 0x40 != 0 {
11676 reasons.push("HW Thermal Slowdown");
11677 }
11678 if val & 0x80 != 0 {
11679 reasons.push("HW Power Brake Slowdown");
11680 }
11681 if val & 0x100 != 0 {
11682 reasons.push("Display Clock Setting");
11683 }
11684
11685 reasons.join(", ")
11686}
11687
11688#[cfg(windows)]
11691fn run_powershell(script: &str) -> Result<String, String> {
11692 use std::process::Command;
11693 let out = Command::new("powershell")
11694 .args(["-NoProfile", "-NonInteractive", "-Command", script])
11695 .output()
11696 .map_err(|e| format!("powershell launch failed: {e}"))?;
11697 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
11698}
11699
11700#[cfg(windows)]
11703fn inspect_camera(max_entries: usize) -> Result<String, String> {
11704 let mut out = String::from("=== Camera devices ===\n");
11705
11706 let ps_devices = r#"
11708Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
11709ForEach-Object {
11710 $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
11711 "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
11712}
11713"#;
11714 match run_powershell(ps_devices) {
11715 Ok(o) if !o.trim().is_empty() => {
11716 for line in o.lines().take(max_entries) {
11717 let l = line.trim();
11718 if !l.is_empty() {
11719 out.push_str(&format!("- {l}\n"));
11720 }
11721 }
11722 }
11723 _ => out.push_str("- No camera devices found via PnP\n"),
11724 }
11725
11726 out.push_str("\n=== Windows camera privacy ===\n");
11728 let ps_privacy = r#"
11729$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
11730$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
11731"Global: $global"
11732$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
11733 Where-Object { $_.PSChildName -ne 'NonPackaged' } |
11734 ForEach-Object {
11735 $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
11736 if ($v) { " $($_.PSChildName): $v" }
11737 }
11738$apps
11739"#;
11740 match run_powershell(ps_privacy) {
11741 Ok(o) if !o.trim().is_empty() => {
11742 for line in o.lines().take(max_entries) {
11743 let l = line.trim_end();
11744 if !l.is_empty() {
11745 out.push_str(&format!("{l}\n"));
11746 }
11747 }
11748 }
11749 _ => out.push_str("- Could not read camera privacy registry\n"),
11750 }
11751
11752 out.push_str("\n=== Biometric / Hello camera ===\n");
11754 let ps_bio = r#"
11755Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
11756ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
11757"#;
11758 match run_powershell(ps_bio) {
11759 Ok(o) if !o.trim().is_empty() => {
11760 for line in o.lines().take(max_entries) {
11761 let l = line.trim();
11762 if !l.is_empty() {
11763 out.push_str(&format!("- {l}\n"));
11764 }
11765 }
11766 }
11767 _ => out.push_str("- No biometric devices found\n"),
11768 }
11769
11770 let mut findings: Vec<String> = Vec::new();
11772 if out.contains("Status: Error") || out.contains("Status: Unknown") {
11773 findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
11774 }
11775 if out.contains("Global: Deny") {
11776 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());
11777 }
11778
11779 let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
11780 if findings.is_empty() {
11781 result.push_str("- No obvious camera or privacy gate issue detected.\n");
11782 result.push_str(" If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
11783 } else {
11784 for f in &findings {
11785 result.push_str(&format!("- Finding: {f}\n"));
11786 }
11787 }
11788 result.push('\n');
11789 result.push_str(&out);
11790 Ok(result)
11791}
11792
11793#[cfg(not(windows))]
11794fn inspect_camera(_max_entries: usize) -> Result<String, String> {
11795 Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
11796}
11797
11798#[cfg(windows)]
11801fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
11802 let mut out = String::from("=== Windows Hello and sign-in state ===\n");
11803
11804 let ps_hello = r#"
11806$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
11807$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
11808$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
11809"PIN-style logon path: $helloKey"
11810"WbioSrvc start type: $faceConfigured"
11811"FingerPrint key present: $pinConfigured"
11812"#;
11813 match run_powershell(ps_hello) {
11814 Ok(o) => {
11815 for line in o.lines().take(max_entries) {
11816 let l = line.trim();
11817 if !l.is_empty() {
11818 out.push_str(&format!("- {l}\n"));
11819 }
11820 }
11821 }
11822 Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
11823 }
11824
11825 out.push_str("\n=== Biometric service ===\n");
11827 let ps_bio_svc = r#"
11828$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
11829if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
11830else { "WbioSrvc not found" }
11831"#;
11832 match run_powershell(ps_bio_svc) {
11833 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
11834 Err(_) => out.push_str("- Could not query biometric service\n"),
11835 }
11836
11837 out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
11839 let ps_events = r#"
11840$cutoff = (Get-Date).AddHours(-24)
11841Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
11842ForEach-Object {
11843 $xml = [xml]$_.ToXml()
11844 $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
11845 $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
11846 "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
11847} | Select-Object -First 10
11848"#;
11849 match run_powershell(ps_events) {
11850 Ok(o) if !o.trim().is_empty() => {
11851 let count = o.lines().filter(|l| !l.trim().is_empty()).count();
11852 out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
11853 for line in o.lines().take(max_entries) {
11854 let l = line.trim();
11855 if !l.is_empty() {
11856 out.push_str(&format!(" {l}\n"));
11857 }
11858 }
11859 }
11860 _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
11861 }
11862
11863 out.push_str("\n=== Active credential providers ===\n");
11865 let ps_cp = r#"
11866Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
11867ForEach-Object {
11868 $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
11869 if ($name) { $name }
11870} | Select-Object -First 15
11871"#;
11872 match run_powershell(ps_cp) {
11873 Ok(o) if !o.trim().is_empty() => {
11874 for line in o.lines().take(max_entries) {
11875 let l = line.trim();
11876 if !l.is_empty() {
11877 out.push_str(&format!("- {l}\n"));
11878 }
11879 }
11880 }
11881 _ => out.push_str("- Could not enumerate credential providers\n"),
11882 }
11883
11884 let mut findings: Vec<String> = Vec::new();
11885 if out.contains("WbioSrvc | Status: Stopped") {
11886 findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
11887 }
11888 if out.contains("recent logon failure") && !out.contains("0 recent") {
11889 findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
11890 }
11891
11892 let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
11893 if findings.is_empty() {
11894 result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
11895 result.push_str(" If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
11896 } else {
11897 for f in &findings {
11898 result.push_str(&format!("- Finding: {f}\n"));
11899 }
11900 }
11901 result.push('\n');
11902 result.push_str(&out);
11903 Ok(result)
11904}
11905
11906#[cfg(not(windows))]
11907fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
11908 Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
11909}
11910
11911#[cfg(windows)]
11914fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
11915 let mut out = String::from("=== Windows Search service ===\n");
11916
11917 let ps_svc = r#"
11919$svc = Get-Service WSearch -ErrorAction SilentlyContinue
11920if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
11921else { "WSearch service not found" }
11922"#;
11923 match run_powershell(ps_svc) {
11924 Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
11925 Err(_) => out.push_str("- Could not query WSearch service\n"),
11926 }
11927
11928 out.push_str("\n=== Indexer state ===\n");
11930 let ps_idx = r#"
11931$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
11932$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
11933if ($props) {
11934 "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
11935 "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
11936 "DataDirectory: $($props.DataDirectory)"
11937} else { "Registry key not found" }
11938"#;
11939 match run_powershell(ps_idx) {
11940 Ok(o) => {
11941 for line in o.lines() {
11942 let l = line.trim();
11943 if !l.is_empty() {
11944 out.push_str(&format!("- {l}\n"));
11945 }
11946 }
11947 }
11948 Err(_) => out.push_str("- Could not read indexer registry\n"),
11949 }
11950
11951 out.push_str("\n=== Indexed locations ===\n");
11953 let ps_locs = r#"
11954$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
11955if ($comObj) {
11956 $catalog = $comObj.GetCatalog('SystemIndex')
11957 $manager = $catalog.GetCrawlScopeManager()
11958 $rules = $manager.EnumerateRoots()
11959 while ($true) {
11960 try {
11961 $root = $rules.Next(1)
11962 if ($root.Count -eq 0) { break }
11963 $r = $root[0]
11964 " $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
11965 } catch { break }
11966 }
11967} else { " COM admin interface not available (normal on non-admin sessions)" }
11968"#;
11969 match run_powershell(ps_locs) {
11970 Ok(o) if !o.trim().is_empty() => {
11971 for line in o.lines() {
11972 let l = line.trim_end();
11973 if !l.is_empty() {
11974 out.push_str(&format!("{l}\n"));
11975 }
11976 }
11977 }
11978 _ => {
11979 let ps_reg = r#"
11981Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
11982ForEach-Object { " $($_.PSChildName)" } | Select-Object -First 20
11983"#;
11984 match run_powershell(ps_reg) {
11985 Ok(o) if !o.trim().is_empty() => {
11986 for line in o.lines() {
11987 let l = line.trim_end();
11988 if !l.is_empty() {
11989 out.push_str(&format!("{l}\n"));
11990 }
11991 }
11992 }
11993 _ => out.push_str(" - Could not enumerate indexed locations\n"),
11994 }
11995 }
11996 }
11997
11998 out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
12000 let ps_evts = r#"
12001Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
12002Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
12003ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
12004"#;
12005 match run_powershell(ps_evts) {
12006 Ok(o) if !o.trim().is_empty() => {
12007 for line in o.lines() {
12008 let l = line.trim();
12009 if !l.is_empty() {
12010 out.push_str(&format!("- {l}\n"));
12011 }
12012 }
12013 }
12014 _ => out.push_str("- No recent indexer errors found\n"),
12015 }
12016
12017 let mut findings: Vec<String> = Vec::new();
12018 if out.contains("Status: Stopped") {
12019 findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
12020 }
12021 if out.contains("IsContentIndexingEnabled: 0")
12022 || out.contains("IsContentIndexingEnabled: False")
12023 {
12024 findings.push(
12025 "Content indexing is disabled — file content won't be searchable, only filenames."
12026 .into(),
12027 );
12028 }
12029 if out.contains("SetupCompletedSuccessfully: 0")
12030 || out.contains("SetupCompletedSuccessfully: False")
12031 {
12032 findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
12033 }
12034
12035 let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
12036 if findings.is_empty() {
12037 result.push_str("- Windows Search service and indexer appear healthy.\n");
12038 result.push_str(" If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
12039 } else {
12040 for f in &findings {
12041 result.push_str(&format!("- Finding: {f}\n"));
12042 }
12043 }
12044 result.push('\n');
12045 result.push_str(&out);
12046 Ok(result)
12047}
12048
12049#[cfg(not(windows))]
12050fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
12051 Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
12052}