From bc0d6533154685bd137b6ab846005f6ac19cccca Mon Sep 17 00:00:00 2001 From: jaimergp Date: Thu, 13 Jul 2023 12:29:26 +0200 Subject: [PATCH] Test CI on the uninstaller fixes (#696) * Merge pull request from GHSA-v8h5-w775-5rrw * Only run uninstaller on directory with _conda.exe * Do not run uninstaller in system-critical directories * Add loop to check for minimum conda files * Add missing goto after passing all checks * Add push/pop operations to un.onInit * Fix typo * Use WordFind instead of UnStrTok * Start WordFind loop with 1 * Add additional files to check for conda * Add confirmation dialog before uninstalling * Resolve INSTDIR * Add exceptions to fill_template * Do not replace NSIS predefines * Add example template * Update constructor/nsis/main.nsi.tmpl Co-authored-by: jaimergp * Add check for GetFullPathName * Add additional directory checks for uninstaller * Add quotes to INSTDIR in uninstaller message * Add check to GetFullPathName * Add additional directory checks for uninstaller * Add check to GetFullPathName for uninstaller * Remove duplicate comment * Replace missing global variable with local variable * Fix INSTDIR path resolution command * Fix GetFullPathName logic * Replace @NAME@ with ${NAME} --------- Co-authored-by: jaimergp * add news * Remove Scripts\activate.bat from required files on $INSTDIR (some installers do not ship conda) --------- Co-authored-by: Marco Esters --- constructor/nsis/main.nsi.tmpl | 84 ++- constructor/utils.py | 4 +- constructor/winexe.py | 21 +- examples/custom_nsis_template/EULA.txt | 28 + examples/custom_nsis_template/construct.yaml | 12 + examples/custom_nsis_template/custom.nsi.tmpl | 714 ++++++++++++++++++ news/696-uninstaller-fixes | 19 + 7 files changed, 877 insertions(+), 5 deletions(-) create mode 100644 examples/custom_nsis_template/EULA.txt create mode 100644 examples/custom_nsis_template/construct.yaml create mode 100644 examples/custom_nsis_template/custom.nsi.tmpl create mode 100644 news/696-uninstaller-fixes diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index e9ba17729..865ebb3eb 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -167,6 +167,7 @@ Page Custom mui_AnaCustomOptions_Show !insertmacro MUI_PAGE_FINISH !insertmacro MUI_UNPAGE_WELCOME +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.OnDirectoryLeave !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_UNPAGE_FINISH @@ -490,9 +491,9 @@ Function .onInit # If we're a 64-bit installer, make sure it's 64-bit Windows ${IfNot} ${RunningX64} MessageBox MB_OK|MB_ICONEXCLAMATION \ - "This installer is for a 64-bit version for @NAME@$\n\ + "This installer is for a 64-bit version for ${NAME}$\n\ but your system is 32-bit. Please use the 32-bit Windows$\n\ - @NAME@ installer." \ + ${NAME} installer." \ /SD IDOK Abort ${EndIf} @@ -671,6 +672,60 @@ Function .onInit FunctionEnd Function un.onInit + Push $0 + Push $1 + Push $2 + Push $3 + + # Resolve INSTDIR + GetFullPathName $0 $INSTDIR + # If the directory does not exist or cannot be resolved, $0 will be empty + StrCmp $0 "" invalid_dir + StrCpy $INSTDIR $0 + + # Never run the uninstaller when $INSTDIR points at system-critical directories + + StrLen $InstDirLen $INSTDIR + # INSTDIR is a full path and has no trailing backslash, + # so if its length is 2, it is pointed at a system root + StrCmp $InstdirLen 2 invalid_dir + + # Never delete anything inside Windows + StrCpy $0 $INSTDIR 7 3 + StrCmp $0 "Windows" invalid_dir + + StrCpy $0 "ALLUSERSPROFILE APPDATA LOCALAPPDATA PROGRAMDATA PROGRAMFILES PROGRAMFILES(x86) PUBLIC SYSTEMDRIVE SYSTEMROOT USERPROFILE" + StrCpy $1 1 + loop_critical: + ${WordFind} $0 " " "E+$1" $2 + IfErrors endloop_critical + ReadEnvStr $3 $2 + StrCmp $3 $INSTDIR invalid_dir + IntOp $1 $1 + 1 + goto loop_critical + endloop_critical: + + # Primitive check to see that $INSTDIR points to a conda directory + StrCpy $0 "_conda.exe conda-meta\history" + StrCpy $1 1 + loop_conda: + ${WordFind} $0 " " "E+$1" $2 + IfErrors endloop_conda + IfFileExists $INSTDIR\$2 0 invalid_dir + IntOp $1 $1 + 1 + goto loop_conda + endloop_conda: + + # All checks have passed + goto valid_dir + + invalid_dir: + MessageBox MB_OK|MB_ICONSTOP \ + "Error: $INSTDIR is not a valid conda directory. Please run the uninstaller from a conda directory." \ + /SD IDABORT + abort + valid_dir: + # Select the correct registry to look at, depending # on whether it's a 32-bit or 64-bit installer SetRegView @BITS@ @@ -691,6 +746,11 @@ Function un.onInit ${Else} SetShellVarContext All ${EndIf} + + Pop $3 + Pop $2 + Pop $1 + Pop $0 FunctionEnd # http://nsis.sourceforge.net/Check_for_spaces_in_a_directory_path @@ -940,6 +1000,17 @@ Function .onVerifyInstDir PathGood: FunctionEnd +Function un.OnDirectoryLeave + MessageBox MB_YESNO \ + "Are you sure you want to remove '$INSTDIR' and all of its contents?" \ + /SD IDYES \ + IDYES confirmed_yes IDNO confirmed_no + confirmed_no: + MessageBox MB_OK|MB_ICONSTOP "Uninstallation aborted by user." /SD IDOK + Quit + confirmed_yes: +FunctionEnd + # Make function available for both installer and uninstaller # Uninstaller functions need an `un.` prefix, so we use a macro to do both # see https://nsis.sourceforge.io/Sharing_functions_between_Installer_and_Uninstaller @@ -996,6 +1067,15 @@ Section "Install" File "@NSIS_DIR@\_nsis.py" File "@NSIS_DIR@\_system_path.py" + # Resolve INSTDIR so that paths and registry keys do not contain '..' or similar strings. + # $0 is empty if the directory doesn't exist, but the File commands should have created it already. + GetFullPathName $0 $INSTDIR + ${If} $0 == "" + MessageBox MB_ICONSTOP "Error resolving installation directory." /SD IDABORT + Quit + ${EndIf} + StrCpy $INSTDIR $0 + ReadEnvStr $0 SystemRoot # set PATH for the installer process, so that MSVC runtimes get found OK # This is also isolating PATH to be just us and Windows core stuff, which hopefully avoids diff --git a/constructor/utils.py b/constructor/utils.py index 7ae049dc7..37eedb41d 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -26,12 +26,12 @@ def filename_dist(dist): return dist -def fill_template(data, d): +def fill_template(data, d, exceptions=[]): pat = re.compile(r'__(\w+)__') def replace(match): key = match.group(1) - return d[key] + return key if key in exceptions else d[key] return pat.sub(replace, data) diff --git a/constructor/winexe.py b/constructor/winexe.py index de0382f30..826243af0 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -247,6 +247,25 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): 'INDEX_CACHE': '@cache', 'REPODATA_RECORD': '@repodata_record.json', } + + # These are NSIS predefines and must not be replaced + # https://nsis.sourceforge.io/Docs/Chapter5.html#precounter + nsis_predefines = [ + "COUNTER", + "DATE", + "FILE", + "FILEDIR", + "FUNCTION", + "GLOBAL", + "LINE", + "MACRO", + "PAGEEX", + "SECTION", + "TIME", + "TIMESTAMP", + "UNINSTALL", + ] + conclusion_text = info.get("conclusion_text", "") if conclusion_text: conclusion_lines = conclusion_text.strip().splitlines() @@ -277,7 +296,7 @@ def make_nsi(info, dir_path, extra_files=None, temp_extra_files=None): ppd["custom_welcome"] = info.get("welcome_file", "").endswith(".nsi") ppd["custom_conclusion"] = info.get("conclusion_file", "").endswith(".nsi") data = preprocess(data, ppd) - data = fill_template(data, replace) + data = fill_template(data, replace, exceptions=nsis_predefines) if info['_platform'].startswith("win") and sys.platform != 'win32': # Branding /TRIM commannd is unsupported on non win platform data_lines = data.split("\n") diff --git a/examples/custom_nsis_template/EULA.txt b/examples/custom_nsis_template/EULA.txt new file mode 100644 index 000000000..d3bdc5b4e --- /dev/null +++ b/examples/custom_nsis_template/EULA.txt @@ -0,0 +1,28 @@ +Copyright (c) 2016, Example, Inc. +All rights reserved. + +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 Example, Inc. 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 EXAMPLE, INC. 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. + +The License has to be read correctly so that dollar signs don't lead to an unset +parameter error: +You have to pay US $8, if you can read this. diff --git a/examples/custom_nsis_template/construct.yaml b/examples/custom_nsis_template/construct.yaml new file mode 100644 index 000000000..59bf7f503 --- /dev/null +++ b/examples/custom_nsis_template/construct.yaml @@ -0,0 +1,12 @@ +name: custom +version: X +ignore_duplicate_files: True +installer_filename: {{ name }}-installer.exe +installer_type: exe +license_file: EULA.txt + +nsis_template: custom.nsi.tmpl + +# This is required, even if we include no specs in this installer. +channels: + - https://repo.anaconda.com/pkgs/main diff --git a/examples/custom_nsis_template/custom.nsi.tmpl b/examples/custom_nsis_template/custom.nsi.tmpl new file mode 100644 index 000000000..6b45e6b68 --- /dev/null +++ b/examples/custom_nsis_template/custom.nsi.tmpl @@ -0,0 +1,714 @@ +# Installer template file for creating a Windows installer using NSIS. + +# Dependencies: +# NSIS >=3.08 conda install "nsis>=3.08" (includes extra unicode plugins) + +Unicode "true" + +#if enable_debugging is True +# Special logging build needed for ENABLE_LOGGING +# See https://nsis.sourceforge.io/Special_Builds +!define ENABLE_LOGGING +#endif + +# Comes from https://nsis.sourceforge.io/Logging:Enable_Logs_Quickly +!define LogSet "!insertmacro LogSetMacro" +!macro LogSetMacro SETTING + !ifdef ENABLE_LOGGING + LogSet ${SETTING} + !endif +!macroend + +!define LogText "!insertmacro LogTextMacro" +!macro LogTextMacro INPUT_TEXT + !ifdef ENABLE_LOGGING + LogText ${INPUT_TEXT} + !endif +!macroend + +!include "WinMessages.nsh" +!include "WordFunc.nsh" +!include "LogicLib.nsh" +!include "WinVer.nsh" +!include "MUI2.nsh" +!include "x64.nsh" + +!include "FileFunc.nsh" +!insertmacro GetParameters +!insertmacro GetOptions + +!include "UAC.nsh" +!include "nsDialogs.nsh" + +!include "Utils.nsh" + +!define NAME __NAME__ +!define VERSION __VERSION__ +!define COMPANY __COMPANY__ +!define ARCH __ARCH__ +!define PLATFORM __PLATFORM__ +!define CONSTRUCTOR_VERSION __CONSTRUCTOR_VERSION__ +!define DEFAULT_PREFIX __DEFAULT_PREFIX__ +!define DEFAULT_PREFIX_DOMAIN_USER __DEFAULT_PREFIX_DOMAIN_USER__ +!define DEFAULT_PREFIX_ALL_USERS __DEFAULT_PREFIX_ALL_USERS__ +# The SHOW_* and *_DESC variables are required by the +# OptionsDialog.nsh plug-in constructor uses +!define PRE_INSTALL_DESC __PRE_INSTALL_DESC__ +!define POST_INSTALL_DESC __POST_INSTALL_DESC__ +!define SHOW_REGISTER_PYTHON __SHOW_REGISTER_PYTHON__ +!define SHOW_ADD_TO_PATH __SHOW_ADD_TO_PATH__ +!define PRODUCT_NAME "${NAME} Uninstaller Patch" +!define UNINSTREG "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + +var /global INSTDIR_JUSTME +var /global INSTALLER_VERSION +var /global INSTALLER_NAME_FULL + +# UAC shield overlay +!ifndef BCM_SETSHIELD + !define BCM_SETSHIELD 0x0000160C +!endif + +var /global IsDomainUser + +var /global InstMode # 0 = Just Me, 1 = All Users. +!define JUST_ME 0 +!define ALL_USERS 1 + +# Include this one after our defines +!include "OptionsDialog.nsh" + +CRCCheck On + +# Basic options +var /global ProductName +Name "$ProductName" +OutFile __OUTFILE__ +ShowInstDetails "hide" +ShowUninstDetails "hide" +SetCompress "off" + +# Start off with the lowest permissions and work our way up. +RequestExecutionLevel user + +# Version information & branding text +VIAddVersionKey "ProductName" "${PRODUCT_NAME}" +VIAddVersionKey "FileVersion" "${VERSION}" +VIAddVersionKey "CompanyName" "${COMPANY}" +VIAddVersionKey "LegalCopyright" "(c) ${COMPANY}" +VIAddVersionKey "FileDescription" "${NAME} Installer" +VIAddVersionKey "Comments" "Created by constructor ${CONSTRUCTOR_VERSION}" +VIProductVersion __VIPV__ +BrandingText /TRIMLEFT "${COMPANY}" + +# Interface configuration +!define MUI_ICON __ICONFILE__ +!define MUI_UNICON __ICONFILE__ +!define MUI_HEADERIMAGE +!define MUI_HEADERIMAGE_BITMAP __HEADERIMAGE__ +!define MUI_HEADERIMAGE_UNBITMAP __HEADERIMAGE__ +!define MUI_ABORTWARNING +!define MUI_FINISHPAGE_NOAUTOCLOSE +!define MUI_UNFINISHPAGE_NOAUTOCLOSE +!define MUI_WELCOMEFINISHPAGE_BITMAP __WELCOMEIMAGE__ +!define MUI_UNWELCOMEFINISHPAGE_BITMAP __WELCOMEIMAGE__ + +# Pages +#if custom_welcome +# Custom welcome file(s) +@CUSTOM_WELCOME_FILE@ +#else +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipPageIfUACInnerInstance +!insertmacro MUI_PAGE_WELCOME +#endif +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipPageIfUACInnerInstance +!insertmacro MUI_PAGE_LICENSE __LICENSEFILE__ +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipPageIfUACInnerInstance +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE OnDirectoryLeave +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +#if with_conclusion_text is True +!define MUI_FINISHPAGE_TITLE __CONCLUSION_TITLE__ +!define MUI_FINISHPAGE_TITLE_3LINES +!define MUI_FINISHPAGE_TEXT __CONCLUSION_TEXT__ +#endif + +# Custom conclusion file(s) +@CUSTOM_CONCLUSION_FILE@ + +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_WELCOME +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.OnDirectoryLeave +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_UNPAGE_FINISH + +# Language +!insertmacro MUI_LANGUAGE "English" + +# Pass variable from an inner instance to an outer instance +# https://nsis-dev.github.io/NSIS-Forums/html/t-324491.html +!macro _SyncVariable _variable + !define Lprefix L{__LINE__} + push $R0 + goto _SyncVariableInner${Lprefix} + _SyncVariableOuter${Lprefix}: + StrCpy $R0 ${_variable} + return + _SyncVariableInner${Lprefix}: + !insertmacro UAC_AsUser_Call Label _SyncVariableOuter${Lprefix} ${UAC_SYNCREGISTERS} + StrCpy ${_variable} $R0 + !undef Lprefix + pop $R0 +!macroend +!define SyncVariable `!insertmacro _SyncVariable` + +Function SkipPageIfUACInnerInstance + ${LogSet} on + ${If} ${UAC_IsInnerInstance} + Abort + ${EndIf} +FunctionEnd + +!macro DoElevation + GetDlgItem $1 $HWNDParent 1 + System::Call user32::GetFocus()i.s + # Disable 'Next' button. + EnableWindow $1 0 + !insertmacro UAC_PageElevation_RunElevated + EnableWindow $1 1 + System::call user32::SetFocus(is) + ${If} $2 = 0x666 + MessageBox MB_ICONEXCLAMATION \ + "You need to log in with an administrative account \ + in order to perform an 'All Users' installation." + Abort + ${ElseIf} $0 = 1223 + # UAC canceled by user. + Abort + ${Else} + ${If} $0 <> 0 + ${If} $0 = 1062 + MessageBox MB_ICONSTOP \ + "Elevation failed; Secondary Logon service is \ + not running." + ${Else} + MessageBox MB_ICONSTOP \ + "Elevation failed; error code: $0." + ${EndIf} + Abort + ${EndIf} + ${EndIf} + # UAC worked, we're the outer installer, so we can quit. + Quit +!macroend + +Function GetUninstallString + Push $R0 + Push $R1 + Push $R2 + Push $0 + Push $1 + Push $2 + StrCpy $R0 "" + StrLen $R1 ${NAME} + StrCpy $0 0 + + loop: + EnumRegKey $1 SHCTX ${UNINSTREG} $0 + StrCmp $1 "" endloop + StrCpy $2 $1 $R1 + ${If} $2 == ${NAME} + ReadRegStr $R0 SHCTX "${UNINSTREG}\$1" "UninstallString" + goto endloop + ${EndIf} + IntOp $0 $0 + 1 + goto loop + endloop: + + Pop $R1 + Pop $0 + Pop $1 + Pop $2 + Exch $R0 +FunctionEnd + +Function .onInit + ${LogSet} on + Push $0 + Push $1 + Push $2 + Push $R1 + Push $R2 + + StrCpy $ProductName "${PRODUCT_NAME}" + + InitPluginsDir + + # Select the correct registry to look at, depending + # on whether it's a 32-bit or 64-bit installer + SetRegView @BITS@ +#if win64 + # If we're a 64-bit installer, make sure it's 64-bit Windows + ${IfNot} ${RunningX64} + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "This installer is for a 64-bit version for ${NAME}$\n\ + but your system is 32-bit. Please use the 32-bit Windows$\n\ + ${NAME} installer." \ + /SD IDOK + Abort + ${EndIf} +#endif + + !insertmacro UAC_PageElevation_OnInit + ${If} ${UAC_IsInnerInstance} + ${IfNot} ${UAC_IsAdmin} + SetErrorLevel 0x666 + Quit + ${EndIf} + ${SyncVariable} $INSTDIR + SetShellVarContext All + ${EndIf} + + # The actual installation mode will be determined by the presence/absence + # of the .nonadmin file. Here, InstMode will be used to get a good default + # value for $INSTDIR + StrCpy $InstMode ${JUST_ME} + ${IfThen} ${UAC_IsAdmin} ${|} StrCpy $InstMode ${ALL_USERS} ${|} + # If running as 'SYSTEM' then JustMe is not appropriate; note that + # we should advise against this. SCCM has an option to run as user + System::Call "advapi32::GetUserName(t .r0, *i ${NSIS_MAX_STRLEN} r1) i.r2" + ${IfThen} $0 == "SYSTEM" ${|} StrCpy $InstMode ${ALL_USERS} ${|} + + # /D was not used, check the registry + ${If} $InstDir == "" + # Skip HKCU if running as 'SYSTEM' + StrCpy $0 "" + StrCmp $InstMode ${JUST_ME} check_hklm check_hkcu + check_hklm: + SetShellVarContext current + call GetUninstallString + pop $0 + + check_hkcu: + # It is possible for a regular user to have run an AllUsers installation, + # so check HKLM as well + ${If} $0 == "" + SetShellVarContext all + call GetUninstallString + pop $0 + SetShellVarContext current + ${EndIf} + ${IfNot} $0 == "" + StrLen $1 "Uninstall-${NAME}.exe" + StrLen $2 $0 + IntOp $2 $2 - $1 + IntOp $2 $2 - 3 + StrCpy $InstDir $0 $2 1 + ${EndIf} + ${EndIf} + + # Nothing found in the registry, so take a default value, + # based on if we are admin or not + ${If} $InstDir == "" + # Look for a number of signs that indicate the user is a domain user and + # alter the default installation directory for 'Just Me' accordingly. We + # want to ensure that if we're a user domain account, we always install to + # %LOCALAPPDATA% (i.e. C:\Users\Trent\AppData\Local\Continuum\Anaconda), + # as this is the only place guaranteed to not be backed by a network share + # or included in a user's roaming profile. However, if we're a normal user + # account, then C:\Users\Trent\Anaconda is fine. + ReadEnvStr $0 USERDNSDOMAIN + ${If} $0 != "" + # If not null, USERDNSDOMAIN is an unambiguous indication that we're + # logged into a domain account. + StrCpy $IsDomainUser 1 + ${Else} + # If it's not set, apply some simple heuristics to discern whether or + # not we're logged in as a domain user. + ReadEnvStr $0 LOGONSERVER + ${If} $0 == "" + # This should never be unset; but if it is, we're definitely not + # a domain user. + StrCpy $IsDomainUser 0 + ${Else} + StrCpy $1 $0 "" 2 # lop-off the leading \\. + ${StrFilter} $1 "+" "" "" $2 # convert to uppercase, store in $2 + ${If} $2 == "MICROSOFTACCOUNT" + # The new Windows 8.x live accounts have \\MicrosoftAccount + # set as LOGONSERVER; interpret this as being a non-domain + # user. + StrCpy $IsDomainUser 0 + ${Else} + ReadEnvStr $R1 COMPUTERNAME + ${If} $R1 == "" + # This should never be unset either; if it is, assume + # we're not a domain user. + StrCpy $IsDomainUser 0 + ${Else} + # We've got a value for both LOGONSERVER and COMPUTERNAME + # environment variables (which should always be the case). + # Proceed to compare LOGONSERVER[-2:] to COMPUTERNAME; if + # they match, assume we're not a domain user account. + + ${StrFilter} $R1 "+" "" "" $R2 # convert to uppercase + ${If} $2 != $R2 + # COMPUTERNAME doesn't match LOGONSERVER; assume we're + # logged in via a domain account. + StrCpy $IsDomainUser 1 + ${Else} + # COMPUTERNAME matches LOGONSERVER; safe to assume + # we're logged in as a user account. (I guess there's + # the remote possibility a domain user has logged onto + # a server that has the same NetBIOS name as the Active + # Directory name... if that's the case, potentially + # installing Anaconda into an area that gets picked up + # by a roaming profile is the very least of your + # problems.) + StrCpy $IsDomainUser 0 + + ${EndIf} # LOGONSERVER[-2:] != COMPUTERNAME + + ${EndIf} # COMPUTERNAME != "" + + ${EndIf} # LOGONSERVER != "\\MicrosoftAccount" + + ${EndIf} # LOGONSERVER != "" + + ${EndIf} # USERDNSDOMAIN != "" + + ${If} $IsDomainUser = 0 + ExpandEnvStrings $0 ${DEFAULT_PREFIX} + StrCpy $INSTDIR_JUSTME $0 + ${ElseIf} $IsDomainUser = 1 + ExpandEnvStrings $0 ${DEFAULT_PREFIX_DOMAIN_USER} + StrCpy $INSTDIR_JUSTME $0 + ${Else} + # Should never happen; indicates a logic error above. + MessageBox MB_OK "Internal error: IsUserDomain not set properly!" \ + /SD IDOK + Abort + ${EndIf} + + ${If} $InstMode == ${ALL_USERS} + ExpandEnvStrings $0 ${DEFAULT_PREFIX_ALL_USERS} + StrCpy $INSTDIR $0 + ${Else} + strcpy $INSTDIR $INSTDIR_JUSTME + ${EndIf} + ${EndIf} + + Pop $R2 + Pop $R1 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +Function un.onInit + Push $0 + Push $1 + Push $2 + Push $3 + Push $R0 + + # Resolve INSTDIR + GetFullPathName $0 $INSTDIR + # If the directory does not exist or cannot be resolved, $0 will be empty + StrCmp $0 "" invalid_dir + StrCpy $INSTDIR $0 + + # Read variables the uninstaller needs from the registry + StrLen $R0 "Uninstall-${NAME}.exe" + IntOp $R0 $R0 + 3 + StrCpy $0 0 + loop_path: + EnumRegKey $1 SHCTX ${UNINSTREG} $0 + StrCmp $1 "" endloop_path + StrCpy $2 "${UNINSTREG}\$1" + ReadRegStr $4 SHCTX $2 "UninstallString" + StrLen $5 $4 + IntOp $5 $5 - $R0 + StrCpy $4 $4 $5 1 + ${If} $4 == $INSTDIR + StrCpy $INSTALLER_NAME_FULL $1 + ReadRegStr $INSTALLER_VERSION SHCTX $2 "DisplayVersion" + goto endloop_path + ${EndIf} + IntOp $0 $0 + 1 + goto loop_path + endloop_path: + + StrCpy $ProductName "${NAME} $INSTALLER_VERSION" + + # Never run the uninstaller when $INSTDIR points at system-critical directories + + StrLen $0 $INSTDIR + # INSTDIR is a full path and has no trailing backslash, + # so if its length is 2, it is pointed at a system root + StrCmp $0 2 invalid_dir + + # Never delete anything inside Windows + StrCpy $0 $INSTDIR 7 3 + StrCmp $0 "Windows" invalid_dir + + StrCpy $0 "ALLUSERSPROFILE APPDATA LOCALAPPDATA PROGRAMDATA PROGRAMFILES PROGRAMFILES(x86) PUBLIC SYSTEMDRIVE SYSTEMROOT USERPROFILE" + StrCpy $1 1 + loop_critical: + ${WordFind} $0 " " "E+$1" $2 + IfErrors endloop_critical + ReadEnvStr $3 $2 + StrCmp $3 $INSTDIR invalid_dir + IntOp $1 $1 + 1 + goto loop_critical + endloop_critical: + + # Primitive check to see that $INSTDIR points to a conda directory + StrCpy $0 "_conda.exe conda-meta\history Scripts\activate.bat" + StrCpy $1 1 + loop_conda: + ${WordFind} $0 " " "E+$1" $2 + IfErrors endloop_conda + IfFileExists $INSTDIR\$2 0 invalid_dir + IntOp $1 $1 + 1 + goto loop_conda + endloop_conda: + + # All checks have passed + goto valid_dir + + invalid_dir: + MessageBox MB_OK|MB_ICONSTOP \ + "Error: $INSTDIR is not a valid conda directory. Please run the uninstaller from a conda directory." \ + /SD IDABORT + abort + valid_dir: + + # Select the correct registry to look at, depending + # on whether it's a 32-bit or 64-bit installer + SetRegView @BITS@ + + # Since the switch to a dual-mode installer (All Users/Just Me), the + # uninstaller will inherit the requested execution level of the main + # installer -- which we now have to set to 'user'. Thus, Windows will + # not automatically elevate the uninstaller for us -- we need to do it + # ourselves if we're not a 'Just Me' installation. + !insertmacro UAC_PageElevation_OnInit + ${IfNot} ${FileExists} "$INSTDIR\.nonadmin" + ${AndIfNot} ${UAC_IsAdmin} + !insertmacro DoElevation + ${EndIf} + + ${If} ${FileExists} "$INSTDIR\.nonadmin" + SetShellVarContext Current + ${Else} + SetShellVarContext All + ${EndIf} + + Pop $3 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +Function OnDirectoryLeave + IfFileExists $INSTDIR\Uninstall-${NAME}.exe uninstaller_exists + MessageBox MB_OK|MB_ICONSTOP \ + "Error: $INSTDIR does not contain the uninstaller for ${NAME}. Please choose a different directory." \ + /SD IDABORT + abort + uninstaller_exists: + + ${IfNot} ${UAC_IsInnerInstance} + ${If} ${FileExists} "$INSTDIR\.nonadmin" + SetShellVarContext Current + ${Else} + SetShellVarContext All + ${IfNot} ${UAC_IsAdmin} + !insertmacro DoElevation + ${EndIf} + ${EndIf} + ${EndIf} + + Push $R1 + ${IsWritable} $INSTDIR $R1 + IntCmp $R1 0 pathgood + Pop $R1 + DetailPrint "::error: Path $INSTDIR is not writable. Please check permissions or \ + try respawning the installer with elevated privileges." + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "Error: Path $INSTDIR is not writable. Please check permissions or \ + try respawning the installer with elevated privileges." \ + /SD IDOK + Abort + + pathgood: + Pop $R1 + +FunctionEnd + +Function .onVerifyInstDir + ${LogSet} on + StrLen $0 $Desktop + StrCpy $0 $INSTDIR $0 + StrCmp $0 $Desktop 0 PathGood + Abort + PathGood: +FunctionEnd + +Function un.OnDirectoryLeave + MessageBox MB_YESNO \ + "Are you sure you want to remove '$INSTDIR' and all of its contents?" \ + /SD IDYES \ + IDYES confirmed_yes IDNO confirmed_no + confirmed_no: + MessageBox MB_OK|MB_ICONSTOP "Uninstallation aborted by user." /SD IDOK + Quit + confirmed_yes: +FunctionEnd + +Function un.AbortRetryNSExecWait + # This function expects three arguments in the stack + # $1: 'WithLog' or 'NoLog': Use ExecToLog or just Exec, respectively + # $2: The message to show if an error occurred + # $3: The command to run, quoted + # Note that the args need to be pushed to the stack in reverse order! + # Search 'AbortRetryNSExecWait' in this script to see examples + ${LogSet} on + Pop $1 + Pop $2 + Pop $3 + ${Do} + ${If} $1 == "WithLog" + nsExec::ExecToLog $3 + ${ElseIf} $1 == "NoLog" + nsExec::Exec $3 + ${Else} + DetailPrint "::error:: AbortRetryNSExecWait: 1st argument must be 'WithLog' or 'NoLog'. You used: $1" + Abort + ${EndIf} + pop $0 + ${If} $0 != "0" + DetailPrint "::error:: $2" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON3 \ + $2 /SD IDIGNORE IDABORT abort IDRETRY retry + ; IDIGNORE: Continue anyway + StrCpy $0 "0" + goto retry + abort: + ; Abort installation + Abort + retry: + ; Retry the nsExec command + ${EndIf} + ${LoopWhile} $0 != "0" +FunctionEnd + +# Installer sections +Section "Install" + ${LogSet} on + + DetailPrint "Patching uninstaller..." + WriteUninstaller "$INSTDIR\Uninstall-${NAME}.exe" + + # To address CVE-2022-26526. + # Revoke the write permission on directory "$INSTDIR" for Users if this is + # being run with administrative privileges. Users are: + # AU - authenticated users + # BU - built-in (local) users + # DU - domain users + ${If} ${UAC_IsAdmin} + DetailPrint "Setting installation directory permissions..." + AccessControl::DisableFileInheritance "$INSTDIR" + AccessControl::RevokeOnFile "$INSTDIR" "(AU)" "GenericWrite" + AccessControl::RevokeOnFile "$INSTDIR" "(DU)" "GenericWrite" + AccessControl::RevokeOnFile "$INSTDIR" "(BU)" "GenericWrite" + AccessControl::SetOnFile "$INSTDIR" "(BU)" "GenericRead + GenericExecute" + AccessControl::SetOnFile "$INSTDIR" "(DU)" "GenericRead + GenericExecute" + ${EndIf} +SectionEnd + +!macro AbortRetryNSExecWaitLibNsisCmd cmd + SetDetailsPrint both + DetailPrint "Running ${cmd} scripts..." + SetDetailsPrint listonly + ${If} ${Silent} + push '"$INSTDIR\pythonw.exe" -E -s "$INSTDIR\Lib\_nsis.py" ${cmd}' + ${Else} + push '"$INSTDIR\python.exe" -E -s "$INSTDIR\Lib\_nsis.py" ${cmd}' + ${EndIf} + push "Failed to run ${cmd}" + push 'WithLog' + call un.AbortRetryNSExecWait + SetDetailsPrint both +!macroend + +Section "Uninstall" + # Remove menu items, path entries + DetailPrint "Deleting @NAME@ menus..." + nsExec::ExecToLog '"$INSTDIR\_conda.exe" constructor --prefix "$INSTDIR" --rm-menus' + + # ensure that MSVC runtime DLLs are on PATH during uninstallation + ReadEnvStr $0 PATH + # set PATH for the installer process, so that MSVC runtimes get found OK + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("PATH", \ + "$INSTDIR;$INSTDIR\Library\mingw-w64\bin;$INSTDIR\Library\usr\bin;$INSTDIR\Library\bin;$INSTDIR\Scripts;$INSTDIR\bin;$0;$0\system32;$0\system32\Wbem").r0' + + # our newest Python builds have a patch that allows us to control the PATH search stuff much more + # carefully. More info at https://docs.conda.io/projects/conda/en/latest/user-guide/troubleshooting.html#solution + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_DLL_SEARCH_MODIFICATION_ENABLE", "1").r0' + + # Extra info for pre_uninstall scripts + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("PREFIX", "$INSTDIR").r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_NAME", "${NAME}").r0' + StrCpy $0 "" + ${If} $INSTALLER_VERSION != "" + StrCpy $0 $INSTALLER_VERSION + ${Else} + StrCpy $0 ${VERSION} + ${EndIf} + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_VER", "$0").r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_PLAT", "win-64").r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("INSTALLER_TYPE", "EXE").r0' + + !insertmacro AbortRetryNSExecWaitLibNsisCmd "pre_uninstall" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmpath" + !insertmacro AbortRetryNSExecWaitLibNsisCmd "rmreg" + + DetailPrint "Removing files and folders..." + nsExec::Exec 'cmd.exe /D /C RMDIR /Q /S "$INSTDIR"' + + # In case the last command fails, run the slow method to remove leftover + RMDir /r /REBOOTOK "$INSTDIR" + + ${If} $INSTALLER_NAME_FULL != "" + DeleteRegKey SHCTX "${UNINSTREG}\$INSTALLER_NAME_FULL" + ${EndIf} + + # If Anaconda was registered as the official Python for this version, + # remove it from the registry + StrCpy $R0 "SOFTWARE\Python\PythonCore" + StrCpy $0 0 + loop_py: + EnumRegKey $1 SHCTX $R0 $0 + StrCmp $1 "" endloop_py + ReadRegStr $2 SHCTX "$R0\$1\InstallPath" "" + ${If} $2 == $INSTDIR + StrCpy $R1 $1 + DeleteRegKey SHCTX "$R0\$1" + goto endloop_py + ${EndIf} + IntOp $0 $0 + 1 + goto loop_py + endloop_py: + +SectionEnd + +!if '@SIGNTOOL_COMMAND@' != '' + # Signing for installer and uninstaller; nsis 3.08 required for uninstfinalize! + # "= 0" comparison required to prevent both tasks running in parallel, which would cause signtool to fail + # %1 is replaced by the installer and uninstaller paths, respectively + !finalize '@SIGNTOOL_COMMAND@ "%1"' = 0 + !uninstfinalize '@SIGNTOOL_COMMAND@ "%1"' = 0 +!endif diff --git a/news/696-uninstaller-fixes b/news/696-uninstaller-fixes new file mode 100644 index 000000000..ce4904717 --- /dev/null +++ b/news/696-uninstaller-fixes @@ -0,0 +1,19 @@ +### Enhancements + +* + +### Bug fixes + +* Prevent Windows uninstallers from deleting directories that do not correspond to the installation directory. (#696) + +### Deprecations + +* + +### Docs + +* + +### Other + +*