From 6898f024310a5144d0bf927b2f7ac3fe35450089 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 14 Nov 2018 12:15:04 +1000 Subject: [PATCH] win_shortcut: add run as admin and fix shell folder idempotency (#48584) --- changelogs/fragments/win_shortcut.yaml | 5 + lib/ansible/modules/windows/win_shortcut.ps1 | 233 +++++++++++++++++- lib/ansible/modules/windows/win_shortcut.py | 6 + .../targets/win_shortcut/tasks/clean.yml | 10 + .../targets/win_shortcut/tasks/tests.yml | 110 +++++++++ 5 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/win_shortcut.yaml diff --git a/changelogs/fragments/win_shortcut.yaml b/changelogs/fragments/win_shortcut.yaml new file mode 100644 index 00000000000..e019b8b9031 --- /dev/null +++ b/changelogs/fragments/win_shortcut.yaml @@ -0,0 +1,5 @@ +bugfixes: +- win_shortcut - Added idempotency checks when ``src`` is a special shell folder like ``shell:RecycleBinFolder`` + +minor_changes: +- win_shortcut - Added support for setting the ``Run as administrator`` flag on a shortcut pointing to an executable diff --git a/lib/ansible/modules/windows/win_shortcut.ps1 b/lib/ansible/modules/windows/win_shortcut.ps1 index 3fa45fba28d..291f13cc80b 100644 --- a/lib/ansible/modules/windows/win_shortcut.ps1 +++ b/lib/ansible/modules/windows/win_shortcut.ps1 @@ -6,6 +6,7 @@ # Based on: http://powershellblogger.com/2016/01/create-shortcuts-lnk-or-url-files-with-powershell/ #AnsibleRequires -CSharpUtil Ansible.Basic +#Requires -Module Ansible.ModuleUtils.AddType $spec = @{ options = @{ @@ -18,6 +19,7 @@ $spec = @{ icon = @{ type='path' } description = @{ type='str' } windowstyle = @{ type='str'; choices=@( 'maximized', 'minimized', 'normal' ) } + run_as_admin = @{ type='bool'; default=$false } } supports_check_mode = $true } @@ -33,6 +35,7 @@ $hotkey = $module.Params.hotkey $icon = $module.Params.icon $description = $module.Params.description $windowstyle = $module.Params.windowstyle +$run_as_admin = $module.Params.run_as_admin # Expand environment variables on non-path types if ($null -ne $src) { @@ -45,9 +48,192 @@ if ($null -ne $description) { $description = [System.Environment]::ExpandEnvironmentVariables($description) } +$module.Result.changed = $false $module.Result.dest = $dest $module.Result.state = $state +# TODO: look at consolidating other COM actions into the C# class for future compatibility +Add-CSharpType -AnsibleModule $module -References @' +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; + +namespace Ansible.Shortcut +{ + [ComImport()] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + internal interface IShellLinkW + { + // We only care about GetPath and GetIDList, omit the other methods for now + void GetPath(StringBuilder pszFile, int cch, IntPtr pfd, UInt32 fFlags); + void GetIDList(out IntPtr ppidl); + } + + [ComImport()] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("45E2b4AE-B1C3-11D0-B92F-00A0C90312E1")] + internal interface IShellLinkDataList + { + void AddDataBlock(IntPtr pDataBlock); + void CopyDataBlock(uint dwSig, out IntPtr ppDataBlock); + void RemoveDataBlock(uint dwSig); + void GetFlags(out ShellLinkFlags dwFlags); + void SetFlags(ShellLinkFlags dwFlags); + } + + internal class NativeHelpers + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SHFILEINFO + { + public IntPtr hIcon; + public int iIcon; + public UInt32 dwAttributes; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 260)] public char[] szDisplayName; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)] public char[] szTypeName; + } + } + + internal class NativeMethods + { + [DllImport("shell32.dll")] + public static extern void ILFree( + IntPtr pidl); + + [DllImport("shell32.dll")] + public static extern IntPtr SHGetFileInfoW( + IntPtr pszPath, + UInt32 dwFileAttributes, + ref NativeHelpers.SHFILEINFO psfi, + int sbFileInfo, + UInt32 uFlags); + + [DllImport("shell32.dll")] + public static extern int SHParseDisplayName( + [MarshalAs(UnmanagedType.LPWStr)] string pszName, + IntPtr pbc, + out IntPtr ppidl, + UInt32 sfagoIn, + out UInt32 psfgaoOut); + } + + [System.Flags] + public enum ShellLinkFlags : uint + { + Default = 0x00000000, + HasIdList = 0x00000001, + HasLinkInfo = 0x00000002, + HasName = 0x00000004, + HasRelPath = 0x00000008, + HasWorkingDir = 0x00000010, + HasArgs = 0x00000020, + HasIconLocation = 0x00000040, + Unicode = 0x00000080, + ForceNoLinkInfo = 0x00000100, + HasExpSz = 0x00000200, + RunInSeparate = 0x00000400, + HasLogo3Id = 0x00000800, + HasDarwinId = 0x00001000, + RunAsUser = 0x00002000, + HasExpIconSz = 0x00004000, + NoPidlAlias = 0x00008000, + ForceUncName = 0x00010000, + RunWithShimLayer = 0x00020000, + ForceNoLinkTrack = 0x00040000, + EnableTargetMetadata = 0x00080000, + DisableLinkPathTracking = 0x00100000, + DisableKnownFolderRelativeTracking = 0x00200000, + NoKfAlias = 0x00400000, + AllowLinkToLink = 0x00800000, + UnAliasOnSave = 0x01000000, + PreferEnvironmentPath = 0x02000000, + KeepLocalIdListForUncTarget = 0x04000000, + PersistVolumeIdToRelative = 0x08000000, + Valid = 0x0FFFF7FF, + Reserved = 0x80000000 + } + + public class ShellLink + { + private static Guid CLSID_ShellLink = new Guid("00021401-0000-0000-C000-000000000046"); + + public static ShellLinkFlags GetFlags(string path) + { + IShellLinkW link = InitialiseObj(path); + ShellLinkFlags dwFlags; + ((IShellLinkDataList)link).GetFlags(out dwFlags); + return dwFlags; + } + + public static void SetFlags(string path, ShellLinkFlags flags) + { + IShellLinkW link = InitialiseObj(path); + ((IShellLinkDataList)link).SetFlags(flags); + ((IPersistFile)link).Save(null, false); + } + + public static string GetTargetPath(string path) + { + IShellLinkW link = InitialiseObj(path); + + StringBuilder pathSb = new StringBuilder(260); + link.GetPath(pathSb, pathSb.Capacity, IntPtr.Zero, 0); + string linkPath = pathSb.ToString(); + + // If the path wasn't set, try and get the path from the ItemIDList + ShellLinkFlags flags = GetFlags(path); + if (String.IsNullOrEmpty(linkPath) && ((uint)flags & (uint)ShellLinkFlags.HasIdList) == (uint)ShellLinkFlags.HasIdList) + { + IntPtr idList = IntPtr.Zero; + try + { + link.GetIDList(out idList); + linkPath = GetDisplayNameFromPidl(idList); + } + finally + { + NativeMethods.ILFree(idList); + } + } + return linkPath; + } + + public static string GetDisplayNameFromPath(string path) + { + UInt32 sfgaoOut; + IntPtr pidl = IntPtr.Zero; + try + { + int res = NativeMethods.SHParseDisplayName(path, IntPtr.Zero, out pidl, 0, out sfgaoOut); + Marshal.ThrowExceptionForHR(res); + return GetDisplayNameFromPidl(pidl); + } + finally + { + NativeMethods.ILFree(pidl); + } + } + + private static string GetDisplayNameFromPidl(IntPtr pidl) + { + NativeHelpers.SHFILEINFO shFileInfo = new NativeHelpers.SHFILEINFO(); + UInt32 uFlags = 0x000000208; // SHGFI_DISPLAYNAME | SHGFI_PIDL + NativeMethods.SHGetFileInfoW(pidl, 0, ref shFileInfo, Marshal.SizeOf(typeof(NativeHelpers.SHFILEINFO)), uFlags); + return new string(shFileInfo.szDisplayName).TrimEnd('\0'); + } + + private static IShellLinkW InitialiseObj(string path) + { + IShellLinkW link = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_ShellLink)) as IShellLinkW; + ((IPersistFile)link).Load(path, 0); + return link; + } + } +} +'@ + # Convert from window style name to window style id $windowstyles = @{ normal = 1 @@ -91,16 +277,31 @@ If ($state -eq "absent") { } } - If (($null -ne $src) -and ($ShortCut.TargetPath -ne $src)) { - $module.Result.changed = $true - $ShortCut.TargetPath = $src - } - $module.Result.src = $ShortCut.TargetPath - # Determine if we have a WshShortcut or WshUrlShortcut by checking the Arguments property # A WshUrlShortcut objects only consists of a TargetPath property + $file_shortcut = $false If (Get-Member -InputObject $ShortCut -Name Arguments) { + # File ShortCut, compare multiple properties + $file_shortcut = $true + + $target_path = $ShortCut.TargetPath + If (($null -ne $src) -and ($ShortCut.TargetPath -ne $src)) { + if ((Test-Path -Path $dest) -and (-not $ShortCut.TargetPath)) { + # If the shortcut already exists but not on the COM object, we + # are dealing with a shell path like 'shell:RecycleBinFolder'. + $expanded_src = [Ansible.Shortcut.ShellLink]::GetDisplayNameFromPath($src) + $actual_src = [Ansible.Shortcut.ShellLink]::GetTargetPath($dest) + if ($expanded_src -ne $actual_src) { + $module.Result.changed = $true + $ShortCut.TargetPath = $src + } + } else { + $module.Result.changed = $true + $ShortCut.TargetPath = $src + } + $target_path = $src + } # This is a full-featured application shortcut ! If (($null -ne $arguments) -and ($ShortCut.Arguments -ne $arguments)) { @@ -139,7 +340,15 @@ If ($state -eq "absent") { $ShortCut.WindowStyle = $windowstyles.$windowstyle } $module.Result.windowstyle = $windowstyleids[$ShortCut.WindowStyle] + } else { + # URL Shortcut, just compare the TargetPath + if (($null -ne $src) -and ($ShortCut.TargetPath -ne $src)) { + $module.Result.changed = $true + $ShortCut.TargetPath = $src + } + $target_path = $ShortCut.TargetPath } + $module.Result.src = $target_path If (($module.Result.changed -eq $true) -and ($module.CheckMode -ne $true)) { Try { @@ -148,6 +357,18 @@ If ($state -eq "absent") { $module.FailJson("Failed to create shortcut '$dest'. ($($_.Exception.Message))", $_) } } + + if ((Test-Path -Path $dest) -and $file_shortcut) { + # Only control the run_as_admin flag if using a File Shortcut + $flags = [Ansible.Shortcut.ShellLink]::GetFlags($dest) + if ($run_as_admin -and (-not $flags.HasFlag([Ansible.Shortcut.ShellLinkFlags]::RunAsUser))) { + [Ansible.Shortcut.ShellLink]::SetFlags($dest, ($flags -bor [Ansible.Shortcut.ShellLinkFlags]::RunAsUser)) + $module.Result.changed = $true + } elseif (-not $run_as_admin -and ($flags.HasFlag([Ansible.Shortcut.ShellLinkFlags]::RunAsUser))) { + [Ansible.Shortcut.ShellLink]::SetFlags($dest, ($flags -bxor [Ansible.Shortcut.ShellLinkFlags]::RunAsUser)) + $module.Result.changed = $true + } + } } $module.ExitJson() diff --git a/lib/ansible/modules/windows/win_shortcut.py b/lib/ansible/modules/windows/win_shortcut.py index bae6fd1e379..a14047d4d09 100644 --- a/lib/ansible/modules/windows/win_shortcut.py +++ b/lib/ansible/modules/windows/win_shortcut.py @@ -62,6 +62,12 @@ options: - When C(present), creates or updates the shortcut. choices: [ absent, present ] default: present + run_as_admin: + description: + - When C(src) is an executable, this can control whether the shortcut will be opened as an administrator or not. + type: bool + default: no + version_added: '2.8' author: - Dag Wieers (@dagwieers) notes: diff --git a/test/integration/targets/win_shortcut/tasks/clean.yml b/test/integration/targets/win_shortcut/tasks/clean.yml index a5205bca144..5fa892186b7 100644 --- a/test/integration/targets/win_shortcut/tasks/clean.yml +++ b/test/integration/targets/win_shortcut/tasks/clean.yml @@ -25,3 +25,13 @@ win_file: path: '%Public%\Desktop\Registry Editor.lnk' state: absent + +- name: Clean up Shell path shortcut + win_file: + path: '%Public%\bin.lnk' + state: absent + +- name: Clean up Executable shortcut + win_file: + path: '%Public%\Desktop\cmd.lnk' + state: absent diff --git a/test/integration/targets/win_shortcut/tasks/tests.yml b/test/integration/targets/win_shortcut/tasks/tests.yml index 61809b9e5b9..2797598b43e 100644 --- a/test/integration/targets/win_shortcut/tasks/tests.yml +++ b/test/integration/targets/win_shortcut/tasks/tests.yml @@ -255,3 +255,113 @@ that: - regedit_shortcut_remove_again.changed == false - regedit_shortcut_remove_again.dest == 'C:\\Users\\Public\\Desktop\\Registry Editor.lnk' + +- name: Create shortcut to shell path + win_shortcut: + dest: '%Public%\bin.lnk' + src: shell:RecycleBinFolder + state: present + register: shell_add + +- name: Check there was a change + assert: + that: + - shell_add is changed + - shell_add.dest == 'C:\\Users\\Public\\bin.lnk' + - shell_add.src == 'shell:RecycleBinFolder' + +- name: Create shortcut to shell path again + win_shortcut: + dest: '%Public%\bin.lnk' + src: shell:RecycleBinFolder + state: present + register: shell_add_again + +- name: Check there was no change (normal mode) + assert: + that: + - not shell_add_again is changed + - shell_add_again.src == 'shell:RecycleBinFolder' + when: not in_check_mode + +- name: Check there was a change (check-mode) + assert: + that: + - shell_add_again is changed + when: in_check_mode + +- name: Change shortcut to another shell path + win_shortcut: + dest: '%Public%\bin.lnk' + src: shell:Start Menu + state: present + register: shell_change + +- name: Check there was a change + assert: + that: + - shell_change is changed + - shell_change.src == 'shell:Start Menu' + +- name: Create shortcut to an executable without run as admin + win_shortcut: + dest: '%Public%\Desktop\cmd.lnk' + src: '%SystemRoot%\System32\cmd.exe' + state: present + register: shell_exe_limited + +- name: Get run as admin flag state + win_shell: | + $shortcut = "$env:Public\Desktop\cmd.lnk" + $flags = [System.BitConverter]::ToUInt32([System.IO.FIle]::ReadAllBytes($shortcut), 20) + ($flags -band 0x00002000) -eq 0x00002000 + register: shell_exe_limited_actual + +- name: Check that run as admin flag wasn't set (normal mode) + assert: + that: + - shell_exe_limited is changed + - not shell_exe_limited_actual.stdout_lines[0]|bool + when: not in_check_mode + +- name: Check that exe shortcut results in a change (check-mode) + assert: + that: + - shell_exe_limited is changed + when: in_check_mode + +- name: Set shortcut to run as admin + win_shortcut: + dest: '%Public%\Desktop\cmd.lnk' + src: '%SystemRoot%\System32\cmd.exe' + run_as_admin: True + state: present + register: shell_exe_admin + +- name: Get run as admin flag state + win_shell: | + $shortcut = "$env:Public\Desktop\cmd.lnk" + $flags = [System.BitConverter]::ToUInt32([System.IO.FIle]::ReadAllBytes($shortcut), 20) + ($flags -band 0x00002000) -eq 0x00002000 + register: shell_exe_admin_actual + +- name: Check that run as admin flag was set (normal mode) + assert: + that: + - shell_exe_admin is changed + - shell_exe_admin_actual.stdout_lines[0]|bool + when: not in_check_mode + +- name: Set shortcut to run as admin again + win_shortcut: + dest: '%Public%\Desktop\cmd.lnk' + src: '%SystemRoot%\System32\cmd.exe' + run_as_admin: True + state: present + register: shell_exe_admin_again + +- name: Check that set run as admin wasn't changed (normal mode) + assert: + that: + - not shell_exe_admin_again is changed + when: not in_check_mode