From 922f5d96607b8e3ec4c373b929e3a1075ce3ddbb Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Thu, 1 Oct 2026 12:15:38 +1000 Subject: [PATCH] Refuse unattended purge of a non-empty nested Workspace `uninstall.sh --purge --yes` and `uninstall.ps1 -Purge -Yes` silently deleted the default Workspace at /workspace because the Workspace warning was gated on interactive mode and the summary never listed it. Both adapters now count the Workspace once, list a non-empty nested Workspace (with item count) in the pre-confirmation summary, and refuse unattended purge before any destructive operation unless --delete-workspace / -DeleteWorkspace is passed. Empty or external Workspaces need no flag; the interactive prompt is unchanged. The new flag requires --purge. Also document in the v1.3.0 release notes that the --pids-limit=4096 bound only reaches an existing Box when it is recreated. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 13 ++++++-- docs/releases/v1.3.0.md | 22 +++++++++++++ tests/test-lifecycle-ownership.sh | 48 +++++++++++++++++++++++++++++ tests/test-lifecycle-powershell.ps1 | 36 +++++++++++++++++++++- tests/test-lifecycle-static.sh | 4 +++ uninstall.ps1 | 37 +++++++++++++++------- uninstall.sh | 47 ++++++++++++++++++++++------ 7 files changed, 183 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ae310c0..8885cfd 100644 --- a/README.md +++ b/README.md @@ -611,8 +611,16 @@ it; a custom external Workspace is always preserved: sqrbx-uninstall --purge -A second confirmation is required if the recorded Workspace is non-empty. -Pass `-y` (or `-Yes` on PowerShell) to skip all prompts for scripting. +The confirmation summary lists a non-empty nested Workspace, with its item count, +as something that will be deleted, and a second confirmation is required. +Pass `-y` (or `-Yes` on PowerShell) to skip prompts for scripting. Unattended +purge refuses, before removing anything, to delete a non-empty nested +Workspace unless you also pass `--delete-workspace` (`-DeleteWorkspace` on +PowerShell): + + sqrbx-uninstall --purge --yes --delete-workspace + +An empty Workspace or an external Workspace needs no extra flag. Idempotent for a valid Install identity. Legacy resources require `--adopt`; purging an adopted, unlabeled volume additionally requires `--force`. @@ -621,6 +629,7 @@ purging an adopted, unlabeled volume additionally requires `--force`. sqrbx-uninstall # keep ~/squarebox sqrbx-uninstall -Purge # also remove ~/squarebox sqrbx-uninstall -Yes # skip confirmations + sqrbx-uninstall -Purge -Yes -DeleteWorkspace # also delete a non-empty nested Workspace **Broken-state recovery** (e.g. shell functions are missing, or after partial install): run the script matching the adapter that created the Install identity diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md index 0caa3e6..313f47d 100644 --- a/docs/releases/v1.3.0.md +++ b/docs/releases/v1.3.0.md @@ -20,3 +20,25 @@ Managed home. After success, use only the target adapter for lifecycle work. Native Windows OpenSSH-agent forwarding remains experimental. PowerShell keeps the read-only SSH-directory fallback; Git Bash can forward an already available Unix-compatible socket. Migration does not install a named-pipe relay. + +## Box PID limit applies on recreation + +v1.3.0 bounds each Box to 4096 processes (`--pids-limit=4096`, Compose +`pids_limit: 4096`). The limit is a container creation setting, so an existing +Box keeps running without it until that Box is recreated: + +- Installer users: run `sqrbx-rebuild` or re-run the installer. +- Compose users: after updating `docker-compose.yml`, run `docker compose up -d`; + Compose detects the changed configuration and recreates the container. + +The Workspace and Managed home are preserved across recreation. + +## Purge no longer deletes a non-empty Workspace unattended + +`uninstall.sh --purge --yes` and `uninstall.ps1 -Purge -Yes` previously removed +the default Workspace nested at `/workspace` without warning. The +confirmation summary now lists a non-empty nested Workspace and its item count, +and unattended purge refuses before removing anything unless +`--delete-workspace` (`-DeleteWorkspace` on PowerShell) is also passed. Update +scripts that intentionally purge a populated default Workspace. An empty +Workspace, or an external Workspace (always preserved), needs no flag. diff --git a/tests/test-lifecycle-ownership.sh b/tests/test-lifecycle-ownership.sh index be45da6..449d55a 100755 --- a/tests/test-lifecycle-ownership.sh +++ b/tests/test-lifecycle-ownership.sh @@ -200,6 +200,54 @@ fi grep -q -- '--force is required' "$TMP/force.out" test -d "$TMP/custom" +# Purge must not silently delete a non-empty Workspace nested in the install +# directory. Unattended purge refuses before any destructive operation unless +# --delete-workspace is explicit; an empty nested Workspace needs no flag. +STATE="$TMP/custom/.squarebox/install-state" +reset_host_adapters() { + printf '# >>> squarebox >>>\nmanaged\n# <<< squarebox <<<\n' >"$TMP/home/.bashrc" + printf '# squarebox-install-id=test-install-123\nmanaged\n' >"$TMP/home/.squarebox-shell-init" + rm -f "$MOCK_STATE/"* "$MOCK_LOG" +} +cp -a "$TMP/custom" "$TMP/custom.pristine" +sed -i "s#^WORKSPACE_DIR=.*#WORKSPACE_DIR=$TMP/custom/workspace#" "$STATE" +mkdir -p "$TMP/custom/workspace" +printf code >"$TMP/custom/workspace/project.txt" +printf hidden >"$TMP/custom/workspace/.env" +reset_host_adapters +status=0 +"$ROOT/uninstall.sh" --purge --yes --force >"$TMP/workspace-guard.out" 2>&1 || status=$? +[ "$status" = 1 ] || { echo "unattended purge did not refuse a non-empty nested Workspace (status $status)" >&2; exit 1; } +grep -qF "Workspace inside install directory (2 item(s)): $TMP/custom/workspace" "$TMP/workspace-guard.out" +grep -q -- 'Pass --delete-workspace' "$TMP/workspace-guard.out" +test -f "$TMP/custom/workspace/project.txt" +test -f "$TMP/custom/.squarebox/install-state" +test -f "$TMP/home/.squarebox-shell-init" +grep -qF '# >>> squarebox >>>' "$TMP/home/.bashrc" +! grep -q '^rm \|^rmi \|^volume rm ' "$MOCK_LOG" 2>/dev/null + +status=0 +"$ROOT/uninstall.sh" --yes --delete-workspace >"$TMP/workspace-flag.out" 2>&1 || status=$? +[ "$status" = 64 ] || { echo '--delete-workspace was accepted without --purge' >&2; exit 1; } +grep -q -- '--delete-workspace requires --purge' "$TMP/workspace-flag.out" +test -f "$TMP/home/.squarebox-shell-init" + +reset_host_adapters +"$ROOT/uninstall.sh" --purge --yes --force --delete-workspace >"$TMP/workspace-delete.out" +test ! -e "$TMP/custom" +grep -q '^volume rm custom-home$' "$MOCK_LOG" + +cp -a "$TMP/custom.pristine" "$TMP/custom" +sed -i "s#^WORKSPACE_DIR=.*#WORKSPACE_DIR=$TMP/custom/workspace#" "$STATE" +mkdir -p "$TMP/custom/workspace" +reset_host_adapters +"$ROOT/uninstall.sh" --purge --yes --force >"$TMP/workspace-empty.out" +test ! -e "$TMP/custom" +! grep -q 'Workspace inside install directory' "$TMP/workspace-empty.out" + +mv "$TMP/custom.pristine" "$TMP/custom" +reset_host_adapters + export FAIL_SHARED_RELEASE_REMOVE=1 "$ROOT/uninstall.sh" --purge --yes --force >"$TMP/purge.out" test ! -e "$TMP/custom" diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index 470bb8d..b05d68a 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -73,6 +73,7 @@ foreach ($boundary in @('checkout', 'image-alias', 'managed-home-create', 'manag Assert-True ($install.Contains('Malformed squarebox marker block') -and $uninstall.Contains('Malformed squarebox marker block')) 'profile marker validation is absent' Assert-True ($install.Contains('[regex]::Replace($profileBlock')) 'profile interpolation can rescan inserted path placeholders' Assert-True ($uninstall.Contains('Assert-PurgeCheckout')) 'purge does not revalidate checkout identity' +Assert-True ($uninstall.Contains('[switch]$DeleteWorkspace') -and $uninstall.Contains('$Yes -and -not $DeleteWorkspace')) 'unattended purge can delete a nested Workspace without -DeleteWorkspace' Assert-True ($install.Contains('Get-BoundedJson') -and $install.Contains('MaximumRetryCount 3')) 'release metadata HTTP is unbounded or lacks retries' Assert-True ($install.Contains('{{range .RepoDigests}}{{println .}}{{end}}') -and -not $install.Contains('index .RepoDigests 0')) 'PowerShell trusts the first repository digest instead of enumerating identities' Assert-True ($install -match '\$repoDigestOutput = @\(\)\s+if \(-not \$Build\)') 'local builds still derive identity from unordered RepoDigests' @@ -233,6 +234,7 @@ $mockBin = Join-Path $migrationRoot 'bin' $mockRuntime = Join-Path $mockBin 'mock-runtime.ps1' [IO.File]::WriteAllText($mockRuntime, @' $command = $args -join ' ' +if ($env:SQUAREBOX_TEST_RUNTIME_LOG) { Add-Content -LiteralPath $env:SQUAREBOX_TEST_RUNTIME_LOG -Value $command } if ($command.Contains('{{.Id}}')) { Write-Output ('sha256:' + ('c' * 64)) } elseif ($command.Contains('io.squarebox.install-id')) { Write-Output 'test-install-123' } exit 0 @@ -302,7 +304,9 @@ try { [IO.File]::WriteAllText($uninstallHarness, @' $PROFILE.CurrentUserAllHosts = $env:SQUAREBOX_TEST_PROFILE_ALL $PROFILE.CurrentUserCurrentHost = $env:SQUAREBOX_TEST_PROFILE_HOST -& $env:SQUAREBOX_TEST_UNINSTALL -InstallDir $env:SQUAREBOX_TEST_INSTALL_DIR -Yes +$purge = $env:SQUAREBOX_TEST_PURGE -ceq '1' +$deleteWorkspace = $env:SQUAREBOX_TEST_DELETE_WORKSPACE -ceq '1' +& $env:SQUAREBOX_TEST_UNINSTALL -InstallDir $env:SQUAREBOX_TEST_INSTALL_DIR -Yes -Purge:$purge -DeleteWorkspace:$deleteWorkspace exit $LASTEXITCODE '@, [Text.UTF8Encoding]::new($false)) $oldUserProfile = $env:USERPROFILE @@ -315,9 +319,39 @@ exit $LASTEXITCODE & pwsh -NoProfile -File $uninstallHarness Assert-True ($LASTEXITCODE -eq 0) 'PowerShell uninstaller rejected migrated Install state' Assert-True (-not ((Get-Content $profileAll) -ccontains '# squarebox-install-id=test-install-123')) 'target uninstaller did not remove migrated adapter' + + # Unattended purge must refuse a non-empty nested Workspace before any + # destructive operation unless -DeleteWorkspace is explicit. + $finalState = @{}; Get-Content $migrationState | ForEach-Object { $key, $value = $_ -split '=', 2; $finalState[$key] = $value } + $nestedWorkspace = $finalState.WORKSPACE_DIR + [IO.Directory]::CreateDirectory($nestedWorkspace) | Out-Null + $projectFile = Join-Path $nestedWorkspace 'project.txt' + [IO.File]::WriteAllText($projectFile, 'code', [Text.UTF8Encoding]::new($false)) + $runtimeLog = Join-Path $migrationRoot 'runtime.log' + $env:SQUAREBOX_TEST_RUNTIME_LOG = $runtimeLog + $env:SQUAREBOX_TEST_PURGE = '1' + $guardOutput = (& pwsh -NoProfile -File $uninstallHarness *>&1) -join "`n" + Assert-True ($LASTEXITCODE -ne 0) 'unattended purge deleted a non-empty nested Workspace without -DeleteWorkspace' + Assert-True ($guardOutput.Contains('Workspace inside install directory (1 item(s))')) 'purge summary does not list the nested Workspace' + Assert-True ($guardOutput.Contains('Pass -DeleteWorkspace')) 'Workspace refusal does not name -DeleteWorkspace' + Assert-True (Test-Path -LiteralPath $projectFile -PathType Leaf) 'Workspace refusal removed project files' + Assert-True (Test-Path -LiteralPath $migrationState -PathType Leaf) 'Workspace refusal removed Install identity' + $guardCalls = if (Test-Path -LiteralPath $runtimeLog) { @(Get-Content -LiteralPath $runtimeLog) } else { @() } + Assert-True (-not ($guardCalls | Where-Object { $_ -match '^(rm|rmi|volume rm) ' })) 'Workspace refusal ran a destructive runtime command' + + & git -C $migrationInstall init -q + Assert-True ($LASTEXITCODE -eq 0) 'unable to initialize purge checkout fixture' + & git -C $migrationInstall remote add origin 'https://github.com/SquareWaveSystems/squarebox.git' + Assert-True ($LASTEXITCODE -eq 0) 'unable to set purge checkout fixture origin' + $env:SQUAREBOX_TEST_DELETE_WORKSPACE = '1' + & pwsh -NoProfile -File $uninstallHarness + Assert-True ($LASTEXITCODE -eq 0) 'purge with -DeleteWorkspace failed' + Assert-True (-not (Test-Path -LiteralPath $migrationInstall)) 'purge with -DeleteWorkspace kept the install directory' + Assert-True (@(Get-Content -LiteralPath $runtimeLog) -ccontains 'volume rm custom-home') 'purge with -DeleteWorkspace kept the Managed home' } finally { $env:USERPROFILE = $oldUserProfile Remove-Item Env:SQUAREBOX_TEST_PROFILE_ALL, Env:SQUAREBOX_TEST_PROFILE_HOST, Env:SQUAREBOX_TEST_UNINSTALL, Env:SQUAREBOX_TEST_INSTALL_DIR -ErrorAction SilentlyContinue + Remove-Item Env:SQUAREBOX_TEST_RUNTIME_LOG, Env:SQUAREBOX_TEST_PURGE, Env:SQUAREBOX_TEST_DELETE_WORKSPACE -ErrorAction SilentlyContinue } $global:LASTEXITCODE = 0 } finally { diff --git a/tests/test-lifecycle-static.sh b/tests/test-lifecycle-static.sh index 334d522..4cddab4 100755 --- a/tests/test-lifecycle-static.sh +++ b/tests/test-lifecycle-static.sh @@ -89,6 +89,10 @@ grep -q '\$LegacyStarshipBlob' install.ps1 grep -Fq '.install-state.$([guid]::NewGuid' install.ps1 grep -q 'Recorded Workspace contains' uninstall.ps1 grep -q "Read-Host 'Continue? \[y/N\]'" uninstall.ps1 +grep -q -- '--delete-workspace' uninstall.sh +grep -q 'DeleteWorkspace' uninstall.ps1 +grep -q 'Workspace inside install directory' uninstall.sh +grep -q 'Workspace inside install directory' uninstall.ps1 # FORMAT=1 is deliberately adapter-native. Both readers accept CRLF, but a # Git-Bash C:/... path is not promised to be interchangeable with a native diff --git a/uninstall.ps1 b/uninstall.ps1 index bd7b532..9c7bd4d 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -4,6 +4,7 @@ [CmdletBinding()] param( [switch]$Purge, + [switch]$DeleteWorkspace, [Alias('y')][switch]$Yes, [switch]$Adopt, [switch]$Force, @@ -15,6 +16,7 @@ $ErrorActionPreference = 'Stop' $Repo = 'https://github.com/SquareWaveSystems/squarebox.git' $UserHome = if ($IsWindows -and $env:USERPROFILE) { $env:USERPROFILE } else { $HOME } function Abort([string]$Message) { Write-Host "Error: $Message" -ForegroundColor Red; exit 1 } +if ($DeleteWorkspace -and -not $Purge) { Abort '-DeleteWorkspace requires -Purge.' } $StateFields = @( 'FORMAT', 'INSTALL_ID', 'RUNTIME', 'INSTALL_DIR', 'WORKSPACE_DIR', 'GIT_CONFIG_DIR', 'HOME_VOLUME', 'CONTAINER_NAME', 'IMAGE_ALIAS', 'IMAGE_REPOSITORY', 'IMAGE_REF', @@ -275,6 +277,16 @@ foreach ($path in $ProfilePaths) { } } $HasProfile = $ProfileBlocks.Count -gt 0 +# A Workspace nested in the install directory is deleted by purge. Count it +# once so the summary, the -Yes guard, and the interactive prompt agree. +$PathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } +$WorkspaceInside = ($WorkspaceDir + [IO.Path]::DirectorySeparatorChar).StartsWith( + $InstallDir + [IO.Path]::DirectorySeparatorChar, $PathComparison) +$WorkspaceCount = 0 +if ($Purge -and (Test-Path -LiteralPath $WorkspaceDir -PathType Container)) { + try { $WorkspaceCount = @(Get-ChildItem -Force -LiteralPath $WorkspaceDir -ErrorAction Stop).Count } + catch { Abort "Unable to inspect recorded Workspace '$WorkspaceDir'; nothing was removed." } +} Write-Host 'squarebox uninstall' Write-Host '===================' Write-Host "Install identity: $(if ($InstallId) { $InstallId } else { 'legacy adoption' })" @@ -287,9 +299,16 @@ if ($ContainerOwned) { Write-Host " - Managed Box: $ContainerName"; $Anything = if ($ImageOwned) { Write-Host " - Recorded image alias: $ImageAlias"; $Anything = $true } if ($HasProfile) { Write-Host " - PowerShell adapter(s): $($ProfilePaths -join ', ')"; $Anything = $true } if ($Purge -and (Test-Path $InstallDir)) { Write-Host " - Recorded install directory: $InstallDir"; $Anything = $true } +if ($WorkspaceInside -and $WorkspaceCount -gt 0) { Write-Host " - Workspace inside install directory ($WorkspaceCount item(s)): $WorkspaceDir" } if ($Purge -and $VolumeOwned) { Write-Host " - Managed home: $HomeVolume"; $Anything = $true } if (-not $Anything) { Write-Host ' (nothing)'; exit 0 } +# Unattended purge must never silently delete user project files. Refuse before +# any destructive operation unless deletion was requested explicitly. +if ($WorkspaceInside -and $WorkspaceCount -gt 0 -and $Yes -and -not $DeleteWorkspace) { + Abort "-Purge would delete the Workspace at $WorkspaceDir ($WorkspaceCount item(s)). Pass -DeleteWorkspace to delete it, or move it outside $InstallDir first. Nothing was removed." +} + if ($Purge -and $VolumeOwned -and ($HomeVolumeAdopted -or -not $State) -and -not $Force) { Abort "'$HomeVolume' is an adopted unlabeled volume; -Force is required to purge it." } @@ -301,16 +320,12 @@ if (-not $Yes) { if ([Console]::IsInputRedirected) { Abort 'stdin is not a terminal; pass -Yes.' } if ((Read-Host 'Proceed? [y/N]') -notmatch '^[yY]([eE][sS])?$') { Write-Host 'Aborted.'; exit 1 } } -if ($Purge -and -not $Yes -and (Test-Path -LiteralPath $WorkspaceDir -PathType Container)) { - $workspaceCount = @(Get-ChildItem -Force -LiteralPath $WorkspaceDir -ErrorAction Stop).Count - if ($workspaceCount -gt 0) { - Write-Warning "Recorded Workspace contains $workspaceCount item(s): $WorkspaceDir" - $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } - if ($WorkspaceDir.StartsWith($InstallDir + [IO.Path]::DirectorySeparatorChar, $comparison)) { - Write-Host 'It will be removed with the install directory.' - } else { Write-Host 'It is outside the install directory and will be preserved.' } - if ((Read-Host 'Continue? [y/N]') -notmatch '^[yY]([eE][sS])?$') { Write-Host 'Aborted.'; exit 1 } - } +if ($WorkspaceCount -gt 0 -and -not $Yes) { + Write-Warning "Recorded Workspace contains $WorkspaceCount item(s): $WorkspaceDir" + if ($WorkspaceInside) { + Write-Host 'It will be removed with the install directory.' + } else { Write-Host 'It is outside the install directory and will be preserved.' } + if ((Read-Host 'Continue? [y/N]') -notmatch '^[yY]([eE][sS])?$') { Write-Host 'Aborted.'; exit 1 } } if ($Purge) { Assert-PurgeCheckout } @@ -362,7 +377,7 @@ Write-Host 'Uninstall complete.' if (-not $Purge) { Write-Host "Preserved install identity and Workspace at $InstallDir." if ($VolumeOwned) { Write-Host "Preserved Managed home $HomeVolume." } -} elseif ((Test-Path $WorkspaceDir) -and -not $WorkspaceDir.StartsWith($InstallDir + [IO.Path]::DirectorySeparatorChar)) { +} elseif ((Test-Path $WorkspaceDir) -and -not $WorkspaceInside) { Write-Host "Preserved external Workspace $WorkspaceDir." } Write-Host 'Start a new PowerShell session to drop loaded functions.' diff --git a/uninstall.sh b/uninstall.sh index e0ecc6c..177faa4 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -5,10 +5,13 @@ IDENTITY_LABEL=io.squarebox.install-id REPO=https://github.com/SquareWaveSystems/squarebox.git usage() { cat <<'EOF' -Usage: uninstall.sh [--purge] [-y|--yes] [--runtime docker|podman] - [--adopt] [--force] +Usage: uninstall.sh [--purge [--delete-workspace]] [-y|--yes] + [--runtime docker|podman] [--adopt] [--force] --purge Also remove the recorded install directory and Managed home. + --delete-workspace + With --purge --yes, permit deleting a non-empty Workspace that + lies inside the install directory. Without it, --yes refuses. --adopt Explicitly adopt an origin-verified legacy install with no state. --force Permit purge of an explicitly adopted, unlabeled legacy volume. @@ -17,17 +20,21 @@ ownership. A familiar fixed name is never sufficient authority. EOF } -PURGE=0; YES=0; ADOPT=0; FORCE=0; RUNTIME_OVERRIDE="" +PURGE=0; YES=0; ADOPT=0; FORCE=0; DELETE_WORKSPACE=0; RUNTIME_OVERRIDE="" while [ $# -gt 0 ]; do case "$1" in --purge) PURGE=1; shift ;; -y|--yes) YES=1; shift ;; --adopt) ADOPT=1; shift ;; --force) FORCE=1; shift ;; + --delete-workspace) DELETE_WORKSPACE=1; shift ;; --runtime=*) RUNTIME_OVERRIDE="${1#*=}"; shift ;; --runtime) [ $# -ge 2 ] || { echo "Error: --runtime requires a value." >&2; exit 64; }; RUNTIME_OVERRIDE="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) echo "Error: unknown option '$1'" >&2; usage >&2; exit 64 ;; esac done +if [ "$DELETE_WORKSPACE" = 1 ] && [ "$PURGE" != 1 ]; then + echo "Error: --delete-workspace requires --purge." >&2; exit 64 +fi WINDOWS_BASH=0 [ -n "${MSYSTEM:-}" ] && WINDOWS_BASH=1 @@ -354,6 +361,16 @@ if block_present "$HOME/.bash_profile" '# >>> squarebox bashrc bridge >>>' '# << has_bridge=1 fi +# A Workspace nested in the install directory is deleted by purge. Count it +# once so the summary, the --yes guard, and the interactive prompt agree. +workspace_inside=0; workspace_count=0 +if [ "$PURGE" = 1 ] && [ -d "$WORKSPACE_DIR" ]; then + case "$WORKSPACE_DIR/" in "$INSTALL_DIR"/*) workspace_inside=1 ;; esac + workspace_count="$(find "$WORKSPACE_DIR" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" || { + echo "Error: unable to inspect recorded Workspace '$WORKSPACE_DIR'; nothing was removed." >&2; exit 1; + } +fi + echo "squarebox uninstall" echo "===================" echo "Install identity: ${INSTALL_ID:-legacy adoption}" @@ -369,10 +386,21 @@ for _rc in "${rc_files[@]}"; do echo " - Shell sentinel: $_rc"; anything=1; don [ "$has_bridge" = 1 ] && { echo " - Git Bash bridge: $HOME/.bash_profile"; anything=1; } if [ "$PURGE" = 1 ]; then [ -d "$INSTALL_DIR" ] && { echo " - Recorded install directory: $INSTALL_DIR"; anything=1; } + if [ "$workspace_inside" = 1 ] && [ "$workspace_count" -gt 0 ]; then + echo " - Workspace inside install directory ($workspace_count item(s)): $WORKSPACE_DIR" + fi [ "$volume_owned" = 1 ] && { echo " - Managed home: $HOME_VOLUME"; anything=1; } fi if [ "$anything" = 0 ]; then echo " (nothing)"; exit 0; fi +# Unattended purge must never silently delete user project files. Refuse before +# any destructive operation unless deletion was requested explicitly. +if [ "$workspace_inside" = 1 ] && [ "$workspace_count" -gt 0 ] && [ "$YES" = 1 ] && [ "$DELETE_WORKSPACE" != 1 ]; then + echo "Error: --purge would delete the Workspace at $WORKSPACE_DIR ($workspace_count item(s))." >&2 + echo "Pass --delete-workspace to delete it, or move it outside $INSTALL_DIR first. Nothing was removed." >&2 + exit 1 +fi + if [ "$PURGE" = 1 ] && [ "$volume_owned" = 1 ] && { [ "$HOME_VOLUME_ADOPTED" = 1 ] || [ "$HAD_STATE" = 0 ]; } && [ "$FORCE" != 1 ]; then echo "Error: '$HOME_VOLUME' is an explicitly adopted unlabeled volume; --force is required to purge it." >&2; exit 1 fi @@ -386,14 +414,13 @@ if [ "$YES" != 1 ]; then printf 'Proceed? [y/N]: '; read -r answer case "$answer" in y|Y|yes|YES|Yes) ;; *) echo "Aborted."; exit 1 ;; esac fi -if [ "$PURGE" = 1 ] && [ -d "$WORKSPACE_DIR" ] && [ "$YES" != 1 ]; then - _count="$(find "$WORKSPACE_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')" - if [ "$_count" -gt 0 ]; then - echo "Warning: recorded Workspace contains $_count item(s): $WORKSPACE_DIR" - case "$WORKSPACE_DIR/" in "$INSTALL_DIR"/*) echo "It will be removed with the install directory." ;; *) echo "It is outside the install directory and will be preserved." ;; esac - printf 'Continue? [y/N]: '; read -r answer - case "$answer" in y|Y|yes|YES|Yes) ;; *) echo "Aborted."; exit 1 ;; esac +if [ "$workspace_count" -gt 0 ] && [ "$YES" != 1 ]; then + echo "Warning: recorded Workspace contains $workspace_count item(s): $WORKSPACE_DIR" + if [ "$workspace_inside" = 1 ]; then echo "It will be removed with the install directory." + else echo "It is outside the install directory and will be preserved." fi + printf 'Continue? [y/N]: '; read -r answer + case "$answer" in y|Y|yes|YES|Yes) ;; *) echo "Aborted."; exit 1 ;; esac fi # The summary and Workspace warning may leave an arbitrarily long interactive