diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index 571100639..1bc3afec6 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -1513,6 +1513,10 @@ Backup database located at %2
Recycle Bin
+
+ Database file read error.
+
+ DatabaseOpenDialog
@@ -2683,24 +2687,6 @@ Save changes?
File has changed
-
- The database file has changed. Do you want to load the changes?
-
-
-
- Merge Request
-
-
-
- The database file has changed and you have unsaved changes.
-Do you want to merge your changes?
-
-
-
- Could not open the new database file while attempting to autoreload.
-Error: %1
-
- Disable safe saves?
@@ -2787,6 +2773,50 @@ Disable safe saves and try again?
Do you want to remove the passkey from this entry?
+
+ The database file "%1" was modified externally
+
+
+
+ Do you want to load the changes?
+
+
+
+ Reload database
+
+
+
+ Reloading database…
+
+
+
+ Reload canceled
+
+
+
+ Reload successful
+
+
+
+ Reload pending user action…
+
+
+
+ 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
+
+
+
+ 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
+
+
+
+ Database file overwritten.
+
+
+
+ Database file on disk cannot be unlocked with current credentials.<br>Enter new credentials and/or present hardware key to continue.
+
+ EditEntryWidget
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 84c6090ba..f6a20e107 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -96,6 +96,7 @@ set(core_SOURCES
keys/PasswordKey.cpp
keys/ChallengeResponseKey.cpp
streams/HashedBlockStream.cpp
+ streams/HashingStream.cpp
streams/HmacBlockStream.cpp
streams/LayeredStream.cpp
streams/qtiocompressor.cpp
diff --git a/src/core/Database.cpp b/src/core/Database.cpp
index 5734f9521..d3254c5b1 100644
--- a/src/core/Database.cpp
+++ b/src/core/Database.cpp
@@ -25,6 +25,7 @@
#include "format/KdbxXmlReader.h"
#include "format/KeePass2Reader.h"
#include "format/KeePass2Writer.h"
+#include "streams/HashingStream.h"
#include
#include
@@ -62,8 +63,8 @@ Database::Database()
updateTagList();
});
connect(this, &Database::modified, this, [this] { updateTagList(); });
- connect(this, &Database::databaseSaved, this, [this]() { updateCommonUsernames(); });
- connect(m_fileWatcher, &FileWatcher::fileChanged, this, &Database::databaseFileChanged);
+ connect(this, &Database::databaseSaved, this, [this] { updateCommonUsernames(); });
+ connect(m_fileWatcher, &FileWatcher::fileChanged, this, [this] { emit databaseFileChanged(false); });
// static uuid map
s_uuidMap.insert(m_uuid, this);
@@ -151,6 +152,20 @@ bool Database::open(const QString& filePath, QSharedPointer
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;
if (!reader.readDatabase(&dbFile, std::move(key), this)) {
if (error) {
@@ -260,14 +275,33 @@ bool Database::saveAs(const QString& filePath, SaveAction action, const QString&
return false;
}
- if (filePath == m_data.filePath) {
- // Fail-safe check to make sure we don't overwrite underlying file changes
- // that have not yet triggered a file reload/merge operation.
- if (!m_fileWatcher->hasSameFileChecksum()) {
- if (error) {
- *error = tr("Database file has unmerged changes.");
+ // Make sure we don't overwrite external modifications unless explicitly allowed
+ if (!m_ignoreFileChangesUntilSaved && !m_fileBlockHash.isEmpty() && filePath == m_data.filePath) {
+ QFile dbFile(filePath);
+ 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) {
+ *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;
+ }
+ } else if (dbFile.size() >= kFileBlockToHashSizeBytes) {
+ if (error) {
+ *error = tr("Database file read error.");
+ }
+ return false;
}
- return false;
}
}
@@ -302,7 +336,7 @@ bool Database::saveAs(const QString& filePath, SaveAction action, const QString&
SetFileAttributes(realFilePath.toStdString().c_str(), FILE_ATTRIBUTE_HIDDEN);
}
#endif
-
+ m_ignoreFileChangesUntilSaved = false;
m_fileWatcher->start(realFilePath, 30, 1);
} else {
// 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: {
QSaveFile saveFile(filePath);
if (saveFile.open(QIODevice::WriteOnly)) {
+ HashingStream hashingStream(&saveFile, QCryptographicHash::Md5, kFileBlockToHashSizeBytes);
+ if (!hashingStream.open(QIODevice::WriteOnly)) {
+ return false;
+ }
// write the database to the file
- if (!writeDatabase(&saveFile, error)) {
+ if (!writeDatabase(&hashingStream, error)) {
return false;
}
@@ -334,6 +372,9 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
saveFile.setFileTime(createTime, QFile::FileBirthTime);
if (saveFile.commit()) {
+ // store the new hash
+ m_fileBlockHash = hashingStream.hashingResult();
+
// successfully saved database file
return true;
}
@@ -347,8 +388,12 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
case TempFile: {
QTemporaryFile tempFile;
if (tempFile.open()) {
+ HashingStream hashingStream(&tempFile, QCryptographicHash::Md5, kFileBlockToHashSizeBytes);
+ if (!hashingStream.open(QIODevice::WriteOnly)) {
+ return false;
+ }
// write the database to the file
- if (!writeDatabase(&tempFile, error)) {
+ if (!writeDatabase(&hashingStream, error)) {
return false;
}
tempFile.close(); // flush to disk
@@ -366,6 +411,8 @@ bool Database::performSave(const QString& filePath, SaveAction action, const QSt
QFile::setPermissions(filePath, perms);
// Retain original creation time
tempFile.setFileTime(createTime, QFile::FileBirthTime);
+ // store the new hash
+ m_fileBlockHash = hashingStream.hashingResult();
return true;
} else if (backupFilePath.isEmpty() || !restoreDatabase(filePath, backupFilePath)) {
// 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
QFile dbFile(filePath);
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;
}
dbFile.close();
+ // store the new hash
+ m_fileBlockHash = hashingStream.hashingResult();
return true;
}
if (error) {
@@ -508,6 +561,9 @@ void Database::releaseData()
m_deletedObjects.clear();
m_commonUsernames.clear();
m_tagList.clear();
+
+ m_fileBlockHash.clear();
+ m_ignoreFileChangesUntilSaved = false;
}
/**
@@ -644,10 +700,33 @@ void Database::setFilePath(const QString& filePath)
m_data.filePath = filePath;
// Don't watch for changes until the next open or save operation
m_fileWatcher->stop();
+ m_ignoreFileChangesUntilSaved = false;
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 Database::deletedObjects()
{
return m_deletedObjects;
diff --git a/src/core/Database.h b/src/core/Database.h
index 29314650e..0d183e778 100644
--- a/src/core/Database.h
+++ b/src/core/Database.h
@@ -75,6 +75,9 @@ public:
~Database() override;
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 backupDatabase(const QString& filePath, const QString& destinationFilePath);
bool restoreDatabase(const QString& filePath, const QString& fromBackupFilePath);
@@ -108,6 +111,10 @@ public:
QString canonicalFilePath() const;
void setFilePath(const QString& filePath);
+ const QByteArray& fileBlockHash() const;
+ void setIgnoreFileChangesUntilSaved(bool ignore);
+ bool ignoreFileChangesUntilSaved() const;
+
QString publicName();
void setPublicName(const QString& name);
QString publicColor();
@@ -181,7 +188,7 @@ signals:
void databaseOpened();
void databaseSaved();
void databaseDiscarded();
- void databaseFileChanged();
+ void databaseFileChanged(bool triggeredBySave);
void databaseNonDataChanged();
void tagListUpdated();
@@ -233,6 +240,8 @@ private:
void startModifiedTimer();
void stopModifiedTimer();
+ QByteArray m_fileBlockHash;
+ bool m_ignoreFileChangesUntilSaved;
QPointer const m_metadata;
DatabaseData m_data;
QPointer m_rootGroup;
diff --git a/src/core/FileWatcher.cpp b/src/core/FileWatcher.cpp
index 1cf3be8e6..3a3c6d83b 100644
--- a/src/core/FileWatcher.cpp
+++ b/src/core/FileWatcher.cpp
@@ -79,17 +79,18 @@ void FileWatcher::stop()
m_fileChecksum.clear();
m_fileChecksumTimer.stop();
m_fileChangeDelayTimer.stop();
+ m_paused = false;
}
void FileWatcher::pause()
{
- m_ignoreFileChange = true;
+ m_paused = true;
m_fileChangeDelayTimer.stop();
}
void FileWatcher::resume()
{
- m_ignoreFileChange = false;
+ m_paused = false;
// Add a short delay to start in the next event loop
if (!m_fileIgnoreDelayTimer.isActive()) {
m_fileIgnoreDelayTimer.start(0);
@@ -98,7 +99,7 @@ void FileWatcher::resume()
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();
}
@@ -118,7 +119,7 @@ void FileWatcher::checkFileChanged()
AsyncTask::runThenCallback([this] { return calculateChecksum(); },
this,
- [this](QByteArray checksum) {
+ [this](const QByteArray& checksum) {
if (checksum != m_fileChecksum) {
m_fileChecksum = checksum;
m_fileChangeDelayTimer.start(0);
diff --git a/src/core/FileWatcher.h b/src/core/FileWatcher.h
index 27159d17a..7a5649764 100644
--- a/src/core/FileWatcher.h
+++ b/src/core/FileWatcher.h
@@ -56,6 +56,7 @@ private:
QTimer m_fileChecksumTimer;
int m_fileChecksumSizeBytes = -1;
bool m_ignoreFileChange = false;
+ bool m_paused = false;
};
#endif // KEEPASSXC_FILEWATCHER_H
diff --git a/src/gui/DatabaseOpenDialog.cpp b/src/gui/DatabaseOpenDialog.cpp
index fa9383ac2..881db4087 100644
--- a/src/gui/DatabaseOpenDialog.cpp
+++ b/src/gui/DatabaseOpenDialog.cpp
@@ -192,11 +192,34 @@ void DatabaseOpenDialog::clearForms()
m_tabBar->blockSignals(false);
}
+void DatabaseOpenDialog::showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout)
+{
+ m_view->showMessage(text, type, autoHideTimeout);
+}
+
QSharedPointer DatabaseOpenDialog::database() const
{
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)
{
// save DB, since DatabaseOpenWidget will reset its data after accept() is called
@@ -210,9 +233,6 @@ void DatabaseOpenDialog::complete(bool accepted)
} else {
reject();
}
-
- emit dialogFinished(accepted, m_currentDbWidget);
- clearForms();
}
void DatabaseOpenDialog::closeEvent(QCloseEvent* e)
diff --git a/src/gui/DatabaseOpenDialog.h b/src/gui/DatabaseOpenDialog.h
index d630ec67b..ba6fcb4e8 100644
--- a/src/gui/DatabaseOpenDialog.h
+++ b/src/gui/DatabaseOpenDialog.h
@@ -19,6 +19,7 @@
#define KEEPASSX_UNLOCKDATABASEDIALOG_H
#include "core/Global.h"
+#include "gui/MessageWidget.h"
#include
#include
@@ -51,11 +52,13 @@ public:
Intent intent() const;
QSharedPointer database() const;
void clearForms();
+ void showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout);
signals:
void dialogFinished(bool accepted, DatabaseWidget* dbWidget);
public slots:
+ void done(int result) override;
void complete(bool accepted);
void tabChanged(int index);
diff --git a/src/gui/DatabaseOpenWidget.cpp b/src/gui/DatabaseOpenWidget.cpp
index 11179da83..1a84a7185 100644
--- a/src/gui/DatabaseOpenWidget.cpp
+++ b/src/gui/DatabaseOpenWidget.cpp
@@ -220,6 +220,11 @@ bool DatabaseOpenWidget::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)
{
clearForms();
diff --git a/src/gui/DatabaseOpenWidget.h b/src/gui/DatabaseOpenWidget.h
index e0a250e3d..7c52717a0 100644
--- a/src/gui/DatabaseOpenWidget.h
+++ b/src/gui/DatabaseOpenWidget.h
@@ -25,6 +25,7 @@
#include "config-keepassx.h"
#include "gui/DialogyWidget.h"
+#include "gui/MessageWidget.h"
#ifdef WITH_XC_YUBIKEY
#include "osutils/DeviceListener.h"
#endif
@@ -51,6 +52,7 @@ public:
void enterKey(const QString& pw, const QString& keyFile);
QSharedPointer database();
bool unlockingDatabase();
+ void showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout);
// Quick Unlock helper functions
bool canPerformQuickUnlock() const;
diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp
index 2b4266c90..57edca636 100644
--- a/src/gui/DatabaseWidget.cpp
+++ b/src/gui/DatabaseWidget.cpp
@@ -225,6 +225,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent)
connectDatabaseSignals();
m_blockAutoSave = false;
+ m_reloading = false;
m_autosaveTimer = new QTimer(this);
m_autosaveTimer->setSingleShot(true);
@@ -1984,6 +1985,11 @@ bool DatabaseWidget::lock()
return isLocked();
}
+ // ignore when reloading
+ if (m_reloading) {
+ return false;
+ }
+
// Don't try to lock the database while saving, this will cause a deadlock
if (m_db->isSaving()) {
QTimer::singleShot(200, this, SLOT(lock()));
@@ -2027,6 +2033,18 @@ bool DatabaseWidget::lock()
if (config()->get(Config::AutoSaveOnExit).toBool()
|| config()->get(Config::AutoSaveAfterEveryChange).toBool()) {
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) {
@@ -2085,52 +2103,76 @@ bool DatabaseWidget::lock()
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 (!m_db || isLocked() || isEntryEditActive() || isGroupEditActive() || isSaving()) {
+ if (triggeredBySave) {
+ // 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;
}
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
- auto result = MessageBox::question(this,
- tr("File has changed"),
- tr("The database file has changed. Do you want to load the changes?"),
- MessageBox::Yes | MessageBox::No);
+ auto result = MessageBox::question(
+ this,
+ tr("File has changed"),
+ 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);
if (result == MessageBox::No) {
// Notify everyone the database does not match the file
m_db->markAsModified();
+ m_reloading = false;
+
+ emit reloadEnd();
return;
}
}
+ // Remove any latent error messages and switch to progress updates
+ hideMessage();
+ emit updateSyncProgress(0, tr("Reloading database…"));
+
// Lock out interactions
m_entryView->setDisabled(true);
m_groupView->setDisabled(true);
m_tagView->setDisabled(true);
QApplication::processEvents();
- QString error;
- auto db = QSharedPointer::create(m_db->filePath());
- if (db->open(database()->key(), &error)) {
- if (m_db->isModified() || db->hasNonDataChanges()) {
- // Ask if we want to merge changes into new database
- 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);
+ auto reloadFinish = [this](bool hideMsg = true) {
+ // Return control
+ m_entryView->setDisabled(false);
+ m_groupView->setDisabled(false);
+ m_tagView->setDisabled(false);
- if (result == MessageBox::Merge) {
- // Merge the old database into the new one
- Merger merger(m_db.data(), db.data());
- merger.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 db, bool merge) {
+ if (merge) {
+ // Merge the old database into the new one
+ Merger merger(m_db.data(), db.data());
+ merger.merge();
}
QUuid groupBeforeReload = m_db->rootGroup()->uuid();
@@ -2147,17 +2189,108 @@ void DatabaseWidget::reloadDatabaseFile()
processAutoOpen();
restoreGroupEntryFocus(groupBeforeReload, entryBeforeReload);
m_blockAutoSave = false;
- } else {
- showMessage(tr("Could not open the new database file while attempting to autoreload.\nError: %1").arg(error),
- MessageWidget::Error);
- // Mark db as modified since existing data may differ from file or file was deleted
- m_db->markAsModified();
+
+ emit updateSyncProgress(100, tr("Reload successful"));
+ reloadFinish(false);
+
+ // If triggered by save, attempt another save
+ if (triggeredBySave) {
+ save();
+ }
+ };
+
+ auto db = QSharedPointer::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_blockAutoSave = false;
+ reloadFinish();
+ return;
}
- // Return control
- m_entryView->setDisabled(false);
- m_groupView->setDisabled(false);
- m_tagView->setDisabled(false);
+ bool merge = false;
+ QString changesActionStr;
+ if (triggeredBySave || m_db->isModified() || m_db->hasNonDataChanges()) {
+ emit updateSyncProgress(50, tr("Reload pending user action…"));
+
+ // Ask how to proceed
+ auto message = tr("The database file \"%1\" was modified externally. "
+ "How would you like to proceed?
"
+ "Merge all changes "
+ "Ignore the changes on disk until save "
+ "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. "
+ "How would you like to proceed?
"
+ "Merge all changes then save "
+ "Overwrite the changes on disk "
+ "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. "
+ "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
@@ -2320,6 +2453,11 @@ bool DatabaseWidget::save()
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
if (m_db->filePath().isEmpty()) {
return saveAs();
@@ -2334,6 +2472,7 @@ bool DatabaseWidget::save()
m_saveAttempts = 0;
m_blockAutoSave = false;
m_autosaveTimer->stop(); // stop autosave delay to avoid triggering another save
+ hideMessage();
return true;
}
@@ -2375,6 +2514,11 @@ bool DatabaseWidget::saveAs()
return true;
}
+ // Do no try to save if the database is being reloaded
+ if (m_reloading) {
+ return false;
+ }
+
QString oldFilePath = m_db->filePath();
if (!QFileInfo::exists(oldFilePath)) {
QString defaultFileName = config()->get(Config::DefaultDatabaseFileName).toString();
diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h
index 1e85dd312..a96c9d488 100644
--- a/src/gui/DatabaseWidget.h
+++ b/src/gui/DatabaseWidget.h
@@ -170,6 +170,8 @@ signals:
void clearSearch();
void requestGlobalAutoType(const QString& search);
void requestSearch(const QString& search);
+ void reloadBegin();
+ void reloadEnd();
public slots:
bool lock();
@@ -287,7 +289,7 @@ private slots:
void finishSync(const RemoteParams* params, RemoteHandler::RemoteResult result);
void emitCurrentModeChanged();
// Database autoreload slots
- void reloadDatabaseFile();
+ void reloadDatabaseFile(bool triggeredBySave);
void restoreGroupEntryFocus(const QUuid& groupUuid, const QUuid& EntryUuid);
void onConfigChanged(Config::ConfigKey key);
@@ -338,6 +340,7 @@ private:
// Autoreload
bool m_blockAutoSave;
+ bool m_reloading;
// Autosave delay
QPointer m_autosaveTimer;
diff --git a/src/streams/HashingStream.cpp b/src/streams/HashingStream.cpp
new file mode 100644
index 000000000..5139dae87
--- /dev/null
+++ b/src/streams/HashingStream.cpp
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#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;
+}
diff --git a/src/streams/HashingStream.h b/src/streams/HashingStream.h
new file mode 100644
index 000000000..5f8c2ac3e
--- /dev/null
+++ b/src/streams/HashingStream.h
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * 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 .
+ */
+
+#ifndef KEEPASSX_HASHINGSTREAM_H
+#define KEEPASSX_HASHINGSTREAM_H
+
+#include
+
+#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
\ No newline at end of file
diff --git a/tests/TestDatabase.cpp b/tests/TestDatabase.cpp
index 3b20d6fc5..78b6dac1d 100644
--- a/tests/TestDatabase.cpp
+++ b/tests/TestDatabase.cpp
@@ -164,7 +164,7 @@ void TestDatabase::testSignals()
// Short delay to allow file system settling to reduce test failures
Tools::wait(100);
- QSignalSpy spyFileChanged(db.data(), SIGNAL(databaseFileChanged()));
+ QSignalSpy spyFileChanged(db.data(), &Database::databaseFileChanged);
QVERIFY(tempFile.copyFromFile(dbFileName));
QTRY_COMPARE(spyFileChanged.count(), 1);
QTRY_VERIFY(!db->isModified());
@@ -268,3 +268,41 @@ void TestDatabase::testCustomIcons()
QCOMPARE(iconData.name, QString("Test"));
QCOMPARE(iconData.lastModified, date);
}
+
+void TestDatabase::testExternallyModified()
+{
+ TemporaryFile tempFile;
+ QVERIFY(tempFile.copyFromFile(dbFileName));
+
+ auto db = QSharedPointer::create();
+ auto key = QSharedPointer::create();
+ key->addKey(QSharedPointer::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);
+}
diff --git a/tests/TestDatabase.h b/tests/TestDatabase.h
index 9f4bfab56..e23b23cf8 100644
--- a/tests/TestDatabase.h
+++ b/tests/TestDatabase.h
@@ -36,6 +36,7 @@ private slots:
void testEmptyRecycleBinOnEmpty();
void testEmptyRecycleBinWithHierarchicalData();
void testCustomIcons();
+ void testExternallyModified();
};
#endif // KEEPASSX_TESTDATABASE_H