53 QString *
error =
nullptr)
59 if (
path.isEmpty() || !state) {
61 *
error = QStringLiteral(
"A save destination and state are required");
69 *
error = checker.error_.isEmpty()
70 ? QStringLiteral(
"Cannot resolve save destination %1").arg(
path)
77 QStringLiteral(
"Save destination changed while being inspected: %1").arg(
path);
80 *state = std::move(captured);
94 QList<PreparedOutput> outputs;
95 for (
const auto &entry : entries_)
96 outputs.append({entry.finalPath, entry.file->fileName(), entry.role});
107 protected_.push_back({
path, allowedRole});
118 protectedDirectories_.push_back(
119 {QDir::cleanPath(QFileInfo(
path).absoluteFilePath()), resolved(
path)});
125 const QString &
error()
const {
return error_; }
149 const QString physical = resolved(
model);
150 const QFileInfo journal(physical + QStringLiteral(
".openswmm-save.json"));
152 if (!journal.exists() && !journal.isSymLink() &&
153 !QFileInfo::exists(physical + QStringLiteral(
".openswmm-save.lock")))
156 if (!transaction.acquire(
model,
true)) {
161 if (!QFileInfo::exists(transaction.journalPath_) &&
162 !QFileInfo(transaction.journalPath_).isSymLink())
164 if (!transaction.readJournal() || !transaction.restoreOrFinish()) {
167 transaction.
error() +
168 QStringLiteral(
" Recovery files retained at %1.").arg(transaction.journalPath_);
173 transaction.committed_
174 ? QStringLiteral(
"Completed cleanup of a previously saved project.")
176 "Recovered an interrupted save; restored the previous saved project.");
192 if (
path.isEmpty()) {
193 error_ = QStringLiteral(
"Empty save destination");
196 const QString finalPath = QFileInfo(
path).absoluteFilePath();
197 if (!checkProtectedDirectories(finalPath))
199 if (
role == Model && !lock_ && !acquire(finalPath,
false))
201 for (
const auto &
p : protected_) {
202 if (
p.allowedRole !=
role && sameFile(
p.path, finalPath)) {
203 error_ = QStringLiteral(
"Output %1 would replace protected project input %2")
204 .arg(finalPath,
p.path);
208 for (
const auto &entry : entries_) {
209 if (!sameFile(entry.finalPath, finalPath))
211 if (reuse && entry.role ==
role && entry.finalPath == finalPath) {
214 if (!captureDestination(finalPath, ¤t, &error_))
218 error_ = QStringLiteral(
"Destination changed since generation: %1")
223 return entry.file->fileName();
225 error_ = QStringLiteral(
"Conflicting save outputs share %1 and %2")
226 .arg(entry.finalPath, finalPath);
229 if (!journalPath_.isEmpty() &&
230 (sameFile(finalPath, journalPath_) ||
231 sameFile(finalPath, modelPath_ + QStringLiteral(
".openswmm-save.lock")) ||
232 resolved(finalPath).startsWith(journalPath_ + QStringLiteral(
".data-")))) {
234 QStringLiteral(
"Output conflicts with Save recovery metadata: %1").arg(finalPath);
237 if (!checkDestination(finalPath))
241 QDir
parent = QFileInfo(finalPath).absoluteDir();
245 error_ = QStringLiteral(
"Model/settings destination directory does not exist: %1")
249 while (!
parent.exists()) {
250 const QString ancestor = QFileInfo(
parent.absolutePath()).absolutePath();
251 if (ancestor ==
parent.absolutePath()) {
252 error_ = QStringLiteral(
"No staging directory for %1").arg(finalPath);
257 std::unique_ptr<StagedFile> file;
262 QTemporaryFile temporary(
parent.filePath(QStringLiteral(
".openswmm-stage-XXXXXX")));
263 if (!temporary.open()) {
265 QStringLiteral(
"Cannot stage %1: %2").arg(finalPath, temporary.errorString());
268 temporary.setAutoRemove(
false);
269 file = std::make_unique<StagedFile>(temporary.fileName());
271 const QString stagedPath = file->fileName();
273 if (!captureDestination(finalPath, ¤t, &error_))
275 if (expected && (current.
resolvedPath != expected->resolvedPath ||
277 error_ = QStringLiteral(
"Destination changed since generation: %1").arg(finalPath);
294 for (
const auto &entry : entries_) {
295 if (!checkProtectedDirectories(entry.finalPath))
297 if (!checkDestination(entry.finalPath))
299 if (!QFileInfo(entry.file->fileName()).isFile()) {
300 error_ = QStringLiteral(
"Missing staged output for %1").arg(entry.finalPath);
304 for (
size_t i = 0;
i < entries_.size(); ++
i) {
305 for (
size_t j = 0; j <
i; ++j) {
306 if (sameFile(entries_[
i].finalPath, entries_[j].finalPath)) {
307 error_ = QStringLiteral(
"Save destinations now alias: %1")
308 .arg(entries_[
i].finalPath);
312 for (
const auto &
p : protected_) {
313 if (
p.allowedRole != entries_[
i].role && sameFile(
p.path, entries_[
i].finalPath)) {
314 error_ = QStringLiteral(
"Save destination now aliases protected input: %1")
321 error_ = QStringLiteral(
"A model destination is required for transactional Save");
326 dataPath_ = journalPath_ + QStringLiteral(
".data-") +
327 QUuid::createUuid().toString(QUuid::WithoutBraces);
328 if (!QDir().mkdir(dataPath_)) {
329 error_ = QStringLiteral(
"Cannot create recovery directory %1").arg(dataPath_);
332 if (!QFile::setPermissions(dataPath_,
333 QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)) {
334 error_ = QStringLiteral(
"Cannot secure recovery directory %1").arg(dataPath_);
335 QDir().rmdir(dataPath_);
338 for (
Role role : {Component, Mesh, Auxiliary, Settings, Model}) {
339 for (
const auto &entry : entries_) {
340 if (entry.role !=
role)
347 const QString nowResolved = resolved(entry.finalPath);
348 if (nowResolved != entry.physicalPath) {
350 nowResolved.isEmpty()
351 ? QStringLiteral(
"Destination path could not be resolved when Save was "
352 "published (it resolved to %1 when Save began): %2")
353 .arg(entry.physicalPath, entry.finalPath)
355 "Destination changed while Save was being prepared — it "
356 "now resolves to %1 instead of %2: %3")
357 .arg(nowResolved, entry.physicalPath, entry.finalPath);
361 if (!fingerprint(entry.finalPath, current)) {
362 error_ = QStringLiteral(
"Destination could not be re-read before publishing "
364 .arg(error_, entry.finalPath);
368 if (current != entry.oldHash) {
369 error_ = QStringLiteral(
"Destination changed while Save was being prepared "
370 "(its contents differ from when Save began): %1")
371 .arg(entry.finalPath);
375 Record record{entry.physicalPath,
378 int(QFile::permissions(entry.finalPath))};
379 records_.push_back(record);
380 const int i = int(records_.size()) - 1;
381 if (!copyFile(entry.file->fileName(), dataFile(
i,
false)) ||
382 !fingerprint(dataFile(
i,
false), records_.back().newHash) ||
383 (current != absent() &&
384 !copyFile(entry.finalPath, dataFile(
i,
true), current))) {
389 if (current != absent() &&
390 (!fingerprint(dataFile(
i,
true), backup) || backup != current)) {
391 error_ = QStringLiteral(
392 "Destination changed while its rollback copy was prepared: %1")
393 .arg(entry.finalPath);
399 if (!writeJournal(
false)) {
403 for (
const auto &entry : entries_)
404 QFile::remove(entry.file->fileName());
406 const QString cause = error_;
407 if (restoreOrFinish()) {
409 error_ = cause + QStringLiteral(
" Previous saved files were restored.");
413 QStringLiteral(
" Recovery could not finish: %1. Recovery files retained at %2.")
414 .arg(error_, journalPath_);
420 for (
int i = 0;
i < int(records_.size()); ++
i) {
421 const auto &record = records_[
i];
423 if (resolved(record.path) != record.path || !fingerprint(record.path, current) ||
424 current != record.oldHash) {
426 QStringLiteral(
"Destination changed before publication: %1").arg(record.path);
429 publicationStarted_ =
true;
430 if (!QDir().mkpath(QFileInfo(record.path).absolutePath()) ||
431 !copyFile(dataFile(
i,
false), record.path, record.newHash)) {
432 if (error_.isEmpty())
434 QStringLiteral(
"Cannot create output directory for %1").arg(record.path);
437 if (!checkpoint(
i + 1))
440 for (
const auto &record : records_) {
442 if (resolved(record.path) != record.path || !fingerprint(record.path, current) ||
443 current != record.newHash) {
445 QStringLiteral(
"Destination changed during publication: %1").arg(record.path);
449 if (!writeJournal(
true))
454 checkpoint(
int(records_.size()) + 1);
465 static std::filesystem::path nativePath(
const QString &
path)
468 return std::filesystem::path(
path.toStdWString());
470 return std::filesystem::path(
path.toUtf8().constData());
479 static bool sameFile(
const QString &
a,
const QString &
b)
482 if (std::filesystem::equivalent(nativePath(
a), nativePath(
b), ec))
484 const auto ca = std::filesystem::weakly_canonical(nativePath(
a), ec);
486 const auto cb = std::filesystem::weakly_canonical(nativePath(
b), ec);
490 return QDir::cleanPath(QFileInfo(
a).absoluteFilePath()) ==
491 QDir::cleanPath(QFileInfo(
b).absoluteFilePath());
498 bool checkDestination(
const QString &
path)
500 const QFileInfo info(
path);
501 if ((info.isSymLink() && !info.exists()) || (info.exists() && !info.isFile())) {
503 QStringLiteral(
"Cannot replace non-regular or dangling destination %1").arg(
path);
507 !(info.permissions() &
508 (QFileDevice::WriteOwner | QFileDevice::WriteGroup | QFileDevice::WriteOther))) {
509 error_ = QStringLiteral(
"Cannot replace read-only destination %1").arg(
path);
519 bool checkProtectedDirectories(
const QString &
path)
521 const auto within = [](
const QString &candidate,
const QString &directory) {
522 if (candidate.isEmpty() || directory.isEmpty())
524 const QString prefix = directory.endsWith(
'/') ? directory : directory +
'/';
526 constexpr auto sensitivity = Qt::CaseInsensitive;
528 constexpr auto sensitivity = Qt::CaseSensitive;
530 return candidate.compare(directory, sensitivity) == 0 ||
531 candidate.startsWith(prefix, sensitivity);
533 const QString logical = QDir::cleanPath(QFileInfo(
path).absoluteFilePath());
534 const QString physical = resolved(
path);
535 for (
const auto &directory : protectedDirectories_) {
538 if (within(logical, directory.path) || within(physical, directory.resolvedPath) ||
539 within(physical, resolved(directory.path))) {
541 QStringLiteral(
"Output %1 would be published inside protected directory %2")
542 .arg(
path, directory.path);
552 struct ProtectedDirectory {
553 QString
path, resolvedPath;
561 explicit StagedFile(QString
path) : path_(std::move(
path)) {}
565 ~StagedFile() { QFile::remove(path_); }
569 StagedFile(
const StagedFile &) =
delete;
573 StagedFile &operator=(
const StagedFile &) =
delete;
578 const QString &fileName()
const {
return path_; }
586 std::unique_ptr<StagedFile> file;
587 QString physicalPath, oldHash;
590 QString
path, oldHash, newHash;
597 static QString absent() {
return QStringLiteral(
"absent"); }
603 static QString resolved(
const QString &
path)
606 const auto canonical =
607 std::filesystem::weakly_canonical(nativePath(QFileInfo(
path).absoluteFilePath()), ec);
611 return QString::fromStdWString(canonical.wstring());
613 return QString::fromUtf8(canonical.string().c_str());
622 bool fingerprint(
const QString &
path, QString &hash)
624 const QFileInfo info(
path);
625 if (!info.exists() && !info.isSymLink()) {
630 QCryptographicHash digest(QCryptographicHash::Sha256);
631 if (!info.isFile() || !file.open(QIODevice::ReadOnly) || !digest.addData(&file)) {
632 error_ = QStringLiteral(
"Cannot verify save file %1: %2").arg(
path, file.errorString());
635 hash = QString::fromLatin1(digest.result().toHex());
645 bool acquire(
const QString &
model,
bool recovering)
647 modelPath_ = resolved(
model);
648 if (modelPath_.isEmpty()) {
649 error_ = QStringLiteral(
"Cannot resolve model destination %1").arg(
model);
652 journalPath_ = modelPath_ + QStringLiteral(
".openswmm-save.json");
653 lock_ = std::make_unique<QLockFile>(modelPath_ + QStringLiteral(
".openswmm-save.lock"));
655 lock_->setStaleLockTime(0);
656 if (!lock_->tryLock()) {
657 error_ = QStringLiteral(
"Another operation is saving or recovering %1").arg(
model);
661 (QFileInfo::exists(journalPath_) || QFileInfo(journalPath_).isSymLink())) {
662 error_ = QStringLiteral(
663 "An interrupted save needs recovery. Reopen %1 before saving; journal: %2")
664 .arg(
model, journalPath_);
675 QString dataFile(
int index,
bool old)
const
677 return QDir(dataPath_).filePath(QString::number(index) +
678 (old ? QStringLiteral(
".old") : QStringLiteral(
".new")));
687 bool copyFile(
const QString &
source,
const QString &
target,
const QString &expected = {})
691 output.setDirectWriteFallback(
false);
692 if (!
input.open(QIODevice::ReadOnly) || !output.open(QIODevice::WriteOnly)) {
693 error_ = QStringLiteral(
"Cannot copy %1 to %2: %3")
695 input.isOpen() ? output.errorString() :
input.errorString());
698 QCryptographicHash digest(QCryptographicHash::Sha256);
701 const qint64
n =
input.read(buffer,
sizeof buffer);
702 if (
n < 0 || (
n > 0 && output.write(buffer,
n) !=
n)) {
703 error_ = QStringLiteral(
"Cannot copy save bytes to %1: %2")
704 .arg(
target,
n < 0 ?
input.errorString() : output.errorString());
705 output.cancelWriting();
710 if (!expected.isEmpty())
711 digest.addData(QByteArrayView(buffer,
n));
713 if (!expected.isEmpty() && QString::fromLatin1(digest.result().toHex()) != expected) {
714 error_ = QStringLiteral(
"Save source changed while copying %1").arg(
source);
715 output.cancelWriting();
718 if (!output.commit()) {
719 error_ = QStringLiteral(
"Cannot publish %1: %2").arg(
target, output.errorString());
729 bool writeJournal(
bool committed)
732 for (
const auto &r : records_)
733 entries.append(QJsonObject{{
"path", r.path},
736 {
"permissions", r.permissions}});
737 const QByteArray
bytes =
738 QJsonDocument(QJsonObject{{
"version", 1},
739 {
"model", modelPath_},
740 {
"data", QFileInfo(dataPath_).fileName()},
741 {
"committed", committed},
742 {
"entries", entries}})
744 QSaveFile file(journalPath_);
745 file.setDirectWriteFallback(
false);
746 if (!file.open(QIODevice::WriteOnly) || file.write(
bytes) !=
bytes.size() ||
748 error_ = QStringLiteral(
"Cannot record save recovery journal %1: %2")
749 .arg(journalPath_, file.errorString());
761 auto invalid = [&]() {
762 error_ = QStringLiteral(
"Invalid save recovery journal %1").arg(journalPath_);
765 QFile file(journalPath_);
766 if (QFileInfo(file).isSymLink() || !file.open(QIODevice::ReadOnly) ||
767 file.size() > 16 * 1024 * 1024)
769 QJsonParseError parse;
770 const auto doc = QJsonDocument::fromJson(file.readAll(), &parse);
771 const auto obj = doc.object();
772 const QString data =
obj.value(
"data").toString();
773 const QString prefix = QFileInfo(journalPath_).fileName() + QStringLiteral(
".data-");
774 const QString
id = data.mid(prefix.size());
775 if (parse.error != QJsonParseError::NoError ||
obj.value(
"version").toInt() != 1 ||
776 obj.value(
"model").toString() != modelPath_ || !
obj.value(
"committed").isBool() ||
777 !data.startsWith(prefix) || QUuid(
id).isNull() ||
778 QUuid(
id).toString(QUuid::WithoutBraces) !=
id || !
obj.value(
"entries").isArray())
780 dataPath_ = QFileInfo(journalPath_).absoluteDir().filePath(data);
781 if (!QFileInfo(dataPath_).isDir() || QFileInfo(dataPath_).isSymLink())
783 committed_ =
obj.value(
"committed").toBool();
784 auto hashValid = [](
const QString &
s) {
785 return s.size() == 64 &&
786 QString::fromLatin1(QByteArray::fromHex(
s.toLatin1()).toHex()) ==
s;
788 bool hasModel =
false;
789 for (
const auto &v :
obj.
value(
"entries").toArray()) {
790 const auto o = v.toObject();
791 Record r{o.value(
"path").toString(), o.value(
"old").toString(),
792 o.value(
"new").toString(), o.value(
"permissions").toInt()};
793 if (r.path.isEmpty() || !QFileInfo(r.path).isAbsolute() ||
794 QDir::cleanPath(r.path) != r.path ||
795 (r.oldHash != absent() && !hashValid(r.oldHash)) || !hashValid(r.newHash) ||
796 sameFile(r.path, journalPath_) ||
797 sameFile(r.path, modelPath_ + QStringLiteral(
".openswmm-save.lock")) ||
798 r.path.startsWith(dataPath_ + QDir::separator()))
800 for (
const auto &prior : records_)
803 hasModel |= r.path == modelPath_;
804 records_.push_back(r);
815 bool restoreOrFinish()
819 for (
int i = 0;
i < int(records_.size()); ++
i) {
820 const auto &r = records_[
i];
821 QString current, backup;
822 if (resolved(r.path) != r.path || !fingerprint(r.path, current) ||
823 (committed_ ? current != r.newHash
824 : (current != r.oldHash && current != r.newHash))) {
826 QStringLiteral(
"Save recovery found a changed destination: %1").arg(r.path);
829 if (!committed_ && r.oldHash != absent() &&
830 (QFileInfo(dataFile(
i,
true)).isSymLink() ||
831 !fingerprint(dataFile(
i,
true), backup) || backup != r.oldHash)) {
832 error_ = QStringLiteral(
"Save recovery copy is missing or damaged: %1")
833 .arg(dataFile(
i,
true));
838 for (
int i =
int(records_.size()) - 1;
i >= 0; --
i) {
839 const auto &r = records_[
i];
841 if (resolved(r.path) != r.path || !fingerprint(r.path, current)) {
842 error_ = QStringLiteral(
"Destination identity changed during recovery: %1")
846 if (current == r.oldHash) {
847 if (current != absent() &&
848 !QFile::setPermissions(r.path, QFile::Permissions(r.permissions))) {
850 QStringLiteral(
"Cannot restore file permissions for %1").arg(r.path);
855 if (current != r.newHash) {
856 error_ = QStringLiteral(
"Destination changed during recovery: %1").arg(r.path);
859 if (r.oldHash == absent()) {
860 if (!QFile::remove(r.path)) {
862 QStringLiteral(
"Cannot remove interrupted new output %1").arg(r.path);
866 if (!copyFile(dataFile(
i,
true), r.path, r.oldHash))
868 if (!QFile::setPermissions(r.path, QFile::Permissions(r.permissions))) {
870 QStringLiteral(
"Cannot restore file permissions for %1").arg(r.path);
875 return cleanupJournal();
881 bool cleanupJournal()
883 if (!QFile::remove(journalPath_)) {
885 QStringLiteral(
"Cannot remove completed recovery journal %1").arg(journalPath_);
898 for (
int i = 0;
i < int(records_.size()); ++
i) {
899 QFile::remove(dataFile(
i,
true));
900 QFile::remove(dataFile(
i,
false));
902 QDir().rmdir(dataPath_);
909 bool checkpoint(
int count)
911 if (!checkpoint_ || checkpoint_(
count))
913 error_ = QStringLiteral(
"Publication interrupted at checkpoint %1").arg(
count);
916 friend class ProjectSaveOutputsTestAccess;
917 std::function<bool(
int)> checkpoint_;
918 std::unique_ptr<QLockFile> lock_;
919 QString modelPath_, journalPath_, dataPath_;
920 std::vector<Record> records_;
921 bool committed_ =
false;
922 bool rolledBack_ =
false;
923 std::vector<Protected> protected_;
924 std::vector<ProtectedDirectory> protectedDirectories_;
925 std::vector<Entry> entries_;
927 bool publicationStarted_ =
false;