diff --git a/spec/std/system/user_spec.cr b/spec/std/system/user_spec.cr index 9fea934bc227..f0cb977d014d 100644 --- a/spec/std/system/user_spec.cr +++ b/spec/std/system/user_spec.cr @@ -1,20 +1,36 @@ -{% skip_file if flag?(:win32) %} - require "spec" require "system/user" -USER_NAME = {{ `id -un`.stringify.chomp }} -USER_ID = {{ `id -u`.stringify.chomp }} +{% if flag?(:win32) %} + {% name, id = `whoami /USER /FO TABLE /NH`.stringify.chomp.split(" ") %} + USER_NAME = {{ name }} + USER_ID = {{ id }} +{% else %} + USER_NAME = {{ `id -un`.stringify.chomp }} + USER_ID = {{ `id -u`.stringify.chomp }} +{% end %} + INVALID_USER_NAME = "this_user_does_not_exist" INVALID_USER_ID = {% if flag?(:android) %}"8888"{% else %}"1234567"{% end %} +def normalized_username(username) + # on Windows, domain names are case-insensitive, so we unify the letter case + # from sources like `whoami`, `hostname`, or Win32 APIs + {% if flag?(:win32) %} + domain, _, user = username.partition('\\') + "#{domain.upcase}\\#{user}" + {% else %} + username + {% end %} +end + describe System::User do describe ".find_by(*, name)" do it "returns a user by name" do user = System::User.find_by(name: USER_NAME) user.should be_a(System::User) - user.username.should eq(USER_NAME) + normalized_username(user.username).should eq(normalized_username(USER_NAME)) user.id.should eq(USER_ID) end @@ -31,7 +47,7 @@ describe System::User do user.should be_a(System::User) user.id.should eq(USER_ID) - user.username.should eq(USER_NAME) + normalized_username(user.username).should eq(normalized_username(USER_NAME)) end it "raises on nonexistent user id" do @@ -46,7 +62,7 @@ describe System::User do user = System::User.find_by?(name: USER_NAME).not_nil! user.should be_a(System::User) - user.username.should eq(USER_NAME) + normalized_username(user.username).should eq(normalized_username(USER_NAME)) user.id.should eq(USER_ID) end @@ -62,7 +78,7 @@ describe System::User do user.should be_a(System::User) user.id.should eq(USER_ID) - user.username.should eq(USER_NAME) + normalized_username(user.username).should eq(normalized_username(USER_NAME)) end it "returns nil on nonexistent user id" do @@ -73,7 +89,8 @@ describe System::User do describe "#username" do it "is the same as the source name" do - System::User.find_by(name: USER_NAME).username.should eq(USER_NAME) + user = System::User.find_by(name: USER_NAME) + normalized_username(user.username).should eq(normalized_username(USER_NAME)) end end @@ -109,7 +126,8 @@ describe System::User do describe "#to_s" do it "returns a string representation" do - System::User.find_by(name: USER_NAME).to_s.should eq("#{USER_NAME} (#{USER_ID})") + user = System::User.find_by(name: USER_NAME) + user.to_s.should eq("#{user.username} (#{user.id})") end end end diff --git a/src/crystal/system/user.cr b/src/crystal/system/user.cr index cb3db8cda026..88766496a9d8 100644 --- a/src/crystal/system/user.cr +++ b/src/crystal/system/user.cr @@ -20,6 +20,8 @@ end require "./wasi/user" {% elsif flag?(:unix) %} require "./unix/user" +{% elsif flag?(:win32) %} + require "./win32/user" {% else %} {% raise "No Crystal::System::User implementation available" %} {% end %} diff --git a/src/crystal/system/win32/path.cr b/src/crystal/system/win32/path.cr index 06f9346a2bae..f7bb1d23191b 100644 --- a/src/crystal/system/win32/path.cr +++ b/src/crystal/system/win32/path.cr @@ -4,18 +4,16 @@ require "c/shlobj_core" module Crystal::System::Path def self.home : String - if home_path = ENV["USERPROFILE"]?.presence - home_path + ENV["USERPROFILE"]?.presence || known_folder_path(LibC::FOLDERID_Profile) + end + + def self.known_folder_path(guid : LibC::GUID) : String + if LibC.SHGetKnownFolderPath(pointerof(guid), 0, nil, out path_ptr) == 0 + path, _ = String.from_utf16(path_ptr) + LibC.CoTaskMemFree(path_ptr) + path else - # TODO: interpreter doesn't implement pointerof(Path)` yet - folderid = LibC::FOLDERID_Profile - if LibC.SHGetKnownFolderPath(pointerof(folderid), 0, nil, out path_ptr) == 0 - home_path, _ = String.from_utf16(path_ptr) - LibC.CoTaskMemFree(path_ptr) - home_path - else - raise RuntimeError.from_winerror("SHGetKnownFolderPath") - end + raise RuntimeError.from_winerror("SHGetKnownFolderPath") end end end diff --git a/src/crystal/system/win32/user.cr b/src/crystal/system/win32/user.cr new file mode 100644 index 000000000000..e5fcdbba10aa --- /dev/null +++ b/src/crystal/system/win32/user.cr @@ -0,0 +1,273 @@ +require "c/sddl" +require "c/lm" +require "c/userenv" +require "c/security" + +# This file contains source code derived from the following: +# +# * https://cs.opensource.google/go/go/+/refs/tags/go1.23.0:src/os/user/lookup_windows.go +# * https://cs.opensource.google/go/go/+/refs/tags/go1.23.0:src/syscall/security_windows.go +# +# The following is their license: +# +# Copyright 2009 The Go Authors. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google LLC nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +module Crystal::System::User + def initialize(@username : String, @id : String, @group_id : String, @name : String, @home_directory : String) + end + + def system_username + @username + end + + def system_id + @id + end + + def system_group_id + @group_id + end + + def system_name + @name + end + + def system_home_directory + @home_directory + end + + def system_shell + Crystal::System::User.cmd_path + end + + class_getter(cmd_path : String) do + "#{Crystal::System::Path.known_folder_path(LibC::FOLDERID_System)}\\cmd.exe" + end + + def self.from_username?(username : String) : ::System::User? + if found = name_to_sid(username) + if found.type.sid_type_user? + from_sid(found.sid) + end + end + end + + def self.from_id?(id : String) : ::System::User? + if sid = sid_from_s(id) + begin + from_sid(sid) + ensure + LibC.LocalFree(sid) + end + end + end + + private def self.from_sid(sid : LibC::SID*) : ::System::User? + canonical = sid_to_name(sid) || return + return unless canonical.type.sid_type_user? + + domain_and_user = "#{canonical.domain}\\#{canonical.name}" + full_name = lookup_full_name(canonical.name, canonical.domain, domain_and_user) || return + pgid = lookup_primary_group_id(canonical.name, canonical.domain) || return + uid = sid_to_s(sid) + home_dir = lookup_home_directory(uid, canonical.name) || return + + ::System::User.new(domain_and_user, uid, pgid, full_name, home_dir) + end + + private def self.lookup_full_name(name : String, domain : String, domain_and_user : String) : String? + if domain_joined? + domain_and_user = Crystal::System.to_wstr(domain_and_user) + Crystal::System.retry_wstr_buffer do |buffer, small_buf| + len = LibC::ULong.new(buffer.size) + if LibC.TranslateNameW(domain_and_user, LibC::EXTENDED_NAME_FORMAT::NameSamCompatible, LibC::EXTENDED_NAME_FORMAT::NameDisplay, buffer, pointerof(len)) != 0 + return String.from_utf16(buffer[0, len - 1]) + elsif small_buf && len > 0 + next len + else + break + end + end + end + + info = uninitialized LibC::USER_INFO_10* + if LibC.NetUserGetInfo(Crystal::System.to_wstr(domain), Crystal::System.to_wstr(name), 10, pointerof(info).as(LibC::BYTE**)) == LibC::NERR_Success + begin + str, _ = String.from_utf16(info.value.usri10_full_name) + return str + ensure + LibC.NetApiBufferFree(info) + end + end + + # domain worked neither as a domain nor as a server + # could be domain server unavailable + # pretend username is fullname + name + end + + # obtains the primary group SID for a user using this method: + # https://support.microsoft.com/en-us/help/297951/how-to-use-the-primarygroupid-attribute-to-find-the-primary-group-for + # The method follows this formula: domainRID + "-" + primaryGroupRID + private def self.lookup_primary_group_id(name : String, domain : String) : String? + domain_sid = name_to_sid(domain) || return + return unless domain_sid.type.sid_type_domain? + + domain_sid_str = sid_to_s(domain_sid.sid) + + # If the user has joined a domain use the RID of the default primary group + # called "Domain Users": + # https://support.microsoft.com/en-us/help/243330/well-known-security-identifiers-in-windows-operating-systems + # SID: S-1-5-21domain-513 + # + # The correct way to obtain the primary group of a domain user is + # probing the user primaryGroupID attribute in the server Active Directory: + # https://learn.microsoft.com/en-us/windows/win32/adschema/a-primarygroupid + # + # Note that the primary group of domain users should not be modified + # on Windows for performance reasons, even if it's possible to do that. + # The .NET Developer's Guide to Directory Services Programming - Page 409 + # https://books.google.bg/books?id=kGApqjobEfsC&lpg=PA410&ots=p7oo-eOQL7&dq=primary%20group%20RID&hl=bg&pg=PA409#v=onepage&q&f=false + return "#{domain_sid_str}-513" if domain_joined? + + # For non-domain users call NetUserGetInfo() with level 4, which + # in this case would not have any network overhead. + # The primary group should not change from RID 513 here either + # but the group will be called "None" instead: + # https://www.adampalmer.me/iodigitalsec/2013/08/10/windows-null-session-enumeration/ + # "Group 'None' (RID: 513)" + info = uninitialized LibC::USER_INFO_4* + if LibC.NetUserGetInfo(Crystal::System.to_wstr(domain), Crystal::System.to_wstr(name), 4, pointerof(info).as(LibC::BYTE**)) == LibC::NERR_Success + begin + "#{domain_sid_str}-#{info.value.usri4_primary_group_id}" + ensure + LibC.NetApiBufferFree(info) + end + end + end + + private REGISTRY_PROFILE_LIST = %q(SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList).to_utf16 + private ProfileImagePath = "ProfileImagePath".to_utf16 + + private def self.lookup_home_directory(uid : String, username : String) : String? + # If this user has logged in at least once their home path should be stored + # in the registry under the specified SID. References: + # https://social.technet.microsoft.com/wiki/contents/articles/13895.how-to-remove-a-corrupted-user-profile-from-the-registry.aspx + # https://support.asperasoft.com/hc/en-us/articles/216127438-How-to-delete-Windows-user-profiles + # + # The registry is the most reliable way to find the home path as the user + # might have decided to move it outside of the default location, + # (e.g. C:\users). Reference: + # https://answers.microsoft.com/en-us/windows/forum/windows_7-security/how-do-i-set-a-home-directory-outside-cusers-for-a/aed68262-1bf4-4a4d-93dc-7495193a440f + reg_home_dir = WindowsRegistry.open?(LibC::HKEY_LOCAL_MACHINE, REGISTRY_PROFILE_LIST) do |key_handle| + WindowsRegistry.open?(key_handle, uid.to_utf16) do |sub_handle| + WindowsRegistry.get_string(sub_handle, ProfileImagePath) + end + end + return reg_home_dir if reg_home_dir + + # If the home path does not exist in the registry, the user might + # have not logged in yet; fall back to using getProfilesDirectory(). + # Find the username based on a SID and append that to the result of + # getProfilesDirectory(). The domain is not relevant here. + # NOTE: the user has not logged in so this directory might not exist + profile_dir = Crystal::System.retry_wstr_buffer do |buffer, small_buf| + len = LibC::DWORD.new(buffer.size) + if LibC.GetProfilesDirectoryW(buffer, pointerof(len)) != 0 + break String.from_utf16(buffer[0, len - 1]) + elsif small_buf && len > 0 + next len + else + break nil + end + end + return "#{profile_dir}\\#{username}" if profile_dir + end + + private record SIDLookupResult, sid : LibC::SID*, domain : String, type : LibC::SID_NAME_USE + + private def self.name_to_sid(name : String) : SIDLookupResult? + utf16_name = Crystal::System.to_wstr(name) + + sid_size = LibC::DWORD.zero + domain_buf_size = LibC::DWORD.zero + LibC.LookupAccountNameW(nil, utf16_name, nil, pointerof(sid_size), nil, pointerof(domain_buf_size), out _) + + unless WinError.value.error_none_mapped? + sid = Pointer(UInt8).malloc(sid_size).as(LibC::SID*) + domain_buf = Slice(LibC::WCHAR).new(domain_buf_size) + if LibC.LookupAccountNameW(nil, utf16_name, sid, pointerof(sid_size), domain_buf, pointerof(domain_buf_size), out sid_type) != 0 + domain = String.from_utf16(domain_buf[..-2]) + SIDLookupResult.new(sid, domain, sid_type) + end + end + end + + private record NameLookupResult, name : String, domain : String, type : LibC::SID_NAME_USE + + private def self.sid_to_name(sid : LibC::SID*) : NameLookupResult? + name_buf_size = LibC::DWORD.zero + domain_buf_size = LibC::DWORD.zero + LibC.LookupAccountSidW(nil, sid, nil, pointerof(name_buf_size), nil, pointerof(domain_buf_size), out _) + + unless WinError.value.error_none_mapped? + name_buf = Slice(LibC::WCHAR).new(name_buf_size) + domain_buf = Slice(LibC::WCHAR).new(domain_buf_size) + if LibC.LookupAccountSidW(nil, sid, name_buf, pointerof(name_buf_size), domain_buf, pointerof(domain_buf_size), out sid_type) != 0 + name = String.from_utf16(name_buf[..-2]) + domain = String.from_utf16(domain_buf[..-2]) + NameLookupResult.new(name, domain, sid_type) + end + end + end + + private def self.domain_joined? : Bool + status = LibC.NetGetJoinInformation(nil, out domain, out type) + if status != LibC::NERR_Success + raise RuntimeError.from_os_error("NetGetJoinInformation", WinError.new(status)) + end + is_domain = type.net_setup_domain_name? + LibC.NetApiBufferFree(domain) + is_domain + end + + private def self.sid_to_s(sid : LibC::SID*) : String + if LibC.ConvertSidToStringSidW(sid, out ptr) == 0 + raise RuntimeError.from_winerror("ConvertSidToStringSidW") + end + str, _ = String.from_utf16(ptr) + LibC.LocalFree(ptr) + str + end + + private def self.sid_from_s(str : String) : LibC::SID* + status = LibC.ConvertStringSidToSidW(Crystal::System.to_wstr(str), out sid) + status != 0 ? sid : Pointer(LibC::SID).null + end +end diff --git a/src/lib_c/x86_64-windows-msvc/c/knownfolders.cr b/src/lib_c/x86_64-windows-msvc/c/knownfolders.cr index 04c16573cc76..6ce1831cb1e5 100644 --- a/src/lib_c/x86_64-windows-msvc/c/knownfolders.cr +++ b/src/lib_c/x86_64-windows-msvc/c/knownfolders.cr @@ -2,4 +2,5 @@ require "c/guiddef" lib LibC FOLDERID_Profile = GUID.new(0x5e6c858f, 0x0e22, 0x4760, UInt8.static_array(0x9a, 0xfe, 0xea, 0x33, 0x17, 0xb6, 0x71, 0x73)) + FOLDERID_System = GUID.new(0x1ac14e77, 0x02e7, 0x4e5d, UInt8.static_array(0xb7, 0x44, 0x2e, 0xb1, 0xae, 0x51, 0x98, 0xb7)) end diff --git a/src/lib_c/x86_64-windows-msvc/c/lm.cr b/src/lib_c/x86_64-windows-msvc/c/lm.cr new file mode 100644 index 000000000000..72f5affc9b55 --- /dev/null +++ b/src/lib_c/x86_64-windows-msvc/c/lm.cr @@ -0,0 +1,59 @@ +require "c/winnt" + +@[Link("netapi32")] +lib LibC + alias NET_API_STATUS = DWORD + + NERR_Success = NET_API_STATUS.new!(0) + + enum NETSETUP_JOIN_STATUS + NetSetupUnknownStatus = 0 + NetSetupUnjoined + NetSetupWorkgroupName + NetSetupDomainName + end + + fun NetGetJoinInformation(lpServer : LPWSTR, lpNameBuffer : LPWSTR*, bufferType : NETSETUP_JOIN_STATUS*) : NET_API_STATUS + + struct USER_INFO_4 + usri4_name : LPWSTR + usri4_password : LPWSTR + usri4_password_age : DWORD + usri4_priv : DWORD + usri4_home_dir : LPWSTR + usri4_comment : LPWSTR + usri4_flags : DWORD + usri4_script_path : LPWSTR + usri4_auth_flags : DWORD + usri4_full_name : LPWSTR + usri4_usr_comment : LPWSTR + usri4_parms : LPWSTR + usri4_workstations : LPWSTR + usri4_last_logon : DWORD + usri4_last_logoff : DWORD + usri4_acct_expires : DWORD + usri4_max_storage : DWORD + usri4_units_per_week : DWORD + usri4_logon_hours : BYTE* + usri4_bad_pw_count : DWORD + usri4_num_logons : DWORD + usri4_logon_server : LPWSTR + usri4_country_code : DWORD + usri4_code_page : DWORD + usri4_user_sid : SID* + usri4_primary_group_id : DWORD + usri4_profile : LPWSTR + usri4_home_dir_drive : LPWSTR + usri4_password_expired : DWORD + end + + struct USER_INFO_10 + usri10_name : LPWSTR + usri10_comment : LPWSTR + usri10_usr_comment : LPWSTR + usri10_full_name : LPWSTR + end + + fun NetUserGetInfo(servername : LPWSTR, username : LPWSTR, level : DWORD, bufptr : BYTE**) : NET_API_STATUS + fun NetApiBufferFree(buffer : Void*) : NET_API_STATUS +end diff --git a/src/lib_c/x86_64-windows-msvc/c/sddl.cr b/src/lib_c/x86_64-windows-msvc/c/sddl.cr new file mode 100644 index 000000000000..64e1fa8b25c1 --- /dev/null +++ b/src/lib_c/x86_64-windows-msvc/c/sddl.cr @@ -0,0 +1,6 @@ +require "c/winnt" + +lib LibC + fun ConvertSidToStringSidW(sid : SID*, stringSid : LPWSTR*) : BOOL + fun ConvertStringSidToSidW(stringSid : LPWSTR, sid : SID**) : BOOL +end diff --git a/src/lib_c/x86_64-windows-msvc/c/security.cr b/src/lib_c/x86_64-windows-msvc/c/security.cr new file mode 100644 index 000000000000..5a904c51df40 --- /dev/null +++ b/src/lib_c/x86_64-windows-msvc/c/security.cr @@ -0,0 +1,21 @@ +require "c/winnt" + +@[Link("secur32")] +lib LibC + enum EXTENDED_NAME_FORMAT + NameUnknown = 0 + NameFullyQualifiedDN = 1 + NameSamCompatible = 2 + NameDisplay = 3 + NameUniqueId = 6 + NameCanonical = 7 + NameUserPrincipal = 8 + NameCanonicalEx = 9 + NameServicePrincipal = 10 + NameDnsDomain = 12 + NameGivenName = 13 + NameSurname = 14 + end + + fun TranslateNameW(lpAccountName : LPWSTR, accountNameFormat : EXTENDED_NAME_FORMAT, desiredNameFormat : EXTENDED_NAME_FORMAT, lpTranslatedName : LPWSTR, nSize : ULong*) : BOOLEAN +end diff --git a/src/lib_c/x86_64-windows-msvc/c/userenv.cr b/src/lib_c/x86_64-windows-msvc/c/userenv.cr new file mode 100644 index 000000000000..bb32977d79f7 --- /dev/null +++ b/src/lib_c/x86_64-windows-msvc/c/userenv.cr @@ -0,0 +1,6 @@ +require "c/winnt" + +@[Link("userenv")] +lib LibC + fun GetProfilesDirectoryW(lpProfileDir : LPWSTR, lpcchSize : DWORD*) : BOOL +end diff --git a/src/lib_c/x86_64-windows-msvc/c/winbase.cr b/src/lib_c/x86_64-windows-msvc/c/winbase.cr index 0a736a4fa89c..7b7a8735ddf2 100644 --- a/src/lib_c/x86_64-windows-msvc/c/winbase.cr +++ b/src/lib_c/x86_64-windows-msvc/c/winbase.cr @@ -4,6 +4,10 @@ require "c/int_safe" require "c/minwinbase" lib LibC + alias HLOCAL = Void* + + fun LocalFree(hMem : HLOCAL) + FORMAT_MESSAGE_ALLOCATE_BUFFER = 0x00000100_u32 FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200_u32 FORMAT_MESSAGE_FROM_STRING = 0x00000400_u32 @@ -69,4 +73,7 @@ lib LibC end fun GetFileInformationByHandleEx(hFile : HANDLE, fileInformationClass : FILE_INFO_BY_HANDLE_CLASS, lpFileInformation : Void*, dwBufferSize : DWORD) : BOOL + + fun LookupAccountNameW(lpSystemName : LPWSTR, lpAccountName : LPWSTR, sid : SID*, cbSid : DWORD*, referencedDomainName : LPWSTR, cchReferencedDomainName : DWORD*, peUse : SID_NAME_USE*) : BOOL + fun LookupAccountSidW(lpSystemName : LPWSTR, sid : SID*, name : LPWSTR, cchName : DWORD*, referencedDomainName : LPWSTR, cchReferencedDomainName : DWORD*, peUse : SID_NAME_USE*) : BOOL end diff --git a/src/lib_c/x86_64-windows-msvc/c/winnt.cr b/src/lib_c/x86_64-windows-msvc/c/winnt.cr index 535ad835c87a..1db4b2def700 100644 --- a/src/lib_c/x86_64-windows-msvc/c/winnt.cr +++ b/src/lib_c/x86_64-windows-msvc/c/winnt.cr @@ -95,6 +95,31 @@ lib LibC WRITE = 0x20006 end + struct SID_IDENTIFIER_AUTHORITY + value : BYTE[6] + end + + struct SID + revision : BYTE + subAuthorityCount : BYTE + identifierAuthority : SID_IDENTIFIER_AUTHORITY + subAuthority : DWORD[1] + end + + enum SID_NAME_USE + SidTypeUser = 1 + SidTypeGroup + SidTypeDomain + SidTypeAlias + SidTypeWellKnownGroup + SidTypeDeletedAccount + SidTypeInvalid + SidTypeUnknown + SidTypeComputer + SidTypeLabel + SidTypeLogonSession + end + enum JOBOBJECTINFOCLASS AssociateCompletionPortInformation = 7 ExtendedLimitInformation = 9