Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions builds/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 40 additions & 10 deletions builds/setup-data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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('\')
Expand All @@ -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
Expand Down
46 changes: 41 additions & 5 deletions tests/setup-uninstall-windows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] : []),
Expand All @@ -54,16 +54,52 @@ 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');
assert.notEqual(f.run(0, 0, 0).status, 0);
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');
});
Loading