[DeckSync] Match server rows by identity and hash the whole deck

Sync keyed local files to server rows by path, but the server names a row
after the deck, not after the file, so any deck whose file name and deck name
differ uploaded again on every run. A second pass then brought the server copy
back as an extra local file.

- Match by the sidecar's row id, then guid, then content, and only then by the
  server path, so renaming either side no longer duplicates a deck.
- Key the server tree by row id: the server allows several rows with the same
  name in one folder, and keying by path silently dropped all but one.
- Stop discarding rows whose name contains '/' ("U/W Control"). They stay
  matchable, and only the download file name is sanitised.
- Build the download path from the folder plus a sanitised row name, including
  every folder segment, so a hostile server name cannot escape the deck folder.
- Download a colliding server row under a unique name and count it, instead of
  skipping it without a trace.
- Hash the whole deck instead of the card hash: SHA-1 over the native deck
  string covers name, printings, banner, tokens and sideboard plans. The client
  and the server hash the exact bytes they exchange, so both agree.

Adds deck_content_hash_test for the client/server hashing invariant and for the
name-only change the card hash cannot see.
This commit is contained in:
Lukas Brübach 2026-11-01 12:43:02 +01:00
parent ad0da98a86
commit 2916fa45af
7 changed files with 215 additions and 81 deletions

View file

@ -9,6 +9,7 @@
#include "tree/inner_deck_list_node.h"
#include <QAnyStringView>
#include <QCryptographicHash>
#include <QDebug>
#include <QSet>
#include <QSharedPointer>
@ -453,6 +454,16 @@ void DeckList::refreshDeckHash()
cachedDeckHash = QString();
}
QString DeckList::contentHash(const QString &nativeDeck)
{
return QString::fromLatin1(QCryptographicHash::hash(nativeDeck.toUtf8(), QCryptographicHash::Sha1).toHex());
}
QString DeckList::getContentHash() const
{
return contentHash(writeToString_Native());
}
/**
* Calls a given function on each card in the deck.
*/

View file

@ -289,6 +289,16 @@ public:
/** @name Deck identity */
///@{
QString getDeckHash() const;
/**
* @brief SHA-1 of a native deck string, covering everything the card hash
* ignores (name, printings, banner, tokens, comments, sideboard plans).
*
* Deck-collection sync hashes the exact string it sends or receives, so both
* peers compare the same bytes.
*/
static QString contentHash(const QString &nativeDeck);
/** @brief contentHash() of this deck's native serialisation. */
QString getContentHash() const;
void refreshDeckHash();
///@}