Fix issues with reloading and handling of externally modified db file (#10612)

Fixes #5290 
Fixes #9062
Fixes #8545 

* Fix data loss on failed reload

- External modifications to the db file can no longer be missed.
- Fixed dialogFinished signal of DatabaseOpenDialog was not emitted when dialog was closed via the 'X' (close) button
- For reloading with a modified db, an additional choice has been added to allow the user to ignore the changes in the file on disk.
- User is now presented with an unlock database dialog if reload fails to open the db automatically. For example when the user removed the YubiKey, failed to touch the YubiKey within the timeout period, or db pw has been changed.
- Mark db as modified when db file is gone or invalid.
- Prevent saving when db is being reloaded
- If merge is triggered by a save action, continue on with the save action after the user makes their choice

---------

Co-authored-by: vuurvlieg <vuurvli3g@protonmail.com>
Co-authored-by: Jonathan White <support@dmapps.us>
This commit is contained in:
vuurvli3g 2025-02-01 17:58:45 +01:00 committed by GitHub
parent 81fa8d5947
commit 811887e591
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 564 additions and 74 deletions

View File

@ -1513,6 +1513,10 @@ Backup database located at %2</source>
<source>Recycle Bin</source> <source>Recycle Bin</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>Database file read error.</source>
<translation type="unfinished"></translation>
</message>
</context> </context>
<context> <context>
<name>DatabaseOpenDialog</name> <name>DatabaseOpenDialog</name>
@ -2683,24 +2687,6 @@ Save changes?</source>
<source>File has changed</source> <source>File has changed</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>The database file has changed. Do you want to load the changes?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Merge Request</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The database file has changed and you have unsaved changes.
Do you want to merge your changes?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Could not open the new database file while attempting to autoreload.
Error: %1</source>
<translation type="unfinished"></translation>
</message>
<message> <message>
<source>Disable safe saves?</source> <source>Disable safe saves?</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
@ -2787,6 +2773,50 @@ Disable safe saves and try again?</source>
<source>Do you want to remove the passkey from this entry?</source> <source>Do you want to remove the passkey from this entry?</source>
<translation type="unfinished"></translation> <translation type="unfinished"></translation>
</message> </message>
<message>
<source>The database file &quot;%1&quot; was modified externally</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Do you want to load the changes?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Reload database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Reloading database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Reload canceled</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Reload successful</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Reload pending user action</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The database file &quot;%1&quot; was modified externally.&lt;br&gt;How would you like to proceed?&lt;br&gt;&lt;br&gt;Merge all changes&lt;br&gt;Ignore the changes on disk until save&lt;br&gt;Discard unsaved changes</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>The database file &quot;%1&quot; was modified externally.&lt;br&gt;How would you like to proceed?&lt;br&gt;&lt;br&gt;Merge all changes then save&lt;br&gt;Overwrite the changes on disk&lt;br&gt;Discard unsaved changes</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Database file overwritten.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Database file on disk cannot be unlocked with current credentials.&lt;br&gt;Enter new credentials and/or present hardware key to continue.</source>
<translation type="unfinished"></translation>
</message>
</context> </context>
<context> <context>
<name>EditEntryWidget</name> <name>EditEntryWidget</name>

View File

@ -96,6 +96,7 @@ set(core_SOURCES
keys/PasswordKey.cpp keys/PasswordKey.cpp
keys/ChallengeResponseKey.cpp keys/ChallengeResponseKey.cpp
streams/HashedBlockStream.cpp streams/HashedBlockStream.cpp
streams/HashingStream.cpp
streams/HmacBlockStream.cpp streams/HmacBlockStream.cpp
streams/LayeredStream.cpp streams/LayeredStream.cpp
streams/qtiocompressor.cpp streams/qtiocompressor.cpp

View File

@ -25,6 +25,7 @@
#include "format/KdbxXmlReader.h" #include "format/KdbxXmlReader.h"
#include "format/KeePass2Reader.h" #include "format/KeePass2Reader.h"
#include "format/KeePass2Writer.h" #include "format/KeePass2Writer.h"
#include "streams/HashingStream.h"
#include <QFileInfo> #include <QFileInfo>
#include <QJsonObject> #include <QJsonObject>
@ -62,8 +63,8 @@ Database::Database()
updateTagList(); updateTagList();
}); });
connect(this, &Database::modified, this, [this] { updateTagList(); }); connect(this, &Database::modified, this, [this] { updateTagList(); });
connect(this, &Database::databaseSaved, this, [this]() { updateCommonUsernames(); }); connect(this, &Database::databaseSaved, this, [this] { updateCommonUsernames(); });
connect(m_fileWatcher, &FileWatcher::fileChanged, this, &Database::databaseFileChanged); connect(m_fileWatcher, &FileWatcher::fileChanged, this, [this] { emit databaseFileChanged(false); });
// static uuid map // static uuid map
s_uuidMap.insert(m_uuid, this); s_uuidMap.insert(m_uuid, this);
@ -151,6 +152,20 @@ bool Database::open(const QString& filePath, QSharedPointer<const CompositeKey>
setEmitModified(false); setEmitModified(false);
// update the hash of the first block
m_fileBlockHash.clear();
auto fileBlockData = dbFile.peek(kFileBlockToHashSizeBytes);
if (fileBlockData.size() != kFileBlockToHashSizeBytes) {
if (dbFile.size() >= kFileBlockToHashSizeBytes) {
if (error) {
*error = tr("Database file read error.");
}
return false;
}
} else {
m_fileBlockHash = QCryptographicHash::hash(fileBlockData, QCryptographicHash::Md5);
}
KeePass2Reader reader; KeePass2Reader reader;
if (!reader.readDatabase(&dbFile, std::move(key), this)) { if (!reader.readDatabase(&dbFile, std::move(key), this)) {
if (error) { if (error) {
@ -260,15 +275,34 @@ bool Database::saveAs(const QString& filePath, SaveAction action, const QString&
return false; return false;
} }
if (filePath == m_data.filePath) { // Make sure we don't overwrite external modifications unless explicitly allowed
// Fail-safe check to make sure we don't overwrite underlying file changes if (!m_ignoreFileChangesUntilSaved && !m_fileBlockHash.isEmpty() && filePath == m_data.filePath) {
// that have not yet triggered a file reload/merge operation. QFile dbFile(filePath);
if (!m_fileWatcher->hasSameFileChecksum()) { if (dbFile.exists()) {
if (!dbFile.open(QIODevice::ReadOnly)) {
if (error) {
*error = tr("Unable to open file %1.").arg(filePath);
}
return false;
}
auto fileBlockData = dbFile.read(kFileBlockToHashSizeBytes);
if (fileBlockData.size() == kFileBlockToHashSizeBytes) {
auto hash = QCryptographicHash::hash(fileBlockData, QCryptographicHash::Md5);
if (m_fileBlockHash != hash) {
if (error) { if (error) {
*error = tr("Database file has unmerged changes."); *error = tr("Database file has unmerged changes.");
} }
// emit the databaseFileChanged(true) signal async
QMetaObject::invokeMethod(this, "databaseFileChanged", Qt::QueuedConnection, Q_ARG(bool, true));
return false; return false;
} }
} else if (dbFile.size() >= kFileBlockToHashSizeBytes) {
if (error) {
*error = tr("Database file read error.");
}
return false;
}
}
} }
// Clear read-only flag // Clear read-only flag
@ -302,7 +336,7 @@ bool Database::saveAs(const QString& filePath, SaveAction action, const QString&
SetFileAttributes(realFilePath.toStdString().c_str(), FILE_ATTRIBUTE_HIDDEN); SetFileAttributes(realFilePath.toStdString().c_str(), FILE_ATTRIBUTE_HIDDEN);
} }
#endif #endif
m_ignoreFileChangesUntilSaved = false;
m_fileWatcher->start(realFilePath, 30, 1); m_fileWatcher->start(realFilePath, 30, 1);
} else { } else {
// Saving failed, don't rewatch file since it does not represent our database // Saving failed, don't rewatch file since it does not represent our database
@ -325,8 +359,12 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
case Atomic: { case Atomic: {
QSaveFile saveFile(filePath); QSaveFile saveFile(filePath);
if (saveFile.open(QIODevice::WriteOnly)) { if (saveFile.open(QIODevice::WriteOnly)) {
HashingStream hashingStream(&saveFile, QCryptographicHash::Md5, kFileBlockToHashSizeBytes);
if (!hashingStream.open(QIODevice::WriteOnly)) {
return false;
}
// write the database to the file // write the database to the file
if (!writeDatabase(&saveFile, error)) { if (!writeDatabase(&hashingStream, error)) {
return false; return false;
} }
@ -334,6 +372,9 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
saveFile.setFileTime(createTime, QFile::FileBirthTime); saveFile.setFileTime(createTime, QFile::FileBirthTime);
if (saveFile.commit()) { if (saveFile.commit()) {
// store the new hash
m_fileBlockHash = hashingStream.hashingResult();
// successfully saved database file // successfully saved database file
return true; return true;
} }
@ -347,8 +388,12 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
case TempFile: { case TempFile: {
QTemporaryFile tempFile; QTemporaryFile tempFile;
if (tempFile.open()) { if (tempFile.open()) {
HashingStream hashingStream(&tempFile, QCryptographicHash::Md5, kFileBlockToHashSizeBytes);
if (!hashingStream.open(QIODevice::WriteOnly)) {
return false;
}
// write the database to the file // write the database to the file
if (!writeDatabase(&tempFile, error)) { if (!writeDatabase(&hashingStream, error)) {
return false; return false;
} }
tempFile.close(); // flush to disk tempFile.close(); // flush to disk
@ -366,6 +411,8 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
QFile::setPermissions(filePath, perms); QFile::setPermissions(filePath, perms);
// Retain original creation time // Retain original creation time
tempFile.setFileTime(createTime, QFile::FileBirthTime); tempFile.setFileTime(createTime, QFile::FileBirthTime);
// store the new hash
m_fileBlockHash = hashingStream.hashingResult();
return true; return true;
} else if (backupFilePath.isEmpty() || !restoreDatabase(filePath, backupFilePath)) { } else if (backupFilePath.isEmpty() || !restoreDatabase(filePath, backupFilePath)) {
// Failed to copy new database in place, and // Failed to copy new database in place, and
@ -387,10 +434,16 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
// Open the original database file for direct-write // Open the original database file for direct-write
QFile dbFile(filePath); QFile dbFile(filePath);
if (dbFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { if (dbFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
if (!writeDatabase(&dbFile, error)) { HashingStream hashingStream(&dbFile, QCryptographicHash::Md5, kFileBlockToHashSizeBytes);
if (!hashingStream.open(QIODevice::WriteOnly)) {
return false;
}
if (!writeDatabase(&hashingStream, error)) {
return false; return false;
} }
dbFile.close(); dbFile.close();
// store the new hash
m_fileBlockHash = hashingStream.hashingResult();
return true; return true;
} }
if (error) { if (error) {
@ -508,6 +561,9 @@ void Database::releaseData()
m_deletedObjects.clear(); m_deletedObjects.clear();
m_commonUsernames.clear(); m_commonUsernames.clear();
m_tagList.clear(); m_tagList.clear();
m_fileBlockHash.clear();
m_ignoreFileChangesUntilSaved = false;
} }
/** /**
@ -644,10 +700,33 @@ void Database::setFilePath(const QString& filePath)
m_data.filePath = filePath; m_data.filePath = filePath;
// Don't watch for changes until the next open or save operation // Don't watch for changes until the next open or save operation
m_fileWatcher->stop(); m_fileWatcher->stop();
m_ignoreFileChangesUntilSaved = false;
emit filePathChanged(oldPath, filePath); emit filePathChanged(oldPath, filePath);
} }
} }
const QByteArray& Database::fileBlockHash() const
{
return m_fileBlockHash;
}
void Database::setIgnoreFileChangesUntilSaved(bool ignore)
{
if (m_ignoreFileChangesUntilSaved != ignore) {
m_ignoreFileChangesUntilSaved = ignore;
if (ignore) {
m_fileWatcher->pause();
} else {
m_fileWatcher->resume();
}
}
}
bool Database::ignoreFileChangesUntilSaved() const
{
return m_ignoreFileChangesUntilSaved;
}
QList<DeletedObject> Database::deletedObjects() QList<DeletedObject> Database::deletedObjects()
{ {
return m_deletedObjects; return m_deletedObjects;

View File

@ -75,6 +75,9 @@ public:
~Database() override; ~Database() override;
private: private:
// size of the block of file data to hash for detecting external changes
static const quint32 kFileBlockToHashSizeBytes = 1024; // 1 KiB
bool writeDatabase(QIODevice* device, QString* error = nullptr); bool writeDatabase(QIODevice* device, QString* error = nullptr);
bool backupDatabase(const QString& filePath, const QString& destinationFilePath); bool backupDatabase(const QString& filePath, const QString& destinationFilePath);
bool restoreDatabase(const QString& filePath, const QString& fromBackupFilePath); bool restoreDatabase(const QString& filePath, const QString& fromBackupFilePath);
@ -108,6 +111,10 @@ public:
QString canonicalFilePath() const; QString canonicalFilePath() const;
void setFilePath(const QString& filePath); void setFilePath(const QString& filePath);
const QByteArray& fileBlockHash() const;
void setIgnoreFileChangesUntilSaved(bool ignore);
bool ignoreFileChangesUntilSaved() const;
QString publicName(); QString publicName();
void setPublicName(const QString& name); void setPublicName(const QString& name);
QString publicColor(); QString publicColor();
@ -181,7 +188,7 @@ signals:
void databaseOpened(); void databaseOpened();
void databaseSaved(); void databaseSaved();
void databaseDiscarded(); void databaseDiscarded();
void databaseFileChanged(); void databaseFileChanged(bool triggeredBySave);
void databaseNonDataChanged(); void databaseNonDataChanged();
void tagListUpdated(); void tagListUpdated();
@ -233,6 +240,8 @@ private:
void startModifiedTimer(); void startModifiedTimer();
void stopModifiedTimer(); void stopModifiedTimer();
QByteArray m_fileBlockHash;
bool m_ignoreFileChangesUntilSaved;
QPointer<Metadata> const m_metadata; QPointer<Metadata> const m_metadata;
DatabaseData m_data; DatabaseData m_data;
QPointer<Group> m_rootGroup; QPointer<Group> m_rootGroup;

View File

@ -79,17 +79,18 @@ void FileWatcher::stop()
m_fileChecksum.clear(); m_fileChecksum.clear();
m_fileChecksumTimer.stop(); m_fileChecksumTimer.stop();
m_fileChangeDelayTimer.stop(); m_fileChangeDelayTimer.stop();
m_paused = false;
} }
void FileWatcher::pause() void FileWatcher::pause()
{ {
m_ignoreFileChange = true; m_paused = true;
m_fileChangeDelayTimer.stop(); m_fileChangeDelayTimer.stop();
} }
void FileWatcher::resume() void FileWatcher::resume()
{ {
m_ignoreFileChange = false; m_paused = false;
// Add a short delay to start in the next event loop // Add a short delay to start in the next event loop
if (!m_fileIgnoreDelayTimer.isActive()) { if (!m_fileIgnoreDelayTimer.isActive()) {
m_fileIgnoreDelayTimer.start(0); m_fileIgnoreDelayTimer.start(0);
@ -98,7 +99,7 @@ void FileWatcher::resume()
bool FileWatcher::shouldIgnoreChanges() bool FileWatcher::shouldIgnoreChanges()
{ {
return m_filePath.isEmpty() || m_ignoreFileChange || m_fileIgnoreDelayTimer.isActive() return m_filePath.isEmpty() || m_ignoreFileChange || m_paused || m_fileIgnoreDelayTimer.isActive()
|| m_fileChangeDelayTimer.isActive(); || m_fileChangeDelayTimer.isActive();
} }
@ -118,7 +119,7 @@ void FileWatcher::checkFileChanged()
AsyncTask::runThenCallback([this] { return calculateChecksum(); }, AsyncTask::runThenCallback([this] { return calculateChecksum(); },
this, this,
[this](QByteArray checksum) { [this](const QByteArray& checksum) {
if (checksum != m_fileChecksum) { if (checksum != m_fileChecksum) {
m_fileChecksum = checksum; m_fileChecksum = checksum;
m_fileChangeDelayTimer.start(0); m_fileChangeDelayTimer.start(0);

View File

@ -56,6 +56,7 @@ private:
QTimer m_fileChecksumTimer; QTimer m_fileChecksumTimer;
int m_fileChecksumSizeBytes = -1; int m_fileChecksumSizeBytes = -1;
bool m_ignoreFileChange = false; bool m_ignoreFileChange = false;
bool m_paused = false;
}; };
#endif // KEEPASSXC_FILEWATCHER_H #endif // KEEPASSXC_FILEWATCHER_H

View File

@ -192,11 +192,34 @@ void DatabaseOpenDialog::clearForms()
m_tabBar->blockSignals(false); m_tabBar->blockSignals(false);
} }
void DatabaseOpenDialog::showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout)
{
m_view->showMessage(text, type, autoHideTimeout);
}
QSharedPointer<Database> DatabaseOpenDialog::database() const QSharedPointer<Database> DatabaseOpenDialog::database() const
{ {
return m_db; return m_db;
} }
void DatabaseOpenDialog::done(int result)
{
hide();
emit dialogFinished(result == QDialog::Accepted, m_currentDbWidget);
clearForms();
QDialog::done(result);
#if QT_VERSION < QT_VERSION_CHECK(6, 3, 0)
// CDialogs are not really closed, just hidden, pre Qt 6.3?
if (testAttribute(Qt::WA_DeleteOnClose)) {
setAttribute(Qt::WA_DeleteOnClose, false);
deleteLater();
}
#endif
}
void DatabaseOpenDialog::complete(bool accepted) void DatabaseOpenDialog::complete(bool accepted)
{ {
// save DB, since DatabaseOpenWidget will reset its data after accept() is called // save DB, since DatabaseOpenWidget will reset its data after accept() is called
@ -210,9 +233,6 @@ void DatabaseOpenDialog::complete(bool accepted)
} else { } else {
reject(); reject();
} }
emit dialogFinished(accepted, m_currentDbWidget);
clearForms();
} }
void DatabaseOpenDialog::closeEvent(QCloseEvent* e) void DatabaseOpenDialog::closeEvent(QCloseEvent* e)

View File

@ -19,6 +19,7 @@
#define KEEPASSX_UNLOCKDATABASEDIALOG_H #define KEEPASSX_UNLOCKDATABASEDIALOG_H
#include "core/Global.h" #include "core/Global.h"
#include "gui/MessageWidget.h"
#include <QDialog> #include <QDialog>
#include <QList> #include <QList>
@ -51,11 +52,13 @@ public:
Intent intent() const; Intent intent() const;
QSharedPointer<Database> database() const; QSharedPointer<Database> database() const;
void clearForms(); void clearForms();
void showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout);
signals: signals:
void dialogFinished(bool accepted, DatabaseWidget* dbWidget); void dialogFinished(bool accepted, DatabaseWidget* dbWidget);
public slots: public slots:
void done(int result) override;
void complete(bool accepted); void complete(bool accepted);
void tabChanged(int index); void tabChanged(int index);

View File

@ -220,6 +220,11 @@ bool DatabaseOpenWidget::unlockingDatabase()
return m_unlockingDatabase; return m_unlockingDatabase;
} }
void DatabaseOpenWidget::showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout)
{
m_ui->messageWidget->showMessage(text, type, autoHideTimeout);
}
void DatabaseOpenWidget::load(const QString& filename) void DatabaseOpenWidget::load(const QString& filename)
{ {
clearForms(); clearForms();

View File

@ -25,6 +25,7 @@
#include "config-keepassx.h" #include "config-keepassx.h"
#include "gui/DialogyWidget.h" #include "gui/DialogyWidget.h"
#include "gui/MessageWidget.h"
#ifdef WITH_XC_YUBIKEY #ifdef WITH_XC_YUBIKEY
#include "osutils/DeviceListener.h" #include "osutils/DeviceListener.h"
#endif #endif
@ -51,6 +52,7 @@ public:
void enterKey(const QString& pw, const QString& keyFile); void enterKey(const QString& pw, const QString& keyFile);
QSharedPointer<Database> database(); QSharedPointer<Database> database();
bool unlockingDatabase(); bool unlockingDatabase();
void showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout);
// Quick Unlock helper functions // Quick Unlock helper functions
bool canPerformQuickUnlock() const; bool canPerformQuickUnlock() const;

View File

@ -225,6 +225,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
connectDatabaseSignals(); connectDatabaseSignals();
m_blockAutoSave = false; m_blockAutoSave = false;
m_reloading = false;
m_autosaveTimer = new QTimer(this); m_autosaveTimer = new QTimer(this);
m_autosaveTimer->setSingleShot(true); m_autosaveTimer->setSingleShot(true);
@ -1984,6 +1985,11 @@ bool DatabaseWidget::lock()
return isLocked(); return isLocked();
} }
// ignore when reloading
if (m_reloading) {
return false;
}
// Don't try to lock the database while saving, this will cause a deadlock // Don't try to lock the database while saving, this will cause a deadlock
if (m_db->isSaving()) { if (m_db->isSaving()) {
QTimer::singleShot(200, this, SLOT(lock())); QTimer::singleShot(200, this, SLOT(lock()));
@ -2027,6 +2033,18 @@ bool DatabaseWidget::lock()
if (config()->get(Config::AutoSaveOnExit).toBool() if (config()->get(Config::AutoSaveOnExit).toBool()
|| config()->get(Config::AutoSaveAfterEveryChange).toBool()) { || config()->get(Config::AutoSaveAfterEveryChange).toBool()) {
saved = save(); saved = save();
if (!saved) {
// detect if a reload was triggered
bool reloadTriggered = false;
auto connection =
connect(this, &DatabaseWidget::reloadBegin, [&reloadTriggered] { reloadTriggered = true; });
QApplication::processEvents();
disconnect(connection);
if (reloadTriggered) {
return false;
}
}
} }
if (!saved) { if (!saved) {
@ -2085,53 +2103,77 @@ bool DatabaseWidget::lock()
return true; return true;
} }
void DatabaseWidget::reloadDatabaseFile() void DatabaseWidget::reloadDatabaseFile(bool triggeredBySave)
{ {
// Ignore reload if we are locked, saving, or currently editing an entry or group if (triggeredBySave) {
if (!m_db || isLocked() || isEntryEditActive() || isGroupEditActive() || isSaving()) { // not a failed save attempt due to file locking
m_saveAttempts = 0;
}
// Ignore reload if we are locked, saving, reloading, or currently editing an entry or group
if (!m_db || isLocked() || isEntryEditActive() || isGroupEditActive() || isSaving() || m_reloading) {
return; return;
} }
m_blockAutoSave = true; m_blockAutoSave = true;
m_reloading = true;
if (!config()->get(Config::AutoReloadOnChange).toBool()) { emit reloadBegin();
if (!triggeredBySave && !config()->get(Config::AutoReloadOnChange).toBool()) {
// Ask if we want to reload the db // Ask if we want to reload the db
auto result = MessageBox::question(this, auto result = MessageBox::question(
this,
tr("File has changed"), tr("File has changed"),
tr("The database file has changed. Do you want to load the changes?"), QString("%1.\n%2").arg(tr("The database file \"%1\" was modified externally").arg(displayFileName()),
tr("Do you want to load the changes?")),
MessageBox::Yes | MessageBox::No); MessageBox::Yes | MessageBox::No);
if (result == MessageBox::No) { if (result == MessageBox::No) {
// Notify everyone the database does not match the file // Notify everyone the database does not match the file
m_db->markAsModified(); m_db->markAsModified();
m_reloading = false;
emit reloadEnd();
return; return;
} }
} }
// Remove any latent error messages and switch to progress updates
hideMessage();
emit updateSyncProgress(0, tr("Reloading database…"));
// Lock out interactions // Lock out interactions
m_entryView->setDisabled(true); m_entryView->setDisabled(true);
m_groupView->setDisabled(true); m_groupView->setDisabled(true);
m_tagView->setDisabled(true); m_tagView->setDisabled(true);
QApplication::processEvents(); QApplication::processEvents();
QString error; auto reloadFinish = [this](bool hideMsg = true) {
auto db = QSharedPointer<Database>::create(m_db->filePath()); // Return control
if (db->open(database()->key(), &error)) { m_entryView->setDisabled(false);
if (m_db->isModified() || db->hasNonDataChanges()) { m_groupView->setDisabled(false);
// Ask if we want to merge changes into new database m_tagView->setDisabled(false);
auto result = MessageBox::question(
this,
tr("Merge Request"),
tr("The database file has changed and you have unsaved changes.\nDo you want to merge your changes?"),
MessageBox::Merge | MessageBox::Discard,
MessageBox::Merge);
if (result == MessageBox::Merge) { m_reloading = false;
// Keep the previous message visible for 2 seconds if not hiding
QTimer::singleShot(hideMsg ? 0 : 2000, this, [this] { emit updateSyncProgress(-1, ""); });
emit reloadEnd();
};
auto reloadCanceled = [this, reloadFinish] {
// Mark db as modified since existing data may differ from file or file was deleted
m_db->markAsModified();
emit updateSyncProgress(100, tr("Reload canceled"));
reloadFinish(false);
};
auto reloadContinue = [this, triggeredBySave, reloadFinish](QSharedPointer<Database> db, bool merge) {
if (merge) {
// Merge the old database into the new one // Merge the old database into the new one
Merger merger(m_db.data(), db.data()); Merger merger(m_db.data(), db.data());
merger.merge(); merger.merge();
} }
}
QUuid groupBeforeReload = m_db->rootGroup()->uuid(); QUuid groupBeforeReload = m_db->rootGroup()->uuid();
if (m_groupView && m_groupView->currentGroup()) { if (m_groupView && m_groupView->currentGroup()) {
@ -2147,17 +2189,108 @@ void DatabaseWidget::reloadDatabaseFile()
processAutoOpen(); processAutoOpen();
restoreGroupEntryFocus(groupBeforeReload, entryBeforeReload); restoreGroupEntryFocus(groupBeforeReload, entryBeforeReload);
m_blockAutoSave = false; m_blockAutoSave = false;
} else {
showMessage(tr("Could not open the new database file while attempting to autoreload.\nError: %1").arg(error), emit updateSyncProgress(100, tr("Reload successful"));
MessageWidget::Error); reloadFinish(false);
// Mark db as modified since existing data may differ from file or file was deleted
// If triggered by save, attempt another save
if (triggeredBySave) {
save();
}
};
auto db = QSharedPointer<Database>::create(m_db->filePath());
bool openResult = db->open(database()->key());
// skip if the db is unchanged, or the db file is gone or for sure not a kp-db
if (bool sameHash = db->fileBlockHash() == m_db->fileBlockHash() || db->fileBlockHash().isEmpty()) {
if (!sameHash) {
// db file gone or invalid so mark modified
m_db->markAsModified(); m_db->markAsModified();
} }
m_blockAutoSave = false;
reloadFinish();
return;
}
// Return control bool merge = false;
m_entryView->setDisabled(false); QString changesActionStr;
m_groupView->setDisabled(false); if (triggeredBySave || m_db->isModified() || m_db->hasNonDataChanges()) {
m_tagView->setDisabled(false); emit updateSyncProgress(50, tr("Reload pending user action…"));
// Ask how to proceed
auto message = tr("The database file \"%1\" was modified externally.<br>"
"How would you like to proceed?<br><br>"
"Merge all changes<br>"
"Ignore the changes on disk until save<br>"
"Discard unsaved changes")
.arg(displayFileName());
auto buttons = MessageBox::Merge | MessageBox::Discard | MessageBox::Ignore | MessageBox::Cancel;
// Different message if we are attempting to save
if (triggeredBySave) {
message = tr("The database file \"%1\" was modified externally.<br>"
"How would you like to proceed?<br><br>"
"Merge all changes then save<br>"
"Overwrite the changes on disk<br>"
"Discard unsaved changes")
.arg(displayFileName());
buttons = MessageBox::Merge | MessageBox::Discard | MessageBox::Overwrite | MessageBox::Cancel;
}
auto result = MessageBox::question(this, tr("Reload database"), message, buttons, MessageBox::Merge);
switch (result) {
case MessageBox::Cancel:
reloadCanceled();
return;
case MessageBox::Overwrite:
case MessageBox::Ignore:
m_db->setIgnoreFileChangesUntilSaved(true);
m_blockAutoSave = false;
reloadFinish(!triggeredBySave);
// If triggered by save, attempt another save
if (triggeredBySave) {
save();
emit updateSyncProgress(100, tr("Database file overwritten."));
}
return;
case MessageBox::Merge:
merge = true;
default:
break;
}
}
// Database file on disk previously opened successfully
if (openResult) {
reloadContinue(std::move(db), merge);
return;
}
// The user needs to provide credentials
auto dbWidget = new DatabaseWidget(std::move(db));
auto openDialog = new DatabaseOpenDialog(this);
connect(openDialog, &QObject::destroyed, [=](QObject*) { dbWidget->deleteLater(); });
connect(openDialog, &DatabaseOpenDialog::dialogFinished, this, [=](bool accepted, DatabaseWidget*) {
if (accepted) {
reloadContinue(openDialog->database(), merge);
} else {
reloadCanceled();
}
});
openDialog->setAttribute(Qt::WA_DeleteOnClose);
openDialog->addDatabaseTab(dbWidget);
openDialog->setActiveDatabaseTab(dbWidget);
openDialog->showMessage(tr("Database file on disk cannot be unlocked with current credentials.<br>"
"Enter new credentials and/or present hardware key to continue."),
MessageWidget::Error,
MessageWidget::DisableAutoHide);
// ensure the main window is visible for this
getMainWindow()->bringToFront();
// show the unlock dialog
openDialog->show();
openDialog->raise();
openDialog->activateWindow();
} }
int DatabaseWidget::numberOfSelectedEntries() const int DatabaseWidget::numberOfSelectedEntries() const
@ -2320,6 +2453,11 @@ bool DatabaseWidget::save()
return true; return true;
} }
// Do no try to save if the database is being reloaded
if (m_reloading) {
return false;
}
// Read-only and new databases ask for filename // Read-only and new databases ask for filename
if (m_db->filePath().isEmpty()) { if (m_db->filePath().isEmpty()) {
return saveAs(); return saveAs();
@ -2334,6 +2472,7 @@ bool DatabaseWidget::save()
m_saveAttempts = 0; m_saveAttempts = 0;
m_blockAutoSave = false; m_blockAutoSave = false;
m_autosaveTimer->stop(); // stop autosave delay to avoid triggering another save m_autosaveTimer->stop(); // stop autosave delay to avoid triggering another save
hideMessage();
return true; return true;
} }
@ -2375,6 +2514,11 @@ bool DatabaseWidget::saveAs()
return true; return true;
} }
// Do no try to save if the database is being reloaded
if (m_reloading) {
return false;
}
QString oldFilePath = m_db->filePath(); QString oldFilePath = m_db->filePath();
if (!QFileInfo::exists(oldFilePath)) { if (!QFileInfo::exists(oldFilePath)) {
QString defaultFileName = config()->get(Config::DefaultDatabaseFileName).toString(); QString defaultFileName = config()->get(Config::DefaultDatabaseFileName).toString();

View File

@ -170,6 +170,8 @@ signals:
void clearSearch(); void clearSearch();
void requestGlobalAutoType(const QString& search); void requestGlobalAutoType(const QString& search);
void requestSearch(const QString& search); void requestSearch(const QString& search);
void reloadBegin();
void reloadEnd();
public slots: public slots:
bool lock(); bool lock();
@ -287,7 +289,7 @@ private slots:
void finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result); void finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result);
void emitCurrentModeChanged(); void emitCurrentModeChanged();
// Database autoreload slots // Database autoreload slots
void reloadDatabaseFile(); void reloadDatabaseFile(bool triggeredBySave);
void restoreGroupEntryFocus(const QUuid& groupUuid, const QUuid& EntryUuid); void restoreGroupEntryFocus(const QUuid& groupUuid, const QUuid& EntryUuid);
void onConfigChanged(Config::ConfigKey key); void onConfigChanged(Config::ConfigKey key);
@ -338,6 +340,7 @@ private:
// Autoreload // Autoreload
bool m_blockAutoSave; bool m_blockAutoSave;
bool m_reloading;
// Autosave delay // Autosave delay
QPointer<QTimer> m_autosaveTimer; QPointer<QTimer> m_autosaveTimer;

View File

@ -0,0 +1,101 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "HashingStream.h"
HashingStream::HashingStream(QIODevice* baseDevice)
: LayeredStream(baseDevice)
, m_hash(QCryptographicHash::Md5)
, m_sizeToHash(0)
{
init();
}
HashingStream::HashingStream(QIODevice* baseDevice, QCryptographicHash::Algorithm hashAlgo, qint64 sizeToHash)
: LayeredStream(baseDevice)
, m_hash(hashAlgo)
, m_sizeToHash(sizeToHash)
{
init();
}
HashingStream::~HashingStream()
{
close();
}
void HashingStream::init()
{
m_sizeHashed = 0;
m_sizeStreamed = 0;
m_hashFinalized = false;
}
bool HashingStream::reset()
{
init();
m_hash.reset();
return LayeredStream::reset();
}
QByteArray HashingStream::hashingResult()
{
if (m_sizeHashed <= 0 || (m_sizeToHash > 0 && m_sizeHashed != m_sizeToHash)) {
setErrorString("Not enough data to compute hash");
return {};
}
m_hashFinalized = true;
return m_hash.result();
}
qint64 HashingStream::readData(char* data, qint64 maxSize)
{
auto sizeRead = LayeredStream::readData(data, maxSize);
if (sizeRead > 0) {
if (!m_hashFinalized) {
qint64 sizeToHash = sizeRead;
if (m_sizeToHash > 0) {
sizeToHash = qMin(m_sizeToHash - m_sizeStreamed, sizeRead);
}
if (sizeToHash > 0) {
m_hash.addData(data, sizeToHash);
m_sizeHashed += sizeToHash;
}
}
m_sizeStreamed += sizeRead;
}
return sizeRead;
}
qint64 HashingStream::writeData(const char* data, qint64 maxSize)
{
auto sizeWritten = LayeredStream::writeData(data, maxSize);
if (sizeWritten > 0) {
if (!m_hashFinalized) {
qint64 sizeToHash = sizeWritten;
if (m_sizeToHash > 0) {
sizeToHash = qMin(m_sizeToHash - m_sizeStreamed, sizeWritten);
}
if (sizeToHash > 0) {
m_hash.addData(data, sizeToHash);
m_sizeHashed += sizeToHash;
}
}
m_sizeStreamed += sizeWritten;
}
return sizeWritten;
}

View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSX_HASHINGSTREAM_H
#define KEEPASSX_HASHINGSTREAM_H
#include <QCryptographicHash>
#include "streams/LayeredStream.h"
class HashingStream : public LayeredStream
{
Q_OBJECT
public:
explicit HashingStream(QIODevice* baseDevice);
HashingStream(QIODevice* baseDevice, QCryptographicHash::Algorithm hashAlgo, qint64 sizeToHash);
~HashingStream() override;
bool reset() override;
QByteArray hashingResult();
protected:
void init();
qint64 readData(char* data, qint64 maxSize) override;
qint64 writeData(const char* data, qint64 maxSize) override;
protected:
QCryptographicHash m_hash;
bool m_hashFinalized;
qint64 m_sizeToHash;
qint64 m_sizeHashed;
qint64 m_sizeStreamed;
};
#endif // KEEPASSX_HASHINGSTREAM_H

View File

@ -164,7 +164,7 @@ void TestDatabase::testSignals()
// Short delay to allow file system settling to reduce test failures // Short delay to allow file system settling to reduce test failures
Tools::wait(100); Tools::wait(100);
QSignalSpy spyFileChanged(db.data(), SIGNAL(databaseFileChanged())); QSignalSpy spyFileChanged(db.data(), &Database::databaseFileChanged);
QVERIFY(tempFile.copyFromFile(dbFileName)); QVERIFY(tempFile.copyFromFile(dbFileName));
QTRY_COMPARE(spyFileChanged.count(), 1); QTRY_COMPARE(spyFileChanged.count(), 1);
QTRY_VERIFY(!db->isModified()); QTRY_VERIFY(!db->isModified());
@ -268,3 +268,41 @@ void TestDatabase::testCustomIcons()
QCOMPARE(iconData.name, QString("Test")); QCOMPARE(iconData.name, QString("Test"));
QCOMPARE(iconData.lastModified, date); QCOMPARE(iconData.lastModified, date);
} }
void TestDatabase::testExternallyModified()
{
TemporaryFile tempFile;
QVERIFY(tempFile.copyFromFile(dbFileName));
auto db = QSharedPointer<Database>::create();
auto key = QSharedPointer<CompositeKey>::create();
key->addKey(QSharedPointer<PasswordKey>::create("a"));
QString error;
QVERIFY(db->open(tempFile.fileName(), key, &error) == true);
db->metadata()->setName("test2");
QVERIFY(db->save(Database::Atomic, {}, &error));
QSignalSpy spyFileChanged(db.data(), &Database::databaseFileChanged);
QVERIFY(tempFile.copyFromFile(dbFileName));
QTRY_COMPARE(spyFileChanged.count(), 1);
// the first argument of the databaseFileChanged signal (triggeredBySave) should be false
QVERIFY(spyFileChanged.at(0).length() == 1);
QVERIFY(spyFileChanged.at(0).at(0).type() == QVariant::Bool);
QVERIFY(spyFileChanged.at(0).at(0).toBool() == false);
spyFileChanged.clear();
// shouldn't be able to save due to external changes
QVERIFY(db->save(Database::Atomic, {}, &error) == false);
QApplication::processEvents();
// save should have triggered another databaseFileChanged signal
QVERIFY(spyFileChanged.count() >= 1);
// the first argument of the databaseFileChanged signal (triggeredBySave) should be true
QVERIFY(spyFileChanged.at(0).at(0).type() == QVariant::Bool);
QVERIFY(spyFileChanged.at(0).at(0).toBool() == true);
// should be able to overwrite externally modified changes when explicitly requested
db->setIgnoreFileChangesUntilSaved(true);
QVERIFY(db->save(Database::Atomic, {}, &error));
// ignoreFileChangesUntilSaved should reset after save
QVERIFY(db->ignoreFileChangesUntilSaved() == false);
}

View File

@ -36,6 +36,7 @@ private slots:
void testEmptyRecycleBinOnEmpty(); void testEmptyRecycleBinOnEmpty();
void testEmptyRecycleBinWithHierarchicalData(); void testEmptyRecycleBinWithHierarchicalData();
void testCustomIcons(); void testCustomIcons();
void testExternallyModified();
}; };
#endif // KEEPASSX_TESTDATABASE_H #endif // KEEPASSX_TESTDATABASE_H