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
23 changes: 16 additions & 7 deletions builds/installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ SetDatablockOptimize on
ShowInstDetails show
ShowUninstDetails show

; common.nsh is loaded after this include and sets nevershow. Override it
; in the late header hook, after electron-builder has declared its pages.
!macro customHeader
ShowInstDetails show
ShowUninstDetails show
!macroend

Var POSNIC_REPAIR_MODE
Var POSNIC_START_MENU_FOLDER
Var POSNIC_KEEP_BUSINESS
Expand Down Expand Up @@ -259,7 +266,7 @@ Function un.PosnicDataPage
${NSD_CreateCheckbox} 0 112u 100% 20u "Keep saved backups"
Pop $POSNIC_BACKUPS_CHECK
${NSD_SetState} $POSNIC_BACKUPS_CHECK $POSNIC_KEEP_BACKUPS
${NSD_CreateLabel} 0 145u 100% 36u "Cloud data will not be deleted. Before removing business data, a verified recovery copy is saved separately in Documents."
${NSD_CreateLabel} 0 145u 100% 36u "Cloud data will not be deleted. Unchecked data is permanently removed from this computer. No extra backup is created."
Pop $0
nsDialogs::Show
FunctionEnd
Expand All @@ -272,7 +279,7 @@ Function un.PosnicDataLeave
${If} $POSNIC_KEEP_BUSINESS == 1
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: kept"
${Else}
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: removed locally; recovery copy kept in Documents\Posnic-Recovery"
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nBusiness data: permanently removed from this computer"
${EndIf}
${If} $POSNIC_KEEP_HARDWARE == 1
StrCpy $POSNIC_REMOVAL_SUMMARY "$POSNIC_REMOVAL_SUMMARY$\r$\nHardware settings: kept"
Expand All @@ -289,7 +296,7 @@ Function un.PosnicDataLeave
${AndIf} $POSNIC_KEEP_BACKUPS == 1
Return
${EndIf}
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "Apply these choices?$\r$\n$POSNIC_REMOVAL_SUMMARY$\r$\n$\r$\nSelected local data will be removed. Continue?" /SD IDNO IDYES +2
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "Apply these choices?$\r$\n$POSNIC_REMOVAL_SUMMARY$\r$\n$\r$\nUnchecked local data will be permanently removed. No extra backup will be created. Continue?" /SD IDNO IDYES +2
Abort
FunctionEnd

Expand All @@ -302,11 +309,13 @@ Function un.PosnicApplyData
${If} ${RunningX64}
StrCpy $POSNIC_POWERSHELL "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe"
${EndIf}
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'
SetDetailsView show
SetDetailsPrint both
DetailPrint "Applying your data choices..."
nsExec::ExecToLog '"$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
MessageBox MB_OK|MB_ICONSTOP "Removal stopped.$\r$\n$1" /SD IDOK
MessageBox MB_OK|MB_ICONSTOP "Removal stopped. See the details above for the error. Your selected cleanup may be incomplete." /SD IDOK
Abort
${EndIf}
FunctionEnd
Expand All @@ -330,7 +339,7 @@ FunctionEnd
DeleteRegValue HKCU "Software\Posnic" "Start Menu Folder"
DeleteRegKey /ifempty HKCU "Software\Posnic"

; Data choices were applied and verified before application removal.
; Data choices were applied before application removal.
!macroend

; Shell plugins may change the shell context. Never rely on that context for
Expand Down
102 changes: 58 additions & 44 deletions builds/setup-data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ param(
)
$ErrorActionPreference = 'Stop'

# Stream the current filename without flooding the UI for every small file.
$script:progressClock = [Diagnostics.Stopwatch]::StartNew()
function Write-RemovalProgress([string]$Message, [switch]$Force) {
if ($Force -or $script:progressClock.ElapsedMilliseconds -ge 100) {
Write-Output $Message
$script:progressClock.Restart()
}
}

function Get-ExtendedPath([string]$File) {
if ($File.StartsWith('\\?\')) { return $File }
$full = [IO.Path]::GetFullPath($File)
Expand All @@ -24,53 +33,59 @@ function Assert-DirectChild([string]$Parent, [string]$Child) {
}
return $resolvedChild
}
function Get-RecoveryHash([string]$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((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.Attributes -band [IO.FileAttributes]::Directory) { Copy-VerifiedTree $entry.FullName $target }
else {
[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.'
# Keep the per-file loop in compiled code: PowerShell recursion and pipeline
# calls are disproportionately expensive for node_modules' many tiny files.
Add-Type -TypeDefinition @"
using System;
using System.IO;
using System.Diagnostics;
public static class PosnicRemoval {
private static readonly Stopwatch progress = Stopwatch.StartNew();
private static void Report(string action, string path) {
if (progress.ElapsedMilliseconds < 100) return;
Console.WriteLine(action + path);
progress.Restart();
}
public static void Validate(FileSystemInfo entry) {
if ((entry.Attributes & FileAttributes.ReparsePoint) != 0)
throw new IOException("Linked folders cannot be deleted here.");
var directory = entry as DirectoryInfo;
if (directory != null)
foreach (var child in directory.EnumerateFileSystemInfos()) Validate(child);
}
public static void Remove(DirectoryInfo directory) {
foreach (var entry in directory.EnumerateFileSystemInfos()) {
// Check again during removal; never follow a link introduced later.
if ((entry.Attributes & FileAttributes.ReparsePoint) != 0)
throw new IOException("Linked folders cannot be deleted here.");
var childDirectory = entry as DirectoryInfo;
if (childDirectory != null) Remove(childDirectory);
else {
Report("Deleting: ", entry.FullName);
if ((entry.Attributes & FileAttributes.ReadOnly) != 0)
entry.Attributes &= ~FileAttributes.ReadOnly;
entry.Delete();
}
}
Report("Removing folder: ", directory.FullName);
directory.Delete();
}
}
"@
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 }
}
[PosnicRemoval]::Validate($Entry)
}
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()
[PosnicRemoval]::Remove($Directory)
}
function Remove-CheckedTree([string]$Root) {
function Remove-CheckedTree([string]$Root, [switch]$AlreadyChecked) {
# 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 }
Write-RemovalProgress ('Removing folder: ' + $Root) -Force
$directory = New-Object IO.DirectoryInfo $extended
Assert-UnlinkedTree $directory
if (!$AlreadyChecked) { Assert-UnlinkedTree $directory }
Remove-UnlinkedTree $directory
}
if ($LibraryOnly) { return }
Expand All @@ -79,17 +94,17 @@ try {
if ([Environment]::Is64BitOperatingSystem -and ![Environment]::Is64BitProcess) {
throw 'Use the native 64-bit PowerShell host so running applications can be checked safely.'
}
Write-RemovalProgress 'Checking that the application is closed...' -Force
$documents = [Environment]::GetFolderPath('MyDocuments')
$DataRoot = Assert-DirectChild $env:APPDATA $DataRoot
$BackupsRoot = Assert-DirectChild $documents $BackupsRoot
$RecoveryRoot = Assert-DirectChild $documents $RecoveryRoot
foreach ($running in Get-Process) {
try { $runningPath = $running.Path } catch { continue }
if ($AppExecutable -and $runningPath -and $runningPath -eq $AppExecutable) {
throw 'The application is still running. Close it before removing data.'
}
}
if ($RecoveryRoot -eq $BackupsRoot -or $DataRoot -eq $BackupsRoot) { throw 'Recovery must be separate from deleted folders.' }
if ($DataRoot -eq $BackupsRoot) { throw 'Data and backup folders must be separate.' }
$hardwareFiles = Get-Content -LiteralPath $HardwareManifest -Raw | ConvertFrom-Json
foreach ($name in $hardwareFiles) {
if ([IO.Path]::GetFileName($name) -ne $name) { throw 'Invalid hardware manifest.' }
Expand All @@ -99,22 +114,21 @@ try {
$lockFile = Join-Path $DataRoot $name
if (Test-Path -LiteralPath $lockFile) { $locks += [IO.File]::Open($lockFile, 'Open', 'ReadWrite', 'None') }
}
$copyRoot = Join-Path $RecoveryRoot ('recovery-' + [Guid]::NewGuid().ToString('N'))
Copy-VerifiedTree $DataRoot $copyRoot
[IO.File]::WriteAllText((Join-Path $copyRoot 'recovery-info.txt'), 'Verified local recovery copy. Not restored or uploaded automatically. Restore only with the original Windows account and compatible application version.')
Write-RemovalProgress 'Preparing selected local data for removal...' -Force
# Validate paths before applying the user's explicit deletion choices.
Assert-UnlinkedTree (New-Object IO.DirectoryInfo (Get-ExtendedPath $DataRoot))
foreach ($lock in $locks) { $lock.Dispose() }; $locks = @()
# Leave selected hardware files in place; remove all other entries only
# after the entire recovery copy has passed hash verification.
# No automatic recovery copy: saved backups are controlled independently.
foreach ($entry in Get-ChildItem -LiteralPath $DataRoot -Force) {
if ($KeepHardware -eq '1' -and $entry.Name -in $hardwareFiles) { continue }
if ($KeepBackups -eq '1' -and $entry.Name -eq 'backups') { continue }
if ($entry.PSIsContainer) { Remove-CheckedTree $entry.FullName }
else { Remove-Item -LiteralPath $entry.FullName -Force }
if ($entry.PSIsContainer) { Remove-CheckedTree $entry.FullName -AlreadyChecked }
else { Write-RemovalProgress ('Deleting: ' + $entry.Name); Remove-Item -LiteralPath $entry.FullName -Force }
}
} elseif ($KeepHardware -eq '0') {
foreach ($name in $hardwareFiles) {
$target = Join-Path $DataRoot $name
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Force }
if (Test-Path -LiteralPath $target) { Write-RemovalProgress ('Deleting hardware settings: ' + $name) -Force; Remove-Item -LiteralPath $target -Force }
}
}
if ($KeepBackups -eq '0') {
Expand Down
25 changes: 7 additions & 18 deletions tests/setup-uninstall-windows.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,17 @@ function fixture(t) {
{ encoding: 'utf8', windowsHide: true });
} };
}
test('Windows uninstaller handles all eight keep combinations and protects unsynced data', { skip: !windows }, t => {
test('Windows uninstaller handles all eight keep combinations without creating extra backups', { skip: !windows }, t => {
for (const business of [0, 1]) for (const hardware of [0, 1]) for (const backups of [0, 1]) {
const f = fixture(t), result = f.run(business, hardware, backups);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /Checking that the application is closed/);
if (!business) assert.match(result.stdout, /Removing folder: .*mongodb/);
assert.equal(fs.existsSync(path.join(f.data, 'mongodb/data/sales.wt')), Boolean(business));
assert.equal(fs.existsSync(path.join(f.data, 'preferences.json')), Boolean(hardware));
assert.equal(fs.existsSync(path.join(f.backups, 'saved.json')), Boolean(backups));
assert.equal(fs.existsSync(path.join(f.data, 'backups/fallback.json')), Boolean(backups));
if (!business) {
const archive = path.join(f.recovery, fs.readdirSync(f.recovery)[0]);
assert.equal(fs.readFileSync(path.join(archive, 'mongodb/data/sales.wt'), 'utf8'), 'offline sale');
assert.equal(fs.readFileSync(path.join(archive, 'preferences.json'), 'utf8'), 'printer settings');
}
assert.equal(fs.existsSync(f.recovery), false);
}
});

Expand All @@ -72,15 +70,7 @@ test('Windows helper refuses a host that cannot inspect 64-bit applications', {
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 => {
test('Windows uninstaller removes long paths while retaining hardware without extra copying', { 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);
Expand All @@ -90,13 +80,12 @@ test('Windows recovery copies and verifies paths beyond MAX_PATH before removal'
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(f.recovery), false);
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 => {
test('Windows uninstaller 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);
Expand Down
Loading