diff --git a/App/shell/desktop/build/MemmyWindowsDataMigration.ps1 b/App/shell/desktop/build/MemmyWindowsDataMigration.ps1 index 01f16fd3..e67ab3b3 100644 --- a/App/shell/desktop/build/MemmyWindowsDataMigration.ps1 +++ b/App/shell/desktop/build/MemmyWindowsDataMigration.ps1 @@ -8,7 +8,6 @@ param( [ValidateSet( "current-install-authority", - "selected-install-authority", "relay-backup-authority", "persisted-install-authority", "untrusted-residual" @@ -55,6 +54,8 @@ param( [string]$InstallerInstallDir = "", + [string]$TargetInstallDir = "", + [switch]$AcquireLock ) @@ -66,6 +67,16 @@ $effectiveSourceAuthority = $SourceAuthority $effectiveSourceInstallDir = $SourceInstallDir $effectiveSourceGeneration = $SourceGeneration $effectiveSourceInstalledVersion = $SourceInstalledVersion +$effectiveTargetInstallDir = if ($TargetInstallDir) { + $TargetInstallDir +} elseif ($InstallerInstallDir) { + $InstallerInstallDir +} elseif ($SourceInstallDir) { + $SourceInstallDir +} else { + Split-Path -Parent $SourceDataPath +} +$verifiedExternalRuntimeHomePath = "" function Ensure-ParentDirectory { param([Parameter(Mandatory = $true)][string]$Path) @@ -106,6 +117,71 @@ function Test-SamePath { ) } +function Test-SameOrDescendantPath { + param( + [Parameter(Mandatory = $true)][string]$Candidate, + [Parameter(Mandatory = $true)][string]$Parent + ) + + $normalizedCandidate = Get-NormalizedPath -Path $Candidate + $normalizedParent = Get-NormalizedPath -Path $Parent + if (Test-SamePath -Left $normalizedCandidate -Right $normalizedParent) { return $true } + return $normalizedCandidate.StartsWith( + "$($normalizedParent.TrimEnd('\'))\", + [System.StringComparison]::OrdinalIgnoreCase + ) +} + +function Get-CanonicalExternalRuntimeHomePath { + param([Parameter(Mandatory = $true)][string]$InstallDir) + + $installRoot = [System.IO.Path]::GetPathRoot((Get-NormalizedPath -Path $InstallDir)) + if (-not $installRoot) { + throw "The source installation has no canonical runtime drive." + } + if (Test-SamePath -Left $installRoot -Right 'C:\') { + $legacyHome = if ($LegacyRuntimeHomePath) { + $LegacyRuntimeHomePath + } else { + Join-Path $env:USERPROFILE '.memmy' + } + return Get-NormalizedPath -Path $legacyHome + } + return Get-NormalizedPath -Path (Join-Path $installRoot 'MemmyData\.memmy') +} + +function Assert-NoReparsePath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Description + ) + + $currentPath = Get-NormalizedPath -Path $Path + while (-not (Test-Path -LiteralPath $currentPath)) { + $parentPath = [System.IO.Path]::GetDirectoryName($currentPath) + if (-not $parentPath -or (Test-SamePath -Left $parentPath -Right $currentPath)) { + throw "$Description has no existing trusted ancestor" + } + $currentPath = $parentPath + } + + while ($currentPath) { + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description crosses a reparse point: $($item.FullName)" + } + if ($item -is [System.IO.FileInfo]) { + $currentPath = $item.DirectoryName + } + elseif ($item.Parent) { + $currentPath = $item.Parent.FullName + } + else { + $currentPath = $null + } + } +} + function Read-DataRootPointer { param([Parameter(Mandatory = $true)][string]$Path) @@ -134,7 +210,9 @@ function Read-DataRootPointer { (Test-SamePath -Left $normalizedValue -Right $TargetRuntimeHomePath) -or ($canonicalDriveRuntimeHome -and (Test-SamePath -Left $normalizedValue -Right $canonicalDriveRuntimeHome)) -or ($AllowedRememberedRuntimeHomePath -and - (Test-SamePath -Left $normalizedValue -Right $AllowedRememberedRuntimeHomePath))) { + (Test-SamePath -Left $normalizedValue -Right $AllowedRememberedRuntimeHomePath)) -or + ($verifiedExternalRuntimeHomePath -and + (Test-SamePath -Left $normalizedValue -Right $verifiedExternalRuntimeHomePath))) { return $normalizedValue } Write-MigrationLog -Message "Ignoring data-root pointer outside a supported Memmy runtime root: $normalizedValue" @@ -157,10 +235,7 @@ function Resolve-TrustedInstallDataPath { $normalizedSourceDataPath = Get-NormalizedPath -Path $effectiveSourceDataPath $normalizedSourceInstallDir = Get-NormalizedPath -Path $effectiveSourceInstallDir - if (@("current-install-authority", "selected-install-authority") -contains $effectiveSourceAuthority) { - if ($effectiveSourceAuthority -eq "selected-install-authority" -and $Owner -ne "installer") { - throw "Selected-install authority requires direct installer ownership." - } + if ($effectiveSourceAuthority -eq "current-install-authority") { $expectedSourceDataPath = Get-NormalizedPath -Path (Join-Path $normalizedSourceInstallDir "data") if (-not (Test-SamePath -Left $normalizedSourceDataPath -Right $expectedSourceDataPath)) { throw "Install source data is outside the exact installation directory." @@ -223,13 +298,79 @@ function Resolve-SourceGeneration { function Test-CompleteInstallUserData { param([Parameter(Mandatory = $true)][string]$Path) - return Test-Path -LiteralPath (Join-Path $Path "app.sqlite") -PathType Leaf + $databasePath = Join-Path $Path "app.sqlite" + if (-not (Test-Path -LiteralPath $databasePath -PathType Leaf)) { + return $false + } + try { + $file = Get-Item -LiteralPath $databasePath -ErrorAction Stop + if ($file.Length -lt 512) { + Write-MigrationLog -Message "Ignoring incomplete install account database: $databasePath" + return $false + } + $stream = [System.IO.File]::Open( + $databasePath, + [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, + [System.IO.FileShare]::ReadWrite + ) + try { + $header = New-Object byte[] 100 + if ($stream.Read($header, 0, $header.Length) -ne $header.Length) { + return $false + } + $expectedHeader = [System.Text.Encoding]::ASCII.GetBytes("SQLite format 3`0") + for ($index = 0; $index -lt $expectedHeader.Length; $index++) { + if ($header[$index] -ne $expectedHeader[$index]) { + Write-MigrationLog -Message "Ignoring install account database with an invalid SQLite header: $databasePath" + return $false + } + } + $pageSize = ([int]$header[16] -shl 8) -bor [int]$header[17] + if ($pageSize -eq 1) { $pageSize = 65536 } + if ($pageSize -lt 512 -or $pageSize -gt 65536 -or + (($pageSize -band ($pageSize - 1)) -ne 0) -or + (($file.Length % $pageSize) -ne 0)) { + Write-MigrationLog -Message "Ignoring install account database with an invalid SQLite page layout: $databasePath" + return $false + } + return $true + } + finally { + $stream.Dispose() + } + } + catch { + Write-MigrationLog -Message "Ignoring unreadable install account database '$databasePath': $($_.Exception.Message)" + return $false + } } function Test-CompleteInstallRuntimeData { param([Parameter(Mandatory = $true)][string]$Path) - return Test-Path -LiteralPath (Join-Path $Path "config.yaml") -PathType Leaf + $configPath = Join-Path $Path "config.yaml" + if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { + return $false + } + try { + $bytes = [System.IO.File]::ReadAllBytes($configPath) + if ($bytes.Length -eq 0) { + Write-MigrationLog -Message "Ignoring empty install runtime config: $configPath" + return $false + } + $strictUtf8 = New-Object System.Text.UTF8Encoding($false, $true) + $contents = $strictUtf8.GetString($bytes).Trim([char]0xfeff).Trim() + if (-not $contents -or $contents.IndexOf([char]0) -ge 0) { + Write-MigrationLog -Message "Ignoring incomplete install runtime config: $configPath" + return $false + } + return $true + } + catch { + Write-MigrationLog -Message "Ignoring unreadable install runtime config '$configPath': $($_.Exception.Message)" + return $false + } } function Read-InstallationRecord { @@ -267,6 +408,24 @@ function Test-IsKnownInstallLocalVersion { return $parsed -ge [Version]'1.0.6' -and $parsed -lt [Version]'1.1.0' } +function Test-CompatibleRecordedVersion { + param( + [Parameter(Mandatory = $true)][string]$RecordedVersion, + [Parameter(Mandatory = $true)][string]$InstalledVersion + ) + + $recordedMatch = [Regex]::Match($RecordedVersion.Trim(), '^(?[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$') + $installedMatch = [Regex]::Match($InstalledVersion.Trim(), '^(?[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)(?:\s.*)?$') + if (-not $recordedMatch.Success -or -not $installedMatch.Success) { return $false } + try { + return ([Version]$recordedMatch.Groups['version'].Value) -eq + ([Version]$installedMatch.Groups['version'].Value) + } + catch { + return $false + } +} + function Resolve-EffectiveInstallSource { $record = Read-InstallationRecord if ($effectiveSourceAuthority -eq "untrusted-residual" -and @@ -291,7 +450,7 @@ function Resolve-EffectiveInstallSource { } Write-MigrationLog -Message "Using the persisted trusted install-local source: $effectiveSourceDataPath" } - elseif (@("current-install-authority", "selected-install-authority", "relay-backup-authority") -contains $effectiveSourceAuthority -and + elseif (@("current-install-authority", "relay-backup-authority") -contains $effectiveSourceAuthority -and $null -ne $record -and [string]$record.dataLayoutGeneration -eq "external-v1" -and (Test-SamePath -Left ([string]$record.installDir) -Right $effectiveSourceInstallDir)) { @@ -300,6 +459,35 @@ function Resolve-EffectiveInstallSource { Write-MigrationLog -Message "Installed version $effectiveSourceInstalledVersion uses the install-local layout; it remains authoritative despite an earlier external marker." } else { + try { + if (-not $record.userDataPath -or -not $record.runtimeHomePath -or -not $record.appVersion -or + -not [System.IO.Path]::IsPathRooted([string]$record.userDataPath) -or + -not [System.IO.Path]::IsPathRooted([string]$record.runtimeHomePath)) { + throw "The external-v1 record is missing absolute data paths." + } + if (-not $effectiveSourceInstalledVersion -or + -not (Test-CompatibleRecordedVersion ` + -RecordedVersion ([string]$record.appVersion) ` + -InstalledVersion $effectiveSourceInstalledVersion)) { + throw "The external-v1 record does not match the installed application version." + } + $recordedExternalUserDataPath = Get-NormalizedPath -Path ([string]$record.userDataPath) + $recordedExternalRuntimeHomePath = Get-NormalizedPath -Path ([string]$record.runtimeHomePath) + $expectedExternalRuntimeHomePath = Get-CanonicalExternalRuntimeHomePath -InstallDir $effectiveSourceInstallDir + if (-not (Test-SamePath -Left $recordedExternalUserDataPath -Right $TargetUserDataPath) -or + -not (Test-SamePath -Left $recordedExternalRuntimeHomePath -Right $expectedExternalRuntimeHomePath) -or + (Test-SameOrDescendantPath -Candidate $recordedExternalRuntimeHomePath -Parent $effectiveSourceInstallDir) -or + -not (Test-Path -LiteralPath $recordedExternalRuntimeHomePath -PathType Container)) { + throw "The external-v1 record does not identify the trusted current data layout." + } + Assert-NoReparsePath -Path $recordedExternalRuntimeHomePath -Description 'recorded external runtimeHomePath' + $script:verifiedExternalRuntimeHomePath = $recordedExternalRuntimeHomePath + Write-MigrationLog -Message "Using the verified external-v1 runtime source for relocation: $verifiedExternalRuntimeHomePath" + } + catch { + Write-MigrationLog -Message "Ignoring an invalid external-v1 runtime source: $($_.Exception.Message)" + $script:verifiedExternalRuntimeHomePath = "" + } Write-MigrationLog -Message "Install is already verified on the external layout; install-local residual data cannot replace targets." $script:effectiveSourceAuthority = "untrusted-residual" $script:effectiveSourceGeneration = "" @@ -310,7 +498,7 @@ function Resolve-EffectiveInstallSource { function Record-TrustedInstallLocalGeneration { if ($Owner -ne "installer" -or -not $InstallationRecordPath -or - @("current-install-authority", "selected-install-authority", "persisted-install-authority") -notcontains $effectiveSourceAuthority) { + @("current-install-authority", "persisted-install-authority") -notcontains $effectiveSourceAuthority) { return } $userDataPath = Join-Path $effectiveSourceDataPath "Memmy" @@ -334,7 +522,7 @@ function Preserve-FailedDirectMigrationSource { if ($Mode -ne "Prepare" -or $Owner -ne "installer" -or -not $InstallationRecordPath -or - @("current-install-authority", "selected-install-authority", "persisted-install-authority") -notcontains $effectiveSourceAuthority -or + @("current-install-authority", "persisted-install-authority") -notcontains $effectiveSourceAuthority -or -not (Test-Path -LiteralPath $effectiveSourceDataPath -PathType Container)) { return } @@ -663,11 +851,13 @@ function Invoke-TransactionalDirectoryCopy { return $null } + Assert-NoReparsePath -Path $Destination -Description 'migration destination' $criticalFileStreams = @(Open-CriticalFilesForMigration -RootPath $Source) $destinationParent = Split-Path -Parent $Destination if (-not (Test-Path -LiteralPath $destinationParent -PathType Container)) { New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null } + Assert-NoReparsePath -Path $Destination -Description 'migration destination' $token = [Guid]::NewGuid().ToString("N") $stagingPath = "$Destination.migrating-$token" @@ -675,6 +865,8 @@ function Invoke-TransactionalDirectoryCopy { $journalUpdated = $false try { Copy-DirectoryContents -Source $Source -Destination $stagingPath -ExcludeTopLevelNames $ExcludeTopLevelNames + Assert-NoReparsePath -Path $Destination -Description 'migration destination' + Assert-NoReparsePath -Path $stagingPath -Description 'migration staging path' $sourceFingerprint = Get-DirectoryFingerprint -Path $Source -ExcludeTopLevelNames $ExcludeTopLevelNames $stagingFingerprint = Get-DirectoryFingerprint -Path $stagingPath if ($sourceFingerprint.FileCount -ne $stagingFingerprint.FileCount -or @@ -705,6 +897,8 @@ function Invoke-TransactionalDirectoryCopy { -DeferredCleanupStates $DeferredCleanupStates $journalUpdated = $true + Assert-NoReparsePath -Path $Destination -Description 'migration destination' + Assert-NoReparsePath -Path $stagingPath -Description 'migration staging path' if ($destinationExisted) { Move-Item -LiteralPath $Destination -Destination $backupPath -Force -ErrorAction Stop } @@ -815,6 +1009,7 @@ function Write-PreparedRollbackJournal { sourceDataPath = (Get-NormalizedPath -Path $effectiveSourceDataPath) targetUserDataPath = (Get-NormalizedPath -Path $TargetUserDataPath) targetRuntimeHomePath = (Get-NormalizedPath -Path $TargetRuntimeHomePath) + targetInstallDir = (Get-NormalizedPath -Path $effectiveTargetInstallDir) pointerPath = (Get-NormalizedPath -Path $PointerPath) backupPaths = @($backupPaths) preparedCopies = @($Copies) @@ -1002,11 +1197,11 @@ function Resume-PreservedMigrationForRetry { $stateAccountAuthority = if ($state.PSObject.Properties.Name -contains "accountSourceAuthority") { [string]$state.accountSourceAuthority } else { "target-existing" } $stateRuntimeAuthority = if ($state.PSObject.Properties.Name -contains "runtimeSourceAuthority") { [string]$state.runtimeSourceAuthority } else { "target-existing" } if ($stateAccountAuthority -and - @("target-existing", "current-install-authority", "selected-install-authority", "relay-backup-authority", "persisted-install-authority") -notcontains $stateAccountAuthority) { + @("target-existing", "current-install-authority", "relay-backup-authority", "persisted-install-authority") -notcontains $stateAccountAuthority) { throw "Preserved migration account authority is invalid." } if ($stateRuntimeAuthority -and - @("target-existing", "legacy-home-fallback", "current-install-authority", "selected-install-authority", "relay-backup-authority", "persisted-install-authority") -notcontains $stateRuntimeAuthority) { + @("target-existing", "legacy-home-fallback", "current-install-authority", "relay-backup-authority", "persisted-install-authority", "persisted-external-authority") -notcontains $stateRuntimeAuthority) { throw "Preserved migration runtime authority is invalid." } if ($stateAccountAuthority -ne "target-existing" -and @@ -1140,6 +1335,12 @@ try { } if ($Mode -eq "Prepare") { + if (-not $effectiveTargetInstallDir -or -not [System.IO.Path]::IsPathRooted($effectiveTargetInstallDir)) { + throw "A target installation directory is required for migration." + } + Assert-NoReparsePath -Path $effectiveTargetInstallDir -Description 'target installDir' + Assert-NoReparsePath -Path $TargetUserDataPath -Description 'target userDataPath' + Assert-NoReparsePath -Path $TargetRuntimeHomePath -Description 'target runtimeHomePath' Resolve-EffectiveInstallSource if (Resume-PreservedMigrationForRetry) { Write-MigrationLog -Message "Migration preparation resumed." @@ -1215,6 +1416,7 @@ try { Write-MigrationLog -Message "No higher-authority account source exists; target user data retained." } elseif (-not (Test-Path -LiteralPath $TargetUserDataPath -PathType Container)) { + Assert-NoReparsePath -Path $TargetUserDataPath -Description 'target userDataPath' New-Item -ItemType Directory -Path $TargetUserDataPath -Force | Out-Null } @@ -1229,12 +1431,17 @@ try { } $runtimeSourceAuthority = if ($runtimeSourcePath) { $effectiveSourceAuthority } else { "target-existing" } if (-not $runtimeSourcePath -and -not $targetRuntimeHadData) { - foreach ($candidate in @($rememberedRuntimeHomePath, $LegacyRuntimeHomePath)) { + foreach ($candidate in @($verifiedExternalRuntimeHomePath, $rememberedRuntimeHomePath, $LegacyRuntimeHomePath)) { if ($candidate -and -not (Test-SamePath -Left $candidate -Right $TargetRuntimeHomePath) -and (Test-DirectoryContainsData -Path $candidate -ExcludeTopLevelNames @("updates"))) { $runtimeSourcePath = Get-NormalizedPath -Path $candidate - $runtimeSourceAuthority = "legacy-home-fallback" + $runtimeSourceAuthority = if ($verifiedExternalRuntimeHomePath -and + (Test-SamePath -Left $candidate -Right $verifiedExternalRuntimeHomePath)) { + "persisted-external-authority" + } else { + "legacy-home-fallback" + } break } } @@ -1271,6 +1478,7 @@ try { Write-MigrationLog -Message "No higher-authority runtime source exists; target runtime data retained: $TargetRuntimeHomePath" } elseif (-not (Test-Path -LiteralPath $TargetRuntimeHomePath -PathType Container)) { + Assert-NoReparsePath -Path $TargetRuntimeHomePath -Description 'target runtimeHomePath' New-Item -ItemType Directory -Path $TargetRuntimeHomePath -Force | Out-Null } @@ -1297,6 +1505,7 @@ try { targetRuntimeHadData = $targetRuntimeHadData targetUserDataPath = (Get-NormalizedPath -Path $TargetUserDataPath) targetRuntimeHomePath = (Get-NormalizedPath -Path $TargetRuntimeHomePath) + targetInstallDir = (Get-NormalizedPath -Path $effectiveTargetInstallDir) pointerPath = (Get-NormalizedPath -Path $PointerPath) backupPaths = @($backupPaths) preparedCopies = @($preparedCopies) @@ -1305,8 +1514,10 @@ try { deferredCleanupStates = $deferredCleanupStates createdAt = [DateTime]::UtcNow.ToString("o") } + Assert-NoReparsePath -Path $StatePath -Description 'migration state path' Write-JsonFileAtomically -Path $StatePath -Value $state $preparedStateWritten = $true + Assert-NoReparsePath -Path $PointerPath -Description 'runtime pointer path' Write-UnicodeFileAtomically -Path $PointerPath -Value "$TargetRuntimeHomePath`r`n" Write-MigrationLog -Message "Migration preparation completed." } diff --git a/App/shell/desktop/build/MemmyWindowsStandardUpgradeCheck.ps1 b/App/shell/desktop/build/MemmyWindowsStandardUpgradeCheck.ps1 new file mode 100644 index 00000000..896ffa12 --- /dev/null +++ b/App/shell/desktop/build/MemmyWindowsStandardUpgradeCheck.ps1 @@ -0,0 +1,260 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$TargetInstallDir, + [Parameter(Mandatory = $true)][string]$TargetUserDataPath, + [Parameter(Mandatory = $true)][string]$TargetRuntimeHomePath, + [Parameter(Mandatory = $true)][string]$InstalledExePath, + [Parameter(Mandatory = $true)][string]$InstallerPath, + [Parameter(Mandatory = $true)][string]$InstallationRecordPath, + [Parameter(Mandatory = $true)][string]$MigrationStatePath, + [switch]$AllowMissingExecutable +) + +$ErrorActionPreference = 'Stop' + +function Get-NormalizedAbsolutePath([string]$Path, [string]$Description) { + $isDriveAbsolute = $Path -match '^[A-Za-z]:[\\/]' + $isUncAbsolute = $Path -match '^\\\\(?![?.]\\)[^\\/]+[\\/][^\\/]+' + if (-not $Path -or (-not $isDriveAbsolute -and -not $isUncAbsolute)) { + throw "$Description is not a fully qualified absolute path" + } + $normalized = [System.IO.Path]::GetFullPath($Path) + $root = [System.IO.Path]::GetPathRoot($normalized) + if ([string]::Equals($normalized, $root, [System.StringComparison]::OrdinalIgnoreCase)) { + return $root + } + return $normalized.TrimEnd('\') +} + +function Assert-NoReparsePath([string]$Path, [string]$Description) { + $currentPath = $Path + while (-not (Test-Path -LiteralPath $currentPath)) { + $parentPath = [System.IO.Path]::GetDirectoryName($currentPath) + if (-not $parentPath -or (Test-SamePath $parentPath $currentPath)) { + throw "$Description has no existing trusted ancestor" + } + $currentPath = $parentPath + } + + while ($currentPath) { + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description crosses a reparse point: $($item.FullName)" + } + if ($item -is [System.IO.FileInfo]) { + $currentPath = $item.DirectoryName + } elseif ($item.Parent) { + $currentPath = $item.Parent.FullName + } else { + $currentPath = $null + } + } +} + +function Test-SamePath([string]$Left, [string]$Right) { + return [string]::Equals($Left, $Right, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Test-SameOrDescendantPath([string]$Candidate, [string]$Parent) { + if (Test-SamePath $Candidate $Parent) { return $true } + return $Candidate.StartsWith("$($Parent.TrimEnd('\'))\", [System.StringComparison]::OrdinalIgnoreCase) +} + +function Test-DirectoryContainsData([string]$Path, [string[]]$ExcludedTopLevelNames = @()) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $false } + foreach ($item in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + if ($ExcludedTopLevelNames -contains $item.Name) { continue } + if (-not $item.PSIsContainer) { return $true } + if ($null -ne (Get-ChildItem -LiteralPath $item.FullName -Recurse -Force -File -ErrorAction Stop | + Select-Object -First 1)) { + return $true + } + } + return $false +} + +function Stop-Installation([string]$Reason) { + throw "installation-blocked:$Reason" +} + +function ConvertTo-ComparableVersion([string]$Version, [bool]$AllowMetadata) { + if (-not $Version) { throw "installed version metadata is missing" } + $pattern = if ($AllowMetadata) { + '^\s*(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?(?:\s.*)?$' + } else { + '^(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$' + } + $match = [regex]::Match($Version, $pattern) + if (-not $match.Success) { throw "version metadata is not numeric and compatible: $Version" } + $segments = @( + [uint32]$match.Groups[1].Value, + [uint32]$match.Groups[2].Value, + [uint32]$match.Groups[3].Value, + $(if ($match.Groups[4].Success) { [uint32]$match.Groups[4].Value } else { [uint32]0 }) + ) + return $segments -join '.' +} + +try { + $normalizedInstallDir = Get-NormalizedAbsolutePath $InstallDir 'installDir' + $normalizedTargetInstallDir = Get-NormalizedAbsolutePath $TargetInstallDir 'target installDir' + $normalizedTargetUserDataPath = Get-NormalizedAbsolutePath $TargetUserDataPath 'target userDataPath' + $normalizedTargetRuntimeHomePath = Get-NormalizedAbsolutePath $TargetRuntimeHomePath 'target runtimeHomePath' + $normalizedInstalledExePath = Get-NormalizedAbsolutePath $InstalledExePath 'installed executable path' + $normalizedInstallerPath = Get-NormalizedAbsolutePath $InstallerPath 'installer path' + $normalizedInstallationRecordPath = Get-NormalizedAbsolutePath $InstallationRecordPath 'installation record path' + $normalizedMigrationStatePath = Get-NormalizedAbsolutePath $MigrationStatePath 'migration state path' + $expectedInstalledExePath = Get-NormalizedAbsolutePath (Join-Path $normalizedInstallDir 'Memmy.exe') 'expected installed executable path' + + Assert-NoReparsePath $normalizedInstallDir 'installDir' + Assert-NoReparsePath $normalizedTargetInstallDir 'target installDir' + Assert-NoReparsePath $normalizedTargetUserDataPath 'target userDataPath' + Assert-NoReparsePath $normalizedTargetRuntimeHomePath 'target runtimeHomePath' + Assert-NoReparsePath $normalizedInstalledExePath 'installed executable path' + Assert-NoReparsePath $normalizedInstallerPath 'installer path' + Assert-NoReparsePath $normalizedInstallationRecordPath 'installation record path' + Assert-NoReparsePath $normalizedMigrationStatePath 'migration state path' + + if (-not (Test-SamePath $normalizedInstalledExePath $expectedInstalledExePath)) { + throw "installed executable does not match installDir" + } + $isRelocation = -not (Test-SamePath $normalizedTargetInstallDir $normalizedInstallDir) + if ($isRelocation) { + if ((Test-SameOrDescendantPath $normalizedTargetInstallDir $normalizedInstallDir) -or + (Test-SameOrDescendantPath $normalizedInstallDir $normalizedTargetInstallDir)) { + Stop-Installation 'selected target overlaps the installed application directory' + } + if (Test-Path -LiteralPath (Join-Path $normalizedTargetInstallDir 'Memmy.exe') -PathType Leaf) { + Stop-Installation 'selected target already contains Memmy.exe' + } + $targetLegacyDataPath = Join-Path $normalizedTargetInstallDir 'data' + if (Test-DirectoryContainsData $targetLegacyDataPath) { + Stop-Installation 'selected target already contains install-local Memmy data' + } + + $targetRuntimeHasData = Test-DirectoryContainsData $normalizedTargetRuntimeHomePath @('updates') + if ($targetRuntimeHasData) { + $targetRuntimeIsRecordedSource = $false + try { + if (Test-Path -LiteralPath $normalizedInstallationRecordPath -PathType Leaf) { + $relocationRecord = Get-Content -LiteralPath $normalizedInstallationRecordPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + if ($relocationRecord.installDir -and $relocationRecord.runtimeHomePath) { + $recordedRelocationInstallDir = Get-NormalizedAbsolutePath ([string]$relocationRecord.installDir) 'recorded relocation installDir' + $recordedRelocationRuntimeHomePath = Get-NormalizedAbsolutePath ([string]$relocationRecord.runtimeHomePath) 'recorded relocation runtimeHomePath' + $targetRuntimeIsRecordedSource = + (Test-SamePath $recordedRelocationInstallDir $normalizedInstallDir) -and + (Test-SamePath $recordedRelocationRuntimeHomePath $normalizedTargetRuntimeHomePath) + } + } + } catch { + $targetRuntimeIsRecordedSource = $false + } + if (-not $targetRuntimeIsRecordedSource) { + Stop-Installation 'selected installation drive already contains Memmy runtime data' + } + } + + Write-Output 'relay-required:installation target differs from the installed application' + exit 1 + } + $installedExeExists = Test-Path -LiteralPath $normalizedInstalledExePath -PathType Leaf + if (-not $installedExeExists -and -not $AllowMissingExecutable) { + throw "installed Memmy.exe is missing" + } + if (-not (Test-Path -LiteralPath $normalizedInstallerPath -PathType Leaf)) { + throw "downloaded installer is missing" + } + if (Test-SameOrDescendantPath $normalizedInstallerPath $normalizedInstallDir) { + throw "downloaded installer is inside installDir" + } + if (Test-Path -LiteralPath $normalizedMigrationStatePath) { + throw "data migration state still exists" + } + + $legacyDataPath = Join-Path $normalizedInstallDir 'data' + if (Test-Path -LiteralPath $legacyDataPath) { + if (-not (Test-Path -LiteralPath $legacyDataPath -PathType Container)) { + throw "legacy install data path is not a directory" + } + if (@(Get-ChildItem -LiteralPath $legacyDataPath -Force -ErrorAction Stop).Count -ne 0) { + throw "legacy install data still requires relay preservation" + } + } + + if (-not (Test-Path -LiteralPath $normalizedInstallationRecordPath -PathType Leaf)) { + if (-not $installedExeExists -and $AllowMissingExecutable) { + Write-Output 'standard-install-safe' + exit 0 + } + throw "external-v1 installation record is missing" + } + + $record = Get-Content -LiteralPath $normalizedInstallationRecordPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + $requiredProperties = @('schemaVersion', 'dataLayoutGeneration', 'installDir', 'userDataPath', 'runtimeHomePath', 'appVersion') + foreach ($property in $requiredProperties) { + if ($record.PSObject.Properties.Name -notcontains $property) { + throw "external-v1 installation record is missing $property" + } + } + if (($record.schemaVersion -isnot [int]) -and ($record.schemaVersion -isnot [long])) { + throw "external-v1 installation record schemaVersion must be an integer" + } + if ([long]$record.schemaVersion -ne 1) { + throw "external-v1 installation record schema is unsupported" + } + foreach ($property in @('dataLayoutGeneration', 'installDir', 'userDataPath', 'runtimeHomePath', 'appVersion')) { + if (($record.$property -isnot [string]) -or [string]::IsNullOrWhiteSpace($record.$property)) { + throw "external-v1 installation record $property must be a non-empty string" + } + } + if (-not [string]::Equals($record.dataLayoutGeneration, 'external-v1', [System.StringComparison]::Ordinal)) { + throw "data layout generation is not external-v1" + } + + $recordedInstallDir = Get-NormalizedAbsolutePath $record.installDir 'recorded installDir' + if (-not (Test-SamePath $recordedInstallDir $normalizedInstallDir)) { + throw "recorded installDir does not match the installed application" + } + $recordedUserDataPath = Get-NormalizedAbsolutePath $record.userDataPath 'recorded userDataPath' + $recordedRuntimeHomePath = Get-NormalizedAbsolutePath $record.runtimeHomePath 'recorded runtimeHomePath' + foreach ($externalPath in @($recordedUserDataPath, $recordedRuntimeHomePath)) { + if (Test-SameOrDescendantPath $externalPath $normalizedInstallDir) { + throw "recorded external data path is inside installDir" + } + if (-not (Test-Path -LiteralPath $externalPath -PathType Container)) { + throw "recorded external data path is missing" + } + Assert-NoReparsePath $externalPath 'recorded external data path' + } + if (-not (Test-SamePath $recordedUserDataPath $normalizedTargetUserDataPath)) { + throw "recorded userDataPath does not match the expected data layout" + } + if (-not (Test-SamePath $recordedRuntimeHomePath $normalizedTargetRuntimeHomePath)) { + throw "recorded runtimeHomePath does not match the expected data layout" + } + + if (-not $installedExeExists -and $AllowMissingExecutable) { + Write-Output 'standard-install-safe' + exit 0 + } + + $recordedVersion = ConvertTo-ComparableVersion $record.appVersion $false + $installedVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($normalizedInstalledExePath) + foreach ($candidate in @($installedVersion.ProductVersion, $installedVersion.FileVersion)) { + $comparableCandidate = ConvertTo-ComparableVersion ([string]$candidate) $true + if (-not [string]::Equals($recordedVersion, $comparableCandidate, [System.StringComparison]::Ordinal)) { + throw "recorded appVersion does not match installed executable version" + } + } + + Write-Output 'standard-upgrade-safe' + exit 0 +} catch { + $message = [string]$_.Exception.Message + if ($message.StartsWith('installation-blocked:', [System.StringComparison]::Ordinal)) { + Write-Output $message + exit 2 + } + Write-Output "relay-required:$message" + exit 1 +} diff --git a/App/shell/desktop/build/MemmyWindowsUpgradeRecovery.ps1 b/App/shell/desktop/build/MemmyWindowsUpgradeRecovery.ps1 index b0102644..32d7b134 100644 --- a/App/shell/desktop/build/MemmyWindowsUpgradeRecovery.ps1 +++ b/App/shell/desktop/build/MemmyWindowsUpgradeRecovery.ps1 @@ -226,6 +226,11 @@ function Invoke-MemmyMigrationRecovery( } else { Split-Path -Parent $migrationSourcePath } + $migrationTargetInstallDir = if ($migrationState.targetInstallDir) { + [string]$migrationState.targetInstallDir + } else { + $normalizedInstallDir + } $powershellPath = Join-Path $PSHOME 'powershell.exe' & $powershellPath @( @@ -237,6 +242,7 @@ function Invoke-MemmyMigrationRecovery( '-SourceDataPath', $migrationSourcePath, '-SourceAuthority', $migrationSourceAuthority, '-SourceInstallDir', $migrationSourceInstallDir, + '-TargetInstallDir', $migrationTargetInstallDir, '-LegacyRuntimeHomePath', $expectedLegacyRuntimeHomePath, '-TargetUserDataPath', $expectedTargetUserDataPath, '-TargetRuntimeHomePath', $expectedTargetRuntimeHomePath, @@ -355,10 +361,30 @@ try { throw "recovery state has an unsupported phase: $phase" } $stateInstallDir = Resolve-MemmyNormalizedPath ([string]$state.installDir) + $stateSourceInstallDir = $stateInstallDir + $stateTargetInstallDir = $stateInstallDir + if ([int]$state.schemaVersion -ge 4) { + $stateSourceInstallDir = Resolve-MemmyNormalizedPath ([string]$state.sourceInstallDir) + $stateTargetInstallDir = Resolve-MemmyNormalizedPath ([string]$state.targetInstallDir) + if (-not [string]::Equals($stateInstallDir, $stateSourceInstallDir, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "recovery state compatibility install directory does not match sourceInstallDir" + } + $dataPath = Join-Path $stateSourceInstallDir 'data' + $expectedBackupParent = "$stateSourceInstallDir.memmy-upgrade-backup" + if (-not $TargetRuntimeHomePathOverride) { + $targetInstallRoot = [System.IO.Path]::GetPathRoot($stateTargetInstallDir) + $expectedTargetRuntimeHomePath = if ([string]::Equals($targetInstallRoot, 'C:\', [System.StringComparison]::OrdinalIgnoreCase)) { + $expectedLegacyRuntimeHomePath + } else { + Resolve-MemmyNormalizedPath (Join-Path $targetInstallRoot 'MemmyData\.memmy') + } + } + } $stateWorkDir = Resolve-MemmyNormalizedPath ([string]$state.workDir) $stateInstallerPath = Resolve-MemmyNormalizedPath ([string]$state.installerPath) $backupRoot = Resolve-MemmyNormalizedPath ([string]$state.backupRoot) - if (-not [string]::Equals($stateInstallDir, $normalizedInstallDir, [System.StringComparison]::OrdinalIgnoreCase)) { + if (-not [string]::Equals($stateSourceInstallDir, $normalizedInstallDir, [System.StringComparison]::OrdinalIgnoreCase) -and + -not [string]::Equals($stateTargetInstallDir, $normalizedInstallDir, [System.StringComparison]::OrdinalIgnoreCase)) { throw "recovery state install directory does not match launcher install directory" } if ([int]$state.schemaVersion -ge 3) { diff --git a/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 b/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 index ebccc060..bf4d864b 100644 --- a/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 +++ b/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 @@ -1,10 +1,12 @@ param( [Parameter(Mandatory = $true)][string]$InstallerPath, - [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][string]$SourceInstallDir, + [Parameter(Mandatory = $true)][string]$TargetInstallDir, [Parameter(Mandatory = $true)][int]$OriginalInstallerPid, [Parameter(Mandatory = $true)][int]$LegacyHelperPid, [Parameter(Mandatory = $true)][string]$ExpectedVersion, [string]$InstalledVersion = '', + [Parameter(Mandatory = $true)][ValidateSet('Silent', 'Interactive')][string]$InstallerMode, [Parameter(Mandatory = $true)][ValidateSet('0', '1')][string]$ReopenAfterInstall, [Parameter(Mandatory = $true)][string]$ReadyPath, [Parameter(Mandatory = $true)][string]$WorkDir, @@ -18,16 +20,18 @@ param( ) $ErrorActionPreference = 'Stop' -$normalizedInstallDir = [System.IO.Path]::GetFullPath($InstallDir).TrimEnd('\') -$dataPath = Join-Path $normalizedInstallDir 'data' -$backupParent = "$normalizedInstallDir.memmy-upgrade-backup" +$normalizedSourceInstallDir = [System.IO.Path]::GetFullPath($SourceInstallDir).TrimEnd('\') +$normalizedTargetInstallDir = [System.IO.Path]::GetFullPath($TargetInstallDir).TrimEnd('\') +$dataPath = Join-Path $normalizedSourceInstallDir 'data' +$backupParent = "$normalizedSourceInstallDir.memmy-upgrade-backup" $backupRoot = Join-Path $backupParent (Split-Path -Leaf $WorkDir) $backupPath = Join-Path $backupRoot 'data-backup' $installerDataPath = Join-Path $backupRoot 'installer-created-data' $stagingRoot = Split-Path -Parent $WorkDir $lockPath = Join-Path $stagingRoot 'active.lock' $lockStatePath = Join-Path $stagingRoot 'active.lock\state.json' -$appExe = Join-Path $normalizedInstallDir 'Memmy.exe' +$sourceAppExe = Join-Path $normalizedSourceInstallDir 'Memmy.exe' +$appExe = Join-Path $normalizedTargetInstallDir 'Memmy.exe' $migrationScriptPath = Join-Path $WorkDir 'MemmyWindowsDataMigration.ps1' $targetUserDataPath = if ($TargetUserDataPathOverride) { $TargetUserDataPathOverride } else { Join-Path $env:APPDATA 'Memmy' } $dataPointerPath = Join-Path $targetUserDataPath 'data-root.txt' @@ -35,7 +39,7 @@ $migrationStatePath = if ($MigrationStatePathOverride) { $MigrationStatePathOver $migrationLogPath = if ($MigrationLogPathOverride) { $MigrationLogPathOverride } else { Join-Path $env:LOCALAPPDATA 'Memmy\upgrade-logs\data-migration.log' } $installationRecordPath = if ($InstallationRecordPathOverride) { $InstallationRecordPathOverride } else { Join-Path $env:LOCALAPPDATA 'Memmy\data-layout\last-install.json' } $legacyRuntimeHomePath = if ($LegacyRuntimeHomePathOverride) { $LegacyRuntimeHomePathOverride } else { Join-Path $env:USERPROFILE '.memmy' } -$installDriveRoot = [System.IO.Path]::GetPathRoot($normalizedInstallDir) +$installDriveRoot = [System.IO.Path]::GetPathRoot($normalizedTargetInstallDir) $targetRuntimeHomePath = if ($TargetRuntimeHomePathOverride) { $TargetRuntimeHomePathOverride } elseif ([string]::Equals($installDriveRoot, 'C:\', [System.StringComparison]::OrdinalIgnoreCase)) { @@ -68,20 +72,151 @@ function Write-MemmyUpgradeLog([string]$Message) { Add-Content -LiteralPath $LogPath -Value ('[{0:O}] {1}' -f (Get-Date), $Message) } +function Test-MemmySamePath([string]$Left, [string]$Right) { + return [string]::Equals( + [System.IO.Path]::GetFullPath($Left).TrimEnd('\'), + [System.IO.Path]::GetFullPath($Right).TrimEnd('\'), + [System.StringComparison]::OrdinalIgnoreCase + ) +} + +function Test-MemmySameOrDescendantPath([string]$Candidate, [string]$Parent) { + $normalizedCandidate = [System.IO.Path]::GetFullPath($Candidate).TrimEnd('\') + $normalizedParent = [System.IO.Path]::GetFullPath($Parent).TrimEnd('\') + return (Test-MemmySamePath $normalizedCandidate $normalizedParent) -or + $normalizedCandidate.StartsWith("$normalizedParent\", [System.StringComparison]::OrdinalIgnoreCase) +} + +function Assert-MemmyNoReparsePath([string]$Path, [string]$Description) { + $currentPath = [System.IO.Path]::GetFullPath($Path) + while (-not (Test-Path -LiteralPath $currentPath)) { + $parentPath = [System.IO.Path]::GetDirectoryName($currentPath) + if (-not $parentPath -or (Test-MemmySamePath $parentPath $currentPath)) { + throw "$Description has no existing trusted ancestor" + } + $currentPath = $parentPath + } + + while ($currentPath) { + $item = Get-Item -LiteralPath $currentPath -Force -ErrorAction Stop + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description crosses a reparse point: $($item.FullName)" + } + if ($item -is [System.IO.FileInfo]) { + $currentPath = $item.DirectoryName + } elseif ($item.Parent) { + $currentPath = $item.Parent.FullName + } else { + $currentPath = $null + } + } +} + +function Test-MemmyDirectoryContainsData([string]$Path, [string[]]$ExcludedTopLevelNames = @()) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $false } + foreach ($item in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + if ($ExcludedTopLevelNames -contains $item.Name) { continue } + if (-not $item.PSIsContainer) { return $true } + if ($null -ne (Get-ChildItem -LiteralPath $item.FullName -Recurse -Force -File -ErrorAction Stop | + Select-Object -First 1)) { + return $true + } + } + return $false +} + +function Test-MemmyPreparedMigrationTarget { + if (-not (Test-Path -LiteralPath $migrationStatePath -PathType Leaf)) { return $false } + try { + $migrationState = Get-Content -LiteralPath $migrationStatePath -Raw -Encoding UTF8 | + ConvertFrom-Json -ErrorAction Stop + return [int]$migrationState.schemaVersion -eq 2 -and + [string]$migrationState.owner -eq 'relay' -and + [string]$migrationState.phase -eq 'prepared' -and + $migrationState.targetInstallDir -and + $migrationState.targetUserDataPath -and + $migrationState.targetRuntimeHomePath -and + (Test-MemmySamePath ([string]$migrationState.targetInstallDir) $normalizedTargetInstallDir) -and + (Test-MemmySamePath ([string]$migrationState.targetUserDataPath) $targetUserDataPath) -and + (Test-MemmySamePath ([string]$migrationState.targetRuntimeHomePath) $targetRuntimeHomePath) + } catch { + return $false + } +} + +function Assert-MemmyRelocationTargetIsSafe([switch]$AllowPreparedMigrationTarget) { + if (Test-MemmySamePath $normalizedSourceInstallDir $normalizedTargetInstallDir) { return } + Assert-MemmyNoReparsePath $normalizedTargetInstallDir 'target installDir' + Assert-MemmyNoReparsePath $targetRuntimeHomePath 'target runtimeHomePath' + if ((Test-MemmySameOrDescendantPath $normalizedTargetInstallDir $normalizedSourceInstallDir) -or + (Test-MemmySameOrDescendantPath $normalizedSourceInstallDir $normalizedTargetInstallDir)) { + throw 'selected target overlaps the source installation directory' + } + if (Test-Path -LiteralPath (Join-Path $normalizedTargetInstallDir 'Memmy.exe') -PathType Leaf) { + throw 'selected target already contains Memmy.exe' + } + if (Test-MemmyDirectoryContainsData (Join-Path $normalizedTargetInstallDir 'data')) { + throw 'selected target already contains install-local Memmy data' + } + + if (Test-MemmyDirectoryContainsData $targetRuntimeHomePath @('updates')) { + $targetRuntimeIsRecordedSource = $AllowPreparedMigrationTarget -and + (Test-MemmyPreparedMigrationTarget) + try { + if (-not $targetRuntimeIsRecordedSource -and + (Test-Path -LiteralPath $installationRecordPath -PathType Leaf)) { + $record = Get-Content -LiteralPath $installationRecordPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + if ([int]$record.schemaVersion -eq 1 -and + [string]$record.dataLayoutGeneration -eq 'external-v1' -and + $record.installDir -and $record.userDataPath -and $record.runtimeHomePath) { + $targetRuntimeIsRecordedSource = + (Test-MemmySamePath ([string]$record.installDir) $normalizedSourceInstallDir) -and + (Test-MemmySamePath ([string]$record.userDataPath) $targetUserDataPath) -and + (Test-MemmySamePath ([string]$record.runtimeHomePath) $targetRuntimeHomePath) + } + } + } catch { + $targetRuntimeIsRecordedSource = $false + } + if (-not $targetRuntimeIsRecordedSource) { + throw 'selected installation drive already contains Memmy runtime data' + } + } +} + +function Assert-MemmyRelaySourceIsSafe { + Assert-MemmyNoReparsePath $normalizedSourceInstallDir 'source installDir' + Assert-MemmyNoReparsePath $dataPath 'source data path' + Assert-MemmyNoReparsePath $backupParent 'upgrade backup parent' + Assert-MemmyNoReparsePath $backupRoot 'upgrade backup root' + Assert-MemmyNoReparsePath $stagingRoot 'upgrade staging root' + Assert-MemmyNoReparsePath $WorkDir 'upgrade workDir' + Assert-MemmyNoReparsePath $normalizedInstallerPath 'staged installer path' +} + function Invoke-MemmyDataMigration([ValidateSet('Prepare', 'Complete', 'Rollback', 'RequireRecovery')][string]$Mode) { if (-not (Test-Path -LiteralPath $migrationScriptPath -PathType Leaf)) { throw "data migration helper is missing: $migrationScriptPath" } $powershellPath = Join-Path $PSHOME 'powershell.exe' + $migrationSourcePath = if (Test-Path -LiteralPath $backupPath -PathType Container) { $backupPath } else { $dataPath } + $migrationSourceAuthority = if (Test-Path -LiteralPath $backupPath -PathType Container) { + 'relay-backup-authority' + } elseif (Test-Path -LiteralPath $sourceAppExe -PathType Leaf) { + 'current-install-authority' + } else { + 'untrusted-residual' + } $arguments = @( '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $migrationScriptPath, '-Mode', $Mode, - '-SourceDataPath', $backupPath, - '-SourceAuthority', 'relay-backup-authority', - '-SourceInstallDir', $normalizedInstallDir, + '-SourceDataPath', $migrationSourcePath, + '-SourceAuthority', $migrationSourceAuthority, + '-SourceInstallDir', $normalizedSourceInstallDir, + '-TargetInstallDir', $normalizedTargetInstallDir, '-InstallationRecordPath', $installationRecordPath, '-LegacyRuntimeHomePath', $legacyRuntimeHomePath, '-TargetUserDataPath', $targetUserDataPath, @@ -140,14 +275,15 @@ function Wait-MemmyProcessExit([int]$ProcessId, [int]$TimeoutSeconds) { } function Get-MemmyInstallProcesses { - $expectedPath = [System.IO.Path]::GetFullPath($appExe) + $expectedPaths = @($sourceAppExe, $appExe) | + ForEach-Object { [System.IO.Path]::GetFullPath($_) } | + Select-Object -Unique foreach ($process in @(Get-Process -Name 'Memmy' -ErrorAction SilentlyContinue)) { try { - if ([string]::Equals( - [System.IO.Path]::GetFullPath($process.Path), - $expectedPath, - [System.StringComparison]::OrdinalIgnoreCase - )) { + $processPath = [System.IO.Path]::GetFullPath($process.Path) + if ($expectedPaths | Where-Object { + [string]::Equals($_, $processPath, [System.StringComparison]::OrdinalIgnoreCase) + }) { $process } } catch { @@ -192,9 +328,13 @@ function Assert-MemmySameVolume([string]$Source, [string]$Destination) { function Move-MemmyDirectory([string]$Source, [string]$Destination) { Assert-MemmySameVolume -Source $Source -Destination $Destination + Assert-MemmyNoReparsePath $Source 'directory move source' + Assert-MemmyNoReparsePath $Destination 'directory move destination' New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Destination) | Out-Null for ($attempt = 1; $attempt -le 120; $attempt++) { try { + Assert-MemmyNoReparsePath $Source 'directory move source' + Assert-MemmyNoReparsePath $Destination 'directory move destination' [System.IO.Directory]::Move($Source, $Destination) return } catch { @@ -218,7 +358,7 @@ function Write-MemmyRelayState { } } $state = [ordered]@{ - schemaVersion = 3 + schemaVersion = 4 phase = $relayPhase stateUpdatedAtUtc = [DateTime]::UtcNow.ToString('O') relayPid = $PID @@ -226,7 +366,9 @@ function Write-MemmyRelayState { installerPid = $installerPid installerStartedAtUtc = $installerStartedAtUtc installerPath = $normalizedInstallerPath - installDir = $normalizedInstallDir + installDir = $normalizedSourceInstallDir + sourceInstallDir = $normalizedSourceInstallDir + targetInstallDir = $normalizedTargetInstallDir workDir = [System.IO.Path]::GetFullPath($WorkDir).TrimEnd('\') backupRoot = $backupRoot migrationStatePath = $migrationStatePath @@ -262,7 +404,7 @@ function Restore-MemmyData { Move-MemmyDirectory -Source $dataPath -Destination $installerDataPath Write-MemmyUpgradeLog "preserved installer-created data at $installerDataPath" } - New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + New-Item -ItemType Directory -Force -Path $normalizedSourceInstallDir | Out-Null Move-MemmyDirectory -Source $backupPath -Destination $dataPath if (-not (Test-Path -LiteralPath $dataPath -PathType Container)) { throw "restored data directory is unavailable: $dataPath" @@ -298,7 +440,7 @@ function Start-MemmyInstalledApp { Write-MemmyUpgradeLog "app executable is unavailable for reopen: $appExe" return } - Start-Process -FilePath $appExe -WorkingDirectory $InstallDir -WindowStyle Normal + Start-Process -FilePath $appExe -WorkingDirectory $normalizedTargetInstallDir -WindowStyle Normal Write-MemmyUpgradeLog "started app $appExe" } @@ -341,7 +483,13 @@ function Schedule-MemmyStagingCleanup { try { New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null - Write-MemmyUpgradeLog "relay starting installer=$InstallerPath installDir=$InstallDir expected=$ExpectedVersion reopenFallback=$ReopenAfterInstall" + Write-MemmyUpgradeLog "relay starting installer=$InstallerPath sourceInstallDir=$SourceInstallDir targetInstallDir=$TargetInstallDir mode=$InstallerMode expected=$ExpectedVersion reopenFallback=$ReopenAfterInstall" + Assert-MemmyRelaySourceIsSafe + Assert-MemmyRelocationTargetIsSafe + if ((Test-MemmySameOrDescendantPath $normalizedInstallerPath $normalizedSourceInstallDir) -or + (Test-MemmySameOrDescendantPath $normalizedInstallerPath $normalizedTargetInstallDir)) { + throw 'staged installer must be outside both source and target installation directories' + } $roamingMarkerPath = Join-Path $targetUserDataPath 'prepared-required-update.json' $legacyMarkerPath = Join-Path $dataPath 'Memmy\prepared-required-update.json' $markerPath = if (Test-Path -LiteralPath $roamingMarkerPath -PathType Leaf) { $roamingMarkerPath } else { $legacyMarkerPath } @@ -353,6 +501,7 @@ try { Write-MemmyUpgradeLog "relay ready reopen=$resolvedReopenAfterInstall" Wait-MemmyProcessExit -ProcessId $OriginalInstallerPid -TimeoutSeconds 120 Wait-MemmyInstallProcessesExit -TimeoutSeconds 20 + Assert-MemmyRelaySourceIsSafe if (Test-Path -LiteralPath $dataPath -PathType Container) { if (Test-Path -LiteralPath $backupRoot) { @@ -378,14 +527,27 @@ try { Write-MemmyUpgradeLog "data migration Prepare failed safely; continuing installation without migration: $migrationFailure" } - $arguments = @('/S', '--updated', '--memmy-upgrade-relayed', '/currentuser', ('/D=' + $InstallDir)) + # Both visible and silent relay children are upgrades. Keeping --updated for the visible child + # makes electron-builder preserve the existing shortcuts and launch proxy while the old + # installation is removed. /S controls visibility independently. + $arguments = @('--updated', '--memmy-upgrade-relayed', '/currentuser', ('/D=' + $normalizedTargetInstallDir)) + if ($InstallerMode -eq 'Silent') { + $arguments = @('/S') + $arguments + } $env:MEMMY_UPGRADE_WORK_DIR = $WorkDir $env:MEMMY_UPGRADE_BACKUP_ROOT = $backupRoot $env:MEMMY_UPGRADE_REOPEN_AFTER_INSTALL = $resolvedReopenAfterInstall - Write-MemmyUpgradeLog "child installer context workDir=$env:MEMMY_UPGRADE_WORK_DIR backupRoot=$env:MEMMY_UPGRADE_BACKUP_ROOT reopen=$env:MEMMY_UPGRADE_REOPEN_AFTER_INSTALL" + $env:MEMMY_UPGRADE_SOURCE_INSTALL_DIR = $normalizedSourceInstallDir + $env:MEMMY_UPGRADE_TARGET_INSTALL_DIR = $normalizedTargetInstallDir + Write-MemmyUpgradeLog "child installer context workDir=$env:MEMMY_UPGRADE_WORK_DIR backupRoot=$env:MEMMY_UPGRADE_BACKUP_ROOT source=$env:MEMMY_UPGRADE_SOURCE_INSTALL_DIR target=$env:MEMMY_UPGRADE_TARGET_INSTALL_DIR reopen=$env:MEMMY_UPGRADE_REOPEN_AFTER_INSTALL mode=$InstallerMode" $relayPhase = 'installer-starting' Write-MemmyRelayState - $installerProcess = Start-Process -FilePath $InstallerPath -ArgumentList $arguments -PassThru -WindowStyle Hidden + Assert-MemmyRelocationTargetIsSafe -AllowPreparedMigrationTarget:$migrationPrepared + if ($InstallerMode -eq 'Interactive') { + $installerProcess = Start-Process -FilePath $InstallerPath -ArgumentList $arguments -PassThru -WindowStyle Normal + } else { + $installerProcess = Start-Process -FilePath $InstallerPath -ArgumentList $arguments -PassThru -WindowStyle Hidden + } $installerProcess.WaitForExit() $installerExit = if ($null -eq $installerProcess.ExitCode) { 1 } else { $installerProcess.ExitCode } Write-MemmyUpgradeLog "installer exit $installerExit" diff --git a/App/shell/desktop/build/installer-win-unsigned.nsh b/App/shell/desktop/build/installer-win-unsigned.nsh index 58e060f9..bb3ce1f3 100644 --- a/App/shell/desktop/build/installer-win-unsigned.nsh +++ b/App/shell/desktop/build/installer-win-unsigned.nsh @@ -45,6 +45,7 @@ Var pid ; close-and-verify flow above and must never prepare or mutate migration state. !ifndef BUILD_UNINSTALLER StrCmp $MemmyIsRelayedUpgrade "1" memmy_check_app_running_done + StrCmp $MemmyStandardUpgradeSafe "1" memmy_check_app_running_done Call MemmyPrepareDirectDataMigration Pop $0 StrCmp $0 "1" memmy_check_app_running_done @@ -55,12 +56,30 @@ Var pid Quit memmy_check_app_running_done: + ; electron-builder checks $appExe before deciding whether the old uninstaller may keep + ; shortcuts. A relayed directory move has no executable in the new target yet, so bridge + ; only that probe to the verified source installation. customUnInstallCheck restores the + ; packaged target before any new files or registry entries are written. + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_check_app_running_complete + StrCpy $appExe "$MemmyUpgradeSourceInstallDir\${PRODUCT_FILENAME}.exe" + + memmy_check_app_running_complete: !endif !macroend !ifndef BUILD_UNINSTALLER !define MUI_CUSTOMFUNCTION_ABORT MemmyOnUserAbort Var MemmyIsRelayedUpgrade + Var MemmyStandardUpgradeSafe + Var MemmyInstalledExePath + Var MemmyInstalledInstallDir + Var MemmySelectedInstallDir + Var MemmyUpgradeRoute + Var MemmyFinalDirectoryReady + Var MemmyRelayInstallerMode + Var MemmyUpgradeSourceInstallDir + Var MemmyUpgradeTargetInstallDir + Var MemmyStandardUpgradeCheckScriptPath Var MemmyUpgradeWorkDir Var MemmyUpgradeBackupRoot Var MemmyUpgradeReopenAfterInstall @@ -81,14 +100,15 @@ Var pid Var MemmyMigrationLogPath Var MemmyInstallerPid - ; The 1.0.8 updater starts the downloaded installer from $INSTDIR\data. electron-builder's - ; normal upgrade path asks the old uninstaller to move all of $INSTDIR, so that running - ; installer keeps the directory busy and the old uninstaller exits with code 2. Relay only - ; this legacy --updated invocation through a copy outside $INSTDIR. The relayed child carries - ; an explicit marker so normal installs and future upgrades continue through electron-builder. + ; Completed external-v1 installations can use electron-builder's standard NSIS upgrade. + ; Legacy or uncertain layouts still relay through a copy outside $INSTDIR so old install-local + ; data survives the uninstall. The relayed child carries an explicit marker to prevent recursion. !macro customInit StrCpy $MemmyDirectMigrationPrepared "0" + StrCpy $MemmyStandardUpgradeSafe "0" StrCpy $MemmyPreparedInstallDir "" + StrCpy $MemmyUpgradeRoute "relay" + StrCpy $MemmyFinalDirectoryReady "0" System::Call 'kernel32::GetCurrentProcessId() i.r0' StrCpy $MemmyInstallerPid $0 ReadRegStr $MemmyPreviousInstallDir HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" @@ -100,7 +120,12 @@ Var pid ${If} ${Silent} Call MemmyValidateSelectedDirectories Pop $0 - StrCmp $0 "1" memmy_custom_init_done memmy_custom_init_failed + StrCmp $0 "1" memmy_custom_init_route memmy_custom_init_failed + + memmy_custom_init_route: + StrCpy $MemmyFinalDirectoryReady "1" + Call MemmyRelayLegacyUpgrade + StrCmp $MemmyUpgradeRoute "blocked" memmy_custom_init_failed memmy_custom_init_done memmy_custom_init_failed: SetErrorLevel 5 @@ -114,15 +139,51 @@ Var pid Page custom MemmyValidateInstallPage !macroend + ; A relay child is still visible for manual upgrades, but the relay owns the final reopen after + ; migration completion and version verification. Skipping the child's finish page avoids a + ; second launch through a shortcut while its proxy is being preserved or refreshed. + !macro customFinishPage + Function MemmySkipRelayedFinishPage + StrCmp $MemmyIsRelayedUpgrade "1" memmy_skip_relayed_finish_page memmy_show_finish_page + + memmy_skip_relayed_finish_page: + Abort + + memmy_show_finish_page: + FunctionEnd + + Function MemmyStartAppAfterInstall + ${if} ${isUpdated} + StrCpy $1 "--updated" + ${else} + StrCpy $1 "" + ${endif} + ${StdUtils.ExecShellAsUser} $0 "$launchLink" "open" "$1" + FunctionEnd + + !define MUI_PAGE_CUSTOMFUNCTION_PRE MemmySkipRelayedFinishPage + !define MUI_FINISHPAGE_RUN + !define MUI_FINISHPAGE_RUN_FUNCTION MemmyStartAppAfterInstall + !insertmacro MUI_PAGE_FINISH + !macroend + ; electron-builder quits directly when the old uninstaller returns a failure code, ; so recover the prepared migration before preserving its existing error behavior. !macro customUnInstallCheck - IfErrors memmy_uninstall_check_exec_failed memmy_uninstall_check_result + IfErrors memmy_uninstall_check_exec_failed memmy_uninstall_check_restore_target_app_exe memmy_uninstall_check_exec_failed: + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_uninstall_check_exec_failed_report + StrCpy $appExe "$MemmyUpgradeTargetInstallDir\${PRODUCT_FILENAME}.exe" + + memmy_uninstall_check_exec_failed_report: DetailPrint `Uninstall was not successful. Not able to launch uninstaller!` Return + memmy_uninstall_check_restore_target_app_exe: + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_uninstall_check_result + StrCpy $appExe "$MemmyUpgradeTargetInstallDir\${PRODUCT_FILENAME}.exe" + memmy_uninstall_check_result: ${If} $R0 != 0 Call MemmyRecoverDirectDataMigration @@ -134,6 +195,7 @@ Var pid !macroend !macro customInstall + WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" "InstallLocation" "$INSTDIR" Call MemmyAddCliToUserPath Call MemmyInstallLaunchProxy !insertmacro MemmyPointShortcutsToLaunchProxy @@ -181,11 +243,10 @@ Function MemmyResolveMigrationPaths StrCmp $MemmyPreviousInstallDir "" 0 memmy_resolve_previous_source StrCpy $MemmyDirectSourceDataPath "$INSTDIR\data" StrCpy $MemmyDirectSourceInstallDir "$INSTDIR" - ; The exact directory explicitly selected by the user is the only discoverable old - ; install after a legacy uninstaller removed its registry entry. The migration helper - ; still requires complete category anchors and blocks it when an external-v1 record - ; proves the directory belongs to the new layout. - StrCpy $MemmyDirectSourceAuthority "selected-install-authority" + ; A user-selected directory is only a candidate. Without registry evidence, the + ; migration helper may elevate only an exact persisted install-local record; an + ; arbitrary residual must never replace already verified external data. + StrCpy $MemmyDirectSourceAuthority "untrusted-residual" Return memmy_resolve_previous_source: @@ -314,6 +375,66 @@ Function MemmyExtractDataMigrationScript StrCpy $MemmyMigrationScriptPath "$PLUGINSDIR\MemmyDataMigration\MemmyWindowsDataMigration.ps1" FunctionEnd +Function MemmyExtractStandardUpgradeCheckScript + InitPluginsDir + CreateDirectory "$PLUGINSDIR\MemmyStandardUpgradeCheck" + SetOutPath "$PLUGINSDIR\MemmyStandardUpgradeCheck" + File /oname=MemmyWindowsStandardUpgradeCheck.ps1 "${BUILD_RESOURCES_DIR}\MemmyWindowsStandardUpgradeCheck.ps1" + StrCpy $MemmyStandardUpgradeCheckScriptPath "$PLUGINSDIR\MemmyStandardUpgradeCheck\MemmyWindowsStandardUpgradeCheck.ps1" +FunctionEnd + +; An installed application may bypass relay only when its completed external-v1 record, executable +; version, external data paths, migration state, legacy data directory, and installer location all +; pass the fail-closed PowerShell check. A missing installed executable is a normal fresh install. +Function MemmyEvaluateStandardUpgradeSafety + StrCpy $MemmyStandardUpgradeSafe "0" + StrCpy $MemmyUpgradeRoute "relay" + StrCpy $MemmySelectedInstallDir "$INSTDIR" + StrCpy $MemmyInstalledInstallDir "$INSTDIR" + StrCpy $MemmyInstalledExePath "$INSTDIR\${PRODUCT_FILENAME}.exe" + StrCmp $MemmyPreviousInstallDir "" memmy_standard_check_installed_exe + StrCpy $MemmyInstalledInstallDir "$MemmyPreviousInstallDir" + StrCpy $MemmyInstalledExePath "$MemmyPreviousInstallDir\${PRODUCT_FILENAME}.exe" + + memmy_standard_check_installed_exe: + Call MemmyResolveMigrationPaths + Call MemmyExtractStandardUpgradeCheckScript + IfFileExists "$MemmyStandardUpgradeCheckScriptPath" 0 memmy_standard_check_failed + StrCpy $R5 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + IfFileExists "$R5" 0 memmy_standard_check_failed + StrCpy $R4 "" + IfFileExists "$MemmyInstalledExePath" memmy_standard_check_run + StrCpy $R4 "-AllowMissingExecutable" + + memmy_standard_check_run: + nsExec::ExecToStack '$\"$R5$\" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $\"$MemmyStandardUpgradeCheckScriptPath$\" -InstallDir $\"$MemmyInstalledInstallDir$\" -TargetInstallDir $\"$MemmySelectedInstallDir$\" -TargetUserDataPath $\"$MemmyTargetUserDataPath$\" -TargetRuntimeHomePath $\"$MemmyTargetRuntimeHomePath$\" -InstalledExePath $\"$MemmyInstalledExePath$\" -InstallerPath $\"$EXEPATH$\" -InstallationRecordPath $\"$MemmyInstallationRecordPath$\" -MigrationStatePath $\"$MemmyMigrationStatePath$\" $R4' + Pop $0 + Pop $1 + DetailPrint "$1" + StrCmp $0 "0" memmy_standard_check_safe + StrCmp $0 "2" memmy_standard_check_blocked + + memmy_standard_check_failed: + DetailPrint "Installed data layout requires the compatibility upgrade relay." + Return + + memmy_standard_check_safe: + StrCpy $MemmyStandardUpgradeSafe "1" + StrCpy $MemmyUpgradeRoute "standard" + StrCmp $R4 "-AllowMissingExecutable" memmy_standard_check_fresh + DetailPrint "Completed external-v1 data layout verified; using the standard NSIS upgrade." + Return + + memmy_standard_check_fresh: + DetailPrint "No installed Memmy.exe or trusted install-local source was found; using the standard NSIS install path." + Return + + memmy_standard_check_blocked: + StrCpy $MemmyUpgradeRoute "blocked" + StrCpy $R8 "$1" + DetailPrint "Installation target validation blocked the upgrade." +FunctionEnd + ; Performs the migration while the old installation and its registered location still exist. ; Output: pushes "1" on success, else "0" and leaves a user-facing message in $R8. Function MemmyPrepareDirectDataMigration @@ -333,7 +454,7 @@ Function MemmyPrepareDirectDataMigration memmy_direct_prepare_run: StrCpy $R5 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" - nsExec::ExecToStack '$\"$R5$\" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $\"$MemmyMigrationScriptPath$\" -Mode Prepare -SourceDataPath $\"$MemmyDirectSourceDataPath$\" -SourceAuthority $MemmyDirectSourceAuthority -SourceInstallDir $\"$MemmyDirectSourceInstallDir$\" -SourceInstalledVersion $\"$MemmyPreviousInstalledVersion$\" -InstallationRecordPath $\"$MemmyInstallationRecordPath$\" -LegacyRuntimeHomePath $\"$PROFILE\.memmy$\" -TargetUserDataPath $\"$MemmyTargetUserDataPath$\" -TargetRuntimeHomePath $\"$MemmyTargetRuntimeHomePath$\" -PointerPath $\"$MemmyDataPointerPath$\" -StatePath $\"$MemmyMigrationStatePath$\" -LockPath $\"$MemmyMigrationLockPath$\" -LogPath $\"$MemmyMigrationLogPath$\" -Owner installer -InstallerPid $MemmyInstallerPid -InstallerPath $\"$EXEPATH$\" -InstallerInstallDir $\"$INSTDIR$\" -AcquireLock' + nsExec::ExecToStack '$\"$R5$\" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $\"$MemmyMigrationScriptPath$\" -Mode Prepare -SourceDataPath $\"$MemmyDirectSourceDataPath$\" -SourceAuthority $MemmyDirectSourceAuthority -SourceInstallDir $\"$MemmyDirectSourceInstallDir$\" -TargetInstallDir $\"$INSTDIR$\" -SourceInstalledVersion $\"$MemmyPreviousInstalledVersion$\" -InstallationRecordPath $\"$MemmyInstallationRecordPath$\" -LegacyRuntimeHomePath $\"$PROFILE\.memmy$\" -TargetUserDataPath $\"$MemmyTargetUserDataPath$\" -TargetRuntimeHomePath $\"$MemmyTargetRuntimeHomePath$\" -PointerPath $\"$MemmyDataPointerPath$\" -StatePath $\"$MemmyMigrationStatePath$\" -LockPath $\"$MemmyMigrationLockPath$\" -LogPath $\"$MemmyMigrationLogPath$\" -Owner installer -InstallerPid $MemmyInstallerPid -InstallerPath $\"$EXEPATH$\" -InstallerInstallDir $\"$INSTDIR$\" -AcquireLock' Pop $0 Pop $1 StrCmp $0 "0" memmy_direct_prepare_succeeded @@ -428,12 +549,29 @@ Function MemmyValidateInstallPage Abort ${EndIf} StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_validate_page_direct - Abort + Call MemmyValidateSelectedDirectories + Pop $0 + StrCmp $0 "1" memmy_validate_page_relayed_target memmy_validate_page_show_error + + memmy_validate_page_relayed_target: + GetFullPathName $1 "$INSTDIR" + GetFullPathName $2 "$MemmyUpgradeTargetInstallDir" + StrCmp $1 $2 0 memmy_validate_page_relayed_target_failed + Abort + + memmy_validate_page_relayed_target_failed: + StrCpy $R8 "The relayed upgrade target changed after compatibility migration started. Go back and select $\"$MemmyUpgradeTargetInstallDir$\"." + StrCmp $LANGUAGE ${MEMMY_LANG_SIMPCHINESE} 0 memmy_validate_page_show_error + StrCpy $R8 "兼容迁移开始后安装目录发生了变化。请返回并选择“$MemmyUpgradeTargetInstallDir”。" + Goto memmy_validate_page_show_error memmy_validate_page_direct: Call MemmyValidateSelectedDirectories Pop $0 StrCmp $0 "1" 0 memmy_validate_page_show_error + StrCpy $MemmyFinalDirectoryReady "1" + Call MemmyRelayLegacyUpgrade + StrCmp $MemmyUpgradeRoute "blocked" memmy_validate_page_show_error Abort memmy_validate_page_show_error: @@ -460,9 +598,19 @@ Function MemmyRelayLegacyUpgrade Return memmy_relay_check_legacy: - ClearErrors - ${GetOptions} $R0 "--updated" $R1 - IfErrors memmy_relay_done + StrCmp $MemmyFinalDirectoryReady "1" memmy_relay_evaluate + Return + + memmy_relay_evaluate: + Call MemmyEvaluateStandardUpgradeSafety + StrCmp $MemmyStandardUpgradeSafe "1" memmy_relay_done + StrCmp $MemmyUpgradeRoute "blocked" memmy_relay_blocked + StrCpy $MemmyUpgradeSourceInstallDir "$MemmyInstalledInstallDir" + StrCpy $MemmyUpgradeTargetInstallDir "$MemmySelectedInstallDir" + StrCpy $MemmyRelayInstallerMode "Interactive" + ${If} ${Silent} + StrCpy $MemmyRelayInstallerMode "Silent" + ${EndIf} System::Call 'kernel32::GetCurrentProcessId() i .r2' StrCmp $2 "" memmy_relay_failed @@ -488,15 +636,18 @@ Function MemmyRelayLegacyUpgrade Goto memmy_relay_stage memmy_relay_check_legacy_reopen: - IfFileExists "$INSTDIR\data\Memmy\prepared-required-update.json" 0 memmy_relay_stage - IfFileExists "$INSTDIR\data\Memmy\prepared-required-update.json.attempt" memmy_relay_stage + IfFileExists "$MemmyUpgradeSourceInstallDir\data\Memmy\prepared-required-update.json" 0 memmy_relay_stage + IfFileExists "$MemmyUpgradeSourceInstallDir\data\Memmy\prepared-required-update.json.attempt" memmy_relay_stage StrCpy $R6 "0" memmy_relay_stage: ; Refresh the launch proxy before the relay moves $INSTDIR\data. The 1.0.8 proxy only knows ; the install-local marker lock, which disappears with that move; the refreshed proxy also ; recognizes the relay lock outside $INSTDIR and keeps desktop launches blocked throughout. + StrCpy $R2 "$INSTDIR" + StrCpy $INSTDIR "$MemmyUpgradeSourceInstallDir" Call MemmyInstallLaunchProxy + StrCpy $INSTDIR "$R2" ClearErrors CreateDirectory "$R1" SetOutPath "$R1" @@ -521,7 +672,7 @@ Function MemmyRelayLegacyUpgrade Delete "$R8" ClearErrors - ExecShell "open" "$R5" '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File $\"$R3$\" -InstallerPath $\"$R4$\" -InstallDir $\"$INSTDIR$\" -OriginalInstallerPid $2 -LegacyHelperPid $3 -ExpectedVersion $\"${VERSION}$\" -InstalledVersion $\"$MemmyPreviousInstalledVersion$\" -ReopenAfterInstall $R6 -ReadyPath $\"$R8$\" -WorkDir $\"$R1$\" -LogPath $\"$R7$\"' SW_HIDE + ExecShell "open" "$R5" '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File $\"$R3$\" -InstallerPath $\"$R4$\" -SourceInstallDir $\"$MemmyUpgradeSourceInstallDir$\" -TargetInstallDir $\"$MemmyUpgradeTargetInstallDir$\" -OriginalInstallerPid $2 -LegacyHelperPid $3 -ExpectedVersion $\"${VERSION}$\" -InstalledVersion $\"$MemmyPreviousInstalledVersion$\" -InstallerMode $MemmyRelayInstallerMode -ReopenAfterInstall $R6 -ReadyPath $\"$R8$\" -WorkDir $\"$R1$\" -LogPath $\"$R7$\"' SW_HIDE IfErrors memmy_relay_failed StrCpy $R9 "0" @@ -534,9 +685,21 @@ Function MemmyRelayLegacyUpgrade memmy_relay_ready: ; The old 1.0.8 helper must not reopen the old executable while the relay owns the upgrade. + StrCmp $MemmyRelayInstallerMode "Interactive" memmy_relay_interactive_parent_done SetErrorLevel 1602 Quit + memmy_relay_interactive_parent_done: + SetErrorLevel 0 + Quit + + memmy_relay_blocked: + ${If} ${Silent} + SetErrorLevel 5 + Quit + ${EndIf} + Return + memmy_relay_failed: SetOutPath "$INSTDIR" SetErrorLevel 2 @@ -550,12 +713,22 @@ Function MemmyReadRelayedUpgradeContext ReadEnvStr $MemmyUpgradeWorkDir "MEMMY_UPGRADE_WORK_DIR" ReadEnvStr $MemmyUpgradeBackupRoot "MEMMY_UPGRADE_BACKUP_ROOT" ReadEnvStr $MemmyUpgradeReopenAfterInstall "MEMMY_UPGRADE_REOPEN_AFTER_INSTALL" + ReadEnvStr $MemmyUpgradeSourceInstallDir "MEMMY_UPGRADE_SOURCE_INSTALL_DIR" + ReadEnvStr $MemmyUpgradeTargetInstallDir "MEMMY_UPGRADE_TARGET_INSTALL_DIR" StrCmp $MemmyUpgradeWorkDir "" memmy_relay_context_failed StrCmp $MemmyUpgradeBackupRoot "" memmy_relay_context_failed + StrCmp $MemmyUpgradeSourceInstallDir "" memmy_relay_context_failed + StrCmp $MemmyUpgradeTargetInstallDir "" memmy_relay_context_failed StrCmp $MemmyUpgradeReopenAfterInstall "0" memmy_relay_context_validate_path StrCmp $MemmyUpgradeReopenAfterInstall "1" memmy_relay_context_validate_path memmy_relay_context_failed memmy_relay_context_validate_path: + GetFullPathName $6 "$MemmyUpgradeSourceInstallDir" + StrCmp $6 $MemmyUpgradeSourceInstallDir 0 memmy_relay_context_failed + GetFullPathName $7 "$MemmyUpgradeTargetInstallDir" + StrCmp $7 $MemmyUpgradeTargetInstallDir 0 memmy_relay_context_failed + GetFullPathName $8 "$INSTDIR" + StrCmp $8 $MemmyUpgradeTargetInstallDir 0 memmy_relay_context_failed GetFullPathName $4 "$MemmyUpgradeWorkDir" StrCmp $4 $MemmyUpgradeWorkDir 0 memmy_relay_context_failed ; The backup root is optional when the previous installation has no data directory. @@ -572,7 +745,7 @@ Function MemmyReadRelayedUpgradeContext StrCmp $4 "" memmy_relay_context_failed ${GetFileName} "$MemmyUpgradeWorkDir" $5 StrCmp $4 $5 0 memmy_relay_context_failed - StrCpy $0 "$INSTDIR.memmy-upgrade-backup\$4" + StrCpy $0 "$MemmyUpgradeSourceInstallDir.memmy-upgrade-backup\$4" StrCmp $MemmyUpgradeBackupRoot $0 0 memmy_relay_context_failed StrCpy $MemmyIsRelayedUpgrade "1" Return diff --git a/App/shell/desktop/src/main/windows-data-layout.ts b/App/shell/desktop/src/main/windows-data-layout.ts index 1a7a9687..838c374e 100644 --- a/App/shell/desktop/src/main/windows-data-layout.ts +++ b/App/shell/desktop/src/main/windows-data-layout.ts @@ -67,6 +67,7 @@ interface WindowsDataMigrationState { sourceDataPath?: unknown; sourceAuthority?: unknown; sourceInstallDir?: unknown; + targetInstallDir?: unknown; targetUserDataPath?: unknown; targetRuntimeHomePath?: unknown; backupPaths?: unknown; @@ -83,7 +84,6 @@ interface WindowsDataMigrationState { const trustedInstallAuthorities = new Set([ "current-install-authority", - "selected-install-authority", "relay-backup-authority", "persisted-install-authority" ]); @@ -236,8 +236,13 @@ export const advanceWindowsDataMigrationAfterBoot = async ( : []), state ]; + const cleanupLayouts = resolveValidatedCleanupLayouts( + cleanupStates, + layout, + trustedLegacyRuntimeHomePaths + ); for (const cleanupState of cleanupStates) { - const cleanupLayout = resolveValidatedCleanupLayout(cleanupState, layout, trustedLegacyRuntimeHomePaths); + const cleanupLayout = cleanupLayouts.get(cleanupState); if (!cleanupLayout) continue; const backupPaths = Array.isArray(cleanupState.backupPaths) ? cleanupState.backupPaths.filter((value): value is string => typeof value === "string") @@ -350,7 +355,7 @@ const resolveValidatedRelayBackupRoot = ( || typeof state.sourceDataPath !== "string" || typeof state.sourceInstallDir !== "string" || !win32.isAbsolute(state.sourceInstallDir) - || !sameWindowsPath(state.sourceInstallDir, win32.dirname(layout.legacyInstallDataPath)) + || !hasValidatedInstallRelocationContext(state, layout) || !Array.isArray(state.preparedCopies) ) return null; const normalizedSourcePath = win32.normalize(state.sourceDataPath); @@ -396,6 +401,43 @@ const resolveValidatedCleanupLayout = ( return runtimeIsTrusted ? { ...layout, runtimeHomePath: runtimePath } : null; }; +const resolveValidatedCleanupLayouts = ( + states: WindowsDataMigrationState[], + layout: WindowsDataLayout, + trustedLegacyRuntimeHomePaths: string[] +): Map => { + const validated = new Map(); + let expectedTargetInstallDir = win32.dirname(layout.legacyInstallDataPath); + for (let index = states.length - 1; index >= 0; index -= 1) { + const state = states[index]; + if (!state) continue; + const hasInstallContext = typeof state.targetInstallDir === "string" + || typeof state.sourceInstallDir === "string"; + if (!hasInstallContext) { + const stateLayout = resolveValidatedCleanupLayout(state, layout, trustedLegacyRuntimeHomePaths); + if (stateLayout) validated.set(state, stateLayout); + continue; + } + const targetInstallDir = typeof state.targetInstallDir === "string" && win32.isAbsolute(state.targetInstallDir) + ? win32.normalize(state.targetInstallDir) + : typeof state.sourceInstallDir === "string" && win32.isAbsolute(state.sourceInstallDir) + ? win32.normalize(state.sourceInstallDir) + : null; + if (!targetInstallDir || !sameWindowsPath(targetInstallDir, expectedTargetInstallDir)) break; + const stateLayout = resolveValidatedCleanupLayout( + state, + { ...layout, legacyInstallDataPath: win32.join(targetInstallDir, "data") }, + trustedLegacyRuntimeHomePaths + ); + if (!stateLayout) break; + validated.set(state, stateLayout); + if (typeof state.sourceInstallDir === "string" && win32.isAbsolute(state.sourceInstallDir)) { + expectedTargetInstallDir = win32.normalize(state.sourceInstallDir); + } + } + return validated; +}; + const cleanupValidatedInstallSources = async ( state: WindowsDataMigrationState, layout: WindowsDataLayout @@ -412,7 +454,7 @@ const cleanupValidatedInstallSources = async ( const sourceDataPath = win32.normalize(state.sourceDataPath); const sourceInstallDir = win32.normalize(state.sourceInstallDir); - if (!sameWindowsPath(sourceInstallDir, win32.dirname(layout.legacyInstallDataPath))) return; + if (!hasValidatedInstallRelocationContext(state, layout)) return; const directDataPath = win32.join(sourceInstallDir, "data"); const failedBackupRoot = win32.dirname(sourceDataPath); const failedBackupParent = win32.dirname(failedBackupRoot); @@ -446,6 +488,19 @@ const cleanupValidatedInstallSources = async ( } }; +const hasValidatedInstallRelocationContext = ( + state: WindowsDataMigrationState, + layout: WindowsDataLayout +): boolean => { + const activeInstallDir = win32.dirname(layout.legacyInstallDataPath); + if (typeof state.targetInstallDir === "string") { + return win32.isAbsolute(state.targetInstallDir) + && sameWindowsPath(state.targetInstallDir, activeInstallDir); + } + return typeof state.sourceInstallDir === "string" + && sameWindowsPath(state.sourceInstallDir, activeInstallDir); +}; + const writeJsonAtomically = async (statePath: string, state: object): Promise => { await mkdir(win32.dirname(statePath), { recursive: true }); const temporaryPath = `${statePath}.tmp-${process.pid}-${randomUUID()}`; diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index b6e78315..e1beea7a 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -36,6 +36,9 @@ const winUnsignedInstallerIncludePath = fileURLToPath(new URL("../build/installe const winUpgradeRelayScriptPath = fileURLToPath(new URL("../build/MemmyWindowsUpgradeRelay.ps1", import.meta.url)); const winUpgradeRecoveryScriptPath = fileURLToPath(new URL("../build/MemmyWindowsUpgradeRecovery.ps1", import.meta.url)); const winDataMigrationScriptPath = fileURLToPath(new URL("../build/MemmyWindowsDataMigration.ps1", import.meta.url)); +const electronBuilderInstallSectionPath = fileURLToPath( + new URL("../../../../node_modules/app-builder-lib/templates/nsis/installSection.nsh", import.meta.url) +); const desktopInterfacePath = fileURLToPath(new URL("../interface/src/index.ts", import.meta.url)); const localApiContractsPath = fileURLToPath(new URL("../../../../App/backend/local-api-contracts/src/index.ts", import.meta.url)); const rootPackagePath = fileURLToPath(new URL("../../../../package.json", import.meta.url)); @@ -645,8 +648,22 @@ describe("desktop packaged runtime boundaries", () => { expect(includeSource).toContain('GetFullPathName $4 "$MemmyUpgradeWorkDir"'); expect(includeSource).not.toContain('GetFullPathName $5 "$MemmyUpgradeBackupRoot"'); expect(includeSource).toContain('${GetFileName} "$MemmyUpgradeWorkDir" $5'); - expect(includeSource).toContain('StrCpy $0 "$INSTDIR.memmy-upgrade-backup\\$4"'); + expect(includeSource).toContain('StrCpy $0 "$MemmyUpgradeSourceInstallDir.memmy-upgrade-backup\\$4"'); expect(includeSource).toContain('StrCmp $MemmyUpgradeBackupRoot $0 0 memmy_relay_context_failed'); + expect(includeSource).toContain("MEMMY_UPGRADE_SOURCE_INSTALL_DIR"); + expect(includeSource).toContain("MEMMY_UPGRADE_TARGET_INSTALL_DIR"); + const finishPageMacroStart = includeSource.indexOf("!macro customFinishPage"); + const finishPageMacroEnd = includeSource.indexOf("!macroend", finishPageMacroStart); + const finishPageMacroSource = includeSource.slice(finishPageMacroStart, finishPageMacroEnd); + expect(finishPageMacroStart).toBeGreaterThan(-1); + expect(finishPageMacroSource).toContain("Function MemmySkipRelayedFinishPage"); + expect(finishPageMacroSource).toContain('StrCmp $MemmyIsRelayedUpgrade "1"'); + expect(finishPageMacroSource).toContain("Abort"); + expect(finishPageMacroSource).toContain("Function MemmyStartAppAfterInstall"); + expect(finishPageMacroSource).toContain('${StdUtils.ExecShellAsUser} $0 "$launchLink" "open" "$1"'); + expect(finishPageMacroSource).toContain("!define MUI_PAGE_CUSTOMFUNCTION_PRE MemmySkipRelayedFinishPage"); + expect(finishPageMacroSource).toContain("!define MUI_FINISHPAGE_RUN_FUNCTION MemmyStartAppAfterInstall"); + expect(finishPageMacroSource).toContain("!insertmacro MUI_PAGE_FINISH"); const relayInitIndex = includeSource.indexOf("Function MemmyRelayLegacyUpgrade"); const earlyLaunchProxyIndex = includeSource.indexOf("Call MemmyInstallLaunchProxy", relayInitIndex); const relayStartIndex = includeSource.indexOf('ExecShell "open" "$R5"', relayInitIndex); @@ -687,6 +704,52 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain('spawn("/bin/zsh", [helperPath, filePath, destinationAppPath, logPath, String(process.pid), options.openAfterInstall ? "1" : "0"'); }); + it("bridges electron-builder shortcut retention across relayed install-directory changes", () => { + const includeSource = readFileSync(winUnsignedInstallerIncludePath, "utf8"); + const installSectionSource = readFileSync(electronBuilderInstallSectionPath, "utf8"); + const customCheckStart = includeSource.indexOf("!macro customCheckAppRunning"); + const customCheckEnd = includeSource.indexOf("!macroend", customCheckStart); + const customCheckSource = includeSource.slice(customCheckStart, customCheckEnd); + const uninstallCheckStart = includeSource.indexOf("!macro customUnInstallCheck"); + const uninstallCheckEnd = includeSource.indexOf("!macroend", uninstallCheckStart); + const uninstallCheckSource = includeSource.slice(uninstallCheckStart, uninstallCheckEnd); + + const targetAssignment = installSectionSource.indexOf('StrCpy $appExe "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"'); + const runningCheck = installSectionSource.indexOf("!insertmacro CHECK_APP_RUNNING"); + const shortcutProbe = installSectionSource.indexOf('${FileExists} "$appExe"'); + const oldUninstall = installSectionSource.indexOf("!insertmacro uninstallOldVersion SHELL_CONTEXT"); + const newFiles = installSectionSource.indexOf("!insertmacro installApplicationFiles"); + expect(targetAssignment).toBeGreaterThan(-1); + expect(targetAssignment).toBeLessThan(runningCheck); + expect(runningCheck).toBeLessThan(shortcutProbe); + expect(shortcutProbe).toBeLessThan(oldUninstall); + expect(oldUninstall).toBeLessThan(newFiles); + + const sourceBridge = 'StrCpy $appExe "$MemmyUpgradeSourceInstallDir\\${PRODUCT_FILENAME}.exe"'; + const targetRestore = 'StrCpy $appExe "$MemmyUpgradeTargetInstallDir\\${PRODUCT_FILENAME}.exe"'; + expect(customCheckSource).toContain(sourceBridge); + expect(customCheckSource.indexOf("!insertmacro _CHECK_APP_RUNNING")).toBeLessThan( + customCheckSource.indexOf(sourceBridge) + ); + expect(uninstallCheckSource.match(new RegExp(targetRestore.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))).toHaveLength(2); + expect(uninstallCheckSource.indexOf(targetRestore)).toBeLessThan( + uninstallCheckSource.indexOf("${If} $R0 != 0") + ); + }); + + it("records the final Windows install directory in uninstall metadata", () => { + const includeSource = readFileSync(winUnsignedInstallerIncludePath, "utf8"); + const installSectionSource = readFileSync(electronBuilderInstallSectionPath, "utf8"); + const registryInfo = installSectionSource.indexOf("!insertmacro registryAddInstallInfo"); + const customInstall = installSectionSource.indexOf("!insertmacro customInstall"); + + expect(registryInfo).toBeGreaterThan(-1); + expect(customInstall).toBeGreaterThan(registryInfo); + expect(includeSource).toContain( + 'WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" "InstallLocation" "$INSTDIR"' + ); + }); + it("validates Windows install and external data permissions before uninstalling the old version", () => { const includeSource = readFileSync(winUnsignedInstallerIncludePath, "utf8"); const customInitIndex = includeSource.indexOf("!macro customInit"); @@ -1803,6 +1866,35 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('MEMMY_EMBEDDING_MODEL_ROOT: join(options.resourcesPath, "embedding-models")'); }); + + it("prunes and verifies only proven Windows x64 packaged runtime waste", () => { + const source = readFileSync(packageWinX64Path, "utf8"); + + expect(source).toContain("verify_windows_agent_html_lint_runtime"); + expect(source).toContain([ + 'prune-packaged-runtime.mjs" \\', + " --platform win32 \\", + ' --arch "$PACKAGE_ARCH" \\', + ' --runtime-root "$RUNTIME_DIR"', + ].join("\n")); + expect(source).toContain("verify_pruned_windows_runtime"); + expect(source).toContain('require_packaged_runtime_absent "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/darwin"'); + expect(source).toContain('require_packaged_runtime_absent "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/linux"'); + expect(source).toContain('require_packaged_runtime_absent "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/win32/arm64"'); + expect(source).toContain('require_packaged_runtime_absent "$RUNTIME_DIR/memmy-agent/node_modules/vitest"'); + expect(source).toContain('require_packaged_runtime_absent "$RUNTIME_DIR/memmy-agent/node_modules/@vitest"'); + expect(source).toContain('require_no_packaged_runtime_glob "$RUNTIME_DIR/memmy-agent/node_modules/@rolldown/binding-*"'); + expect(source).toContain("Packaged runtime contains a third-party production source map"); + expect(source.indexOf("verify_windows_agent_html_lint_runtime")).toBeLessThan( + source.indexOf("prune-packaged-runtime.mjs"), + ); + expect(source.indexOf("prune-packaged-runtime.mjs")).toBeLessThan( + source.lastIndexOf("verify_windows_agent_html_lint_runtime"), + ); + expect(source.lastIndexOf("verify_pruned_windows_runtime")).toBeLessThan( + source.indexOf("npx electron-builder"), + ); + }); }); function readJson(path: string): T { diff --git a/App/shell/desktop/tests/windows-data-migration-state.test.ts b/App/shell/desktop/tests/windows-data-migration-state.test.ts index f1429c65..5db61ba4 100644 --- a/App/shell/desktop/tests/windows-data-migration-state.test.ts +++ b/App/shell/desktop/tests/windows-data-migration-state.test.ts @@ -260,6 +260,112 @@ describe.runIf(process.platform === "win32")("Windows data migration boot verifi expect(existsSync(layout.migrationStatePath)).toBe(false); }); + it("cleans a validated relocation relay backup and old drive runtime only after boot verification", async () => { + const root = await mkdtemp(join(tmpdir(), "memmy-migration-state-")); + temporaryDirectories.push(root); + const layout = createLayout(root); + const sourceInstallDir = join(root, "old-install"); + const targetInstallDir = dirname(layout.legacyInstallDataPath); + const relayBackupRoot = join(`${sourceInstallDir}.memmy-upgrade-backup`, "1234"); + const sourceDataPath = join(relayBackupRoot, "data-backup"); + const accountSourcePath = join(sourceDataPath, "Memmy"); + const oldRuntimeHomePath = join(root, "old-drive", "MemmyData", ".memmy"); + await Promise.all([ + mkdir(accountSourcePath, { recursive: true }), + mkdir(oldRuntimeHomePath, { recursive: true }), + mkdir(layout.runtimeHomePath, { recursive: true }), + mkdir(dirname(layout.migrationStatePath), { recursive: true }) + ]); + await Promise.all([ + writeFile(join(accountSourcePath, "app.sqlite"), "legacy-account", "utf8"), + writeFile(join(oldRuntimeHomePath, "config.yaml"), "old-runtime", "utf8"), + writeFile(join(layout.runtimeHomePath, "config.yaml"), "new-runtime", "utf8"), + writeFile(layout.migrationStatePath, JSON.stringify({ + owner: "relay", + phase: "awaiting-app-verification", + sourceAuthority: "relay-backup-authority", + sourceInstallDir, + targetInstallDir, + sourceDataPath, + targetUserDataPath: layout.userDataPath, + targetRuntimeHomePath: layout.runtimeHomePath, + runtimeSourcePath: oldRuntimeHomePath, + runtimeSourcePaths: [oldRuntimeHomePath], + preparedCopies: [ + { SourcePath: accountSourcePath, DestinationPath: layout.userDataPath, BackupPath: null }, + { SourcePath: oldRuntimeHomePath, DestinationPath: layout.runtimeHomePath, BackupPath: null } + ], + backupPaths: [] + }), "utf8") + ]); + + await expect(advanceWindowsDataMigrationAfterBoot(layout, [oldRuntimeHomePath])).resolves.toBe("verified"); + expect(existsSync(relayBackupRoot)).toBe(true); + expect(existsSync(oldRuntimeHomePath)).toBe(true); + + await expect(advanceWindowsDataMigrationAfterBoot(layout, [oldRuntimeHomePath])).resolves.toBe("cleaned"); + expect(existsSync(relayBackupRoot)).toBe(false); + expect(existsSync(oldRuntimeHomePath)).toBe(false); + expect(existsSync(layout.migrationStatePath)).toBe(false); + }); + + it("cleans carry-forward relay backups across consecutive installation relocations", async () => { + const root = await mkdtemp(join(tmpdir(), "memmy-migration-state-")); + temporaryDirectories.push(root); + const layout = createLayout(root); + const firstSourceInstallDir = join(root, "install-a"); + const intermediateInstallDir = join(root, "install-b"); + const activeInstallDir = dirname(layout.legacyInstallDataPath); + const firstRelayBackupRoot = join(`${firstSourceInstallDir}.memmy-upgrade-backup`, "1111"); + const secondRelayBackupRoot = join(`${intermediateInstallDir}.memmy-upgrade-backup`, "2222"); + const firstSourceDataPath = join(firstRelayBackupRoot, "data-backup"); + const secondSourceDataPath = join(secondRelayBackupRoot, "data-backup"); + const createRelayState = ( + sourceInstallDir: string, + targetInstallDir: string, + sourceDataPath: string + ) => ({ + owner: "relay", + phase: "app-verified", + sourceAuthority: "relay-backup-authority", + sourceInstallDir, + targetInstallDir, + sourceDataPath, + targetUserDataPath: layout.userDataPath, + targetRuntimeHomePath: layout.runtimeHomePath, + runtimeSourcePaths: [], + preparedCopies: [ + { + SourcePath: join(sourceDataPath, "Memmy"), + DestinationPath: layout.userDataPath, + BackupPath: null + } + ], + backupPaths: [] + }); + const firstState = createRelayState(firstSourceInstallDir, intermediateInstallDir, firstSourceDataPath); + const secondState = { + ...createRelayState(intermediateInstallDir, activeInstallDir, secondSourceDataPath), + deferredCleanupStates: [firstState] + }; + await Promise.all([ + mkdir(join(firstSourceDataPath, "Memmy"), { recursive: true }), + mkdir(join(secondSourceDataPath, "Memmy"), { recursive: true }), + mkdir(layout.runtimeHomePath, { recursive: true }), + mkdir(dirname(layout.migrationStatePath), { recursive: true }) + ]); + await Promise.all([ + writeFile(join(firstSourceDataPath, "Memmy", "app.sqlite"), "first", "utf8"), + writeFile(join(secondSourceDataPath, "Memmy", "app.sqlite"), "second", "utf8"), + writeFile(layout.migrationStatePath, JSON.stringify(secondState), "utf8") + ]); + + await expect(advanceWindowsDataMigrationAfterBoot(layout)).resolves.toBe("cleaned"); + expect(existsSync(firstRelayBackupRoot)).toBe(false); + expect(existsSync(secondRelayBackupRoot)).toBe(false); + expect(existsSync(layout.migrationStatePath)).toBe(false); + }); + it("does not delete a relay-shaped backup outside the active installation sibling", async () => { const root = await mkdtemp(join(tmpdir(), "memmy-migration-state-")); temporaryDirectories.push(root); diff --git a/App/shell/desktop/tests/windows-data-migration.test.ts b/App/shell/desktop/tests/windows-data-migration.test.ts index 03b96b9b..4eb1347b 100644 --- a/App/shell/desktop/tests/windows-data-migration.test.ts +++ b/App/shell/desktop/tests/windows-data-migration.test.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, open, readFile as readFileRaw, rename, rm, symlink, writeFile as writeFileRaw } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -9,6 +9,30 @@ const scriptPath = new URL("../build/MemmyWindowsDataMigration.ps1", import.meta .replace(/^\/(?:[A-Za-z]:)/u, (value) => value.slice(1)); const temporaryDirectories: string[] = []; +async function writeFile(path: string, data: string | Uint8Array, encoding?: BufferEncoding): Promise { + if (path.toLowerCase().endsWith("app.sqlite") && typeof data === "string") { + const sqliteFixture = Buffer.alloc(4096); + Buffer.from("SQLite format 3\0", "ascii").copy(sqliteFixture, 0); + sqliteFixture[16] = 0x10; + sqliteFixture[17] = 0x00; + Buffer.from(data, "utf8").copy(sqliteFixture, 100); + await writeFileRaw(path, sqliteFixture); + return; + } + await writeFileRaw(path, data, encoding); +} + +function readFile(path: string, encoding: "utf8"): Promise; +function readFile(path: string): Promise; +async function readFile(path: string, encoding?: "utf8"): Promise { + if (path.toLowerCase().endsWith("app.sqlite") && encoding === "utf8") { + const contents = await readFileRaw(path); + const markerEnd = contents.indexOf(0, 100); + return contents.subarray(100, markerEnd < 0 ? contents.length : markerEnd).toString("utf8"); + } + return encoding ? readFileRaw(path, encoding) : readFileRaw(path); +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) @@ -58,6 +82,29 @@ describe.runIf(process.platform === "win32")("Windows data migration helper", () expect(existsSync(fixture.lockPath)).toBe(true); }); + it("rejects a migration target whose existing ancestor is a directory junction", async () => { + const fixture = await createFixture(); + const redirectedDrive = join(fixture.root, "redirected-drive"); + const targetDrive = dirname(dirname(fixture.targetRuntimeHomePath)); + await Promise.all([ + mkdir(join(fixture.sourceDataPath, "Memmy"), { recursive: true }), + mkdir(join(fixture.sourceDataPath, ".memmy"), { recursive: true }), + mkdir(redirectedDrive, { recursive: true }) + ]); + await Promise.all([ + writeFile(join(fixture.sourceDataPath, "Memmy", "app.sqlite"), "login-state", "utf8"), + writeFile(join(fixture.sourceDataPath, ".memmy", "config.yaml"), "runtime-state", "utf8"), + symlink(redirectedDrive, targetDrive, "junction") + ]); + + const prepared = runMigration("Prepare", fixture, false, "", "current-install-authority", "1.1.0"); + + expect(prepared.status).toBe(1); + expect(prepared.stdout + prepared.stderr) + .toMatch(/target runtimeHomePath cr\s*osses a reparse point/u); + expect(existsSync(join(redirectedDrive, "MemmyData", ".memmy", "config.yaml"))).toBe(false); + }); + it("rebases only staged Windows runtime defaults and standalone session bindings", async () => { const fixture = await createFixture(); const sourceRuntimeHomePath = join(fixture.sourceDataPath, ".memmy"); @@ -417,7 +464,35 @@ describe.runIf(process.platform === "win32")("Windows data migration helper", () }); }); - it("trusts the exact user-selected install directory after a legacy uninstall removed its registry entry", async () => { + it("keeps valid targets when trusted install anchors are structurally incomplete", async () => { + const fixture = await createFixture(); + await Promise.all([ + mkdir(join(fixture.sourceDataPath, "Memmy"), { recursive: true }), + mkdir(join(fixture.sourceDataPath, ".memmy"), { recursive: true }), + mkdir(fixture.targetUserDataPath, { recursive: true }), + mkdir(fixture.targetRuntimeHomePath, { recursive: true }) + ]); + await Promise.all([ + writeFileRaw(join(fixture.sourceDataPath, "Memmy", "app.sqlite"), "not-a-sqlite-database", "utf8"), + writeFileRaw(join(fixture.sourceDataPath, ".memmy", "config.yaml"), "", "utf8"), + writeFile(join(fixture.targetUserDataPath, "app.sqlite"), "verified-login", "utf8"), + writeFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "verified-runtime", "utf8") + ]); + + const prepared = runMigration("Prepare", fixture); + expect(prepared.status, prepared.stderr || prepared.stdout).toBe(0); + await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")) + .resolves.toBe("verified-login"); + await expect(readFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "utf8")) + .resolves.toBe("verified-runtime"); + expect(JSON.parse(await readFile(fixture.statePath, "utf8"))).toMatchObject({ + accountSourceAuthority: "target-existing", + runtimeSourceAuthority: "target-existing", + preparedCopies: [] + }); + }); + + it("keeps verified targets when a user-selected install directory has no registry or persisted authority", async () => { const fixture = await createFixture(); await Promise.all([ mkdir(join(fixture.sourceDataPath, "Memmy"), { recursive: true }), @@ -432,18 +507,19 @@ describe.runIf(process.platform === "win32")("Windows data migration helper", () writeFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "historical-runtime", "utf8") ]); - const prepared = runMigration("Prepare", fixture, false, "", "selected-install-authority"); + const prepared = runMigration("Prepare", fixture, false, "", "untrusted-residual"); expect(prepared.status, prepared.stderr || prepared.stdout).toBe(0); await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")) - .resolves.toBe("selected-login"); + .resolves.toBe("historical-login"); await expect(readFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "utf8")) - .resolves.toBe("selected-runtime"); + .resolves.toBe("historical-runtime"); expect(JSON.parse(await readFile(fixture.statePath, "utf8"))).toMatchObject({ - sourceAuthority: "selected-install-authority" + sourceAuthority: "untrusted-residual", + preparedCopies: [] }); }); - it("does not trust a selected install residual already marked as the verified external generation", async () => { + it("does not trust a user-selected residual already marked as the verified external generation", async () => { const fixture = await createFixture(); await Promise.all([ mkdir(join(fixture.sourceDataPath, "Memmy"), { recursive: true }), @@ -466,7 +542,7 @@ describe.runIf(process.platform === "win32")("Windows data migration helper", () }), "utf8") ]); - expect(runMigration("Prepare", fixture, false, "", "selected-install-authority").status).toBe(0); + expect(runMigration("Prepare", fixture, false, "", "untrusted-residual").status).toBe(0); await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")).resolves.toBe("verified-login"); await expect(readFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "utf8")).resolves.toBe("verified-runtime"); }); @@ -574,7 +650,7 @@ describe.runIf(process.platform === "win32")("Windows data migration helper", () }); }); - it("keeps the exact user-selected install source instead of an older persisted install record", async () => { + it("uses the persisted install source instead of an arbitrary user-selected residual", async () => { const fixture = await createFixture(); const olderInstallDir = join(fixture.root, "older-install"); const olderDataPath = join(olderInstallDir, "data"); @@ -600,13 +676,13 @@ describe.runIf(process.platform === "win32")("Windows data migration helper", () }), "utf8") ]); - const prepared = runMigration("Prepare", fixture, false, "", "selected-install-authority"); + const prepared = runMigration("Prepare", fixture, false, "", "untrusted-residual"); expect(prepared.status, prepared.stderr || prepared.stdout).toBe(0); - await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")).resolves.toBe("selected-login"); - await expect(readFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "utf8")).resolves.toBe("selected-runtime"); + await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")).resolves.toBe("older-login"); + await expect(readFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "utf8")).resolves.toBe("older-runtime"); expect(JSON.parse(await readFile(fixture.statePath, "utf8"))).toMatchObject({ - sourceAuthority: "selected-install-authority", - sourceDataPath: fixture.sourceDataPath + sourceAuthority: "persisted-install-authority", + sourceDataPath: olderDataPath }); }); @@ -944,7 +1020,7 @@ function runMigration( fixture: MigrationFixture, acquireLock = false, allowedRememberedRuntimeHomePath = "", - sourceAuthority: "current-install-authority" | "selected-install-authority" | "relay-backup-authority" | "persisted-install-authority" | "untrusted-residual" = "current-install-authority", + sourceAuthority: "current-install-authority" | "relay-backup-authority" | "persisted-install-authority" | "untrusted-residual" = "current-install-authority", sourceInstalledVersion = "" ) { const args = [ @@ -956,6 +1032,7 @@ function runMigration( "-SourceDataPath", fixture.sourceDataPath, "-SourceAuthority", sourceAuthority, "-SourceInstallDir", dirname(fixture.sourceDataPath), + "-TargetInstallDir", join(fixture.root, "new-install"), "-SourceInstalledVersion", sourceInstalledVersion, "-InstallationRecordPath", fixture.installationRecordPath, "-LegacyRuntimeHomePath", fixture.legacyRuntimeHomePath, diff --git a/App/shell/desktop/tests/windows-standard-upgrade-check.test.ts b/App/shell/desktop/tests/windows-standard-upgrade-check.test.ts new file mode 100644 index 00000000..7bac8770 --- /dev/null +++ b/App/shell/desktop/tests/windows-standard-upgrade-check.test.ts @@ -0,0 +1,302 @@ +import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const describeOnWindows = process.platform === "win32" ? describe : describe.skip; +const scriptPath = fileURLToPath(new URL("../build/MemmyWindowsStandardUpgradeCheck.ps1", import.meta.url)); +const installerIncludePath = fileURLToPath(new URL("../build/installer-win-unsigned.nsh", import.meta.url)); +const fixtureRoots: string[] = []; + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describeOnWindows("Windows standard upgrade safety check", () => { + it("allows a completed external-v1 installation", () => { + const fixture = createFixture(); + + const result = runCheck(fixture); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("standard-upgrade-safe"); + }); + + it("routes an existing installation to relay when the final install directory changes", () => { + const fixture = createFixture(); + fixture.targetInstallDir = join(fixture.root, "other-drive", "Memmy"); + + const result = runCheck(fixture); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("relay-required:installation target differs from the installed application"); + }); + + it("blocks relocation when the selected target already contains another Memmy executable", () => { + const fixture = createFixture(); + fixture.targetInstallDir = join(fixture.root, "other-drive", "Memmy"); + mkdirSync(fixture.targetInstallDir, { recursive: true }); + copyFileSync(fixture.installedExePath, join(fixture.targetInstallDir, "Memmy.exe")); + + const result = runCheck(fixture); + + expect(result.status).toBe(2); + expect(result.stdout).toContain("installation-blocked:selected target already contains Memmy.exe"); + }); + + it("blocks relocation when the selected drive already contains runtime data", () => { + const fixture = createFixture(); + fixture.targetInstallDir = join(fixture.root, "other-drive", "Memmy"); + fixture.targetRuntimeHomePath = join(fixture.root, "other-drive", "MemmyData", ".memmy"); + writeFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "existing-runtime"); + + const result = runCheck(fixture); + + expect(result.status).toBe(2); + expect(result.stdout).toContain("installation-blocked:selected installation drive already contains Memmy runtime data"); + }); + + it("allows an empty legacy data directory because it contains no data to preserve", () => { + const fixture = createFixture(); + mkdirSync(join(fixture.installDir, "data")); + + expect(runCheck(fixture).status).toBe(0); + }); + + it("allows a clean reinstall without an executable when an external-v1 record remains", () => { + const fixture = createFixture(); + rmSync(fixture.installedExePath); + + const result = runCheck(fixture, true); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("standard-install-safe"); + }); + + it("requires relay when no executable remains but an install-local backup record is authoritative", () => { + const fixture = createFixture(); + const failedBackup = join(`${fixture.installDir}.memmy-migration-failed`, "20260825120000-0123456789abcdef0123456789abcdef", "data-backup"); + rmSync(fixture.installedExePath); + writeFile(join(failedBackup, "Memmy", "app.sqlite"), "legacy"); + updateRecord(fixture, { + dataLayoutGeneration: "install-local-v1", + sourceDataPath: failedBackup, + sourceGeneration: "legacy-install:test", + }); + + const result = runCheck(fixture, true); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("relay-required:"); + }); + + it.each([ + ["missing installation record", (fixture: Fixture) => rmSync(fixture.installationRecordPath)], + ["damaged installation record", (fixture: Fixture) => writeFileSync(fixture.installationRecordPath, "{broken", "utf8")], + ["mismatched install directory", (fixture: Fixture) => updateRecord(fixture, { installDir: join(fixture.root, "other-install") })], + ["account data inside the install directory", (fixture: Fixture) => updateRecord(fixture, { userDataPath: join(fixture.installDir, "data", "Memmy") })], + ["runtime data inside the install directory", (fixture: Fixture) => updateRecord(fixture, { runtimeHomePath: join(fixture.installDir, "data", ".memmy") })], + ["unfinished migration state", (fixture: Fixture) => writeJson(fixture.migrationStatePath, { phase: "prepared" })], + ["installer inside the install directory", (fixture: Fixture) => { + fixture.installerPath = join(fixture.installDir, "data", "update.exe"); + writeFile(fixture.installerPath, "installer"); + }], + ["legacy data that still needs preservation", (fixture: Fixture) => writeFile(join(fixture.installDir, "data", "Memmy", "app.sqlite"), "legacy")], + ["installed version mismatch", (fixture: Fixture) => updateRecord(fixture, { appVersion: "999.0.0" })], + ["string schema version", (fixture: Fixture) => updateRecord(fixture, { schemaVersion: "1" })], + ["drive-relative data path", (fixture: Fixture) => updateRecord(fixture, { runtimeHomePath: "C:relative-runtime" })], + ["rooted-relative data path", (fixture: Fixture) => updateRecord(fixture, { runtimeHomePath: "\\relative-runtime" })], + ])("requires relay for %s", (_name, mutate) => { + const fixture = createFixture(); + mutate(fixture); + + const result = runCheck(fixture); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("relay-required:"); + }); + + it("requires both recorded external paths to be absolute", () => { + const fixture = createFixture(); + updateRecord(fixture, { runtimeHomePath: ".memmy" }); + + expect(runCheck(fixture).status).toBe(1); + }); + + it("requires the recorded runtime path to match the canonical target", () => { + const fixture = createFixture(); + const unrelatedPath = join(fixture.root, "unrelated-runtimeHomePath"); + mkdirSync(unrelatedPath, { recursive: true }); + updateRecord(fixture, { runtimeHomePath: unrelatedPath }); + + const result = runCheck(fixture); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("recorded runtimeHomePath does not match the expected data layout"); + }); + + it("requires the recorded user-data path to match the canonical target", () => { + const fixture = createFixture(); + const unrelatedPath = join(fixture.root, "unrelated-userDataPath"); + mkdirSync(unrelatedPath, { recursive: true }); + updateRecord(fixture, { userDataPath: unrelatedPath }); + + const result = runCheck(fixture); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("recorded userDataPath does not match the expected data layout"); + }); + + it("rejects an external path that crosses a junction", () => { + const fixture = createFixture(); + const target = join(fixture.installDir, "aliased-user-data"); + const junction = join(fixture.root, "junction-user-data"); + mkdirSync(target, { recursive: true }); + symlinkSync(target, junction, "junction"); + updateRecord(fixture, { userDataPath: junction }); + + const result = runCheck(fixture); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("reparse point"); + }); + + it("keeps relayed children non-recursive and evaluates installed layouts without an --updated gate", () => { + const source = readFile(installerIncludePath, "utf8"); + const relayedMarker = source.indexOf('${GetOptions} $R0 "--memmy-upgrade-relayed" $R1'); + const safetyCheck = source.indexOf("Call MemmyEvaluateStandardUpgradeSafety"); + const relayLaunch = source.indexOf("ExecShell \"open\" \"$R5\""); + + expect(relayedMarker).toBeGreaterThanOrEqual(0); + expect(relayedMarker).toBeLessThan(safetyCheck); + expect(safetyCheck).toBeLessThan(relayLaunch); + expect(source).not.toContain('${GetOptions} $R0 "--updated" $R1'); + expect(source).not.toContain('IfFileExists "$MemmyInstalledExePath" 0 memmy_relay_done'); + expect(source).toContain('StrCmp $MemmyStandardUpgradeSafe "1" memmy_relay_done'); + expect(source).toContain('StrCmp $MemmyStandardUpgradeSafe "1" memmy_check_app_running_done'); + expect(source).toContain("MemmyWindowsStandardUpgradeCheck.ps1"); + expect(source).toContain("-AllowMissingExecutable"); + expect(source).toMatch(/memmy_standard_check_safe:[\s\S]*StrCmp \$R4 "-AllowMissingExecutable" memmy_standard_check_fresh/u); + }); + + it("evaluates routing after the interactive directory page and keeps relay child UI mode explicit", () => { + const source = readFile(installerIncludePath, "utf8"); + const pageValidation = source.indexOf("Function MemmyValidateInstallPage"); + const directoryValidation = source.indexOf("Call MemmyValidateSelectedDirectories", pageValidation); + const finalRoute = source.indexOf("Call MemmyRelayLegacyUpgrade", directoryValidation); + + expect(pageValidation).toBeGreaterThanOrEqual(0); + expect(directoryValidation).toBeGreaterThan(pageValidation); + expect(finalRoute).toBeGreaterThan(directoryValidation); + expect(source).toContain('-TargetInstallDir $\\"$MemmySelectedInstallDir$\\"'); + expect(source).toContain('-SourceInstallDir $\\"$MemmyUpgradeSourceInstallDir$\\"'); + expect(source).toContain("-InstallerMode $MemmyRelayInstallerMode"); + }); +}); + +interface Fixture { + root: string; + installDir: string; + installedExePath: string; + installerPath: string; + installationRecordPath: string; + migrationStatePath: string; + targetInstallDir: string; + targetUserDataPath: string; + targetRuntimeHomePath: string; + record: Record; +} + +function createFixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), "memmy-standard-upgrade-check-")); + fixtureRoots.push(root); + const installDir = join(root, "install", "Memmy"); + const installedExePath = join(installDir, "Memmy.exe"); + const installerPath = join(root, "downloads", "Memmy-update.exe"); + const installationRecordPath = join(root, "local", "Memmy", "data-layout", "last-install.json"); + const migrationStatePath = join(root, "local", "Memmy", "data-migration", "state.json"); + const userDataPath = join(root, "roaming", "Memmy"); + const runtimeHomePath = join(root, "runtime", ".memmy"); + mkdirSync(installDir, { recursive: true }); + copyFileSync(process.env.ComSpec ?? join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe"), installedExePath); + writeFile(installerPath, "installer"); + mkdirSync(userDataPath, { recursive: true }); + mkdirSync(runtimeHomePath, { recursive: true }); + const appVersion = readProductVersion(installedExePath); + const fixture: Fixture = { + root, + installDir, + installedExePath, + installerPath, + installationRecordPath, + migrationStatePath, + targetInstallDir: installDir, + targetUserDataPath: userDataPath, + targetRuntimeHomePath: runtimeHomePath, + record: { + schemaVersion: 1, + dataLayoutGeneration: "external-v1", + installDir, + userDataPath, + runtimeHomePath, + appVersion, + }, + }; + writeJson(installationRecordPath, fixture.record); + return fixture; +} + +function updateRecord(fixture: Fixture, updates: Record) { + fixture.record = { ...fixture.record, ...updates }; + writeJson(fixture.installationRecordPath, fixture.record); +} + +function runCheck(fixture: Fixture, allowMissingExecutable = false) { + const args = [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", scriptPath, + "-InstallDir", fixture.installDir, + "-TargetInstallDir", fixture.targetInstallDir, + "-TargetUserDataPath", fixture.targetUserDataPath, + "-TargetRuntimeHomePath", fixture.targetRuntimeHomePath, + "-InstalledExePath", fixture.installedExePath, + "-InstallerPath", fixture.installerPath, + "-InstallationRecordPath", fixture.installationRecordPath, + "-MigrationStatePath", fixture.migrationStatePath, + ]; + if (allowMissingExecutable) args.push("-AllowMissingExecutable"); + return spawnSync("powershell.exe", args, { encoding: "utf8" }); +} + +function readProductVersion(executablePath: string): string { + const escapedPath = executablePath.replaceAll("'", "''"); + const result = spawnSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `[System.Diagnostics.FileVersionInfo]::GetVersionInfo('${escapedPath}').ProductVersion`, + ], { encoding: "utf8" }); + if (result.status !== 0 || !result.stdout.trim()) { + throw new Error(result.stderr || "Cannot read fixture product version"); + } + return result.stdout.trim(); +} + +function readFile(path: string): string { + return readFileSync(path, "utf8"); +} + +function writeFile(path: string, contents: string) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, "utf8"); +} + +function writeJson(path: string, value: unknown) { + writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/App/shell/desktop/tests/windows-upgrade-relay.test.ts b/App/shell/desktop/tests/windows-upgrade-relay.test.ts index e1b796df..2754a933 100644 --- a/App/shell/desktop/tests/windows-upgrade-relay.test.ts +++ b/App/shell/desktop/tests/windows-upgrade-relay.test.ts @@ -1,6 +1,6 @@ import { execFile as execFileCallback, spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; -import { copyFile, mkdtemp, mkdir, readFile, rename, rm, utimes, writeFile } from "node:fs/promises"; +import { copyFile, mkdtemp, mkdir, readFile as readFileRaw, rename, rm, symlink, utimes, writeFile as writeFileRaw } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -16,6 +16,30 @@ const helperProcesses: ChildProcess[] = []; const descendantProcessIds: number[] = []; const describeOnWindows = process.platform === "win32" ? describe : describe.skip; +async function writeFile(path: string, data: string | Uint8Array, encoding?: BufferEncoding): Promise { + if (path.toLowerCase().endsWith("app.sqlite") && typeof data === "string") { + const sqliteFixture = Buffer.alloc(4096); + Buffer.from("SQLite format 3\0", "ascii").copy(sqliteFixture, 0); + sqliteFixture[16] = 0x10; + sqliteFixture[17] = 0x00; + Buffer.from(data, "utf8").copy(sqliteFixture, 100); + await writeFileRaw(path, sqliteFixture); + return; + } + await writeFileRaw(path, data, encoding); +} + +function readFile(path: string, encoding: "utf8"): Promise; +function readFile(path: string): Promise; +async function readFile(path: string, encoding?: "utf8"): Promise { + if (path.toLowerCase().endsWith("app.sqlite") && encoding === "utf8") { + const contents = await readFileRaw(path); + const markerEnd = contents.indexOf(0, 100); + return contents.subarray(100, markerEnd < 0 ? contents.length : markerEnd).toString("utf8"); + } + return encoding ? readFileRaw(path, encoding) : readFileRaw(path); +} + afterEach(async () => { await Promise.all(helperProcesses.splice(0).map(async (process) => { if (process.exitCode !== null || process.signalCode !== null) return; @@ -56,11 +80,14 @@ const createRelayFixture = async ( failMigrationComplete?: boolean; failMigrationRollback?: boolean; failFirstMigrationRollback?: boolean; + replaceTargetWithJunctionAfterPrepare?: boolean; + relocate?: boolean; } = {} ) => { const root = await mkdtemp(join(tmpdir(), "memmy-upgrade-relay-")); temporaryDirectories.push(root); const installDir = join(root, "installed Memmy"); + const targetInstallDir = options.relocate ? join(root, "relocated Memmy") : installDir; const dataDir = join(installDir, "data", "Memmy"); const workDir = join(root, "relay-work"); const backupRoot = join(`${installDir}.memmy-upgrade-backup`, basename(workDir)); @@ -81,7 +108,10 @@ const createRelayFixture = async ( await copyFile(migrationScriptPath, join(workDir, "MemmyWindowsDataMigration.ps1")); if (options.failMigrationPrepare) { await writeFile(join(workDir, "MemmyWindowsDataMigration.ps1"), "Write-Error 'injected migration failure'\r\nexit 5\r\n", "utf8"); - } else if (options.failMigrationComplete || options.failMigrationRollback || options.failFirstMigrationRollback) { + } else if (options.failMigrationComplete + || options.failMigrationRollback + || options.failFirstMigrationRollback + || options.replaceTargetWithJunctionAfterPrepare) { const copiedMigrationPath = join(workDir, "MemmyWindowsDataMigration.ps1"); let copiedMigration = await readFile(copiedMigrationPath, "utf8"); if (options.failMigrationComplete) { @@ -103,6 +133,14 @@ const createRelayFixture = async ( `elseif ($Mode -eq "Rollback") {\r\n if (-not (Test-Path -LiteralPath '${firstRollbackMarker}')) { Set-Content -LiteralPath '${firstRollbackMarker}' -Value 'failed'; throw "injected first Rollback failure" }` ); } + if (options.replaceTargetWithJunctionAfterPrepare) { + const redirectedTarget = join(root, "post-prepare-redirected-target").replaceAll("'", "''"); + await mkdir(redirectedTarget, { recursive: true }); + copiedMigration = copiedMigration.replace( + 'Write-MigrationLog -Message "Migration preparation completed."', + `Write-MigrationLog -Message "Migration preparation completed."\r\n New-Item -ItemType Junction -Path $TargetInstallDir -Target '${redirectedTarget}' -ErrorAction Stop | Out-Null` + ); + } await writeFile(copiedMigrationPath, copiedMigration, "utf8"); } await writeFile(join(dataDir, "sentinel.txt"), "keep-me", "utf8"); @@ -114,7 +152,7 @@ const createRelayFixture = async ( const windowsDirectory = process.env.SystemRoot ?? "C:\\Windows"; const appStubPath = join(windowsDirectory, "System32", "where.exe"); await writeFile(join(installDir, "Memmy.exe"), await readFile(appStubPath)); - const escapedInstallDir = installDir.replaceAll("%", "%%"); + const escapedTargetInstallDir = targetInstallDir.replaceAll("%", "%%"); const escapedAppStubPath = appStubPath.replaceAll("%", "%%"); const installerLines = [ "@echo off", @@ -132,9 +170,9 @@ const createRelayFixture = async ( installerLines.push(`start "" /b "${powershellPath.replaceAll("%", "%%")}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${descendantScriptPath.replaceAll("%", "%%")}"`); } installerLines.push( - `rmdir /s /q "${escapedInstallDir}"`, - `mkdir "${escapedInstallDir}"`, - `copy /y "${escapedAppStubPath}" "${escapedInstallDir}\\Memmy.exe" >nul`, + `rmdir /s /q "${escapedTargetInstallDir}"`, + `mkdir "${escapedTargetInstallDir}"`, + `copy /y "${escapedAppStubPath}" "${escapedTargetInstallDir}\\Memmy.exe" >nul`, "if not defined MEMMY_UPGRADE_WORK_DIR goto installer_done", ">\"%MEMMY_UPGRADE_WORK_DIR%\\child-reopen-intent.txt\" echo(%MEMMY_UPGRADE_REOPEN_AFTER_INSTALL%", ":installer_done", @@ -145,6 +183,7 @@ const createRelayFixture = async ( return { root, installDir, + targetInstallDir, dataDir, workDir, backupRoot, @@ -234,7 +273,7 @@ const runDataMigration = async ( const runRelay = async ( fixture: Awaited>, - options: { appDataPath?: string; legacyHelperPid?: number; reopenAfterInstall?: "0" | "1"; installedVersion?: string } = {} + options: { appDataPath?: string; legacyHelperPid?: number; reopenAfterInstall?: "0" | "1"; installedVersion?: string; installerMode?: "Silent" | "Interactive" } = {} ) => { const powershellPath = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); return execFile(powershellPath, buildRelayArguments(fixture, options), { @@ -246,7 +285,7 @@ const runRelay = async ( const buildRelayArguments = ( fixture: Awaited>, - options: { legacyHelperPid?: number; reopenAfterInstall?: "0" | "1"; installedVersion?: string } = {} + options: { legacyHelperPid?: number; reopenAfterInstall?: "0" | "1"; installedVersion?: string; installerMode?: "Silent" | "Interactive" } = {} ) => [ "-NoProfile", "-ExecutionPolicy", @@ -255,8 +294,10 @@ const buildRelayArguments = ( relayScriptPath, "-InstallerPath", fixture.installerPath, - "-InstallDir", + "-SourceInstallDir", fixture.installDir, + "-TargetInstallDir", + fixture.targetInstallDir, "-OriginalInstallerPid", "2147483647", "-LegacyHelperPid", @@ -265,6 +306,8 @@ const buildRelayArguments = ( "10.", "-InstalledVersion", options.installedVersion ?? "1.0.9", + "-InstallerMode", + options.installerMode ?? "Silent", "-ReopenAfterInstall", options.reopenAfterInstall ?? "0", "-ReadyPath", @@ -326,6 +369,91 @@ const waitForPathPresent = async (path: string) => { }; describeOnWindows("Windows upgrade relay", () => { + it("revalidates relocation safety immediately before starting the child installer", async () => { + const source = await readFile(relayScriptPath, "utf8"); + const safetyCalls = [...source.matchAll(/^\s+Assert-MemmyRelocationTargetIsSafe(?:\s|$)/gmu)]; + const migrationPrepare = source.indexOf("Invoke-MemmyDataMigration -Mode Prepare"); + const childStart = source.indexOf("$installerProcess = Start-Process -FilePath $InstallerPath"); + + expect(safetyCalls).toHaveLength(2); + expect(safetyCalls[1]?.index).toBeGreaterThan(migrationPrepare); + expect(safetyCalls[1]?.index).toBeLessThan(childStart); + expect(source).toContain("Assert-MemmyNoReparsePath $normalizedTargetInstallDir 'target installDir'"); + expect(source).toContain("Assert-MemmyNoReparsePath $targetRuntimeHomePath 'target runtimeHomePath'"); + }); + + it("rejects a relocation target that crosses a directory junction", async () => { + const fixture = await createRelayFixture(0, { relocate: true }); + const redirectedTarget = join(fixture.root, "redirected-target"); + await mkdir(redirectedTarget, { recursive: true }); + await symlink(redirectedTarget, fixture.targetInstallDir, "junction"); + + await expect(runRelay(fixture)).rejects.toMatchObject({ code: 1 }); + expect(existsSync(join(redirectedTarget, "Memmy.exe"))).toBe(false); + expect(existsSync(fixture.dataDir)).toBe(true); + const log = await readFile(fixture.logPath, "utf8"); + expect(log).toContain("target installDir crosses a reparse point"); + }, 15_000); + + it("rejects a relocation target replaced with a junction after migration preparation", async () => { + const fixture = await createRelayFixture(0, { + relocate: true, + replaceTargetWithJunctionAfterPrepare: true + }); + const redirectedTarget = join(fixture.root, "post-prepare-redirected-target"); + + await expect(runRelay(fixture)).rejects.toMatchObject({ code: 1 }); + expect(existsSync(join(redirectedTarget, "Memmy.exe"))).toBe(false); + expect(existsSync(fixture.dataDir)).toBe(true); + const log = await readFile(fixture.logPath, "utf8"); + expect(log).toContain("target installDir crosses a reparse point"); + }, 15_000); + + it("rejects a relocation source that crosses a directory junction before moving data", async () => { + const fixture = await createRelayFixture(0, { relocate: true }); + const realSource = join(fixture.root, "real-source"); + await rename(fixture.installDir, realSource); + await symlink(realSource, fixture.installDir, "junction"); + + await expect(runRelay(fixture)).rejects.toMatchObject({ code: 1 }); + + expect(existsSync(join(realSource, "data", "Memmy", "sentinel.txt"))).toBe(true); + expect(existsSync(fixture.backupRoot)).toBe(false); + expect(existsSync(join(fixture.targetInstallDir, "Memmy.exe"))).toBe(false); + const log = await readFile(fixture.logPath, "utf8"); + expect(log).toContain("source installDir crosses a reparse point"); + }, 15_000); + + it("waits for the running source installation before relocating", async () => { + const fixture = await createRelayFixture(0, { relocate: true }); + const pingPath = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "PING.EXE"); + await copyFile(pingPath, join(fixture.installDir, "Memmy.exe")); + const sourceProcess = spawn(join(fixture.installDir, "Memmy.exe"), ["127.0.0.1", "-n", "8"], { + windowsHide: true, + stdio: "ignore" + }); + helperProcesses.push(sourceProcess); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + expect(sourceProcess.exitCode).toBeNull(); + + await runRelay(fixture); + + expect(sourceProcess.exitCode).not.toBeNull(); + const log = await readFile(fixture.logPath, "utf8"); + expect(log).not.toContain("forcing remaining installed app processes to exit"); + }, 20_000); + + it("keeps interactive relay children visible while silent updates stay hidden", async () => { + const source = await readFile(relayScriptPath, "utf8"); + + expect(source).toContain("[ValidateSet('Silent', 'Interactive')][string]$InstallerMode"); + expect(source).toContain("$arguments = @('--updated', '--memmy-upgrade-relayed', '/currentuser', ('/D=' + $normalizedTargetInstallDir))"); + expect(source).toContain("$arguments = @('/S') + $arguments"); + expect(source).not.toContain("$arguments = @('/S', '--updated') + $arguments"); + expect(source).toMatch(/InstallerMode -eq 'Interactive'[\s\S]*WindowStyle Normal/u); + expect(source).toMatch(/InstallerMode -eq 'Silent'[\s\S]*'\/S'[\s\S]*WindowStyle Hidden/u); + }); + it("migrates install-local data outside the installation directory before a verified upgrade", async () => { expect(existsSync(relayScriptPath)).toBe(true); const fixture = await createRelayFixture(0); @@ -380,6 +508,171 @@ describeOnWindows("Windows upgrade relay", () => { expect(existsSync(fixture.workDir)).toBe(false); }, 15_000); + it("relocates a completed external-v1 runtime from the source drive to the selected target drive", async () => { + const fixture = await createRelayFixture(0, { relocate: true }); + const sourceRuntimeHomePath = fixture.legacyRuntimeHomePath; + const sourceWorkspacePath = join(sourceRuntimeHomePath, "workspace"); + const targetWorkspacePath = join(fixture.targetRuntimeHomePath, "workspace"); + const sessionPath = join(sourceWorkspacePath, "sessions", "websocket_relocation.jsonl"); + await rm(join(fixture.installDir, "data"), { recursive: true, force: true }); + await Promise.all([ + mkdir(dirname(fixture.installationRecordPath), { recursive: true }), + mkdir(fixture.targetUserDataPath, { recursive: true }), + mkdir(dirname(sessionPath), { recursive: true }) + ]); + await Promise.all([ + writeFile(join(fixture.targetUserDataPath, "app.sqlite"), "external-account", "utf8"), + writeFile(join(sourceRuntimeHomePath, "config.yaml"), `workspace: '${sourceWorkspacePath}'\n`, "utf8"), + writeFile(sessionPath, `${JSON.stringify({ + key: "websocket:relocation", + metadata: { webui: true, webuiProjectId: null, webuiWorkspaceCwd: sourceWorkspacePath } + })}\n{\"role\":\"user\",\"content\":\"keep relocation history\"}\n`, "utf8"), + writeFile(fixture.installationRecordPath, JSON.stringify({ + schemaVersion: 1, + dataLayoutGeneration: "external-v1", + installDir: fixture.installDir, + userDataPath: fixture.targetUserDataPath, + runtimeHomePath: sourceRuntimeHomePath, + appVersion: "1.1.0" + }), "utf8"), + writeFile( + join(fixture.targetUserDataPath, "data-root.txt"), + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(`${sourceRuntimeHomePath}\r\n`, "utf16le")]) + ) + ]); + + await runRelay(fixture, { installedVersion: "1.1.0" }); + + expect(existsSync(join(fixture.targetInstallDir, "Memmy.exe"))).toBe(true); + await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")) + .resolves.toBe("external-account"); + const migratedConfig = await readFile(join(fixture.targetRuntimeHomePath, "config.yaml"), "utf8"); + expect(migratedConfig).toContain(targetWorkspacePath); + expect(migratedConfig).not.toContain(sourceWorkspacePath); + const migratedSession = await readFile( + join(fixture.targetRuntimeHomePath, "workspace", "sessions", "websocket_relocation.jsonl"), + "utf8" + ); + expect(migratedSession).toContain("keep relocation history"); + expect(migratedSession).toContain(targetWorkspacePath.replaceAll("\\", "\\\\")); + expect(existsSync(sourceRuntimeHomePath)).toBe(true); + expect(JSON.parse(await readFile(fixture.migrationStatePath, "utf8"))).toMatchObject({ + phase: "awaiting-app-verification", + sourceInstallDir: fixture.installDir, + targetInstallDir: fixture.targetInstallDir, + runtimeSourceAuthority: "persisted-external-authority", + runtimeSourcePath: sourceRuntimeHomePath + }); + }, 15_000); + + it("rolls an external-v1 relocation back to the source runtime when the child installer fails", async () => { + const fixture = await createRelayFixture(2, { relocate: true }); + const sourceRuntimeHomePath = fixture.legacyRuntimeHomePath; + await rm(join(fixture.installDir, "data"), { recursive: true, force: true }); + await Promise.all([ + mkdir(dirname(fixture.installationRecordPath), { recursive: true }), + mkdir(fixture.targetUserDataPath, { recursive: true }), + mkdir(sourceRuntimeHomePath, { recursive: true }) + ]); + await Promise.all([ + writeFile(join(fixture.targetUserDataPath, "app.sqlite"), "external-account", "utf8"), + writeFile(join(sourceRuntimeHomePath, "config.yaml"), "source-runtime", "utf8"), + writeFile(fixture.installationRecordPath, JSON.stringify({ + schemaVersion: 1, + dataLayoutGeneration: "external-v1", + installDir: fixture.installDir, + userDataPath: fixture.targetUserDataPath, + runtimeHomePath: sourceRuntimeHomePath, + appVersion: "1.1.0" + }), "utf8"), + writeFile( + join(fixture.targetUserDataPath, "data-root.txt"), + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(`${sourceRuntimeHomePath}\r\n`, "utf16le")]) + ) + ]); + + await expect(runRelay(fixture, { installedVersion: "1.1.0" })).rejects.toMatchObject({ code: 2 }); + + expect(existsSync(join(fixture.installDir, "Memmy.exe"))).toBe(true); + expect(existsSync(fixture.targetRuntimeHomePath)).toBe(false); + await expect(readFile(join(sourceRuntimeHomePath, "config.yaml"), "utf8")) + .resolves.toBe("source-runtime"); + const pointerBytes = await readFile(join(fixture.targetUserDataPath, "data-root.txt")); + expect(pointerBytes.subarray(2).toString("utf16le").trim()).toBe(sourceRuntimeHomePath); + expect(existsSync(fixture.migrationStatePath)).toBe(false); + }, 15_000); + + it("does not accept an unrelated existing runtime as persisted external authority", async () => { + const fixture = await createRelayFixture(0, { relocate: true }); + const unrelatedRuntimeHomePath = join(fixture.root, "unrelated-existing-runtime"); + await rm(join(fixture.installDir, "data"), { recursive: true, force: true }); + await Promise.all([ + mkdir(dirname(fixture.installationRecordPath), { recursive: true }), + mkdir(fixture.targetUserDataPath, { recursive: true }), + mkdir(unrelatedRuntimeHomePath, { recursive: true }) + ]); + await Promise.all([ + writeFile(join(fixture.targetUserDataPath, "app.sqlite"), "external-account", "utf8"), + writeFile(join(unrelatedRuntimeHomePath, "config.yaml"), "unrelated-runtime", "utf8"), + writeFile(fixture.installationRecordPath, JSON.stringify({ + schemaVersion: 1, + dataLayoutGeneration: "external-v1", + installDir: fixture.installDir, + userDataPath: fixture.targetUserDataPath, + runtimeHomePath: unrelatedRuntimeHomePath, + appVersion: "1.1.0" + }), "utf8"), + writeFile( + join(fixture.targetUserDataPath, "data-root.txt"), + Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(`${unrelatedRuntimeHomePath}\r\n`, "utf16le")]) + ) + ]); + + await runRelay(fixture, { installedVersion: "1.1.0" }); + + expect(existsSync(join(fixture.targetRuntimeHomePath, "config.yaml"))).toBe(false); + const migrationLog = await readFile(fixture.migrationLogPath, "utf8"); + expect(migrationLog).toContain("Ignoring an invalid external-v1 runtime source"); + expect(migrationLog).not.toContain("authority=persisted-external-authority"); + }, 15_000); + + it("recovers a persisted install-local backup when the executable and install data are gone", async () => { + const fixture = await createRelayFixture(0); + const failedBackup = join( + `${fixture.installDir}.memmy-migration-failed`, + "20260825120000-0123456789abcdef0123456789abcdef", + "data-backup", + ); + await mkdir(dirname(failedBackup), { recursive: true }); + await rename(join(fixture.installDir, "data"), failedBackup); + await rm(join(fixture.installDir, "Memmy.exe"), { force: true }); + await mkdir(dirname(fixture.installationRecordPath), { recursive: true }); + await writeFile(fixture.installationRecordPath, JSON.stringify({ + schemaVersion: 1, + dataLayoutGeneration: "install-local-v1", + installDir: fixture.installDir, + sourceDataPath: failedBackup, + sourceGeneration: `legacy-install:${fixture.installDir.toLowerCase()}`, + sourceAppVersion: "1.0.9", + }), "utf8"); + + await runRelay(fixture); + + await expect(readFile(join(fixture.targetUserDataPath, "sentinel.txt"), "utf8")) + .resolves.toBe("keep-me"); + await expect(readFile(join(fixture.targetUserDataPath, "app.sqlite"), "utf8")) + .resolves.toBe("account-state"); + expect(JSON.parse(await readFile(fixture.migrationStatePath, "utf8"))).toMatchObject({ + phase: "awaiting-app-verification", + sourceAuthority: "persisted-install-authority", + sourceDataPath: failedBackup, + }); + const migrationLog = await readFile(fixture.migrationLogPath, "utf8"); + expect(migrationLog).toContain(`Using the persisted trusted install-local source: ${failedBackup}`); + const relayLog = await readFile(fixture.logPath, "utf8"); + expect(relayLog).not.toContain("data moved to"); + }); + it("runs migration when the previous installer did not record a DisplayVersion", async () => { const fixture = await createRelayFixture(0); await Promise.all([ diff --git a/package.json b/package.json index 8f98c32d..6667c6aa 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "typecheck": "npm run memory:lint && npm run workspace:typecheck", "test": "npm run test:release-workflow && npm run test:packaging-guards && npm run memory:test && npm run workspace:test && npm run agent:test:tui-cursor", "test:release-workflow": "vitest run tests/release-workflow.test.ts tests/oss-crc64.test.mjs", - "test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs tests/package-logging.test.mjs", + "test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs tests/prune-packaged-runtime.test.mjs tests/package-logging.test.mjs", "test:linux-cli": "vitest run tests/linux-cli-packaging.test.mjs && npm --prefix App/memmy-agent exec vitest run tests/entrypoints/cli/linux-systemd-gateway.test.ts tests/entrypoints/cli/gateway-lifecycle.test.ts tests/entrypoints/cli/root-terminal-options.test.ts tests/entrypoints/cli/terminal-target.test.ts && npm --prefix Memory test -- tests/cli-setup.test.ts", "serve": "npm run memory:serve", "serve:local": "npm run memory:serve:local", diff --git a/scripts/internal/mac/build-dmg.sh b/scripts/internal/mac/build-dmg.sh index a70484ca..abc0368e 100755 --- a/scripts/internal/mac/build-dmg.sh +++ b/scripts/internal/mac/build-dmg.sh @@ -650,7 +650,9 @@ verify_packaged_runtime_config_boundary() { fi node "$ROOT_DIR/scripts/internal/shared/verify-packaged-asar.mjs" \ --asar "$asar_file" \ - --expected "$DESKTOP_VERSION" + --expected "$DESKTOP_VERSION" \ + --platform darwin \ + --arch "$target_cpu" } prune_mac_runtime_artifacts() { diff --git a/scripts/internal/shared/prune-packaged-runtime-lib.mjs b/scripts/internal/shared/prune-packaged-runtime-lib.mjs new file mode 100644 index 00000000..ef6d482d --- /dev/null +++ b/scripts/internal/shared/prune-packaged-runtime-lib.mjs @@ -0,0 +1,365 @@ +import { + lstat, + readFile, + readdir, + rm, +} from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; + +const supportedPlatforms = new Set(["darwin", "linux", "win32"]); +const supportedArchitectures = new Set(["arm64", "x64"]); +const runtimeComponents = ["memory", "memmy-agent"]; + +/** + * Removes only platform-incompatible native files and packaging-only production residue. + * Every destructive path is derived beneath the supplied runtime root after all safety + * prerequisites have passed. + */ +export const prunePackagedRuntime = async ({ platform, arch, runtimeRoot }) => { + validateOptions({ platform, arch, runtimeRoot }); + const normalizedRuntimeRoot = resolve(runtimeRoot); + const onnxRuntimeRoot = join( + normalizedRuntimeRoot, + "memory", + "node_modules", + "onnxruntime-node", + "bin", + "napi-v3", + ); + const requiredOnnxRuntimeRoot = join(onnxRuntimeRoot, platform, arch); + await requireDirectory(requiredOnnxRuntimeRoot, "required onnxruntime-node target directory"); + if (platform === "win32") { + await requireFile( + join(requiredOnnxRuntimeRoot, "onnxruntime_binding.node"), + "required onnxruntime-node Windows binding", + ); + await requireFile( + join(requiredOnnxRuntimeRoot, "onnxruntime.dll"), + "required onnxruntime-node Windows DLL", + ); + } + + const agentNodeModules = join(normalizedRuntimeRoot, "memmy-agent", "node_modules"); + const optionalPeerToolchain = await resolveOptionalPeerToolchainTargets(agentNodeModules); + if (optionalPeerToolchain.paths.length > 0) { + await requireOptionalVitestPeer(join(agentNodeModules, "html-validate", "package.json")); + await requireExclusiveOptionalPeerDependencyPaths({ + packageLockPath: join(normalizedRuntimeRoot, "memmy-agent", "package-lock.json"), + targetPackageKeys: optionalPeerToolchain.packageKeys, + }); + } + + const categories = { + incompatibleOnnxRuntime: createCategoryResult(), + thirdPartySourceMaps: createCategoryResult(), + optionalPeerToolchain: createCategoryResult(), + }; + + await pruneIncompatibleOnnxRuntime({ + onnxRuntimeRoot, + platform, + arch, + result: categories.incompatibleOnnxRuntime, + }); + for (const component of runtimeComponents) { + await pruneThirdPartySourceMaps( + join(normalizedRuntimeRoot, component, "node_modules"), + categories.thirdPartySourceMaps, + ); + } + for (const targetPath of optionalPeerToolchain.paths) { + await removeMeasured(targetPath, categories.optionalPeerToolchain); + } + + const totals = Object.values(categories).reduce( + (result, category) => ({ + removedFiles: result.removedFiles + category.removedFiles, + removedBytes: result.removedBytes + category.removedBytes, + removedDirectories: result.removedDirectories + category.removedDirectories, + }), + createCategoryResult(), + ); + + return { + platform, + arch, + runtimeRoot: normalizedRuntimeRoot, + ...totals, + categories, + }; +}; + +const validateOptions = ({ platform, arch, runtimeRoot }) => { + if (!supportedPlatforms.has(platform)) { + throw new Error(`Unsupported packaged runtime platform: ${platform ?? ""}`); + } + if (!supportedArchitectures.has(arch)) { + throw new Error(`Unsupported packaged runtime architecture: ${arch ?? ""}`); + } + if (typeof runtimeRoot !== "string" || runtimeRoot.trim() === "" || !isAbsolute(runtimeRoot)) { + throw new Error("Packaged runtime root must be a non-empty absolute path"); + } +}; + +const requireDirectory = async (path, description) => { + const entry = await lstat(path).catch(() => null); + if (!entry?.isDirectory()) { + throw new Error(`Missing ${description}: ${path}`); + } +}; + +const requireFile = async (path, description) => { + const entry = await lstat(path).catch(() => null); + if (!entry?.isFile()) { + throw new Error(`Missing ${description}: ${path}`); + } +}; + +const requireOptionalVitestPeer = async (packageJsonPath) => { + let manifest; + try { + manifest = JSON.parse(await readFile(packageJsonPath, "utf8")); + } catch (error) { + throw new Error(`Cannot verify html-validate optional Vitest peer: ${error}`); + } + if ( + typeof manifest?.peerDependencies?.vitest !== "string" + || manifest?.peerDependenciesMeta?.vitest?.optional !== true + ) { + throw new Error("html-validate does not declare an optional Vitest peer"); + } +}; + +const resolveOptionalPeerToolchainTargets = async (nodeModulesRoot) => { + const paths = []; + const packageKeys = []; + for (const packageName of ["vitest", "vite", "rolldown"]) { + if (await pushExistingTarget(paths, join(nodeModulesRoot, packageName))) { + packageKeys.push(`node_modules/${packageName}`); + } + } + const vitestScope = join(nodeModulesRoot, "@vitest"); + if (await pushExistingTarget(paths, vitestScope)) { + for (const entry of await readDirectoryOrEmpty(vitestScope)) { + if (entry.isDirectory()) packageKeys.push(`node_modules/@vitest/${entry.name}`); + } + } + + const rolldownScope = join(nodeModulesRoot, "@rolldown"); + for (const entry of await readDirectoryOrEmpty(rolldownScope)) { + if (entry.name.startsWith("binding-")) { + if (await pushExistingTarget(paths, join(rolldownScope, entry.name))) { + packageKeys.push(`node_modules/@rolldown/${entry.name}`); + } + } + } + + const binariesRoot = join(nodeModulesRoot, ".bin"); + for (const entry of await readDirectoryOrEmpty(binariesRoot)) { + if (/^(?:rolldown|vite|vitest)(?:\..+)?$/u.test(entry.name)) { + paths.push(join(binariesRoot, entry.name)); + } + } + return { paths, packageKeys }; +}; + +const pushExistingTarget = async (targets, targetPath) => { + if (await lstat(targetPath).catch(() => null)) { + targets.push(targetPath); + return true; + } + return false; +}; + +const requireExclusiveOptionalPeerDependencyPaths = async ({ packageLockPath, targetPackageKeys }) => { + let lock; + try { + lock = JSON.parse(await readFile(packageLockPath, "utf8")); + } catch (error) { + throw new Error(`Cannot verify optional-peer dependency paths: ${error}`); + } + const packages = lock?.packages; + if (!packages || typeof packages !== "object" || !packages[""]) { + throw new Error("Cannot verify optional-peer dependency paths: package-lock.json has no package graph"); + } + + const targetSet = new Set(targetPackageKeys); + const visited = new Set(); + const approvedTargets = new Set(); + const queue = []; + for (const dependencyName of dependencyNames(packages[""])) { + const packageKey = resolveLockDependency(packages, "", dependencyName); + if (packageKey) queue.push({ packageKey, approved: false, path: ["", dependencyName] }); + } + + while (queue.length > 0) { + const current = queue.shift(); + const visitKey = `${current.packageKey}|${current.approved}`; + if (visited.has(visitKey)) continue; + visited.add(visitKey); + + if (targetSet.has(current.packageKey)) { + if (!current.approved) { + throw new Error(`Cannot prune optional-peer toolchain: production dependency path reaches ${packageNameFromKey(current.packageKey)}: ${current.path.join(" -> ")}`); + } + approvedTargets.add(current.packageKey); + } + + const manifest = packages[current.packageKey]; + if (!manifest || typeof manifest !== "object") continue; + for (const dependencyName of dependencyNames(manifest)) { + const childKey = resolveLockDependency(packages, current.packageKey, dependencyName); + if (childKey) { + queue.push({ + packageKey: childKey, + approved: current.approved, + path: [...current.path, dependencyName], + }); + } + } + for (const dependencyName of optionalPeerDependencyNames(manifest)) { + const childKey = resolveLockDependency(packages, current.packageKey, dependencyName); + if (!childKey) continue; + const approved = current.approved + || (packageNameFromKey(current.packageKey) === "html-validate" && dependencyName === "vitest"); + queue.push({ packageKey: childKey, approved, path: [...current.path, `${dependencyName} (optional peer)`] }); + } + for (const dependencyName of requiredPeerDependencyNames(manifest)) { + const childKey = resolveLockDependency(packages, current.packageKey, dependencyName); + if (childKey) { + queue.push({ + packageKey: childKey, + approved: current.approved, + path: [...current.path, `${dependencyName} (required peer)`], + }); + } + } + } + + for (const targetPackageKey of targetSet) { + if (!packages[targetPackageKey]) { + throw new Error(`Cannot verify optional-peer dependency path for installed package: ${targetPackageKey}`); + } + if (!approvedTargets.has(targetPackageKey)) { + throw new Error(`Cannot verify that ${packageNameFromKey(targetPackageKey)} is reachable only through html-validate's optional Vitest peer`); + } + } +}; + +const dependencyNames = (manifest) => [ + ...Object.keys(manifest?.dependencies ?? {}), + ...Object.keys(manifest?.optionalDependencies ?? {}), +]; + +const optionalPeerDependencyNames = (manifest) => Object.keys(manifest?.peerDependencies ?? {}) + .filter((name) => manifest?.peerDependenciesMeta?.[name]?.optional === true); + +const requiredPeerDependencyNames = (manifest) => Object.keys(manifest?.peerDependencies ?? {}) + .filter((name) => manifest?.peerDependenciesMeta?.[name]?.optional !== true); + +const resolveLockDependency = (packages, parentKey, dependencyName) => { + let ancestor = parentKey; + while (true) { + const candidate = ancestor + ? `${ancestor}/node_modules/${dependencyName}` + : `node_modules/${dependencyName}`; + if (packages[candidate]) return candidate; + const marker = ancestor.lastIndexOf("/node_modules/"); + if (marker < 0) { + if (!ancestor) return null; + ancestor = ""; + } else { + ancestor = ancestor.slice(0, marker); + } + } +}; + +const packageNameFromKey = (packageKey) => { + const marker = packageKey.lastIndexOf("/node_modules/"); + const relative = marker >= 0 ? packageKey.slice(marker + "/node_modules/".length) : packageKey.slice("node_modules/".length); + const parts = relative.split("/"); + return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]; +}; + +const pruneIncompatibleOnnxRuntime = async ({ onnxRuntimeRoot, platform, arch, result }) => { + for (const platformEntry of await readDirectoryOrEmpty(onnxRuntimeRoot)) { + if (!platformEntry.isDirectory()) continue; + const platformPath = join(onnxRuntimeRoot, platformEntry.name); + if (platformEntry.name !== platform) { + await removeMeasured(platformPath, result); + continue; + } + + for (const archEntry of await readDirectoryOrEmpty(platformPath)) { + if (archEntry.isDirectory() && archEntry.name !== arch) { + await removeMeasured(join(platformPath, archEntry.name), result); + } + } + } +}; + +const pruneThirdPartySourceMaps = async (nodeModulesRoot, result) => { + const nodeModulesEntry = await lstat(nodeModulesRoot).catch(() => null); + if (!nodeModulesEntry?.isDirectory()) return; + + const visit = async (directory, relativeParts = []) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + const nextRelativeParts = [...relativeParts, entry.name]; + if (entry.isDirectory()) { + await visit(entryPath, nextRelativeParts); + } else if (entry.name.toLowerCase().endsWith(".map") && !isFirstPartyPackagePath(nextRelativeParts)) { + await removeMeasured(entryPath, result); + } + } + }; + await visit(nodeModulesRoot); +}; + +const isFirstPartyPackagePath = (relativeParts) => { + const nestedNodeModules = relativeParts.lastIndexOf("node_modules"); + const ownerStart = nestedNodeModules + 1; + return relativeParts[ownerStart] === "@memmy" && Boolean(relativeParts[ownerStart + 1]); +}; + +const removeMeasured = async (targetPath, result) => { + const measurement = await measurePath(targetPath); + if (!measurement) return; + await rm(targetPath, { recursive: true, force: true }); + result.removedFiles += measurement.removedFiles; + result.removedBytes += measurement.removedBytes; + result.removedDirectories += measurement.removedDirectories; +}; + +const measurePath = async (targetPath) => { + const entry = await lstat(targetPath).catch(() => null); + if (!entry) return null; + if (!entry.isDirectory()) { + return { removedFiles: 1, removedBytes: entry.size, removedDirectories: 0 }; + } + + const result = { removedFiles: 0, removedBytes: 0, removedDirectories: 1 }; + for (const child of await readdir(targetPath)) { + const measurement = await measurePath(join(targetPath, child)); + if (!measurement) continue; + result.removedFiles += measurement.removedFiles; + result.removedBytes += measurement.removedBytes; + result.removedDirectories += measurement.removedDirectories; + } + return result; +}; + +const readDirectoryOrEmpty = async (path) => { + try { + return await readdir(path, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } +}; + +const createCategoryResult = () => ({ + removedFiles: 0, + removedBytes: 0, + removedDirectories: 0, +}); diff --git a/scripts/internal/shared/prune-packaged-runtime.mjs b/scripts/internal/shared/prune-packaged-runtime.mjs new file mode 100644 index 00000000..ff818d3f --- /dev/null +++ b/scripts/internal/shared/prune-packaged-runtime.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { prunePackagedRuntime } from "./prune-packaged-runtime-lib.mjs"; + +const options = readOptions(process.argv.slice(2)); +const result = await prunePackagedRuntime(options); +console.log(`Pruned ${result.removedFiles} file(s), ${result.removedBytes} byte(s), and ${result.removedDirectories} directorie(s)`); +console.log(JSON.stringify(result)); + +function readOptions(args) { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const option = args[index]; + const value = args[index + 1]; + if (!option?.startsWith("--") || value === undefined) { + throw new Error("Usage: prune-packaged-runtime.mjs --platform --arch --runtime-root "); + } + values.set(option, value); + } + if (values.size !== 3) { + throw new Error("Usage: prune-packaged-runtime.mjs --platform --arch --runtime-root "); + } + return { + platform: values.get("--platform"), + arch: values.get("--arch"), + runtimeRoot: values.get("--runtime-root"), + }; +} diff --git a/scripts/internal/shared/verify-packaged-asar.mjs b/scripts/internal/shared/verify-packaged-asar.mjs index bce29e91..05f5bc86 100644 --- a/scripts/internal/shared/verify-packaged-asar.mjs +++ b/scripts/internal/shared/verify-packaged-asar.mjs @@ -2,7 +2,7 @@ import { extractFile, listPackage } from "@electron/asar"; -const { asarPath, expected } = parseArgs(process.argv.slice(2)); +const { asarPath, expected, platform, arch } = parseArgs(process.argv.slice(2)); if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(expected)) { throw new Error("Expected packaged version must use semantic version syntax"); } @@ -20,11 +20,41 @@ const requiredFiles = [ "dist/runtime/memmy-agent/node_modules/@memmy/local-api-contracts/dist/index.js", "node_modules/@memmy/backend/dist/src/adapters/outbound/skill-writer/workspace-bridge/memmy-workspace-bridge.mjs", ]; +if (platform === "win32") { + requiredFiles.push( + `dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/${platform}/${arch}/onnxruntime_binding.node`, + `dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/${platform}/${arch}/onnxruntime.dll`, + "dist/runtime/memmy-agent/dist/main.js.map", + ); +} const entrySet = new Set(entries); for (const file of requiredFiles) { if (!entrySet.has(file)) throw new Error(`Packaged ASAR is missing required runtime file: ${file}`); } +if (platform === "win32") { + const onnxRuntimePrefix = "dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/"; + const targetOnnxRuntimePrefix = `${onnxRuntimePrefix}${platform}/${arch}/`; + if (entries.some((entry) => entry.startsWith(onnxRuntimePrefix) + && !entry.startsWith(targetOnnxRuntimePrefix) + && !targetOnnxRuntimePrefix.startsWith(`${entry.replace(/\/+$/u, "")}/`))) { + throw new Error("Packaged ASAR contains an incompatible onnxruntime-node platform"); + } + + const optionalPeerToolchainPattern = /^dist\/runtime\/memmy-agent\/node_modules\/(?:vitest|vite|rolldown)(?:\/|$)|^dist\/runtime\/memmy-agent\/node_modules\/@vitest(?:\/|$)|^dist\/runtime\/memmy-agent\/node_modules\/@rolldown\/binding-[^/]+(?:\/|$)/u; + if (entries.some((entry) => optionalPeerToolchainPattern.test(entry))) { + throw new Error("Packaged ASAR contains the html-validate optional-peer test toolchain"); + } + + const thirdPartySourceMap = entries.find((entry) => + /^dist\/runtime\/(?:memory|memmy-agent)\/node_modules\//u.test(entry) + && entry.endsWith(".map") + && !isFirstPartyPackageFile(entry)); + if (thirdPartySourceMap) { + throw new Error(`Packaged ASAR contains a third-party production source map: ${thirdPartySourceMap}`); + } +} + for (const [file, lock] of [ ["package.json", false], ["dist/runtime/memory/package.json", false], @@ -55,22 +85,34 @@ function readAsarJson(path, file) { } } +function isFirstPartyPackageFile(entry) { + const marker = "/node_modules/"; + const owner = entry.slice(entry.lastIndexOf(marker) + marker.length); + return owner.startsWith("@memmy/"); +} + function parseArgs(args) { const parsed = {}; for (let index = 0; index < args.length; index += 2) { const flag = args[index]; const value = args[index + 1]; if (!flag?.startsWith("--") || value === undefined) { - throw new Error("Usage: verify-packaged-asar.mjs --asar --expected "); + throw new Error("Usage: verify-packaged-asar.mjs --asar --expected --platform --arch "); } const key = flag.slice(2); - if (!new Set(["asar", "expected"]).has(key) || parsed[key]) { + if (!new Set(["asar", "expected", "platform", "arch"]).has(key) || parsed[key]) { throw new Error(`Unknown or duplicate option: ${flag}`); } parsed[key] = value; } - if (!parsed.asar || !parsed.expected) { - throw new Error("--asar and --expected are required"); + if (!parsed.asar || !parsed.expected || !parsed.platform || !parsed.arch) { + throw new Error("--asar, --expected, --platform, and --arch are required"); + } + if (!new Set(["darwin", "linux", "win32"]).has(parsed.platform)) { + throw new Error(`Unsupported packaged platform: ${parsed.platform}`); + } + if (!new Set(["arm64", "x64"]).has(parsed.arch)) { + throw new Error(`Unsupported packaged architecture: ${parsed.arch}`); } - return { asarPath: parsed.asar, expected: parsed.expected }; + return { asarPath: parsed.asar, expected: parsed.expected, platform: parsed.platform, arch: parsed.arch }; } diff --git a/scripts/internal/win/build-nsis.sh b/scripts/internal/win/build-nsis.sh index fb0126e5..4f9d8a11 100755 --- a/scripts/internal/win/build-nsis.sh +++ b/scripts/internal/win/build-nsis.sh @@ -169,6 +169,24 @@ require_packaged_runtime_glob() { fi } +require_packaged_runtime_absent() { + local forbidden_path="$1" + + if [ -e "$forbidden_path" ] || [ -L "$forbidden_path" ]; then + echo "Packaged runtime contains a forbidden path: $forbidden_path" >&2 + exit 1 + fi +} + +require_no_packaged_runtime_glob() { + local forbidden_pattern="$1" + + if compgen -G "$forbidden_pattern" >/dev/null; then + echo "Packaged runtime contains a forbidden path matching: $forbidden_pattern" >&2 + exit 1 + fi +} + verify_migration_state_compatibility_module() { local module_path module_path="$(to_node_readable_path "$1")" @@ -537,6 +555,46 @@ verify_windows_agent_native_artifacts() { require_packaged_runtime_glob "$RUNTIME_DIR/memmy-agent/node_modules/openclaw/node_modules/sqlite-vec-windows-x64/vec0.*" } +verify_windows_agent_html_lint_runtime() { + ( + cd "$RUNTIME_DIR/memmy-agent" + node --input-type=module --eval ' + import { HtmlValidate } from "html-validate"; + const validator = new HtmlValidate({ extends: ["html-validate:recommended"] }); + const valid = await validator.validateString("Memmy
ready
"); + const invalid = await validator.validateString("
"); + if (!valid.valid || invalid.valid) throw new Error("html-validate packaged runtime smoke failed"); + ' + ) +} + +verify_pruned_windows_runtime() { + local forbidden_source_map + + verify_windows_onnxruntime_module + require_packaged_runtime_file "$RUNTIME_DIR/memmy-agent/dist/main.js.map" + require_packaged_runtime_absent "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/darwin" + require_packaged_runtime_absent "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/linux" + require_packaged_runtime_absent "$RUNTIME_DIR/memory/node_modules/onnxruntime-node/bin/napi-v3/win32/arm64" + require_packaged_runtime_absent "$RUNTIME_DIR/memmy-agent/node_modules/vitest" + require_packaged_runtime_absent "$RUNTIME_DIR/memmy-agent/node_modules/vite" + require_packaged_runtime_absent "$RUNTIME_DIR/memmy-agent/node_modules/rolldown" + require_packaged_runtime_absent "$RUNTIME_DIR/memmy-agent/node_modules/@vitest" + require_no_packaged_runtime_glob "$RUNTIME_DIR/memmy-agent/node_modules/@rolldown/binding-*" + + forbidden_source_map="$(find \ + "$RUNTIME_DIR/memory/node_modules" \ + "$RUNTIME_DIR/memmy-agent/node_modules" \ + -type f -name '*.map' \ + ! -path "$RUNTIME_DIR/memory/node_modules/@memmy/*" \ + ! -path "$RUNTIME_DIR/memmy-agent/node_modules/@memmy/*" \ + -print -quit)" + if [ -n "$forbidden_source_map" ]; then + echo "Packaged runtime contains a third-party production source map: $forbidden_source_map" >&2 + exit 1 + fi +} + verify_packaged_windows_unpacked_artifacts() { local unpacked_runtime="$DESKTOP_DIR/release/win-unpacked/resources/app.asar.unpacked/dist/runtime" local packaged_embedding_model="$DESKTOP_DIR/release/win-unpacked/resources/embedding-models/$EMBEDDING_MODEL_ID" @@ -589,7 +647,9 @@ verify_packaged_runtime_config_boundary() { fi node "$ROOT_DIR/scripts/internal/shared/verify-packaged-asar.mjs" \ --asar "$(to_node_readable_path "$asar_file")" \ - --expected "$DESKTOP_VERSION" + --expected "$DESKTOP_VERSION" \ + --platform win32 \ + --arch "$PACKAGE_ARCH" } npm_ci_win_x64() { @@ -750,6 +810,19 @@ package_step_start "Verify Windows memmy-agent runtime exports" ' ) +package_step_start "Smoke test packaged HTML lint before optional-peer pruning" +verify_windows_agent_html_lint_runtime + +package_step_start "Prune platform-incompatible and packaging-only Windows runtime files" +node "$ROOT_DIR/scripts/internal/shared/prune-packaged-runtime.mjs" \ + --platform win32 \ + --arch "$PACKAGE_ARCH" \ + --runtime-root "$RUNTIME_DIR" + +package_step_start "Verify pruned Windows runtime boundaries" +verify_windows_agent_html_lint_runtime +verify_pruned_windows_runtime + package_step_start "Prune and verify Windows runtime versions" node "$ROOT_DIR/scripts/internal/shared/prune-runtime-env-files.mjs" "$RUNTIME_DIR" RUNTIME_NODE_DIR="$(to_node_readable_path "$RUNTIME_DIR")" diff --git a/tests/packaged-runtime-config.test.mjs b/tests/packaged-runtime-config.test.mjs index 4b3b8877..788915ac 100644 --- a/tests/packaged-runtime-config.test.mjs +++ b/tests/packaged-runtime-config.test.mjs @@ -154,36 +154,83 @@ describe("packaged desktop runtime configuration", () => { "verify-packaged-asar.mjs", ); const goodAsar = await createAsarFixture(root, "good", "1.0.8"); - const good = spawnSync(process.execPath, [verifier, "--asar", goodAsar, "--expected", "1.0.8"], { + const good = spawnSync(process.execPath, [verifier, ...verifierArgs(goodAsar, "1.0.8")], { encoding: "utf8", }); expect(good.status, good.stderr).toBe(0); + const darwinAsar = await createAsarFixture(root, "darwin", "1.0.8", false, true, [], "darwin"); + const darwin = spawnSync(process.execPath, [verifier, ...verifierArgs(darwinAsar, "1.0.8", "darwin", "arm64")], { + encoding: "utf8", + }); + expect(darwin.status, darwin.stderr).toBe(0); + const noLocksAsar = await createAsarFixture(root, "without-locks", "1.0.8", false, false); const withoutLocks = spawnSync( process.execPath, - [verifier, "--asar", noLocksAsar, "--expected", "1.0.8"], + [verifier, ...verifierArgs(noLocksAsar, "1.0.8")], { encoding: "utf8" }, ); expect(withoutLocks.status, withoutLocks.stderr).toBe(0); const staleAsar = await createAsarFixture(root, "stale", "1.0.7"); - const stale = spawnSync(process.execPath, [verifier, "--asar", staleAsar, "--expected", "1.0.8"], { + const stale = spawnSync(process.execPath, [verifier, ...verifierArgs(staleAsar, "1.0.8")], { encoding: "utf8", }); expect(stale.status).not.toBe(0); expect(stale.stderr).toContain("does not match the requested version"); const envAsar = await createAsarFixture(root, "with-env", "1.0.8", true); - const withEnv = spawnSync(process.execPath, [verifier, "--asar", envAsar, "--expected", "1.0.8"], { + const withEnv = spawnSync(process.execPath, [verifier, ...verifierArgs(envAsar, "1.0.8")], { encoding: "utf8", }); expect(withEnv.status).not.toBe(0); expect(withEnv.stderr).toContain("forbidden environment file"); + + const foreignNativeAsar = await createAsarFixture(root, "foreign-native", "1.0.8", false, true, [ + ["dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/linux/x64/libonnxruntime.so", "foreign"], + ]); + const foreignNative = spawnSync(process.execPath, [verifier, ...verifierArgs(foreignNativeAsar, "1.0.8")], { + encoding: "utf8", + }); + expect(foreignNative.status).not.toBe(0); + expect(foreignNative.stderr).toContain("incompatible onnxruntime-node platform"); + + const toolchainAsar = await createAsarFixture(root, "toolchain", "1.0.8", false, true, [ + ["dist/runtime/memmy-agent/node_modules/vitest/index.js", "test-only"], + ]); + const toolchain = spawnSync(process.execPath, [verifier, ...verifierArgs(toolchainAsar, "1.0.8")], { + encoding: "utf8", + }); + expect(toolchain.status).not.toBe(0); + expect(toolchain.stderr).toContain("optional-peer test toolchain"); + + const thirdPartyMapAsar = await createAsarFixture(root, "third-party-map", "1.0.8", false, true, [ + ["dist/runtime/memory/node_modules/dependency/dist/index.js.map", "third-party-map"], + ]); + const thirdPartyMap = spawnSync(process.execPath, [verifier, ...verifierArgs(thirdPartyMapAsar, "1.0.8")], { + encoding: "utf8", + }); + expect(thirdPartyMap.status).not.toBe(0); + expect(thirdPartyMap.stderr).toContain("third-party production source map"); + }); + + it("passes the defined Windows package architecture to the shared ASAR verifier", () => { + const buildScript = readFileSync(join( + dirname(fileURLToPath(import.meta.url)), + "..", "scripts", "internal", "win", "build-nsis.sh", + ), "utf8"); + const verifierCall = buildScript.slice( + buildScript.indexOf('node "$ROOT_DIR/scripts/internal/shared/verify-packaged-asar.mjs"'), + buildScript.indexOf("\n}", buildScript.indexOf('node "$ROOT_DIR/scripts/internal/shared/verify-packaged-asar.mjs"')), + ); + + expect(verifierCall).toContain('--arch "$PACKAGE_ARCH"'); + expect(verifierCall).not.toContain("TARGET_ARCH"); }); }); -async function createAsarFixture(root, name, version, includeEnv = false, includeLocks = true) { +async function createAsarFixture(root, name, version, includeEnv = false, includeLocks = true, extraFiles = [], platform = "win32") { const source = join(root, `${name}-source`); const asar = join(root, `${name}.asar`); const manifest = { version }; @@ -202,17 +249,36 @@ async function createAsarFixture(root, name, version, includeEnv = false, includ ); mkdirSync(dirname(contracts), { recursive: true }); writeFileSync(contracts, "export {};\n"); + if (platform === "win32") { + const ownSourceMap = join(source, "dist/runtime/memmy-agent/dist/main.js.map"); + mkdirSync(dirname(ownSourceMap), { recursive: true }); + writeFileSync(ownSourceMap, "own-production-map\n"); + } + const targetArch = platform === "darwin" ? "arm64" : "x64"; + const onnxRuntimeRoot = join(source, `dist/runtime/memory/node_modules/onnxruntime-node/bin/napi-v3/${platform}/${targetArch}`); + mkdirSync(onnxRuntimeRoot, { recursive: true }); + writeFileSync(join(onnxRuntimeRoot, "onnxruntime_binding.node"), `${platform}-${targetArch}-node`); + if (platform === "win32") writeFileSync(join(onnxRuntimeRoot, "onnxruntime.dll"), "win-x64-dll"); const lifecycleSidecar = join( source, "node_modules/@memmy/backend/dist/src/adapters/outbound/skill-writer/workspace-bridge/memmy-workspace-bridge.mjs", ); mkdirSync(dirname(lifecycleSidecar), { recursive: true }); writeFileSync(lifecycleSidecar, "export {};\n"); + for (const [relativePath, contents] of extraFiles) { + const targetPath = join(source, relativePath); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, contents); + } if (includeEnv) writeFileSync(join(source, ".env.production"), "TOKEN=decoy\n"); await createPackage(source, asar); return asar; } +function verifierArgs(asar, expected, platform = "win32", arch = "x64") { + return ["--asar", asar, "--expected", expected, "--platform", platform, "--arch", arch]; +} + function writeFixtureJson(path, value) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value)}\n`); diff --git a/tests/prune-packaged-runtime.test.mjs b/tests/prune-packaged-runtime.test.mjs new file mode 100644 index 00000000..6d67b81d --- /dev/null +++ b/tests/prune-packaged-runtime.test.mjs @@ -0,0 +1,229 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { prunePackagedRuntime } from "../scripts/internal/shared/prune-packaged-runtime-lib.mjs"; + +const roots = []; + +afterEach(() => { + while (roots.length) rmSync(roots.pop(), { recursive: true, force: true }); +}); + +describe("packaged runtime pruning", () => { + it("keeps the Windows x64 runtime and Memmy source maps while pruning proven package waste", async () => { + const runtimeRoot = createRuntimeFixture(); + + const result = await prunePackagedRuntime({ + platform: "win32", + arch: "x64", + runtimeRoot, + }); + + expect(result.removedFiles).toBeGreaterThan(0); + expect(result.removedBytes).toBeGreaterThan(0); + expect(result.categories).toMatchObject({ + incompatibleOnnxRuntime: { removedFiles: 4 }, + thirdPartySourceMaps: { removedFiles: 3 }, + optionalPeerToolchain: { removedFiles: 8 }, + }); + + expect(existsSync(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "win32", "x64", "onnxruntime_binding.node"))).toBe(true); + expect(existsSync(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "darwin"))).toBe(false); + expect(existsSync(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "linux"))).toBe(false); + expect(existsSync(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "win32", "arm64"))).toBe(false); + + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "dist", "main.js.map"))).toBe(true); + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "@memmy", "migrations", "dist", "index.js.map"))).toBe(true); + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "@memmy", "migrations", "node_modules", "third-party", "index.js.map"))).toBe(false); + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "third-party", "index.js.map"))).toBe(false); + expect(existsSync(runtimePath(runtimeRoot, "memory", "node_modules", "third-party", "index.js.map"))).toBe(false); + + for (const packagePath of [ + ["vitest"], + ["vite"], + ["rolldown"], + ["@vitest"], + ["@rolldown", "binding-win32-x64-msvc"], + ["@rolldown", "binding-linux-x64-gnu"], + ]) { + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", ...packagePath))).toBe(false); + } + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "html-validate", "dist", "cjs", "index.js"))).toBe(true); + expect(existsSync(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-web", "dist", "ort.wasm"))).toBe(true); + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "openclaw", "dist", "index.js"))).toBe(true); + expect(existsSync(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "typescript", "lib", "typescript.js"))).toBe(true); + }); + + it("fails before changing files when the required target onnxruntime is missing", async () => { + const runtimeRoot = createRuntimeFixture({ includeRequiredOnnxRuntime: false }); + const incompatibleRuntime = runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "linux", "x64", "libonnxruntime.so"); + + await expect(prunePackagedRuntime({ platform: "win32", arch: "x64", runtimeRoot })) + .rejects.toThrow(/required onnxruntime-node target directory/i); + expect(existsSync(incompatibleRuntime)).toBe(true); + }); + + it("fails before changing files unless html-validate declares vitest as an optional peer", async () => { + const runtimeRoot = createRuntimeFixture({ optionalVitestPeer: false }); + const thirdPartyMap = runtimePath(runtimeRoot, "memmy-agent", "node_modules", "third-party", "index.js.map"); + const vitestPath = runtimePath(runtimeRoot, "memmy-agent", "node_modules", "vitest"); + + await expect(prunePackagedRuntime({ platform: "win32", arch: "x64", runtimeRoot })) + .rejects.toThrow(/optional vitest peer/i); + expect(existsSync(thirdPartyMap)).toBe(true); + expect(existsSync(vitestPath)).toBe(true); + }); + + it("fails before changing files when another production dependency uses the optional-peer toolchain", async () => { + const runtimeRoot = createRuntimeFixture({ otherProductionViteConsumer: true }); + const thirdPartyMap = runtimePath(runtimeRoot, "memmy-agent", "node_modules", "third-party", "index.js.map"); + const vitePath = runtimePath(runtimeRoot, "memmy-agent", "node_modules", "vite"); + + await expect(prunePackagedRuntime({ platform: "win32", arch: "x64", runtimeRoot })) + .rejects.toThrow(/production dependency path.*vite/i); + expect(existsSync(thirdPartyMap)).toBe(true); + expect(existsSync(vitePath)).toBe(true); + }); + + it("fails before changing files when another production dependency requires Vite as a peer", async () => { + const runtimeRoot = createRuntimeFixture({ otherProductionVitePeerConsumer: true }); + const vitePath = runtimePath(runtimeRoot, "memmy-agent", "node_modules", "vite"); + + await expect(prunePackagedRuntime({ platform: "win32", arch: "x64", runtimeRoot })) + .rejects.toThrow(/production dependency path.*vite/i); + expect(existsSync(vitePath)).toBe(true); + }); + + it("rejects a relative runtime root before changing files", async () => { + await expect(prunePackagedRuntime({ platform: "win32", arch: "x64", runtimeRoot: "relative-runtime" })) + .rejects.toThrow(/absolute path/i); + }); + + it("reports deleted files and bytes through the CLI", () => { + const runtimeRoot = createRuntimeFixture(); + const cliPath = fileURLToPath(new URL("../scripts/internal/shared/prune-packaged-runtime.mjs", import.meta.url)); + const result = spawnSync(process.execPath, [ + cliPath, + "--platform", "win32", + "--arch", "x64", + "--runtime-root", runtimeRoot, + ], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toMatch(/Pruned \d+ file\(s\), \d+ byte\(s\)/u); + expect(JSON.parse(result.stdout.trim().split("\n").at(-1))).toMatchObject({ + platform: "win32", + arch: "x64", + }); + }); +}); + +function createRuntimeFixture(options = {}) { + const { + includeRequiredOnnxRuntime = true, + optionalVitestPeer = true, + otherProductionViteConsumer = false, + otherProductionVitePeerConsumer = false, + } = options; + const runtimeRoot = mkdtempSync(join(tmpdir(), "memmy-packaged-runtime-prune-")); + roots.push(runtimeRoot); + + if (includeRequiredOnnxRuntime) { + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "win32", "x64", "onnxruntime_binding.node"), "win-x64-node"); + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "win32", "x64", "onnxruntime.dll"), "win-x64-dll"); + } + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "win32", "arm64", "onnxruntime_binding.node"), "win-arm64"); + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "darwin", "x64", "onnxruntime_binding.node"), "darwin-x64"); + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "darwin", "arm64", "onnxruntime_binding.node"), "darwin-arm64"); + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-node", "bin", "napi-v3", "linux", "x64", "libonnxruntime.so"), "linux-x64"); + + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "dist", "main.js.map"), "own-map"); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "@memmy", "migrations", "dist", "index.js.map"), "memmy-map"); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "@memmy", "migrations", "node_modules", "third-party", "index.js.map"), "nested-third-party-map"); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "third-party", "index.js.map"), "third-party-agent-map"); + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "third-party", "index.js.map"), "third-party-memory-map"); + + writeJson(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "html-validate", "package.json"), { + name: "html-validate", + peerDependencies: { vitest: "^4.0.1" }, + peerDependenciesMeta: optionalVitestPeer ? { vitest: { optional: true } } : {}, + }); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "html-validate", "dist", "cjs", "index.js"), "html-validate-runtime"); + for (const packagePath of [ + ["vitest", "index.js"], + ["vite", "index.js"], + ["rolldown", "index.js"], + ["@vitest", "runner", "index.js"], + ["@rolldown", "binding-win32-x64-msvc", "binding.node"], + ["@rolldown", "binding-linux-x64-gnu", "binding.node"], + ]) { + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", ...packagePath), packagePath.join("/")); + } + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", ".bin", "vitest"), "bin"); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", ".bin", "vite.cmd"), "bin"); + + writeFixture(runtimePath(runtimeRoot, "memory", "node_modules", "onnxruntime-web", "dist", "ort.wasm"), "keep"); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "openclaw", "dist", "index.js"), "keep"); + writeFixture(runtimePath(runtimeRoot, "memmy-agent", "node_modules", "typescript", "lib", "typescript.js"), "keep"); + writeJson(runtimePath(runtimeRoot, "memmy-agent", "package-lock.json"), createToolchainLock({ + optionalVitestPeer, + otherProductionViteConsumer, + otherProductionVitePeerConsumer, + })); + return runtimeRoot; +} + +function createToolchainLock({ optionalVitestPeer, otherProductionViteConsumer, otherProductionVitePeerConsumer }) { + const rootDependencies = { "html-validate": "10.17.0" }; + if (otherProductionViteConsumer) rootDependencies["runtime-vite-consumer"] = "1.0.0"; + if (otherProductionVitePeerConsumer) rootDependencies["runtime-vite-peer-consumer"] = "1.0.0"; + return { + lockfileVersion: 3, + packages: { + "": { + dependencies: rootDependencies, + devDependencies: { vitest: "4.1.7" }, + }, + "node_modules/html-validate": { + peerDependencies: { vitest: "^4.0.1" }, + peerDependenciesMeta: optionalVitestPeer ? { vitest: { optional: true } } : {}, + }, + "node_modules/runtime-vite-consumer": { dependencies: { vite: "8.0.14" } }, + "node_modules/runtime-vite-peer-consumer": { peerDependencies: { vite: "^8.0.0" } }, + "node_modules/vitest": { dependencies: { "@vitest/runner": "4.1.7", vite: "8.0.14" } }, + "node_modules/@vitest/runner": {}, + "node_modules/vite": { dependencies: { rolldown: "1.0.2" } }, + "node_modules/rolldown": { + optionalDependencies: { + "@rolldown/binding-win32-x64-msvc": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + }, + }, + "node_modules/@rolldown/binding-win32-x64-msvc": {}, + "node_modules/@rolldown/binding-linux-x64-gnu": {}, + }, + }; +} + +function runtimePath(runtimeRoot, ...parts) { + return join(runtimeRoot, ...parts); +} + +function writeFixture(path, contents) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + +function writeJson(path, value) { + writeFixture(path, `${JSON.stringify(value, null, 2)}\n`); +}