Skip to main content

windows_erg/system/
mod.rs

1//! Host system inventory (native Windows API and registry).
2//!
3//! This module intentionally avoids WMI and uses native APIs plus registry reads.
4
5mod types;
6
7use std::borrow::Cow;
8use std::collections::HashSet;
9use std::net::{Ipv4Addr, Ipv6Addr};
10
11use windows::Win32::Foundation::HANDLE;
12use windows::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_NOT_ALL_ASSIGNED, GetLastError};
13use windows::Win32::NetworkManagement::IpHelper::{
14    GAA_FLAG_INCLUDE_PREFIX, GET_ADAPTERS_ADDRESSES_FLAGS, GetAdaptersAddresses,
15    IP_ADAPTER_ADDRESSES_LH,
16};
17use windows::Win32::Networking::WinSock::{
18    AF_INET, AF_INET6, AF_UNSPEC, SOCKADDR_IN, SOCKADDR_IN6,
19};
20use windows::Win32::Security::{
21    AdjustTokenPrivileges, LookupPrivilegeValueW, SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES,
22    TOKEN_PRIVILEGES, TOKEN_QUERY,
23};
24use windows::Win32::Storage::FileSystem::{
25    CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_MODE, FILE_SHARE_READ, FILE_SHARE_WRITE,
26    GetDiskFreeSpaceExW, GetLogicalDriveStringsW, GetVolumeInformationW, OPEN_EXISTING,
27};
28use windows::Win32::System::IO::DeviceIoControl;
29use windows::Win32::System::Ioctl::{GET_LENGTH_INFORMATION, IOCTL_DISK_GET_LENGTH_INFO};
30use windows::Win32::System::Shutdown::{
31    InitiateSystemShutdownExW, SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_OTHER,
32    SHTDN_REASON_MINOR_OTHER, SHUTDOWN_REASON,
33};
34use windows::Win32::System::SystemInformation::{
35    FIRMWARE_TABLE_PROVIDER, GetSystemFirmwareTable, OSVERSIONINFOW,
36};
37use windows::Win32::System::Threading::GetCurrentProcess;
38use windows::Win32::System::Threading::OpenProcessToken;
39use windows::core::{HRESULT, PCWSTR, PWSTR};
40
41use crate::error::{AccessDeniedError, Error, Result, WindowsApiError};
42use crate::registry::{self, Hive};
43use crate::utils::{OwnedHandle, pwstr_to_string, to_utf16_nul};
44
45pub use types::{
46    BiosInfo, GuidInfo, HostIdentity, HostSnapshot, LogicalDiskInfo, MachineGuid,
47    NetworkInterfaceInfo, OsInfo, PhysicalDiskInfo, PowerAction, PowerActionOptions,
48    SnapshotSection, SnapshotSectionError, UserInfo,
49};
50
51/// Collect a host inventory snapshot.
52///
53/// The snapshot is resilient by design: section failures are recorded in
54/// `section_errors` and collection continues.
55pub fn snapshot() -> HostSnapshot {
56    let mut section_errors = Vec::new();
57
58    let identity = match hostname() {
59        Ok(hostname) => HostIdentity { hostname },
60        Err(err) => {
61            section_errors.push(section_error(SnapshotSection::Identity, err));
62            HostIdentity {
63                hostname: "unknown".to_string(),
64            }
65        }
66    };
67
68    let os = match os_info() {
69        Ok(os) => os,
70        Err(err) => {
71            section_errors.push(section_error(SnapshotSection::Os, err));
72            OsInfo {
73                product_name: None,
74                release_label: None,
75                build_number: 0,
76                major_version: 0,
77                minor_version: 0,
78            }
79        }
80    };
81
82    let guids = match guid_info() {
83        Ok(guids) => guids,
84        Err(err) => {
85            section_errors.push(section_error(SnapshotSection::Guids, err));
86            GuidInfo {
87                machine_guid: None,
88                firmware_guid: None,
89            }
90        }
91    };
92
93    let bios = match bios_info() {
94        Ok(bios) => bios,
95        Err(err) => {
96            section_errors.push(section_error(SnapshotSection::Bios, err));
97            None
98        }
99    };
100
101    let logical_disks = match logical_disks() {
102        Ok(disks) => disks,
103        Err(err) => {
104            section_errors.push(section_error(SnapshotSection::LogicalDisks, err));
105            Vec::new()
106        }
107    };
108
109    let physical_disks = match physical_disks() {
110        Ok(disks) => disks,
111        Err(err) => {
112            section_errors.push(section_error(SnapshotSection::PhysicalDisks, err));
113            Vec::new()
114        }
115    };
116
117    let networks = match network_interfaces() {
118        Ok(interfaces) => interfaces,
119        Err(err) => {
120            section_errors.push(section_error(SnapshotSection::Network, err));
121            Vec::new()
122        }
123    };
124
125    let users = match users() {
126        Ok(users) => users,
127        Err(err) => {
128            section_errors.push(section_error(SnapshotSection::Users, err));
129            Vec::new()
130        }
131    };
132
133    HostSnapshot {
134        identity,
135        os,
136        guids,
137        bios,
138        logical_disks,
139        physical_disks,
140        networks,
141        users,
142        section_errors,
143    }
144}
145
146/// Get hostname via native API with environment fallback.
147pub fn hostname() -> Result<String> {
148    if let Ok(value) = std::env::var("COMPUTERNAME")
149        && !value.trim().is_empty()
150    {
151        return Ok(value);
152    }
153
154    let value = registry::read_string(
155        Hive::LocalMachine,
156        r"SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName",
157        "ComputerName",
158    )?;
159
160    if value.trim().is_empty() {
161        return Err(Error::Other(crate::error::OtherError::new(
162            "hostname is empty",
163        )));
164    }
165
166    Ok(value)
167}
168
169/// Read OS version and product name.
170pub fn os_info() -> Result<OsInfo> {
171    let mut version = OSVERSIONINFOW {
172        dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32,
173        ..Default::default()
174    };
175
176    query_real_os_version(&mut version)?;
177
178    let product_name = resolve_product_name(
179        version.dwMajorVersion,
180        version.dwMinorVersion,
181        version.dwBuildNumber,
182    );
183
184    let release_label = derive_release_label(
185        version.dwMajorVersion,
186        version.dwMinorVersion,
187        version.dwBuildNumber,
188    );
189
190    Ok(OsInfo {
191        product_name,
192        release_label,
193        build_number: version.dwBuildNumber,
194        major_version: version.dwMajorVersion,
195        minor_version: version.dwMinorVersion,
196    })
197}
198
199fn query_real_os_version(out_version: &mut OSVERSIONINFOW) -> Result<()> {
200    #[link(name = "ntdll")]
201    unsafe extern "system" {
202        fn RtlGetVersion(lpVersionInformation: *mut OSVERSIONINFOW) -> i32;
203    }
204
205    let status = unsafe { RtlGetVersion(out_version as *mut OSVERSIONINFOW) };
206    if status < 0 {
207        return Err(Error::Other(crate::error::OtherError::new(Cow::Owned(
208            format!("RtlGetVersion failed with NTSTATUS 0x{status:08X}"),
209        ))));
210    }
211
212    Ok(())
213}
214
215/// Get machine and firmware GUID information.
216pub fn guid_info() -> Result<GuidInfo> {
217    let machine_guid = machine_guid().ok();
218    let firmware_guid = firmware_guid_from_smbios().ok().flatten();
219
220    Ok(GuidInfo {
221        machine_guid,
222        firmware_guid,
223    })
224}
225
226/// Read machine GUID from registry.
227pub fn machine_guid() -> Result<MachineGuid> {
228    let guid = registry::read_string(
229        Hive::LocalMachine,
230        r"SOFTWARE\Microsoft\Cryptography",
231        "MachineGuid",
232    )?;
233
234    if guid.trim().is_empty() {
235        return Err(Error::Other(crate::error::OtherError::new(
236            "MachineGuid registry value is empty",
237        )));
238    }
239
240    Ok(MachineGuid::new(guid))
241}
242
243/// Read BIOS info from registry-provided firmware descriptors.
244pub fn bios_info() -> Result<Option<BiosInfo>> {
245    let path = r"HARDWARE\DESCRIPTION\System\BIOS";
246
247    let vendor = registry::read_string(Hive::LocalMachine, path, "BIOSVendor").ok();
248    let version = registry::read_string(Hive::LocalMachine, path, "BIOSVersion").ok();
249    let release_date = registry::read_string(Hive::LocalMachine, path, "BIOSReleaseDate").ok();
250    let system_manufacturer =
251        registry::read_string(Hive::LocalMachine, path, "SystemManufacturer").ok();
252    let system_product_name =
253        registry::read_string(Hive::LocalMachine, path, "SystemProductName").ok();
254
255    if vendor.is_none()
256        && version.is_none()
257        && release_date.is_none()
258        && system_manufacturer.is_none()
259        && system_product_name.is_none()
260    {
261        return Ok(None);
262    }
263
264    Ok(Some(BiosInfo {
265        vendor,
266        version,
267        release_date,
268        system_manufacturer,
269        system_product_name,
270    }))
271}
272
273/// List logical disks.
274pub fn logical_disks() -> Result<Vec<LogicalDiskInfo>> {
275    let mut out_disks = Vec::with_capacity(16);
276    logical_disks_with_filter(&mut out_disks, |_| true)?;
277    Ok(out_disks)
278}
279
280/// Fill caller-provided logical disk buffer.
281pub fn logical_disks_with_buffer(out_disks: &mut Vec<LogicalDiskInfo>) -> Result<usize> {
282    logical_disks_with_filter(out_disks, |_| true)
283}
284
285/// Fill caller-provided logical disk buffer with in-enumeration filtering.
286pub fn logical_disks_with_filter<F>(
287    out_disks: &mut Vec<LogicalDiskInfo>,
288    filter: F,
289) -> Result<usize>
290where
291    F: Fn(&LogicalDiskInfo) -> bool,
292{
293    out_disks.clear();
294
295    let mut work_buffer = vec![0u16; 512];
296    let chars = unsafe { GetLogicalDriveStringsW(Some(&mut work_buffer)) } as usize;
297
298    if chars == 0 {
299        return Err(Error::WindowsApi(WindowsApiError::with_context(
300            windows::core::Error::from_win32(),
301            "GetLogicalDriveStringsW",
302        )));
303    }
304
305    if chars > work_buffer.len() {
306        work_buffer.resize(chars, 0);
307        let second = unsafe { GetLogicalDriveStringsW(Some(&mut work_buffer)) } as usize;
308        if second == 0 {
309            return Err(Error::WindowsApi(WindowsApiError::with_context(
310                windows::core::Error::from_win32(),
311                "GetLogicalDriveStringsW",
312            )));
313        }
314    }
315
316    for root in parse_multi_sz(&work_buffer) {
317        let root_wide = to_utf16_nul(&root);
318
319        let mut free_bytes_available = 0u64;
320        let mut total_bytes = 0u64;
321        let mut total_free_bytes = 0u64;
322
323        unsafe {
324            GetDiskFreeSpaceExW(
325                windows::core::PCWSTR(root_wide.as_ptr()),
326                Some(&mut free_bytes_available),
327                Some(&mut total_bytes),
328                Some(&mut total_free_bytes),
329            )
330        }
331        .map_err(|e| Error::WindowsApi(WindowsApiError::with_context(e, "GetDiskFreeSpaceExW")))?;
332
333        let mut label = vec![0u16; 261];
334        let mut fs = vec![0u16; 261];
335        let mut serial = 0u32;
336        let mut max_comp_len = 0u32;
337        let mut flags = 0u32;
338
339        let _ = unsafe {
340            GetVolumeInformationW(
341                windows::core::PCWSTR(root_wide.as_ptr()),
342                Some(&mut label),
343                Some(&mut serial),
344                Some(&mut max_comp_len),
345                Some(&mut flags),
346                Some(&mut fs),
347            )
348        };
349
350        let disk = LogicalDiskInfo {
351            root,
352            volume_label: first_nul_terminated(&label),
353            file_system: first_nul_terminated(&fs),
354            total_bytes,
355            free_bytes_available,
356            total_free_bytes,
357        };
358
359        if filter(&disk) {
360            out_disks.push(disk);
361        }
362    }
363
364    Ok(out_disks.len())
365}
366
367/// Enumerate physical disks.
368///
369/// Initial implementation uses a conservative probe and returns disks with known size
370/// when available. This can be expanded to richer metadata in follow-up phases.
371pub fn physical_disks() -> Result<Vec<PhysicalDiskInfo>> {
372    let mut out_disks = Vec::with_capacity(8);
373    physical_disks_with_filter(&mut out_disks, |_| true)?;
374    Ok(out_disks)
375}
376
377/// Fill caller-provided physical disk buffer.
378pub fn physical_disks_with_buffer(out_disks: &mut Vec<PhysicalDiskInfo>) -> Result<usize> {
379    physical_disks_with_filter(out_disks, |_| true)
380}
381
382/// Fill caller-provided physical disk buffer with in-enumeration filtering.
383pub fn physical_disks_with_filter<F>(
384    out_disks: &mut Vec<PhysicalDiskInfo>,
385    filter: F,
386) -> Result<usize>
387where
388    F: Fn(&PhysicalDiskInfo) -> bool,
389{
390    out_disks.clear();
391
392    for index in 0..64 {
393        let path = format!(r"\\.\PhysicalDrive{}", index);
394        let path_wide = to_utf16_nul(&path);
395
396        let handle = match unsafe {
397            CreateFileW(
398                PCWSTR::from_raw(path_wide.as_ptr()),
399                windows::Win32::Foundation::GENERIC_READ.0,
400                FILE_SHARE_MODE(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0),
401                None,
402                OPEN_EXISTING,
403                FILE_FLAGS_AND_ATTRIBUTES(0),
404                None,
405            )
406        } {
407            Ok(handle) => OwnedHandle::new(handle),
408            Err(_) => continue,
409        };
410
411        let mut length = GET_LENGTH_INFORMATION::default();
412        let mut bytes_returned = 0u32;
413
414        let ok = unsafe {
415            DeviceIoControl(
416                handle.raw(),
417                IOCTL_DISK_GET_LENGTH_INFO,
418                None,
419                0,
420                Some(std::ptr::addr_of_mut!(length) as *mut _),
421                std::mem::size_of::<GET_LENGTH_INFORMATION>() as u32,
422                Some(&mut bytes_returned),
423                None,
424            )
425        }
426        .is_ok();
427
428        if !ok {
429            continue;
430        }
431
432        let disk = PhysicalDiskInfo {
433            path,
434            size_bytes: length.Length.max(0) as u64,
435        };
436
437        if filter(&disk) {
438            out_disks.push(disk);
439        }
440    }
441
442    Ok(out_disks.len())
443}
444
445/// Enumerate network interfaces.
446///
447/// Native adapter traversal (IpHelper) is added in a follow-up patch.
448pub fn network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
449    let mut out_interfaces = Vec::with_capacity(8);
450    network_interfaces_with_filter(&mut out_interfaces, |_| true)?;
451    Ok(out_interfaces)
452}
453
454/// Fill caller-provided network interface buffer.
455pub fn network_interfaces_with_buffer(
456    out_interfaces: &mut Vec<NetworkInterfaceInfo>,
457) -> Result<usize> {
458    network_interfaces_with_filter(out_interfaces, |_| true)
459}
460
461/// Fill caller-provided network interface buffer with in-enumeration filtering.
462pub fn network_interfaces_with_filter<F>(
463    out_interfaces: &mut Vec<NetworkInterfaceInfo>,
464    filter: F,
465) -> Result<usize>
466where
467    F: Fn(&NetworkInterfaceInfo) -> bool,
468{
469    out_interfaces.clear();
470    let mut buffer_len = 16_384u32;
471    let mut work_buffer = vec![0u8; buffer_len as usize];
472
473    let flags = GET_ADAPTERS_ADDRESSES_FLAGS(GAA_FLAG_INCLUDE_PREFIX.0);
474    let mut status = unsafe {
475        GetAdaptersAddresses(
476            AF_UNSPEC.0 as u32,
477            flags,
478            None,
479            Some(work_buffer.as_mut_ptr() as *mut IP_ADAPTER_ADDRESSES_LH),
480            &mut buffer_len,
481        )
482    };
483
484    // ERROR_BUFFER_OVERFLOW
485    if status == 111 {
486        work_buffer.resize(buffer_len as usize, 0);
487        status = unsafe {
488            GetAdaptersAddresses(
489                AF_UNSPEC.0 as u32,
490                flags,
491                None,
492                Some(work_buffer.as_mut_ptr() as *mut IP_ADAPTER_ADDRESSES_LH),
493                &mut buffer_len,
494            )
495        };
496    }
497
498    if status != 0 {
499        return Err(Error::Other(crate::error::OtherError::new(Cow::Owned(
500            format!("GetAdaptersAddresses failed with status {}", status),
501        ))));
502    }
503
504    let mut adapter_ptr = work_buffer.as_mut_ptr() as *mut IP_ADAPTER_ADDRESSES_LH;
505
506    while !adapter_ptr.is_null() {
507        let adapter = unsafe { &*adapter_ptr };
508
509        let name = pwstr_to_string(adapter.FriendlyName)
510            .or_else(|| pwstr_to_string(adapter.Description))
511            .unwrap_or_else(|| "unknown".to_string());
512
513        let mac_address = format_mac(
514            &adapter.PhysicalAddress,
515            adapter.PhysicalAddressLength as usize,
516        );
517
518        let addresses = collect_unicast_addresses(adapter.FirstUnicastAddress);
519
520        let iface = NetworkInterfaceInfo {
521            name,
522            mac_address,
523            addresses,
524        };
525
526        if filter(&iface) {
527            out_interfaces.push(iface);
528        }
529
530        adapter_ptr = adapter.Next;
531    }
532
533    Ok(out_interfaces.len())
534}
535
536/// Enumerate users from profile list and current process context.
537pub fn users() -> Result<Vec<UserInfo>> {
538    let mut out_users = Vec::new();
539    users_with_filter(&mut out_users, |_| true)?;
540    Ok(out_users)
541}
542
543/// Fill caller-provided user buffer.
544pub fn users_with_buffer(out_users: &mut Vec<UserInfo>) -> Result<usize> {
545    users_with_filter(out_users, |_| true)
546}
547
548/// Fill caller-provided user buffer with in-enumeration filtering.
549pub fn users_with_filter<F>(out_users: &mut Vec<UserInfo>, filter: F) -> Result<usize>
550where
551    F: Fn(&UserInfo) -> bool,
552{
553    out_users.clear();
554
555    let mut dedup = HashSet::new();
556
557    if let Ok(username) = std::env::var("USERNAME") {
558        let username = username.trim().to_string();
559        if !username.is_empty() {
560            let user = UserInfo {
561                username: username.clone(),
562                sid: None,
563                source: "current_user",
564            };
565            if dedup.insert(username) && filter(&user) {
566                out_users.push(user);
567            }
568        }
569    }
570
571    let profile_list = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList";
572    if let Ok(key) = crate::registry::RegistryKey::open(Hive::LocalMachine, profile_list)
573        && let Ok(subkeys) = key.subkeys()
574    {
575        for sid in subkeys {
576            if let Ok(profile_path) = crate::registry::read_string(
577                Hive::LocalMachine,
578                &format!(r"{}\{}", profile_list, sid),
579                "ProfileImagePath",
580            ) && let Some(username) = username_from_profile_path(&profile_path)
581            {
582                let user = UserInfo {
583                    username: username.clone(),
584                    sid: Some(sid),
585                    source: "profile_list",
586                };
587                if dedup.insert(username) && filter(&user) {
588                    out_users.push(user);
589                }
590            }
591        }
592    }
593
594    Ok(out_users.len())
595}
596
597/// Shut down the local machine, enabling `SeShutdownPrivilege` for the current process.
598pub fn shutdown(options: &PowerActionOptions) -> Result<()> {
599    enable_shutdown_privilege()?;
600    shutdown_with_enabled_privilege(options)
601}
602
603/// Shut down the local machine, assuming `SeShutdownPrivilege` is already enabled.
604pub fn shutdown_with_enabled_privilege(options: &PowerActionOptions) -> Result<()> {
605    execute_power_action(PowerAction::Shutdown, options)
606}
607
608/// Restart the local machine, enabling `SeShutdownPrivilege` for the current process.
609pub fn restart(options: &PowerActionOptions) -> Result<()> {
610    enable_shutdown_privilege()?;
611    restart_with_enabled_privilege(options)
612}
613
614/// Restart the local machine, assuming `SeShutdownPrivilege` is already enabled.
615pub fn restart_with_enabled_privilege(options: &PowerActionOptions) -> Result<()> {
616    execute_power_action(PowerAction::Restart, options)
617}
618
619fn execute_power_action(action: PowerAction, options: &PowerActionOptions) -> Result<()> {
620    let mut comment = options.comment.as_ref().and_then(|value| {
621        let trimmed = value.trim();
622        (!trimmed.is_empty()).then(|| to_utf16_nul(trimmed))
623    });
624
625    let message = comment
626        .as_mut()
627        .map_or(PWSTR::null(), |value| PWSTR(value.as_mut_ptr()));
628
629    unsafe {
630        InitiateSystemShutdownExW(
631            PWSTR::null(),
632            message,
633            options.timeout_secs,
634            options.force_apps_closed,
635            matches!(action, PowerAction::Restart),
636            shutdown_reason(options),
637        )
638    }
639    .map_err(|e| map_power_api_error(e, action, "InitiateSystemShutdownExW"))
640}
641
642fn enable_shutdown_privilege() -> Result<()> {
643    let mut token = HANDLE(std::ptr::null_mut());
644    unsafe {
645        OpenProcessToken(
646            GetCurrentProcess(),
647            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
648            &mut token,
649        )
650    }
651    .map_err(|e| {
652        map_power_api_error(
653            e,
654            PowerAction::Shutdown,
655            "OpenProcessToken for SeShutdownPrivilege",
656        )
657    })?;
658
659    let token = OwnedHandle::new(token);
660
661    let mut token_privileges = TOKEN_PRIVILEGES {
662        PrivilegeCount: 1,
663        Privileges: Default::default(),
664    };
665
666    let privilege_name = to_utf16_nul("SeShutdownPrivilege");
667    unsafe {
668        LookupPrivilegeValueW(
669            None,
670            PCWSTR(privilege_name.as_ptr()),
671            &mut token_privileges.Privileges[0].Luid,
672        )
673    }
674    .map_err(|e| {
675        map_power_api_error(
676            e,
677            PowerAction::Shutdown,
678            "LookupPrivilegeValueW for SeShutdownPrivilege",
679        )
680    })?;
681
682    token_privileges.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
683
684    unsafe { AdjustTokenPrivileges(token.raw(), false, Some(&token_privileges), 0, None, None) }
685        .map_err(|e| {
686            map_power_api_error(
687                e,
688                PowerAction::Shutdown,
689                "AdjustTokenPrivileges for SeShutdownPrivilege",
690            )
691        })?;
692
693    let last_error = unsafe { GetLastError() };
694    if last_error == ERROR_NOT_ALL_ASSIGNED {
695        return Err(Error::AccessDenied(AccessDeniedError::with_reason(
696            "local machine",
697            "shutdown privilege",
698            "Privilege 'SeShutdownPrivilege' was not assigned to current token",
699        )));
700    }
701
702    Ok(())
703}
704
705fn shutdown_reason(options: &PowerActionOptions) -> SHUTDOWN_REASON {
706    let mut reason = options
707        .reason_code
708        .unwrap_or(SHTDN_REASON_MAJOR_OTHER.0 | SHTDN_REASON_MINOR_OTHER.0);
709
710    if options.planned {
711        reason |= SHTDN_REASON_FLAG_PLANNED.0;
712    }
713
714    SHUTDOWN_REASON(reason)
715}
716
717fn map_power_api_error(err: windows::core::Error, action: PowerAction, context: &str) -> Error {
718    let code = err.code().0;
719    let access_denied_hresult = HRESULT::from_win32(ERROR_ACCESS_DENIED.0).0;
720
721    if code == access_denied_hresult || code == ERROR_ACCESS_DENIED.0 as i32 {
722        let operation = match action {
723            PowerAction::Shutdown => "shutdown",
724            PowerAction::Restart => "restart",
725        };
726
727        return Error::AccessDenied(AccessDeniedError::with_reason(
728            "local machine",
729            operation,
730            Cow::Owned(format!("Access denied while calling {context}")),
731        ));
732    }
733
734    Error::WindowsApi(WindowsApiError::with_context(
735        err,
736        Cow::Owned(context.to_string()),
737    ))
738}
739
740fn section_error(section: SnapshotSection, err: Error) -> SnapshotSectionError {
741    SnapshotSectionError {
742        section,
743        message: Cow::Owned(err.to_string()),
744    }
745}
746
747fn derive_release_label(major: u32, minor: u32, build: u32) -> Option<String> {
748    match (major, minor) {
749        (10, 0) if build >= 22000 => Some("Windows 11".to_string()),
750        (10, 0) => Some("Windows 10".to_string()),
751        (6, 3) => Some("Windows 8.1".to_string()),
752        (6, 2) => Some("Windows 8".to_string()),
753        (6, 1) => Some("Windows 7".to_string()),
754        _ => None,
755    }
756}
757
758fn resolve_product_name(major: u32, minor: u32, build: u32) -> Option<String> {
759    let current_version_key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion";
760    let registry_name =
761        registry::read_string(Hive::LocalMachine, current_version_key, "ProductName").ok();
762    let edition_id =
763        registry::read_string(Hive::LocalMachine, current_version_key, "EditionID").ok();
764    let installation_type =
765        registry::read_string(Hive::LocalMachine, current_version_key, "InstallationType").ok();
766
767    resolve_product_name_from_registry(
768        major,
769        minor,
770        build,
771        registry_name,
772        edition_id,
773        installation_type,
774    )
775}
776
777fn resolve_product_name_from_registry(
778    major: u32,
779    minor: u32,
780    build: u32,
781    registry_name: Option<String>,
782    edition_id: Option<String>,
783    installation_type: Option<String>,
784) -> Option<String> {
785    let is_server = is_server_installation(installation_type.as_deref(), edition_id.as_deref());
786
787    // Microsoft often leaves ProductName as "Windows 10 ..." on Windows 11.
788    if major == 10 && minor == 0 && build >= 22000 {
789        if let Some(name) = registry_name {
790            if let Some(rest) = name.strip_prefix("Windows 10") {
791                return Some(format!("Windows 11{}", rest));
792            }
793            return Some(name);
794        }
795
796        if let Some(edition_id) = edition_id {
797            let edition = normalize_edition_id(&edition_id);
798            let family = if is_server {
799                "Windows Server"
800            } else {
801                "Windows 11"
802            };
803            let edition_suffix = if is_server {
804                edition.strip_prefix("Server ").unwrap_or(&edition)
805            } else {
806                &edition
807            };
808
809            if edition.is_empty() {
810                return Some(family.to_string());
811            }
812
813            return Some(format!("{} {}", family, edition_suffix));
814        }
815
816        return Some(if is_server {
817            "Windows Server".to_string()
818        } else {
819            "Windows 11".to_string()
820        });
821    }
822
823    registry_name
824}
825
826fn is_server_installation(installation_type: Option<&str>, edition_id: Option<&str>) -> bool {
827    installation_type
828        .map(str::trim)
829        .is_some_and(|value| value.eq_ignore_ascii_case("server"))
830        || edition_id
831            .map(str::trim)
832            .is_some_and(|value| value.starts_with("Server"))
833}
834
835fn normalize_edition_id(edition_id: &str) -> String {
836    match edition_id.trim() {
837        "Core" => "Home".to_string(),
838        "Professional" => "Pro".to_string(),
839        "EnterpriseS" => "Enterprise LTSC".to_string(),
840        "ServerStandard" => "Server Standard".to_string(),
841        "ServerDatacenter" => "Server Datacenter".to_string(),
842        "IoTEnterprise" => "IoT Enterprise".to_string(),
843        value => value.to_string(),
844    }
845}
846
847fn firmware_guid_from_smbios() -> Result<Option<String>> {
848    let provider = FIRMWARE_TABLE_PROVIDER(u32::from_le_bytes(*b"RSMB"));
849
850    let required = unsafe { GetSystemFirmwareTable(provider, 0, None) } as usize;
851
852    if required == 0 {
853        return Ok(None);
854    }
855
856    let mut work_buffer = vec![0u8; required];
857    let written =
858        unsafe { GetSystemFirmwareTable(provider, 0, Some(work_buffer.as_mut_slice())) } as usize;
859
860    if written == 0 {
861        return Err(Error::WindowsApi(WindowsApiError::with_context(
862            windows::core::Error::from_win32(),
863            "GetSystemFirmwareTable",
864        )));
865    }
866
867    if written > work_buffer.len() {
868        return Err(Error::Other(crate::error::OtherError::new(
869            "GetSystemFirmwareTable returned larger payload than buffer",
870        )));
871    }
872
873    parse_firmware_uuid_from_raw_smbios(&work_buffer)
874}
875
876fn parse_firmware_uuid_from_raw_smbios(work_buffer: &[u8]) -> Result<Option<String>> {
877    // Raw SMBIOS data starts with: Used20CallingMethod (1), SMBIOSMajor (1), SMBIOSMinor (1),
878    // DmiRevision (1), Length (4), then SMBIOS table bytes.
879    if work_buffer.len() < 8 {
880        return Ok(None);
881    }
882
883    let table_len = u32::from_le_bytes([
884        work_buffer[4],
885        work_buffer[5],
886        work_buffer[6],
887        work_buffer[7],
888    ]) as usize;
889    if work_buffer.len() < 8 + table_len {
890        return Ok(None);
891    }
892
893    let mut cursor = 8usize;
894    let end = 8 + table_len;
895
896    while cursor + 4 <= end {
897        let ty = work_buffer[cursor];
898        let len = work_buffer[cursor + 1] as usize;
899
900        if len < 4 || cursor + len > end {
901            break;
902        }
903
904        if ty == 1 && len >= 0x19 {
905            let uuid = &work_buffer[cursor + 8..cursor + 24];
906            if let Some(formatted) = format_smbios_uuid(uuid) {
907                return Ok(Some(formatted));
908            }
909        }
910
911        cursor += len;
912        while cursor + 1 < end {
913            if work_buffer[cursor] == 0 && work_buffer[cursor + 1] == 0 {
914                cursor += 2;
915                break;
916            }
917            cursor += 1;
918        }
919    }
920
921    Ok(None)
922}
923
924fn format_smbios_uuid(raw: &[u8]) -> Option<String> {
925    if raw.len() != 16 {
926        return None;
927    }
928
929    // SMBIOS UUID uses mixed endianness for the first 3 fields.
930    let d1 = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]);
931    let d2 = u16::from_le_bytes([raw[4], raw[5]]);
932    let d3 = u16::from_le_bytes([raw[6], raw[7]]);
933
934    Some(format!(
935        "{d1:08x}-{d2:04x}-{d3:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
936        raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15]
937    ))
938}
939
940fn parse_multi_sz(work_buffer: &[u16]) -> Vec<String> {
941    let mut out = Vec::new();
942    let mut start = 0usize;
943
944    for i in 0..work_buffer.len() {
945        if work_buffer[i] == 0 {
946            if i == start {
947                break;
948            }
949            out.push(String::from_utf16_lossy(&work_buffer[start..i]));
950            start = i + 1;
951        }
952    }
953
954    out
955}
956
957fn first_nul_terminated(work_buffer: &[u16]) -> Option<String> {
958    let end = work_buffer
959        .iter()
960        .position(|c| *c == 0)
961        .unwrap_or(work_buffer.len());
962    if end == 0 {
963        return None;
964    }
965    Some(String::from_utf16_lossy(&work_buffer[..end]))
966}
967
968fn username_from_profile_path(path: &str) -> Option<String> {
969    let trimmed = path.trim_end_matches(['\\', '/']);
970    let username = trimmed
971        .rsplit(['\\', '/'])
972        .next()
973        .map(str::trim)
974        .unwrap_or_default();
975
976    if username.is_empty() {
977        None
978    } else {
979        Some(username.to_string())
980    }
981}
982
983fn collect_unicast_addresses(
984    mut unicast_ptr: *mut windows::Win32::NetworkManagement::IpHelper::IP_ADAPTER_UNICAST_ADDRESS_LH,
985) -> Vec<String> {
986    let mut addresses = Vec::with_capacity(4);
987    let mut dedup = HashSet::new();
988
989    while !unicast_ptr.is_null() {
990        let unicast = unsafe { &*unicast_ptr };
991        if let Some(value) = sockaddr_to_ip_string(unicast.Address)
992            && dedup.insert(value.clone())
993        {
994            addresses.push(value);
995        }
996        unicast_ptr = unicast.Next;
997    }
998
999    addresses
1000}
1001
1002fn sockaddr_to_ip_string(
1003    socket_address: windows::Win32::Networking::WinSock::SOCKET_ADDRESS,
1004) -> Option<String> {
1005    if socket_address.lpSockaddr.is_null() {
1006        return None;
1007    }
1008
1009    let family = unsafe { (*socket_address.lpSockaddr).sa_family };
1010
1011    if family == AF_INET {
1012        let v4 = unsafe { &*(socket_address.lpSockaddr as *const SOCKADDR_IN) };
1013        let octets = unsafe {
1014            let b = v4.sin_addr.S_un.S_un_b;
1015            [b.s_b1, b.s_b2, b.s_b3, b.s_b4]
1016        };
1017        return Some(Ipv4Addr::from(octets).to_string());
1018    }
1019
1020    if family == AF_INET6 {
1021        let v6 = unsafe { &*(socket_address.lpSockaddr as *const SOCKADDR_IN6) };
1022        let bytes = unsafe { v6.sin6_addr.u.Byte };
1023        return Some(Ipv6Addr::from(bytes).to_string());
1024    }
1025
1026    None
1027}
1028
1029fn format_mac(bytes: &[u8], len: usize) -> Option<String> {
1030    if len == 0 || len > bytes.len() {
1031        return None;
1032    }
1033
1034    let mut out = String::new();
1035    for (i, b) in bytes[..len].iter().enumerate() {
1036        if i > 0 {
1037            out.push(':');
1038        }
1039        out.push_str(&format!("{b:02x}"));
1040    }
1041    Some(out)
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::{
1047        PowerActionOptions, format_smbios_uuid, parse_multi_sz, resolve_product_name_from_registry,
1048        shutdown_reason, username_from_profile_path,
1049    };
1050    use windows::Win32::System::Shutdown::{
1051        SHTDN_REASON_FLAG_PLANNED, SHTDN_REASON_MAJOR_OTHER, SHTDN_REASON_MINOR_OTHER,
1052    };
1053
1054    #[test]
1055    fn parse_multi_sz_extracts_entries() {
1056        let data = [
1057            'C' as u16,
1058            ':' as u16,
1059            '\\' as u16,
1060            0,
1061            'D' as u16,
1062            ':' as u16,
1063            '\\' as u16,
1064            0,
1065            0,
1066        ];
1067        let drives = parse_multi_sz(&data);
1068        assert_eq!(drives, vec!["C:\\".to_string(), "D:\\".to_string()]);
1069    }
1070
1071    #[test]
1072    fn username_from_profile_path_parses_tail_component() {
1073        let value = username_from_profile_path(r"C:\\Users\\alice");
1074        assert_eq!(value.as_deref(), Some("alice"));
1075    }
1076
1077    #[test]
1078    fn format_smbios_uuid_formats_expected_shape() {
1079        let raw = [
1080            0x33, 0x22, 0x11, 0x00, 0x55, 0x44, 0x77, 0x66, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
1081            0xee, 0xff,
1082        ];
1083        let value = format_smbios_uuid(&raw).expect("uuid");
1084        assert_eq!(value, "00112233-4455-6677-8899-aabbccddeeff");
1085    }
1086
1087    #[test]
1088    fn resolve_product_name_preserves_server_sku_names() {
1089        let value = resolve_product_name_from_registry(
1090            10,
1091            0,
1092            26100,
1093            Some("Windows Server 2025 Standard".to_string()),
1094            Some("ServerStandard".to_string()),
1095            Some("Server".to_string()),
1096        );
1097
1098        assert_eq!(value.as_deref(), Some("Windows Server 2025 Standard"));
1099    }
1100
1101    #[test]
1102    fn resolve_product_name_synthesizes_server_family_from_server_installation() {
1103        let value = resolve_product_name_from_registry(
1104            10,
1105            0,
1106            26100,
1107            None,
1108            Some("ServerStandard".to_string()),
1109            Some("Server".to_string()),
1110        );
1111
1112        assert_eq!(value.as_deref(), Some("Windows Server Standard"));
1113    }
1114
1115    #[test]
1116    fn resolve_product_name_rewrites_windows_10_desktop_name_on_windows_11() {
1117        let value = resolve_product_name_from_registry(
1118            10,
1119            0,
1120            26100,
1121            Some("Windows 10 Pro".to_string()),
1122            Some("Professional".to_string()),
1123            Some("Client".to_string()),
1124        );
1125
1126        assert_eq!(value.as_deref(), Some("Windows 11 Pro"));
1127    }
1128
1129    #[test]
1130    fn shutdown_reason_uses_default_other_reason() {
1131        let options = PowerActionOptions::default();
1132        assert_eq!(
1133            shutdown_reason(&options).0,
1134            SHTDN_REASON_MAJOR_OTHER.0 | SHTDN_REASON_MINOR_OTHER.0
1135        );
1136    }
1137
1138    #[test]
1139    fn shutdown_reason_sets_planned_flag() {
1140        let options = PowerActionOptions {
1141            planned: true,
1142            ..Default::default()
1143        };
1144
1145        assert_eq!(
1146            shutdown_reason(&options).0,
1147            (SHTDN_REASON_MAJOR_OTHER.0 | SHTDN_REASON_MINOR_OTHER.0) | SHTDN_REASON_FLAG_PLANNED.0
1148        );
1149    }
1150
1151    #[test]
1152    fn shutdown_reason_preserves_custom_reason_code() {
1153        let options = PowerActionOptions {
1154            reason_code: Some(0x0004_0000),
1155            ..Default::default()
1156        };
1157
1158        assert_eq!(shutdown_reason(&options).0, 0x0004_0000);
1159    }
1160}