[Installer][Client] Back up user data before old uninstallers can delete it (#7503)

* [Installer][Client] Back up user data before old uninstallers can delete it

* [Installer][Client] Fix stale backup resurrection, batch session backups, extend coverage

NSIS: clear stale %TEMP% backup and only arm restore when something was copied, directory-existence checks, back up loose .dec decks, drop downloaded pics but keep pics\CUSTOM.

Helper: single session dir per run (ms timestamps), same-basename disambiguation, partial copies still report their location.

* [Installer][Client] Fix review findings: throttle backups, NSIS old dir, portable messaging

* [Installer][Client] Fix review findings: backup markers, NSIS relocate, size cap

* [Installer][Client] Fix review findings: sounds, session coverage, NSIS wording

Client: back up data/sounds in portable mode and mention it in the
warning; reuse a recent session only when complete and covering the
current sources (source manifest), estimate size only for fresh copies,
normalize dedup via isInsideDir.

Helper: source manifest per session with sessionCoversSources(), clear
out-params on all failure paths.

NSIS: document defensive portable branch, merge backup over surviving
data/ to repair partial deletes, copy (not move) wording in relocate,
note .txt exclusion in deck notice.

* [Installer][Client] Fix review findings: stale backup, empty reuse, relocate, portable warning

NSIS: wipe TEMP backup once per run to avoid resurrecting deleted files,
check data dir existence in relocate so dir-only trees are moved.

Client: never reuse a recent session for an empty source set, only fall
back to a covering session on copy failure, warn fresh portable installs
instead of staying silent.

* [Installer][Client] Fix review findings: usable session reuse, loose decks, NSIS pics, symlinks

* [Installer][Client] Fix portable backup exclusions in offender merge

* [Client] Split at-risk data warning into focused helpers

Ricky's nit on the 200+ line warnAboutDataPathsInProgramDir: extract
collection (portable/installed), backup, message building and dialog
into named helpers. No behavior change, all tr() strings untouched.

* [Client] Read path settings inside isPortableBackupExcluded

* [Client] Make stateless at-risk helpers static

* [Client] Extract at-risk data warning out of MainWindow

* [Installer][Client] Fix review findings: subdir backup, portable warning, backup API

NSIS:
- Back up unknown top-level install subdirs (e.g. <installdir>\decks),
  not just loose deck files, so the 3.1.1 -> new upgrade no longer loses
  data folders the old RMDir /r deletes before the client ever runs.
  Payload dirs (plugins/translations/sounds/themes) stay in place to avoid
  restoring stale DLLs; restore + relocate handle the new backup set and
  the one-time notice covers both decks and folders.
- Restart the %TEMP% backup when the 32/64-bit passes point at different
  dirs instead of merging (which restored one dir's files into the other);
  document that only the last pass relocates and the %TEMP% copy is not
  a durable second copy.

Client:
- Drop the portable at-risk warning: portable installs never shipped an
  uninstaller and portable updates preserve data/, so every portable user
  got a spurious dialog plus daily host-machine copies.
- AtRiskDataWarning is now a plain class with Q_DECLARE_TR_FUNCTIONS
  instead of QObject+Q_OBJECT; logging category renamed to
  at_risk_data_warning; MainWindow wrapper inlined into startupConfigCheck.
- Lower the synchronous startup backup cap 500 -> 200 MB.

Utility/tests:
- Remove test-only backupAtRiskPath()/findRecentBackupSession() and the
  no-marker/no-manifest legacy paths; isBackupSessionComplete() now
  requires the complete marker; tests updated to the plural API.

* [Client] Restore collectInstalledAtRiskPaths per review

a23e02b5 removed the definition along with the portable helpers,
breaking the build (undefined reference at link time, and
isInsideAnyDir unused under -Werror on Arch/Debian 13). Restores
the 202c0d65 version (pathsInsideDir plus loose top-level deck
files) as suggested.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL
2026-10-16 22:05:42 +02:00
committed by GitHub
parent 711386574d
commit 6c660d6b21
11 changed files with 1734 additions and 43 deletions

View File

@@ -15,8 +15,17 @@ Var ReinstallMode
Var AllowForceClose
Var GuardDir
Var OldUninstallString
Var OldInstallDir
Var DataBackupDir
Var DataBackupDone
Var DataBackupForDir
Var LooseDecksBackupDone
Var InstallSubdirsBackupDone
Var LooseDeckNoticeShown
Var StaleFindHandle
Var StaleFindName
Var DataBackupFindHandle
Var DataBackupFindName
Var IsUpgrade
Var AbortReason
Var WaitPid
@@ -125,6 +134,17 @@ FunctionEnd
Function .onInit
StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log"
; No backup has been taken yet; RestoreUserDataAfterUninstall must be a no-op
; until BackupUserDataBeforeUninstall sets this after actually copying something.
StrCpy $DataBackupDir ""
StrCpy $DataBackupDone ""
StrCpy $DataBackupForDir ""
StrCpy $LooseDecksBackupDone ""
StrCpy $InstallSubdirsBackupDone ""
StrCpy $LooseDeckNoticeShown ""
; No old install location resolved yet; Backup/Restore fall back to $INSTDIR
; when this is empty (the /R path where $INSTDIR already is the old dir).
StrCpy $OldInstallDir ""
; Start each run from a clean file so the log that gets sent back is the last
; run, not every past run glued together.
FileOpen $LogHandle "$InstallLogPath" w
@@ -314,10 +334,15 @@ SetShellVarContext all
SetRegView 32
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'"
; On the /R path the updater passes /D=<appDir>, so $INSTDIR already is the old
; install dir. Publish it for the backup/restore pair below.
StrCpy $OldInstallDir $INSTDIR
!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$OldInstallDir'"
StrCmp $R0 "" done32
DetailPrint "Removing previous version (32-bit)..."
ExecWait '$R0 _?=$INSTDIR'
Call BackupUserDataBeforeUninstall
ExecWait '$R0 _?=$OldInstallDir'
Call RestoreUserDataAfterUninstall
done32:
@@ -326,16 +351,312 @@ ${If} ${RunningX64}
SetRegView 64
ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString"
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'"
StrCpy $OldInstallDir $INSTDIR
!insertmacro CockLog "uninstall (64-bit): '$R0 _?=$OldInstallDir'"
StrCmp $R0 "" done64
DetailPrint "Removing previous version (64-bit)..."
ExecWait '$R0 _?=$INSTDIR'
Call BackupUserDataBeforeUninstall
ExecWait '$R0 _?=$OldInstallDir'
Call RestoreUserDataAfterUninstall
done64:
${EndIf}
FunctionEnd
; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall
;
; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR".
; For a portable install that deletes $INSTDIR\data (decks, settings, pictures)
; along with the payload, and for a normal install it deletes anything the user
; pointed into $INSTDIR. The fixed uninstaller preserves foreign files, but the
; destructive one is already on disk on affected machines, so stash a copy
; outside $INSTDIR before invoking it and put it back afterwards. Best effort
; only: a failed backup must never abort the install.
;
; The backup is left in %TEMP% afterwards on a best-effort basis only: the next
; installer run that takes a new backup wipes it first (see below), so it is not
; a durable second copy. Anything this backup does not cover - e.g. loose files
; with other extensions - relies on the client's startup safety backup
; (warnAboutDataPathsInProgramDir), which only exists after the first launch of
; the new client.
;
; The 32-bit and 64-bit uninstall blocks below share $TEMP\CockatriceDataBackup.
; When both are registered with different InstallLocations, backing up the second
; directory into the first one's backup would restore the first dir's files into
; the second one. BackupUserDataBeforeUninstall therefore starts over when
; $OldInstallDir changes: the previous pass already restored its own backup, so
; only its leftover %TEMP% copy is lost. RelocateRestoredDataToInstDir only sees
; the last pass, so with two different old dirs plus a new target dir the first
; dir's restored data stays in place instead of moving along - no loss, but the
; user has to copy it manually.
;
; The portable-data branch below is defensive: both callers (AutoUninstallIfNeeded
; on /R, componentsPagePre interactively) only run for non-portable installs, and
; portable installs never write the registry uninstall keys that trigger them.
; It fires only when $OldInstallDir nevertheless contains portable.dat - e.g. a
; stale registry InstallLocation pointing at a portable dir, or portable.dat added
; to a normal install dir. The loose-decks and install-subdirs branches are the
; live ones for normal installs.
Function BackupUserDataBeforeUninstall
; $OldInstallDir is the directory being uninstalled: on the /R path it equals
; $INSTDIR (updater passes /D=<appDir>), on the interactive path componentsPagePre
; sets it from the registry InstallLocation via $GuardDir, because $INSTDIR is
; still the default there (directory page comes later).
${If} $OldInstallDir == ""
StrCpy $OldInstallDir $INSTDIR
${EndIf}
; %TEMP% survives across installer runs, so a previous run's safety copy would
; otherwise merge into this run: CopyFiles merges/overwrites, resurrecting files
; the user deleted since (e.g. deck A from run 1 reappearing alongside deck B
; from run 2) or restoring stale content after a failed copy. Wipe once per
; installer run - $DataBackupDir is empty until the first backup of this run,
; so the second call in sequence (32-bit then 64-bit uninstall) still merges.
${If} $DataBackupDir == ""
RMDir /r "$TEMP\CockatriceDataBackup"
${EndIf}
; The 32-bit and 64-bit uninstall blocks share one backup dir. When the second
; block points at a different directory, merging would restore the first dir's
; files into the second one, so start over instead. The previous pass already
; restored its own backup; only its leftover %TEMP% copy is discarded.
; StrCmp is case-sensitive while Windows paths are not, so a casing-only
; difference triggers a harmless redundant re-backup of the same content.
${If} $DataBackupDir != ""
${AndIf} $DataBackupForDir != ""
${AndIf} $OldInstallDir != $DataBackupForDir
!insertmacro CockLog "pre-uninstall backup: old dir changed ($DataBackupForDir -> $OldInstallDir), starting a fresh backup"
RMDir /r "$TEMP\CockatriceDataBackup"
StrCpy $DataBackupDir ""
StrCpy $DataBackupDone ""
StrCpy $LooseDecksBackupDone ""
StrCpy $InstallSubdirsBackupDone ""
${EndIf}
${If} ${FileExists} "$OldInstallDir\portable.dat"
${AndIf} ${FileExists} "$OldInstallDir\data"
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
StrCpy $DataBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying $OldInstallDir\data (downloaded pics excluded)"
DetailPrint "Backing up portable user data before removing previous version..."
CreateDirectory "$DataBackupDir\data"
; Copy every top-level entry except pics (downloaded pictures are large and
; re-downloadable): copying everything first and deleting pics afterwards
; would duplicate gigabytes into %TEMP% just to throw them away.
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\data\*.*"
${If} $DataBackupFindHandle != ""
backup_data_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} $DataBackupFindName != "pics"
${If} ${FileExists} "$OldInstallDir\data\$DataBackupFindName\*.*"
CreateDirectory "$DataBackupDir\data\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName\*.*" "$DataBackupDir\data\$DataBackupFindName\"
${Else}
CopyFiles /SILENT "$OldInstallDir\data\$DataBackupFindName" "$DataBackupDir\data\"
${EndIf}
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 backup_data_loop
FindClose $DataBackupFindHandle
${EndIf}
; Keep user-made custom pics (data\pics\CUSTOM).
${If} ${FileExists} "$OldInstallDir\data\pics\CUSTOM"
CreateDirectory "$DataBackupDir\data\pics\CUSTOM"
CopyFiles /SILENT "$OldInstallDir\data\pics\CUSTOM\*.*" "$DataBackupDir\data\pics\CUSTOM\"
${EndIf}
DetailPrint "Portable user data backup complete."
${EndIf}
; Deck extensions mirror DeckLoader::ACCEPTED_FILE_EXTENSIONS, minus *.txt: a
; blanket *.txt copy would also grab payload-adjacent text files, while the
; deck-specific extensions are unambiguous user content.
${If} ${FileExists} "$OldInstallDir\*.cod"
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
${If} $DataBackupDir == ""
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
${EndIf}
StrCpy $LooseDecksBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying loose deck files"
DetailPrint "Backing up deck files stored in the install directory..."
CreateDirectory "$DataBackupDir\loose_decks"
CopyFiles /SILENT "$OldInstallDir\*.cod" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.dec" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.dek" "$DataBackupDir\loose_decks\"
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$DataBackupDir\loose_decks\"
${EndIf}
; Data folders below the top level (e.g. $OldInstallDir\decks) are deleted by the
; old "RMDir /r $INSTDIR" just like loose decks, and on a 3.1.1 -> new upgrade the
; client's startup backup has never run yet, so stash every top-level directory
; that is not part of the payload. Payload dirs stay in place: they are rewritten
; by the section below, and restoring them could put stale DLLs next to the new
; executables. "data" is skipped once the portable branch above already took it;
; without portable.dat it is user content like any other unknown dir.
FindFirst $DataBackupFindHandle $DataBackupFindName "$OldInstallDir\*.*"
${If} $DataBackupFindHandle != ""
backup_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} $DataBackupFindName != "plugins"
${AndIf} $DataBackupFindName != "Plugins"
${AndIf} $DataBackupFindName != "translations"
${AndIf} $DataBackupFindName != "sounds"
${AndIf} $DataBackupFindName != "themes"
${If} $DataBackupFindName == "data"
${AndIf} $DataBackupDone == "1"
Goto backup_subdirs_next
${EndIf}
${If} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
${If} $DataBackupDir == ""
StrCpy $DataBackupDir "$TEMP\CockatriceDataBackup"
${EndIf}
StrCpy $InstallSubdirsBackupDone "1"
!insertmacro CockLog "pre-uninstall backup: copying subdir $OldInstallDir\$DataBackupFindName"
DetailPrint "Backing up $DataBackupFindName stored in the install directory..."
CreateDirectory "$DataBackupDir\install_subdirs\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$DataBackupDir\install_subdirs\$DataBackupFindName\"
${EndIf}
${EndIf}
backup_subdirs_next:
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 backup_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
; Remember which directory the current backup belongs to, so a later pass for a
; different directory starts over instead of merging (see above).
${If} $DataBackupDir != ""
StrCpy $DataBackupForDir $OldInstallDir
${EndIf}
FunctionEnd
Function RestoreUserDataAfterUninstall
StrCmp $DataBackupDir "" done
${If} $OldInstallDir == ""
StrCpy $OldInstallDir $INSTDIR
${EndIf}
${If} $DataBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\data"
${If} ${FileExists} "$OldInstallDir\data"
; Existence-only: a partially-deleted data dir (locked files during the old
; uninstall) looks the same as a survived one, so the backup is always kept.
; Merge the backup back over it to repair partial deletes; CopyFiles only
; adds/overwrites, so a fully-survived dir is unaffected.
!insertmacro CockLog "post-uninstall restore: $OldInstallDir\data still exists, merging backup and keeping it"
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
DetailPrint "Previous user data is still in place; pre-uninstall backup merged back and kept at $DataBackupDir\data."
${Else}
!insertmacro CockLog "post-uninstall restore: restoring $OldInstallDir\data"
CreateDirectory "$OldInstallDir\data"
CopyFiles /SILENT "$DataBackupDir\data\*.*" "$OldInstallDir\data\"
DetailPrint "Restored portable user data from pre-uninstall backup."
${EndIf}
${EndIf}
${If} $LooseDecksBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\loose_decks"
!insertmacro CockLog "post-uninstall restore: restoring loose deck files"
CreateDirectory "$OldInstallDir"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.cod" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dec" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.dek" "$OldInstallDir\"
CopyFiles /SILENT "$DataBackupDir\loose_decks\*.mwDeck" "$OldInstallDir\"
DetailPrint "Restored deck files stored in the install directory."
${EndIf}
${If} $InstallSubdirsBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
!insertmacro CockLog "post-uninstall restore: restoring install subdirectories"
CreateDirectory "$OldInstallDir"
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
${If} $DataBackupFindHandle != ""
restore_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*"
!insertmacro CockLog "post-uninstall restore: restoring subdir $OldInstallDir\$DataBackupFindName"
CreateDirectory "$OldInstallDir\$DataBackupFindName"
CopyFiles /SILENT "$DataBackupDir\install_subdirs\$DataBackupFindName\*.*" "$OldInstallDir\$DataBackupFindName\"
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 restore_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
DetailPrint "Restored folders stored in the install directory."
${EndIf}
; Runs once per uninstall block, and there are two (32-bit, 64-bit): show the
; notice a single time per installer run, covering both loose decks and
; restored subdirectories.
${IfNot} ${Silent}
${AndIf} $LooseDeckNoticeShown != "1"
${If} $LooseDecksBackupDone == "1"
${OrIf} $InstallSubdirsBackupDone == "1"
StrCpy $LooseDeckNoticeShown "1"
MessageBox MB_OK|MB_ICONINFORMATION "Your decks or data folders were stored inside the Cockatrice install folder ($OldInstallDir), which the previous version's uninstaller deletes.$\n$\nThey have been restored from a backup to that folder (loose deck files ending in .txt are not backed up - move those manually). If you install to a different folder, they are moved there automatically. Please move them out of the install folder afterwards (Settings -> General -> Paths) so a future uninstall cannot delete them again."
${EndIf}
${EndIf}
done:
FunctionEnd
; RelocateRestoredDataToInstDir
;
; componentsPagePre backs up and restores around the old uninstaller before the
; directory page, so $OldInstallDir (registry InstallLocation) is authoritative
; there while $INSTDIR is still the default. When the user then picks a different
; target directory, the restored user data would otherwise stay orphaned in the
; old directory while the new install starts empty. Copy it across when they differ
; (a copy, not a move: the source is user data and is deliberately left in place).
; Only the last uninstall pass's $OldInstallDir is relocated (see the shared-backup
; comment above); with two different old dirs the first one's data stays restored
; in place instead of moving along.
; StrCmp is case-sensitive while Windows paths are not; a casing-only difference
; triggers a harmless redundant copy onto itself.
Function RelocateRestoredDataToInstDir
StrCmp $DataBackupDir "" done
StrCmp $OldInstallDir "" done
StrCmp $OldInstallDir $INSTDIR done
${If} $DataBackupDone == "1"
${AndIf} ${FileExists} "$OldInstallDir\data"
!insertmacro CockLog "relocate: copying restored data $OldInstallDir\data -> $INSTDIR\data"
CreateDirectory "$INSTDIR\data"
CopyFiles /SILENT "$OldInstallDir\data\*.*" "$INSTDIR\data\"
DetailPrint "Copied restored user data to the new install folder."
${EndIf}
${If} $LooseDecksBackupDone == "1"
${If} ${FileExists} "$OldInstallDir\*.cod"
${OrIf} ${FileExists} "$OldInstallDir\*.dec"
${OrIf} ${FileExists} "$OldInstallDir\*.dek"
${OrIf} ${FileExists} "$OldInstallDir\*.mwDeck"
!insertmacro CockLog "relocate: copying restored decks $OldInstallDir -> $INSTDIR"
CreateDirectory "$INSTDIR"
CopyFiles /SILENT "$OldInstallDir\*.cod" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.dec" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.dek" "$INSTDIR\"
CopyFiles /SILENT "$OldInstallDir\*.mwDeck" "$INSTDIR\"
DetailPrint "Copied restored deck files to the new install folder."
${EndIf}
${EndIf}
${If} $InstallSubdirsBackupDone == "1"
${AndIf} ${FileExists} "$DataBackupDir\install_subdirs"
FindFirst $DataBackupFindHandle $DataBackupFindName "$DataBackupDir\install_subdirs\*.*"
${If} $DataBackupFindHandle != ""
relocate_subdirs_loop:
${If} $DataBackupFindName != "."
${AndIf} $DataBackupFindName != ".."
${AndIf} ${FileExists} "$OldInstallDir\$DataBackupFindName\*.*"
!insertmacro CockLog "relocate: copying restored subdir $OldInstallDir\$DataBackupFindName -> $INSTDIR\$DataBackupFindName"
CreateDirectory "$INSTDIR\$DataBackupFindName"
CopyFiles /SILENT "$OldInstallDir\$DataBackupFindName\*.*" "$INSTDIR\$DataBackupFindName\"
DetailPrint "Copied restored folder $DataBackupFindName to the new install folder."
${EndIf}
ClearErrors
FindNext $DataBackupFindHandle $DataBackupFindName
IfErrors 0 relocate_subdirs_loop
FindClose $DataBackupFindHandle
${EndIf}
${EndIf}
done:
FunctionEnd
; --- Running instance handling ---
; Cockatrice, Oracle and Servatrice must not be running while files are
; replaced or deleted. A still-running process holds locks on its .exe and
@@ -1037,11 +1358,17 @@ ${If} $PortableMode = 0
; instance of the application is still running out of the *old* install
; directory before letting it delete anything. The uninstall string is
; stashed away first, the check works on $R0.
; $INSTDIR is still the default here (directory page comes later), so the
; backup/restore dir comes from the registry InstallLocation via $GuardDir,
; not from $INSTDIR - otherwise a custom-location install is unprotected.
StrCpy $OldUninstallString $R0
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'"
Call EnsureOldInstallNotRunning
StrCpy $OldInstallDir $GuardDir
!insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
ClearErrors
ExecWait "$OldUninstallString _?=$INSTDIR"
Call BackupUserDataBeforeUninstall
ExecWait "$OldUninstallString _?=$OldInstallDir"
Call RestoreUserDataAfterUninstall
done32:
@@ -1061,10 +1388,13 @@ ${If} $PortableMode = 0
uninst64:
StrCpy $OldUninstallString $R0
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'"
Call EnsureOldInstallNotRunning
StrCpy $OldInstallDir $GuardDir
!insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)"
ClearErrors
ExecWait "$OldUninstallString _?=$INSTDIR"
Call BackupUserDataBeforeUninstall
ExecWait "$OldUninstallString _?=$OldInstallDir"
Call RestoreUserDataAfterUninstall
done64:
${EndIf}
@@ -1188,6 +1518,11 @@ ${EndIf}
!insertmacro CockLog "section: writing payload to $INSTDIR"
@CPACK_NSIS_FULL_INSTALL@
!insertmacro CockLog "section: payload written"
; The pre-uninstall backup/restore above runs before the directory page, so a
; user picking a different target folder would otherwise leave the restored data
; orphaned in the old directory. Move it across when they differ (no-op on the
; /R path where they are equal).
Call RelocateRestoredDataToInstDir
!insertmacro CockLogListing "final" "Qt6*.dll"
${If} $PortableMode = 0

View File

@@ -141,6 +141,8 @@ set(cockatrice_SOURCES
src/game_graphics/zones/view_zone.cpp
src/game_graphics/zones/view_zone_widget.cpp
src/game_graphics/board/abstract_graphics_item.cpp
src/interface/at_risk_data_warning.cpp
src/interface/at_risk_data_warning.h
src/interface/card_database_update/card_database_update_status_bar.cpp
src/interface/card_database_update/card_database_update_status_bar.h
src/interface/card_database_update/card_update_progress.cpp

View File

@@ -0,0 +1,215 @@
#include "at_risk_data_warning.h"
#include "../client/settings/cache_settings.h"
#include <QCheckBox>
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QMessageBox>
#include <QWidget>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/user_data_backup.h>
Q_LOGGING_CATEGORY(AtRiskDataWarningLog, "at_risk_data_warning");
static bool isDeckFileSuffix(const QString &suffix)
{
return suffix == QStringLiteral("cod") || suffix == QStringLiteral("dec") || suffix == QStringLiteral("dek") ||
suffix == QStringLiteral("mwdeck");
}
static bool isInsideAnyDir(const QString &path, const QStringList &bases)
{
for (const QString &base : bases) {
if (PathsSettings::isInsideDir(path, base)) {
return true;
}
}
return false;
}
static bool hasLooseDeckFile(const QStringList &offenders)
{
for (const QString &offender : offenders) {
if (isDeckFileSuffix(QFileInfo(offender).suffix().toLower())) {
return true;
}
}
return false;
}
static QString resolveBackupRoot(const QString &appDir)
{
QString backupRoot = UserDataBackup::defaultBackupRoot();
if (PathsSettings::isInsideDir(backupRoot, appDir)) {
backupRoot = QDir::temp().absoluteFilePath(QStringLiteral("CockatriceBackups"));
}
return backupRoot;
}
QStringList AtRiskDataWarning::collectInstalledAtRiskPaths(const QString &appDir)
{
QStringList offenders = SettingsCache::instance().paths().pathsInsideDir(appDir);
// Loose decks sitting directly in the program folder are wiped by the old uninstaller
// just like configured paths. Mirror the installer backup set (DeckLoader extensions
// minus *.txt) so a manual run of the old uninstall.exe is covered too.
const QDir appDirHandle(appDir);
const QStringList topLevelFiles = appDirHandle.entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot);
for (const QString &fileName : topLevelFiles) {
if (!isDeckFileSuffix(QFileInfo(fileName).suffix().toLower())) {
continue;
}
const QString looseDeck = QDir::cleanPath(appDirHandle.absoluteFilePath(fileName));
if (!isInsideAnyDir(looseDeck, offenders)) {
offenders.append(looseDeck);
}
}
return offenders;
}
AtRiskDataWarning::AtRiskBackupOutcome AtRiskDataWarning::backUpAtRiskPaths(const QStringList &sources,
const QString &appDir)
{
AtRiskBackupOutcome outcome;
const QString backupRoot = resolveBackupRoot(appDir);
// Throttle: dismissing the dialog without "Don't show again" must not create
// a full duplicate on every launch. A recent session is reused only when it is
// complete and its source manifest covers the current source set; otherwise a
// fresh copy is taken so the dialog never claims an uncovered path is backed up.
// The size estimate runs only when a fresh copy is actually going to happen,
// keeping a synchronous tree walk off the reuse fast path at startup.
// Search all recent sessions for a complete, covering one: a partial failure must not
// pin the newest slot and force a full re-copy on every later launch.
const QString reusableSession = UserDataBackup::findRecentUsableBackupSession(backupRoot, sources, 24);
if (!reusableSession.isEmpty()) {
outcome.sessionDir = reusableSession;
outcome.locations = UserDataBackup::listSessionBackupLocations(reusableSession);
outcome.reused = true;
qCInfo(AtRiskDataWarningLog) << "Reusing recent at-risk user data backup at" << outcome.sessionDir;
} else if (!sources.isEmpty()) {
// The copy runs synchronously on the UI thread at startup, so cap it: a
// pictures folder pointed into the program dir can be gigabytes, and freezing
// the client to duplicate it is worse than asking for a manual copy.
outcome.bytes = UserDataBackup::estimateBackupSize(sources);
if (outcome.bytes >= UserDataBackup::MAX_AUTO_BACKUP_BYTES) {
outcome.skippedDueToSize = true;
qCWarning(AtRiskDataWarningLog)
<< "At-risk user data backup skipped: estimated" << outcome.bytes << "bytes exceeds automatic cap";
} else {
// One timestamped session directory for all sources, so a second boundary
// never splits a single startup check across two backup folders and
// same-named sources cannot overwrite each other.
outcome.complete =
UserDataBackup::backupAtRiskPaths(sources, backupRoot, &outcome.locations, &outcome.sessionDir);
UserDataBackup::pruneOldBackupSessions(backupRoot, 5);
if (outcome.locations.isEmpty()) {
// Fresh copy failed entirely: fall back to the newest covering session so the
// dialog still points at something, flagged incomplete below.
const QString fallbackSession =
UserDataBackup::findRecentCoveringBackupSession(backupRoot, sources, 24);
if (!fallbackSession.isEmpty()) {
outcome.sessionDir = fallbackSession;
outcome.locations = UserDataBackup::listSessionBackupLocations(fallbackSession);
outcome.complete = false;
}
}
}
}
for (const QString &backupLocation : outcome.locations) {
qCInfo(AtRiskDataWarningLog) << "Backed up at-risk user data to" << backupLocation;
}
if (!outcome.complete) {
qCWarning(AtRiskDataWarningLog) << "At-risk user data backup incomplete, session dir:" << outcome.sessionDir;
}
return outcome;
}
QString AtRiskDataWarning::buildInstalledAtRiskMessage(const QStringList &offenders,
const QStringList &sources,
const AtRiskBackupOutcome &outcome)
{
const QString backupVerb = outcome.reused ? tr("is already available") : tr("has been created");
const QString sizeInfo = tr("%1 MB (automatic backups are capped at %2 MB)")
.arg(QString::number(outcome.bytes / 1048576.0, 'f', 1),
QString::number(UserDataBackup::MAX_AUTO_BACKUP_BYTES / 1048576));
QString message = tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n')));
if (!outcome.locations.isEmpty()) {
message += tr("\n\nA safety backup %1 at:\n%2").arg(backupVerb, outcome.locations.join(QLatin1Char('\n')));
if (!outcome.complete) {
message += tr("\n\nThe backup may be incomplete; please back up these folders manually as well.");
}
if (hasLooseDeckFile(offenders)) {
message += tr("\n\nDeck files ending in .txt are not backed up - move those manually.");
}
} else if (outcome.skippedDueToSize) {
message += tr("\n\nThe automatic safety backup was skipped because of its size: %1. "
"Please back up these folders manually.")
.arg(sizeInfo);
} else if (sources.isEmpty()) {
message += tr("\n\nNone of these paths exist on disk yet, so there was nothing to back up. "
"Files created there later are still at risk until the paths are moved.");
} else {
message += tr("\n\nThe automatic safety backup failed; please back up these folders manually.");
}
return message;
}
void AtRiskDataWarning::showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message)
{
QMessageBox box(QMessageBox::Warning, title, message, QMessageBox::Ok, parent);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
SettingsCache::instance().paths().setAppDirWarningAcknowledged(true);
}
}
void AtRiskDataWarning::warnAboutDataPathsInProgramDir(QWidget *parent)
{
auto &paths = SettingsCache::instance().paths();
if (paths.getAppDirWarningAcknowledged()) {
return;
}
// Portable installs keep their data inside the program directory by design, but they
// never shipped an uninstaller, and portable updates use the installer's payload
// purge, which preserves data/ - so there is nothing destructive to warn about.
if (SettingsCache::instance().getIsPortableBuild()) {
return;
}
const QString appDir = QCoreApplication::applicationDirPath();
// Sources to back up outside the install directory before warning. The uninstallers
// shipped in 3.1.0/3.1.1 delete the whole install tree, so a safety copy is kept
// even though the fixed uninstaller preserves foreign files - the destructive one
// is already on disk on affected machines.
const QStringList offenders = collectInstalledAtRiskPaths(appDir);
if (offenders.isEmpty()) {
return;
}
// Only existing paths can be copied; a configured-but-never-created path has
// nothing to lose yet and must not turn the warning into a bogus "failed".
QStringList sourcesToBackUp;
for (const QString &offender : offenders) {
if (QFileInfo::exists(offender)) {
sourcesToBackUp.append(offender);
}
}
const AtRiskBackupOutcome outcome = backUpAtRiskPaths(sourcesToBackUp, appDir);
const QString message = buildInstalledAtRiskMessage(offenders, sourcesToBackUp, outcome);
showAtRiskDataWarning(parent, tr("Data paths inside the program directory"), message);
}

View File

@@ -0,0 +1,50 @@
#ifndef AT_RISK_DATA_WARNING_H
#define AT_RISK_DATA_WARNING_H
#include <QCoreApplication>
#include <QString>
#include <QStringList>
#include <QtGlobal>
class QWidget;
/**
* @brief Warns when user data lives inside the program directory and backs it up first.
*
* The uninstallers shipped in Cockatrice 3.1.0/3.1.1 delete the whole install
* tree, so data paths configured inside the program directory get a safety
* backup outside the install directory before the warning is shown.
* Portable installs keep their data inside the program directory by design but
* never shipped an uninstaller, so they are skipped.
*/
class AtRiskDataWarning
{
Q_DECLARE_TR_FUNCTIONS(AtRiskDataWarning)
public:
struct AtRiskBackupOutcome
{
QStringList locations; ///< backup targets created for this check
QString sessionDir; ///< session directory holding the backup
bool complete = true; ///< false when only a partial/fallback backup exists
bool reused = false; ///< true when a recent session was reused instead of copying
bool skippedDueToSize = false; ///< true when the automatic copy was skipped for size
qint64 bytes = 0; ///< estimated bytes, only set when a fresh copy was attempted
};
/**
* @brief Backs up at-risk data paths and warns about them.
* @param parent Parent widget for the warning dialog
*/
static void warnAboutDataPathsInProgramDir(QWidget *parent);
private:
static QStringList collectInstalledAtRiskPaths(const QString &appDir);
static AtRiskBackupOutcome backUpAtRiskPaths(const QStringList &sources, const QString &appDir);
static QString buildInstalledAtRiskMessage(const QStringList &offenders,
const QStringList &sources,
const AtRiskBackupOutcome &outcome);
static void showAtRiskDataWarning(QWidget *parent, const QString &title, const QString &message);
};
#endif // AT_RISK_DATA_WARNING_H

View File

@@ -37,6 +37,7 @@
#include "../interface/widgets/tabs/tab_supervisor.h"
#include "../main.h"
#include "abstract_client.h"
#include "at_risk_data_warning.h"
#include "card_database_update/card_database_update_status_bar.h"
#include "card_database_update/card_update_progress.h"
#include "intents/contexts/context_connect_to_server.h"
@@ -58,13 +59,12 @@
#include <QAction>
#include <QApplication>
#include <QButtonGroup>
#include <QCheckBox>
#include <QCloseEvent>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDesktopServices>
#include <QDialog>
#include <QDir>
#include <QEvent>
#include <QFile>
#include <QFileDialog>
@@ -606,38 +606,9 @@ MainWindow::MainWindow(QWidget *parent)
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
}
void MainWindow::warnAboutDataPathsInProgramDir()
{
auto &paths = SettingsCache::instance().paths();
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
return;
}
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
if (offenders.isEmpty()) {
return;
}
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
"Cockatrice keeps its own data outside the program directory by default. Files "
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
"Change them in the Settings dialog - most are on the General page - to keep "
"them safe.")
.arg(offenders.join(QLatin1Char('\n'))),
QMessageBox::Ok, this);
box.setTextFormat(Qt::PlainText);
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
box.setCheckBox(dontShowAgain);
box.exec();
if (dontShowAgain->isChecked()) {
paths.setAppDirWarningAcknowledged(true);
}
}
void MainWindow::startupConfigCheck()
{
warnAboutDataPathsInProgramDir();
AtRiskDataWarning::warnAboutDataPathsInProgramDir(this);
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;

View File

@@ -149,9 +149,6 @@ private:
void runFirstRunWizard(bool firstRun = false);
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
void warnAboutDataPathsInProgramDir();
inline QString getCardUpdaterBinaryName()
{
return "oracle";

View File

@@ -13,6 +13,7 @@ set(UTILITY_SOURCES
libcockatrice/utility/report_categories.cpp
libcockatrice/utility/server_rate_limiter.cpp
libcockatrice/utility/translation_loader.cpp
libcockatrice/utility/user_data_backup.cpp
libcockatrice/utility/warning_categories.cpp
)
@@ -35,6 +36,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/days_years_between.h
libcockatrice/utility/server_rate_limiter.h
libcockatrice/utility/translation_loader.h
libcockatrice/utility/user_data_backup.h
libcockatrice/utility/warning_categories.h
)

View File

@@ -0,0 +1,405 @@
#include "user_data_backup.h"
#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
namespace UserDataBackup
{
namespace
{
const QString COMPLETE_MARKER = QStringLiteral("backup.complete");
const QString INCOMPLETE_MARKER = QStringLiteral("backup.incomplete");
const QString SOURCES_MANIFEST = QStringLiteral("backup.sources");
bool isMarkerFile(const QString &fileName)
{
return fileName == COMPLETE_MARKER || fileName == INCOMPLETE_MARKER || fileName == SOURCES_MANIFEST;
}
bool pathsEqual(const QString &a, const QString &b)
{
#if defined(Q_OS_WIN) || defined(Q_OS_MACOS)
return QDir::cleanPath(a).compare(QDir::cleanPath(b), Qt::CaseInsensitive) == 0;
#else
return QDir::cleanPath(a) == QDir::cleanPath(b);
#endif
}
void writeCompletionMarker(const QString &sessionDir, bool complete)
{
// Best effort: a missing marker only means isBackupSessionComplete()
// reports the session as incomplete.
QFile marker(QDir(sessionDir).absoluteFilePath(complete ? COMPLETE_MARKER : INCOMPLETE_MARKER));
if (marker.open(QIODevice::WriteOnly | QIODevice::Text)) {
marker.write(complete ? "complete\n" : "incomplete\n");
}
}
QString uniqueTargetPath(const QString &sessionDir, const QString &fileName)
{
QDir dir(sessionDir);
QString candidate = dir.absoluteFilePath(fileName);
if (!QFileInfo::exists(candidate)) {
return candidate;
}
const QFileInfo info(fileName);
const QString base = info.completeBaseName();
const QString suffix = info.suffix();
for (int i = 2;; ++i) {
QString numbered = suffix.isEmpty() ? QStringLiteral("%1 (%2)").arg(base).arg(i)
: QStringLiteral("%1 (%2).%3").arg(base).arg(i).arg(suffix);
candidate = dir.absoluteFilePath(numbered);
if (!QFileInfo::exists(candidate)) {
return candidate;
}
}
}
} // namespace
bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir)
{
QDir source(sourceDir);
if (!source.exists()) {
return false;
}
if (!QDir().mkpath(destDir)) {
return false;
}
bool ok = true;
QDirIterator it(sourceDir, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden,
QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
QString entryRel = source.relativeFilePath(it.filePath());
QString target = QDir(destDir).absoluteFilePath(entryRel);
if (it.fileInfo().isSymLink()) {
// Copy the link itself best-effort without dereferencing it. QDirIterator does not
// follow symlinks here, so a symlink-to-directory must not take the isDir() branch
// below (which would create an empty dir and silently drop the contents).
QDir().mkpath(QFileInfo(target).absolutePath());
if (QFile::exists(target) && !QFile::remove(target)) {
ok = false;
continue;
}
if (!QFile::copy(it.filePath(), target)) {
ok = false;
}
} else if (it.fileInfo().isDir()) {
if (!QDir().mkpath(target)) {
ok = false;
}
} else {
// Ensure the parent exists even if the iterator yields a file
// before its directory entry.
QDir().mkpath(QFileInfo(target).absolutePath());
if (QFile::exists(target) && !QFile::remove(target)) {
ok = false;
continue;
}
if (!QFile::copy(it.filePath(), target)) {
ok = false;
}
}
}
return ok;
}
bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut)
{
if (!QDir().mkpath(backupRoot)) {
return false;
}
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-hhmmss-zzz"));
const QDir root(backupRoot);
QString sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-") + timestamp);
for (int i = 1; QFileInfo::exists(sessionDir); ++i) {
sessionDir = root.absoluteFilePath(QStringLiteral("CockatriceDataBackup-%1-%2").arg(timestamp).arg(i));
}
if (!QDir().mkpath(sessionDir)) {
return false;
}
if (sessionDirOut) {
*sessionDirOut = sessionDir;
}
return true;
}
bool backupAtRiskPaths(const QStringList &sourcePaths,
const QString &backupRoot,
QStringList *backupLocationsOut,
QString *sessionDirOut)
{
QStringList existing;
for (const QString &sourcePath : sourcePaths) {
if (QFileInfo::exists(sourcePath)) {
existing.append(sourcePath);
}
}
if (existing.isEmpty()) {
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
QString sessionDir;
if (!createBackupSessionDir(backupRoot, &sessionDir)) {
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
if (sessionDirOut) {
*sessionDirOut = sessionDir;
}
bool allOk = true;
QStringList locations;
for (const QString &sourcePath : existing) {
const QFileInfo source(sourcePath);
const QString backupLocation = uniqueTargetPath(sessionDir, source.fileName());
bool ok;
if (source.isDir()) {
ok = copyDirectoryRecursively(sourcePath, backupLocation);
} else {
ok = QFile::copy(sourcePath, backupLocation);
}
// Report the location whenever a target was created, even for partial
// copies, so callers can point the user at what was salvaged.
if (QFileInfo::exists(backupLocation)) {
locations.append(backupLocation);
} else {
// Total failure with no target created (e.g. QFile::copy failed).
ok = false;
}
allOk = allOk && ok;
}
if (locations.isEmpty()) {
// Nothing salvaged: remove the session again so the empty directory
// neither blocks a retry via the recent-session reuse nor evicts a
// good session in pruneOldBackupSessions().
QDir(sessionDir).removeRecursively();
if (backupLocationsOut) {
backupLocationsOut->clear();
}
if (sessionDirOut) {
sessionDirOut->clear();
}
return false;
}
writeCompletionMarker(sessionDir, allOk);
// Record which sources this session covers so callers can reuse it only
// when it actually covers the current source set (see sessionCoversSources).
QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST));
if (manifest.open(QIODevice::WriteOnly | QIODevice::Text)) {
for (const QString &sourcePath : existing) {
manifest.write(QDir::cleanPath(sourcePath).toUtf8() + "\n");
}
}
if (backupLocationsOut) {
*backupLocationsOut = locations;
}
return allOk;
}
qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes)
{
qint64 total = 0;
for (const QString &sourcePath : sourcePaths) {
const QFileInfo source(sourcePath);
if (!source.exists()) {
continue;
}
if (source.isDir()) {
QDirIterator it(sourcePath, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories);
while (it.hasNext()) {
it.next();
total += it.fileInfo().size();
if (total >= capBytes) {
return total;
}
}
} else {
total += source.size();
if (total >= capBytes) {
return total;
}
}
}
return total;
}
QString defaultBackupRoot()
{
QString documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
if (!documents.isEmpty()) {
return QDir(documents).absoluteFilePath("CockatriceBackups");
}
QString appData = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
if (!appData.isEmpty()) {
return QDir::cleanPath(QDir(appData).absoluteFilePath("../CockatriceBackups"));
}
return QDir::temp().absoluteFilePath("CockatriceBackups");
}
QStringList listBackupSessions(const QString &backupRoot)
{
QDir root(backupRoot);
if (!root.exists()) {
return {};
}
const QStringList names = root.entryList(QStringList() << QStringLiteral("CockatriceDataBackup-*"),
QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name | QDir::Reversed);
QStringList sessions;
sessions.reserve(names.size());
for (const QString &name : names) {
sessions.append(root.absoluteFilePath(name));
}
return sessions;
}
QString findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours)
{
const QStringList sessions = listBackupSessions(backupRoot);
const QDateTime now = QDateTime::currentDateTime();
for (const QString &session : sessions) {
const QFileInfo info(session);
if (!info.exists()) {
continue;
}
if (info.lastModified().secsTo(now) >= static_cast<qint64>(maxAgeHours) * 3600) {
continue;
}
if (listSessionBackupLocations(session).isEmpty()) {
continue;
}
if (!sessionCoversSources(session, sourcePaths)) {
continue;
}
return session;
}
return {};
}
QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours)
{
if (sourcePaths.isEmpty()) {
return {};
}
const QStringList sessions = listBackupSessions(backupRoot);
const QDateTime now = QDateTime::currentDateTime();
for (const QString &session : sessions) {
const QFileInfo info(session);
if (!info.exists()) {
continue;
}
if (info.lastModified().secsTo(now) >= static_cast<qint64>(maxAgeHours) * 3600) {
continue;
}
if (listSessionBackupLocations(session).isEmpty()) {
continue;
}
if (!isBackupSessionComplete(session)) {
continue;
}
if (!sessionCoversSources(session, sourcePaths)) {
continue;
}
return session;
}
return {};
}
bool isBackupSessionComplete(const QString &sessionDir)
{
const QDir session(sessionDir);
if (!session.exists()) {
return false;
}
return QFileInfo::exists(session.absoluteFilePath(COMPLETE_MARKER));
}
void pruneOldBackupSessions(const QString &backupRoot, int keepCount)
{
const QStringList sessions = listBackupSessions(backupRoot);
QStringList kept;
kept.reserve(sessions.size());
for (const QString &session : sessions) {
// Empty sessions are junk from failed attempts: drop them outright so
// they can never evict a good session from the kept set.
if (listSessionBackupLocations(session).isEmpty()) {
QDir(session).removeRecursively();
continue;
}
if (kept.size() < keepCount) {
kept.append(session);
continue;
}
QDir(session).removeRecursively();
}
}
QStringList listSessionBackupLocations(const QString &sessionDir)
{
const QDir session(sessionDir);
if (!session.exists()) {
return {};
}
const QStringList entries = session.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden);
QStringList locations;
locations.reserve(entries.size());
for (const QString &entry : entries) {
if (isMarkerFile(entry)) {
continue;
}
locations.append(session.absoluteFilePath(entry));
}
return locations;
}
bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths)
{
QFile manifest(QDir(sessionDir).absoluteFilePath(SOURCES_MANIFEST));
if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) {
// No readable manifest (e.g. the write failed): coverage cannot be
// verified, so report not-covered and let the caller take a fresh copy.
return false;
}
QStringList recorded;
while (!manifest.atEnd()) {
const QString line = QString::fromUtf8(manifest.readLine()).trimmed();
if (!line.isEmpty()) {
recorded.append(line);
}
}
for (const QString &source : sourcePaths) {
bool found = false;
for (const QString &entry : recorded) {
if (pathsEqual(source, entry)) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
} // namespace UserDataBackup

View File

@@ -0,0 +1,132 @@
/**
* @file user_data_backup.h
* @ingroup Utility
* @brief Best-effort safety copies of user data that lives inside the install directory.
*
* Background: Windows uninstallers shipped in 3.1.0/3.1.1 ran a blanket
* "RMDir /r $INSTDIR". For a portable install that deletes $INSTDIR\data
* (decks, settings, pictures) along with the payload, and for a normal install
* it deletes anything the user pointed into $INSTDIR. The fixed uninstaller
* preserves foreign files, but the destructive one is already on disk on
* affected machines, so the client keeps a safety copy outside the install
* directory before warning the user. Detection of at-risk paths lives in
* PathsSettings::pathsInsideDir; this helper only copies.
*/
#ifndef USER_DATA_BACKUP_H
#define USER_DATA_BACKUP_H
#include <QString>
#include <QStringList>
namespace UserDataBackup
{
/** @brief Above this total, the client skips the automatic startup backup and asks for a manual copy.
* Kept small on purpose: the copy runs synchronously on the UI thread at startup. */
constexpr qint64 MAX_AUTO_BACKUP_BYTES = 200LL * 1024 * 1024;
/**
* @brief Recursively copies @p sourceDir into @p destDir, creating it as needed.
* @returns true when every file was copied (or there was nothing to copy).
* Symlinks (file or directory) are copied best-effort via QFile::copy without
* dereferencing them; a dangling link fails that entry only.
*/
bool copyDirectoryRecursively(const QString &sourceDir, const QString &destDir);
/**
* @brief Creates a fresh timestamped session directory under @p backupRoot.
* @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups.
* @param sessionDirOut receives the created directory on success.
* @returns true on success. Millisecond timestamps plus a numeric suffix keep rapid
* successive calls (e.g. one per installer phase) from sharing a directory.
*/
bool createBackupSessionDir(const QString &backupRoot, QString *sessionDirOut);
/**
* @brief Copies several at-risk paths into a single timestamped session directory.
* @param sourcePaths files or directories holding user data; missing entries are skipped.
* @param backupRoot directory OUTSIDE the install tree that will hold timestamped backups.
* @param backupLocationsOut receives one entry per source that produced a backup target
* (including partial copies).
* @param sessionDirOut optionally receives the shared session directory; set to empty
* when nothing was backed up (all sources missing, or every copy failed outright).
* @returns true when every existing source was copied completely.
* A false return with a non-empty @p backupLocationsOut means a partial backup exists.
* When nothing at all could be salvaged the session directory is removed again, so a
* failed attempt never leaves an empty session behind that would block retries.
* Completeness is recorded with a marker file; see isBackupSessionComplete().
*/
bool backupAtRiskPaths(const QStringList &sourcePaths,
const QString &backupRoot,
QStringList *backupLocationsOut,
QString *sessionDirOut = nullptr);
/**
* @brief Estimates the total bytes that backing up @p sourcePaths would copy.
* @param capBytes stop counting once the total exceeds this; the returned value is then
* at least @p capBytes (used to skip the scan early on huge trees).
* Missing entries contribute 0. Best effort only.
*/
qint64 estimateBackupSize(const QStringList &sourcePaths, qint64 capBytes = MAX_AUTO_BACKUP_BYTES);
/**
* @brief Default backup root guaranteed to live outside the install directory.
*/
QString defaultBackupRoot();
/**
* @brief Lists existing backup session directories under @p backupRoot, newest first.
*/
QStringList listBackupSessions(const QString &backupRoot);
/**
* @brief Returns the newest session that is non-empty, complete and covering @p sourcePaths, or empty.
*
* An incomplete newest session (partial failure) or one covering a different
* source set does not block reuse of an older good session.
*/
QString findRecentUsableBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24);
/**
* @brief Returns the newest non-empty session covering @p sourcePaths, regardless of completeness.
*
* Used as a fallback pointer when a fresh copy fails entirely, so the dialog can still
* point at salvaged content. Completeness is left to the caller to report.
*/
QString
findRecentCoveringBackupSession(const QString &backupRoot, const QStringList &sourcePaths, int maxAgeHours = 24);
/**
* @brief Reports whether @p sessionDir holds a complete backup.
* @returns true when the session carries the complete marker, false otherwise
* (partial backups and empty/missing sessions).
*/
bool isBackupSessionComplete(const QString &sessionDir);
/**
* @brief Deletes oldest sessions keeping only the newest @p keepCount ones.
* Empty sessions are junk from failed attempts and are always removed, so they
* can never evict a good session from the kept set.
*/
void pruneOldBackupSessions(const QString &backupRoot, int keepCount = 5);
/**
* @brief Lists the backup targets inside an existing session directory.
* @returns absolute paths of the entries, for reuse messages without a new copy.
* Completeness marker and source manifest files are internal bookkeeping and are never listed.
*/
QStringList listSessionBackupLocations(const QString &sessionDir);
/**
* @brief Reports whether @p sessionDir was recorded as covering all of @p sourcePaths.
* @returns true when the session's source manifest lists every entry of @p sourcePaths
* (normalized via QDir::cleanPath, case-insensitive on Windows/macOS). Sessions
* without a readable manifest return false so a changed source set always triggers
* a fresh copy instead of reusing a backup that may not cover it.
*/
bool sessionCoversSources(const QString &sessionDir, const QStringList &sourcePaths);
} // namespace UserDataBackup
#endif // USER_DATA_BACKUP_H

View File

@@ -174,6 +174,7 @@ endfunction()
cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)

View File

@@ -0,0 +1,581 @@
#include "gtest/gtest.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <libcockatrice/utility/user_data_backup.h>
namespace
{
TEST(UserDataBackup, CopyDirectoryKeepsNestedStructure)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
QDir source(sourceDir.path());
ASSERT_TRUE(source.mkpath("decks/sub"));
QFile deck(source.absoluteFilePath("decks/sub/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
EXPECT_TRUE(QFile::exists(dest + "/decks/sub/mydeck.cod"));
}
TEST(UserDataBackup, CopyDirectoryKeepsHiddenFiles)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
QFile hidden(QDir(sourceDir.path()).absoluteFilePath(".hidden.ini"));
ASSERT_TRUE(hidden.open(QIODevice::WriteOnly | QIODevice::Text));
hidden.write("[general]\n");
hidden.close();
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
EXPECT_TRUE(QFile::exists(dest + "/.hidden.ini"));
}
TEST(UserDataBackup, CopyDirectoryOverwritesExistingTarget)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
const QString sourceFile = QDir(sourceDir.path()).absoluteFilePath("settings.ini");
QFile first(sourceFile);
ASSERT_TRUE(first.open(QIODevice::WriteOnly | QIODevice::Text));
first.write("version=1\n");
first.close();
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
ASSERT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
QFile second(sourceFile);
ASSERT_TRUE(second.open(QIODevice::WriteOnly | QIODevice::Text));
second.write("version=2\n");
second.close();
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
QFile result(dest + "/settings.ini");
ASSERT_TRUE(result.open(QIODevice::ReadOnly | QIODevice::Text));
EXPECT_EQ(result.readAll(), QByteArray("version=2\n"));
}
TEST(UserDataBackup, CopyEmptyDirectorySucceeds)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
ASSERT_TRUE(QDir(sourceDir.path()).mkpath("empty"));
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
EXPECT_TRUE(UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest));
EXPECT_TRUE(QDir(dest + "/empty").exists());
}
TEST(UserDataBackup, CopyMissingDirectoryFails)
{
QTemporaryDir destParent;
ASSERT_TRUE(destParent.isValid());
EXPECT_FALSE(
UserDataBackup::copyDirectoryRecursively("/nonexistent-cockatrice-path-xyz", destParent.path() + "/copy"));
}
TEST(UserDataBackup, BackupDirectoryCreatesTimestampedCopy)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
ASSERT_EQ(locations.size(), 1);
EXPECT_TRUE(QFile::exists(locations.first() + "/mydeck.cod"));
EXPECT_TRUE(locations.first().startsWith(QDir(backupRoot.path()).absolutePath()));
}
TEST(UserDataBackup, BackupSingleFile)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QString cardDb = QDir(sourceDir.path()).absoluteFilePath("cards.xml");
QFile db(cardDb);
ASSERT_TRUE(db.open(QIODevice::WriteOnly | QIODevice::Text));
db.write("<cockatrice_card_database/>\n");
db.close();
QStringList locations;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({cardDb}, backupRoot.path(), &locations, nullptr));
ASSERT_EQ(locations.size(), 1);
EXPECT_TRUE(QFile::exists(locations.first()));
EXPECT_TRUE(locations.first().endsWith("cards.xml"));
}
TEST(UserDataBackup, BackupMissingSourceFails)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QStringList locations;
QString sessionDir;
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod")},
backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(locations.isEmpty());
EXPECT_TRUE(sessionDir.isEmpty());
}
TEST(UserDataBackup, BackupPathsShareSingleSessionDir)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QDir source(sourceDir.path());
ASSERT_TRUE(source.mkpath("decks"));
ASSERT_TRUE(source.mkpath("settings"));
QFile deck(source.absoluteFilePath("decks/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QFile settings(source.absoluteFilePath("settings/settings.ini"));
ASSERT_TRUE(settings.open(QIODevice::WriteOnly | QIODevice::Text));
settings.write("[general]\n");
settings.close();
QStringList locations;
QString sessionDir;
EXPECT_TRUE(
UserDataBackup::backupAtRiskPaths({source.absoluteFilePath("decks"), source.absoluteFilePath("settings")},
backupRoot.path(), &locations, &sessionDir));
ASSERT_EQ(locations.size(), 2);
// Both backups live in the same timestamped session, even across a second boundary.
for (const QString &location : locations) {
EXPECT_TRUE(location.startsWith(QDir(sessionDir).absolutePath()));
}
EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod")));
EXPECT_TRUE(QFile::exists(QDir(sessionDir).absoluteFilePath("settings/settings.ini")));
}
TEST(UserDataBackup, BackupPathsWithSameNameDoNotCollide)
{
QTemporaryDir firstDir;
QTemporaryDir secondDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(firstDir.isValid());
ASSERT_TRUE(secondDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
for (const QString &dir : {firstDir.path(), secondDir.path()}) {
QFile deck(QDir(dir).absoluteFilePath("cards.xml"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_card_database/>\n");
deck.close();
}
QStringList locations;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths(
{QDir(firstDir.path()).absoluteFilePath("cards.xml"), QDir(secondDir.path()).absoluteFilePath("cards.xml")},
backupRoot.path(), &locations, nullptr));
ASSERT_EQ(locations.size(), 2);
EXPECT_NE(locations.at(0), locations.at(1));
EXPECT_TRUE(QFile::exists(locations.at(0)));
EXPECT_TRUE(QFile::exists(locations.at(1)));
}
TEST(UserDataBackup, RapidBackupsGetUniqueSessionDirs)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QString firstSession;
QString secondSession;
QStringList locations;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession));
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession));
EXPECT_NE(firstSession, secondSession);
}
TEST(UserDataBackup, DefaultBackupRootIsUsable)
{
QString root = UserDataBackup::defaultBackupRoot();
EXPECT_FALSE(root.isEmpty());
EXPECT_TRUE(QDir(root).isAbsolute());
}
TEST(UserDataBackup, DefaultBackupRootHasNoDotDotSegments)
{
QString root = UserDataBackup::defaultBackupRoot();
EXPECT_FALSE(root.contains(QStringLiteral("..")));
EXPECT_EQ(root, QDir::cleanPath(root));
}
TEST(UserDataBackup, ListSessionsFindsRecentAndPrunesOld)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString firstSession;
QString secondSession;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &firstSession));
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &secondSession));
const QStringList sessions = UserDataBackup::listBackupSessions(backupRoot.path());
ASSERT_EQ(sessions.size(), 2);
// Newest first
EXPECT_EQ(sessions.first(), secondSession);
const QString recent = UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24);
EXPECT_EQ(recent, secondSession);
const QStringList reusedLocations = UserDataBackup::listSessionBackupLocations(recent);
EXPECT_FALSE(reusedLocations.isEmpty());
for (const QString &location : reusedLocations) {
EXPECT_TRUE(location.startsWith(QDir(recent).absolutePath()));
EXPECT_TRUE(QFile::exists(location));
}
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 1);
const QStringList pruned = UserDataBackup::listBackupSessions(backupRoot.path());
ASSERT_EQ(pruned.size(), 1);
EXPECT_EQ(pruned.first(), secondSession);
}
TEST(UserDataBackup, FindRecentReturnsEmptyWhenNone)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(backupRoot.path() + "/missing").isEmpty());
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(backupRoot.path() + "/missing"));
// Pruning an empty root must not fail
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5);
}
TEST(UserDataBackup, AllMissingSourcesCreateNoSession)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QStringList locations;
QString sessionDir;
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({backupRoot.path() + "/nope1", backupRoot.path() + "/nope2"},
backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(locations.isEmpty());
EXPECT_TRUE(sessionDir.isEmpty());
// No empty session left behind to block a retry or evict good sessions
EXPECT_TRUE(UserDataBackup::listBackupSessions(backupRoot.path()).isEmpty());
}
TEST(UserDataBackup, EmptySessionsAreSkippedAndPruned)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QString emptySession;
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &emptySession));
// An empty session from a failed attempt must never block a retry
EXPECT_TRUE(UserDataBackup::listSessionBackupLocations(emptySession).isEmpty());
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(emptySession));
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
UserDataBackup::pruneOldBackupSessions(backupRoot.path(), 5);
EXPECT_FALSE(QDir(emptySession).exists());
}
TEST(UserDataBackup, SuccessfulBackupIsMarkedComplete)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
EXPECT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(UserDataBackup::isBackupSessionComplete(sessionDir));
// Marker files are bookkeeping and must not leak into the user-facing locations
const QStringList listed = UserDataBackup::listSessionBackupLocations(sessionDir);
EXPECT_FALSE(listed.isEmpty());
for (const QString &location : listed) {
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.complete")));
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.incomplete")));
}
}
TEST(UserDataBackup, IncompleteMarkerIsNotUsableButRemainsCovering)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
ASSERT_TRUE(QFile::remove(QDir(sessionDir).absoluteFilePath("backup.complete")));
QFile marker(QDir(sessionDir).absoluteFilePath("backup.incomplete"));
ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text));
marker.write("incomplete\n");
marker.close();
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir));
// Incomplete sessions are skipped by the usable search but still found by the
// covering search, so a failed copy falls back to the salvaged content.
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 24).isEmpty());
EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 24), sessionDir);
}
TEST(UserDataBackup, SessionWithoutMarkerCountsAsIncomplete)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QString sessionDir;
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir));
ASSERT_TRUE(QDir(sessionDir).mkpath("decks"));
QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
EXPECT_FALSE(UserDataBackup::isBackupSessionComplete(sessionDir));
// No source manifest either, so neither search reuses it.
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {backupRoot.path() + "/missing"}, 24)
.isEmpty());
}
TEST(UserDataBackup, FindRecentIgnoresExpiredSessions)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}, 0).isEmpty());
}
TEST(UserDataBackup, EstimateBackupSize)
{
QTemporaryDir sourceDir;
ASSERT_TRUE(sourceDir.isValid());
const QString missing = QDir(sourceDir.path()).absoluteFilePath("missing");
EXPECT_EQ(UserDataBackup::estimateBackupSize({missing}), 0);
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("123456");
deck.close();
EXPECT_EQ(UserDataBackup::estimateBackupSize({sourceDir.path(), missing}), 6);
// Early-out: once over the cap the exact total no longer matters
EXPECT_GE(UserDataBackup::estimateBackupSize({sourceDir.path()}, 1), 1);
}
TEST(UserDataBackup, SessionCoversSourcesRequiresManifestMatch)
{
QTemporaryDir sourceA;
QTemporaryDir sourceB;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceA.isValid());
ASSERT_TRUE(sourceB.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod"));
ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text));
fa.write("a");
fa.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path()}));
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceA.path(), sourceB.path()}));
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {sourceB.path()}));
}
TEST(UserDataBackup, SessionWithoutManifestIsNotCovering)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QString sessionDir;
ASSERT_TRUE(UserDataBackup::createBackupSessionDir(backupRoot.path(), &sessionDir));
ASSERT_TRUE(QDir(sessionDir).mkpath("decks"));
QFile deck(QDir(sessionDir).absoluteFilePath("decks/mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
EXPECT_FALSE(UserDataBackup::sessionCoversSources(sessionDir, {QDir(sessionDir).absoluteFilePath("decks")}));
}
TEST(UserDataBackup, ManifestIsHiddenFromSessionLocations)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("x");
deck.close();
QStringList locations;
QString sessionDir;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &sessionDir));
for (const QString &location : UserDataBackup::listSessionBackupLocations(sessionDir)) {
EXPECT_FALSE(location.endsWith(QStringLiteral("backup.sources")));
}
}
TEST(UserDataBackup, FindRecentUsableSkipsIncompleteNewest)
{
QTemporaryDir sourceDir;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile deck(QDir(sourceDir.path()).absoluteFilePath("mydeck.cod"));
ASSERT_TRUE(deck.open(QIODevice::WriteOnly | QIODevice::Text));
deck.write("<cockatrice_deck version=\"1\"/>\n");
deck.close();
QStringList locations;
QString oldSession;
QString newSession;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &oldSession));
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceDir.path()}, backupRoot.path(), &locations, &newSession));
ASSERT_NE(oldSession, newSession);
// Newest session becomes incomplete: usable search must fall back to the older complete one.
ASSERT_TRUE(QFile::remove(QDir(newSession).absoluteFilePath("backup.complete")));
QFile marker(QDir(newSession).absoluteFilePath("backup.incomplete"));
ASSERT_TRUE(marker.open(QIODevice::WriteOnly | QIODevice::Text));
marker.write("incomplete\n");
marker.close();
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceDir.path()}), oldSession);
// Covering search still finds the newest session even though it is incomplete.
EXPECT_EQ(UserDataBackup::findRecentCoveringBackupSession(backupRoot.path(), {sourceDir.path()}), newSession);
}
TEST(UserDataBackup, FindRecentUsableRequiresCoveringSet)
{
QTemporaryDir sourceA;
QTemporaryDir sourceB;
QTemporaryDir backupRoot;
ASSERT_TRUE(sourceA.isValid());
ASSERT_TRUE(sourceB.isValid());
ASSERT_TRUE(backupRoot.isValid());
QFile fa(QDir(sourceA.path()).absoluteFilePath("a.cod"));
ASSERT_TRUE(fa.open(QIODevice::WriteOnly | QIODevice::Text));
fa.write("a");
fa.close();
QFile fb(QDir(sourceB.path()).absoluteFilePath("b.cod"));
ASSERT_TRUE(fb.open(QIODevice::WriteOnly | QIODevice::Text));
fb.write("b");
fb.close();
QStringList locations;
QString oldSession;
QString newSession;
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceA.path()}, backupRoot.path(), &locations, &oldSession));
ASSERT_TRUE(UserDataBackup::backupAtRiskPaths({sourceB.path()}, backupRoot.path(), &locations, &newSession));
// Newest covers B only, so a query for A must reuse the older session.
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path()}), oldSession);
EXPECT_EQ(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceB.path()}), newSession);
EXPECT_TRUE(
UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {sourceA.path(), sourceB.path()}).isEmpty());
EXPECT_TRUE(UserDataBackup::findRecentUsableBackupSession(backupRoot.path(), {}).isEmpty());
}
TEST(UserDataBackup, CopyDirectoryDoesNotSilentlyEmptyDirSymlink)
{
QTemporaryDir sourceDir;
QTemporaryDir destParent;
ASSERT_TRUE(sourceDir.isValid());
ASSERT_TRUE(destParent.isValid());
QDir source(sourceDir.path());
ASSERT_TRUE(source.mkpath("real"));
QFile inner(source.absoluteFilePath("real/card.cod"));
ASSERT_TRUE(inner.open(QIODevice::WriteOnly | QIODevice::Text));
inner.write("x");
inner.close();
if (!QFile::link(source.absoluteFilePath("real"), source.absoluteFilePath("link"))) {
GTEST_SKIP() << "Symlinks not permitted in this environment";
}
QString dest = QDir(destParent.path()).absoluteFilePath("copy");
const bool ok = UserDataBackup::copyDirectoryRecursively(sourceDir.path(), dest);
const QFileInfo linkCopy(QDir(dest).absoluteFilePath("link"));
// Old behavior created an empty plain dir and reported success, silently dropping contents.
// New behavior copies the link best-effort; if that fails it must report failure.
if (ok) {
EXPECT_TRUE(linkCopy.exists() || linkCopy.isSymLink());
if (linkCopy.isDir() && !linkCopy.isSymLink()) {
EXPECT_FALSE(QDir(linkCopy.absoluteFilePath()).isEmpty());
}
} else {
EXPECT_TRUE(linkCopy.isSymLink() || !linkCopy.exists() || !QDir(linkCopy.absoluteFilePath()).isEmpty());
}
}
TEST(UserDataBackup, PluralBackupClearsOutParamsOnTotalFailure)
{
QTemporaryDir backupRoot;
ASSERT_TRUE(backupRoot.isValid());
QStringList locations = {QStringLiteral("stale")};
QString sessionDir = QStringLiteral("stale");
const QString missing = QDir(backupRoot.path()).absoluteFilePath("does-not-exist.cod");
EXPECT_FALSE(UserDataBackup::backupAtRiskPaths({missing}, backupRoot.path(), &locations, &sessionDir));
EXPECT_TRUE(locations.isEmpty());
EXPECT_TRUE(sessionDir.isEmpty());
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}