From 3946e50457f37eed31286e7191ea331db7232afe Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 18:40:12 +0530 Subject: [PATCH] Handle long recovery paths and native uninstall process checks --- builds/installer.nsh | 13 ++++--- builds/setup-data.ps1 | 50 +++++++++++++++++++++------ tests/setup-uninstall-windows.test.js | 46 +++++++++++++++++++++--- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/builds/installer.nsh b/builds/installer.nsh index 2d5b5dafb..d000a723a 100644 --- a/builds/installer.nsh +++ b/builds/installer.nsh @@ -58,8 +58,10 @@ Var POSNIC_BUSINESS_CHECK Var POSNIC_HARDWARE_CHECK Var POSNIC_BACKUPS_CHECK Var POSNIC_REMOVAL_SUMMARY +Var POSNIC_POWERSHELL !include nsDialogs.nsh !include LogicLib.nsh +!include x64.nsh ; electron-builder loads custom includes before its assisted UI template. ; Our page functions use MUI_HEADER_TEXT while this file is parsed. !include MUI2.nsh @@ -294,12 +296,13 @@ FunctionEnd Function un.PosnicApplyData IfSilent 0 +2 Return - ${If} $POSNIC_KEEP_BUSINESS == 1 - ${AndIf} $POSNIC_KEEP_HARDWARE == 1 - ${AndIf} $POSNIC_KEEP_BACKUPS == 1 - Return + ; NSIS is 32-bit. Its default PowerShell cannot inspect a running 64-bit + ; application's executable path, so use the native host for this check. + StrCpy $POSNIC_POWERSHELL "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + ${If} ${RunningX64} + StrCpy $POSNIC_POWERSHELL "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe" ${EndIf} - nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\setup-data.ps1" -DataRoot "$APPDATA\posnic" -BackupsRoot "$DOCUMENTS\Posnic-Backups" -RecoveryRoot "$DOCUMENTS\Posnic-Recovery" -AppExecutable "$INSTDIR\${APP_EXECUTABLE_FILENAME}" -HardwareManifest "$INSTDIR\resources\setup-hardware-files.json" -KeepBusiness $POSNIC_KEEP_BUSINESS -KeepHardware $POSNIC_KEEP_HARDWARE -KeepBackups $POSNIC_KEEP_BACKUPS' + nsExec::ExecToStack '"$POSNIC_POWERSHELL" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$INSTDIR\resources\setup-data.ps1" -DataRoot "$APPDATA\posnic" -BackupsRoot "$DOCUMENTS\Posnic-Backups" -RecoveryRoot "$DOCUMENTS\Posnic-Recovery" -AppExecutable "$INSTDIR\${APP_EXECUTABLE_FILENAME}" -HardwareManifest "$INSTDIR\resources\setup-hardware-files.json" -KeepBusiness $POSNIC_KEEP_BUSINESS -KeepHardware $POSNIC_KEEP_HARDWARE -KeepBackups $POSNIC_KEEP_BACKUPS' Pop $0 Pop $1 ${If} $0 != 0 diff --git a/builds/setup-data.ps1 b/builds/setup-data.ps1 index b74578bc8..15bf61330 100644 --- a/builds/setup-data.ps1 +++ b/builds/setup-data.ps1 @@ -8,6 +8,13 @@ param( ) $ErrorActionPreference = 'Stop' +function Get-ExtendedPath([string]$File) { + if ($File.StartsWith('\\?\')) { return $File } + $full = [IO.Path]::GetFullPath($File) + if ($full.StartsWith('\\')) { return '\\?\UNC\' + $full.Substring(2) } + return '\\?\' + $full +} + function Assert-DirectChild([string]$Parent, [string]$Child) { $resolvedParent = [IO.Path]::GetFullPath($Parent).TrimEnd('\') $resolvedChild = [IO.Path]::GetFullPath($Child).TrimEnd('\') @@ -18,37 +25,60 @@ function Assert-DirectChild([string]$Parent, [string]$Child) { return $resolvedChild } function Get-RecoveryHash([string]$File) { - $stream = [IO.File]::OpenRead($File) + $stream = [IO.File]::OpenRead((Get-ExtendedPath $File)) $digest = [Security.Cryptography.SHA256]::Create() try { return [BitConverter]::ToString($digest.ComputeHash($stream)) } finally { $stream.Dispose(); $digest.Dispose() } } function Copy-VerifiedTree([string]$Source, [string]$Destination) { - [IO.Directory]::CreateDirectory($Destination) | Out-Null - foreach ($entry in Get-ChildItem -LiteralPath $Source -Force) { + [IO.Directory]::CreateDirectory((Get-ExtendedPath $Destination)) | Out-Null + $directory = New-Object IO.DirectoryInfo (Get-ExtendedPath $Source) + foreach ($entry in $directory.EnumerateFileSystemInfos()) { if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Recovery cannot follow linked folders.' } # These database lock handles are held exclusively by this helper. if ($entry.Name -in @('mongod.lock', 'WiredTiger.lock')) { continue } $target = Join-Path $Destination $entry.Name - if ($entry.PSIsContainer) { Copy-VerifiedTree $entry.FullName $target } + if ($entry.Attributes -band [IO.FileAttributes]::Directory) { Copy-VerifiedTree $entry.FullName $target } else { - [IO.File]::Copy($entry.FullName, $target, $false) + [IO.File]::Copy((Get-ExtendedPath $entry.FullName), (Get-ExtendedPath $target), $false) if ((Get-RecoveryHash $entry.FullName) -ne (Get-RecoveryHash $target)) { throw 'Recovery verification failed. Existing data was not removed.' } } } } +function Assert-UnlinkedTree([IO.FileSystemInfo]$Entry) { + if ($Entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked folders cannot be deleted here.' } + if ($Entry.Attributes -band [IO.FileAttributes]::Directory) { + foreach ($child in $Entry.EnumerateFileSystemInfos()) { Assert-UnlinkedTree $child } + } +} +function Remove-UnlinkedTree([IO.DirectoryInfo]$Directory) { + foreach ($entry in $Directory.EnumerateFileSystemInfos()) { + if ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Linked folders cannot be deleted here.' } + if ($entry.Attributes -band [IO.FileAttributes]::Directory) { Remove-UnlinkedTree $entry } + else { + $entry.IsReadOnly = $false + $entry.Delete() + } + } + $Directory.Delete() +} function Remove-CheckedTree([string]$Root) { - # Check every descendant before Remove-Item can traverse it. - if (!(Test-Path -LiteralPath $Root)) { return } - $linked = Get-ChildItem -LiteralPath $Root -Recurse -Force | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } - if ($linked) { throw 'Linked folders cannot be deleted here.' } - Remove-Item -LiteralPath $Root -Recurse -Force + # Windows PowerShell's provider cannot reliably remove paths over MAX_PATH. + # Validate the entire tree, then use extended paths for the actual removal. + $extended = Get-ExtendedPath $Root + if (![IO.Directory]::Exists($extended)) { return } + $directory = New-Object IO.DirectoryInfo $extended + Assert-UnlinkedTree $directory + Remove-UnlinkedTree $directory } if ($LibraryOnly) { return } $locks = @() try { + if ([Environment]::Is64BitOperatingSystem -and ![Environment]::Is64BitProcess) { + throw 'Use the native 64-bit PowerShell host so running applications can be checked safely.' + } $documents = [Environment]::GetFolderPath('MyDocuments') $DataRoot = Assert-DirectChild $env:APPDATA $DataRoot $BackupsRoot = Assert-DirectChild $documents $BackupsRoot diff --git a/tests/setup-uninstall-windows.test.js b/tests/setup-uninstall-windows.test.js index 8f8dd510f..771bfd5b9 100644 --- a/tests/setup-uninstall-windows.test.js +++ b/tests/setup-uninstall-windows.test.js @@ -27,8 +27,8 @@ function fixture(t) { fs.writeFileSync(path.join(data, 'mongodb/data/sales.wt'), 'offline sale'); fs.writeFileSync(path.join(data, 'preferences.json'), 'printer settings'); fs.writeFileSync(path.join(backups, 'saved.json'), 'old backup'); - return { data, backups, recovery, run(business, hardware, keepBackups, executable = '') { - return spawnSync(ps, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, + return { data, backups, recovery, run(business, hardware, keepBackups, executable = '', interpreter = ps) { + return spawnSync(interpreter, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-DataRoot', data, '-BackupsRoot', backups, '-RecoveryRoot', recovery, '-HardwareManifest', path.resolve(__dirname, '../src/setup-hardware-files.json'), ...(executable ? ['-AppExecutable', executable] : []), @@ -54,12 +54,24 @@ test('Windows uninstaller handles all eight keep combinations and protects unsyn test('Windows uninstaller refuses to change data while its application is running', { skip: !windows }, t => { const f = fixture(t); - const result = f.run(0, 0, 0, process.execPath); - assert.notEqual(result.status, 0); - assert.match(result.stdout, /still running/); + for (const keep of [0, 1]) { + const result = f.run(keep, keep, keep, process.execPath); + assert.notEqual(result.status, 0); + assert.match(result.stdout, /still running/); + } assert.equal(fs.readFileSync(path.join(f.data, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); assert.equal(fs.existsSync(f.recovery), false); }); + +test('Windows helper refuses a host that cannot inspect 64-bit applications', { skip: !windows || process.arch !== 'x64' }, t => { + const f = fixture(t); + const host32 = path.join(process.env.SystemRoot, 'SysWOW64/WindowsPowerShell/v1.0/powershell.exe'); + const result = f.run(0, 0, 0, process.execPath, host32); + assert.notEqual(result.status, 0); + assert.match(result.stdout, /native 64-bit PowerShell/); + assert.equal(fs.existsSync(f.recovery), false); + assert.equal(fs.readFileSync(path.join(f.data, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); +}); test('Windows recovery failure stops before deleting business data or backups', { skip: !windows }, t => { const f = fixture(t); fs.writeFileSync(f.recovery, 'blocked destination'); @@ -67,3 +79,27 @@ test('Windows recovery failure stops before deleting business data or backups', assert.equal(fs.readFileSync(path.join(f.data, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); assert.equal(fs.readFileSync(path.join(f.backups, 'saved.json'), 'utf8'), 'old backup'); }); + +test('Windows recovery copies and verifies paths beyond MAX_PATH before removal', { skip: !windows }, t => { + const f = fixture(t); + const relative = path.join('api-runtime', 'current', 'node_modules', 'nested-runtime-module-'.repeat(4), 'generated-types-'.repeat(4), 'long-generated-type-name.js'); + const original = path.join(f.data, relative); + fs.mkdirSync(path.dirname(original), { recursive: true }); + fs.writeFileSync(original, 'long path recovery fixture'); + fs.chmodSync(original, 0o444); + assert.ok(original.length > 260); + const result = f.run(0, 1, 1); + assert.equal(result.status, 0, result.stdout + result.stderr); + const archive = path.join(f.recovery, fs.readdirSync(f.recovery)[0]); + assert.equal(fs.readFileSync(path.join(archive, relative), 'utf8'), 'long path recovery fixture'); + assert.equal(fs.existsSync(original), false); + assert.equal(fs.readFileSync(path.join(f.data, 'preferences.json'), 'utf8'), 'printer settings'); +}); + +test('Windows recovery refuses linked directories without deleting business data', { skip: !windows }, t => { + const f = fixture(t); + fs.symlinkSync(f.backups, path.join(f.data, 'linked-backups'), 'junction'); + assert.notEqual(f.run(0, 0, 0).status, 0); + assert.equal(fs.readFileSync(path.join(f.backups, 'saved.json'), 'utf8'), 'old backup'); + assert.equal(fs.readFileSync(path.join(f.data, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale'); +});