SWMMVis  6.0.0-alpha.4
Qt6/C++ GIS-based graphical user interface for the SWMMVis engine (6.0.0-alpha.4)
Loading...
Searching...
No Matches
projectsaveoutputs.h
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-3.0-or-later
2
12#pragma once
13
14#include <QCryptographicHash>
15#include <QJsonArray>
16#include <QJsonDocument>
17#include <QJsonObject>
18#include <QLockFile>
19#include <QUuid>
20#include <functional>
21#include <QDir>
22#include <QFile>
23#include <QFileInfo>
24#include <QSaveFile>
25#include <QTemporaryFile>
26#include <QString>
27#include <filesystem>
28#include <memory>
29#include <vector>
30
31// Synchronous built-in Save's explicit output set. Serialization writes only
32// owned adjacent staging files. A checked journal retains old/new bytes until
33// publication commits. Interrupted, uncommitted saves restore the old file set.
35{
36public:
37 enum Role { Model = 0, Mesh = 1, Component = 2, Settings = 3, Auxiliary = 4 };
39 QString resolvedPath;
40 QString fingerprint;
41 };
42 // Capture before a long-running generation job. Save can then distinguish
43 // its original destination from a file created or edited in the meantime.
44 // Missing destinations are valid, and capturing never creates directories.
52 static bool captureDestination(const QString &path, DestinationState *state,
53 QString *error = nullptr)
54 {
55 if (error)
56 error->clear();
57 if (state)
58 *state = {};
59 if (path.isEmpty() || !state) {
60 if (error)
61 *error = QStringLiteral("A save destination and state are required");
62 return false;
63 }
64 ProjectSaveOutputs checker;
65 DestinationState captured;
66 captured.resolvedPath = resolved(path);
67 if (captured.resolvedPath.isEmpty() || !checker.fingerprint(path, captured.fingerprint)) {
68 if (error)
69 *error = checker.error_.isEmpty()
70 ? QStringLiteral("Cannot resolve save destination %1").arg(path)
71 : checker.error_;
72 return false;
73 }
74 if (resolved(path) != captured.resolvedPath) {
75 if (error)
76 *error =
77 QStringLiteral("Save destination changed while being inspected: %1").arg(path);
78 return false;
79 }
80 *state = std::move(captured);
81 return true;
82 }
84 QString finalPath;
85 QString stagedPath;
87 };
92 QList<PreparedOutput> preparedOutputs() const
93 {
94 QList<PreparedOutput> outputs;
95 for (const auto &entry : entries_)
96 outputs.append({entry.finalPath, entry.file->fileName(), entry.role});
97 return outputs;
98 }
104 void protect(const QString &path, int allowedRole)
105 {
106 if (!path.isEmpty())
107 protected_.push_back({path, allowedRole});
108 }
109 // A temporary owner may recursively remove its directory when released.
110 // No published output can safely live anywhere inside that namespace.
115 void protectDirectory(const QString &path)
116 {
117 if (!path.isEmpty())
118 protectedDirectories_.push_back(
119 {QDir::cleanPath(QFileInfo(path).absoluteFilePath()), resolved(path)});
120 }
125 const QString &error() const { return error_; }
130 bool publicationStarted() const { return publicationStarted_; }
135 bool recoveryRequired() const { return publicationStarted_ && !rolledBack_; }
136
137 // Opening a project is the recovery boundary. Save refuses a pending journal
138 // rather than applying an edited in-memory model over an unresolved disk set.
147 static bool recover(const QString &model, QString *error, QString *notice = nullptr)
148 {
149 const QString physical = resolved(model);
150 const QFileInfo journal(physical + QStringLiteral(".openswmm-save.json"));
151 // Ordinary Open must continue to work on read-only project directories.
152 if (!journal.exists() && !journal.isSymLink() &&
153 !QFileInfo::exists(physical + QStringLiteral(".openswmm-save.lock")))
154 return true;
155 ProjectSaveOutputs transaction;
156 if (!transaction.acquire(model, true)) {
157 if (error)
158 *error = transaction.error();
159 return false;
160 }
161 if (!QFileInfo::exists(transaction.journalPath_) &&
162 !QFileInfo(transaction.journalPath_).isSymLink())
163 return true;
164 if (!transaction.readJournal() || !transaction.restoreOrFinish()) {
165 if (error)
166 *error =
167 transaction.error() +
168 QStringLiteral(" Recovery files retained at %1.").arg(transaction.journalPath_);
169 return false;
170 }
171 if (notice)
172 *notice =
173 transaction.committed_
174 ? QStringLiteral("Completed cleanup of a previously saved project.")
175 : QStringLiteral(
176 "Recovered an interrupted save; restored the previous saved project.");
177 return true;
178 }
179
189 QString stage(const QString &path, Role role, bool reuse = false,
190 const DestinationState *expected = nullptr)
191 {
192 if (path.isEmpty()) {
193 error_ = QStringLiteral("Empty save destination");
194 return {};
195 }
196 const QString finalPath = QFileInfo(path).absoluteFilePath();
197 if (!checkProtectedDirectories(finalPath))
198 return {};
199 if (role == Model && !lock_ && !acquire(finalPath, false))
200 return {};
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);
205 return {};
206 }
207 }
208 for (const auto &entry : entries_) {
209 if (!sameFile(entry.finalPath, finalPath))
210 continue;
211 if (reuse && entry.role == role && entry.finalPath == finalPath) {
212 if (expected) {
213 DestinationState current;
214 if (!captureDestination(finalPath, &current, &error_))
215 return {};
216 if (current.resolvedPath != expected->resolvedPath ||
217 current.fingerprint != expected->fingerprint) {
218 error_ = QStringLiteral("Destination changed since generation: %1")
219 .arg(finalPath);
220 return {};
221 }
222 }
223 return entry.file->fileName();
224 }
225 error_ = QStringLiteral("Conflicting save outputs share %1 and %2")
226 .arg(entry.finalPath, finalPath);
227 return {};
228 }
229 if (!journalPath_.isEmpty() &&
230 (sameFile(finalPath, journalPath_) ||
231 sameFile(finalPath, modelPath_ + QStringLiteral(".openswmm-save.lock")) ||
232 resolved(finalPath).startsWith(journalPath_ + QStringLiteral(".data-")))) {
233 error_ =
234 QStringLiteral("Output conflicts with Save recovery metadata: %1").arg(finalPath);
235 return {};
236 }
237 if (!checkDestination(finalPath))
238 return {};
239 // Do not create final directories during preparation. For new nested
240 // component destinations, use the nearest existing ancestor instead.
241 QDir parent = QFileInfo(finalPath).absoluteDir();
242 // GUI reference patching/settings serialization use the staging file's
243 // directory as their anchor, so these two must remain true siblings.
244 if ((role == Model || role == Settings) && !parent.exists()) {
245 error_ = QStringLiteral("Model/settings destination directory does not exist: %1")
246 .arg(finalPath);
247 return {};
248 }
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);
253 return {};
254 }
255 parent = QDir(ancestor);
256 }
257 std::unique_ptr<StagedFile> file;
258 {
259 // QTemporaryFile::close() keeps its handle open, and on Windows that
260 // handle blocks writers from renaming their output over the staged
261 // path. Let it go out of scope to release it; StagedFile owns removal.
262 QTemporaryFile temporary(parent.filePath(QStringLiteral(".openswmm-stage-XXXXXX")));
263 if (!temporary.open()) {
264 error_ =
265 QStringLiteral("Cannot stage %1: %2").arg(finalPath, temporary.errorString());
266 return {};
267 }
268 temporary.setAutoRemove(false);
269 file = std::make_unique<StagedFile>(temporary.fileName());
270 }
271 const QString stagedPath = file->fileName();
272 DestinationState current;
273 if (!captureDestination(finalPath, &current, &error_))
274 return {};
275 if (expected && (current.resolvedPath != expected->resolvedPath ||
276 current.fingerprint != expected->fingerprint)) {
277 error_ = QStringLiteral("Destination changed since generation: %1").arg(finalPath);
278 return {};
279 }
280 entries_.push_back(
281 {finalPath, role, std::move(file), current.resolvedPath, current.fingerprint});
282 return stagedPath;
283 }
284
290 bool publish()
291 {
292 // Validate the complete prepared set before the first final write.
293 // Aliases are checked again after serialization and before journaling.
294 for (const auto &entry : entries_) {
295 if (!checkProtectedDirectories(entry.finalPath))
296 return false;
297 if (!checkDestination(entry.finalPath))
298 return false;
299 if (!QFileInfo(entry.file->fileName()).isFile()) {
300 error_ = QStringLiteral("Missing staged output for %1").arg(entry.finalPath);
301 return false;
302 }
303 }
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);
309 return false;
310 }
311 }
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")
315 .arg(p.path);
316 return false;
317 }
318 }
319 }
320 if (!lock_) {
321 error_ = QStringLiteral("A model destination is required for transactional Save");
322 return false;
323 }
324 // Commit the manifest before changing any destination. Data paths are
325 // derived from an opaque ID; a journal never supplies arbitrary backup paths.
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_);
330 return false;
331 }
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_);
336 return false;
337 }
338 for (Role role : {Component, Mesh, Auxiliary, Settings, Model}) {
339 for (const auto &entry : entries_) {
340 if (entry.role != role)
341 continue;
342 // Three distinct failures, reported distinctly: one message for
343 // all of them hid an unreadable destination (fingerprint's own
344 // error was overwritten) behind "changed", sending users to look
345 // for an edit that never happened.
346 QString current;
347 const QString nowResolved = resolved(entry.finalPath);
348 if (nowResolved != entry.physicalPath) {
349 error_ =
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)
354 : QStringLiteral(
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);
358 cleanupData();
359 return false;
360 }
361 if (!fingerprint(entry.finalPath, current)) {
362 error_ = QStringLiteral("Destination could not be re-read before publishing "
363 "(%1): %2")
364 .arg(error_, entry.finalPath);
365 cleanupData();
366 return false;
367 }
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);
372 cleanupData();
373 return false;
374 }
375 Record record{entry.physicalPath,
376 entry.oldHash,
377 {},
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))) {
385 cleanupData();
386 return false;
387 }
388 QString backup;
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);
394 cleanupData();
395 return false;
396 }
397 }
398 }
399 if (!writeJournal(false)) {
400 cleanupData();
401 return false;
402 }
403 for (const auto &entry : entries_)
404 QFile::remove(entry.file->fileName());
405 auto fail = [&]() {
406 const QString cause = error_;
407 if (restoreOrFinish()) {
408 rolledBack_ = true;
409 error_ = cause + QStringLiteral(" Previous saved files were restored.");
410 } else {
411 error_ =
412 cause +
413 QStringLiteral(" Recovery could not finish: %1. Recovery files retained at %2.")
414 .arg(error_, journalPath_);
415 }
416 return false;
417 };
418 if (!checkpoint(0))
419 return fail();
420 for (int i = 0; i < int(records_.size()); ++i) {
421 const auto &record = records_[i];
422 QString current;
423 if (resolved(record.path) != record.path || !fingerprint(record.path, current) ||
424 current != record.oldHash) {
425 error_ =
426 QStringLiteral("Destination changed before publication: %1").arg(record.path);
427 return fail();
428 }
429 publicationStarted_ = true;
430 if (!QDir().mkpath(QFileInfo(record.path).absolutePath()) ||
431 !copyFile(dataFile(i, false), record.path, record.newHash)) {
432 if (error_.isEmpty())
433 error_ =
434 QStringLiteral("Cannot create output directory for %1").arg(record.path);
435 return fail();
436 }
437 if (!checkpoint(i + 1))
438 return fail();
439 }
440 for (const auto &record : records_) {
441 QString current;
442 if (resolved(record.path) != record.path || !fingerprint(record.path, current) ||
443 current != record.newHash) {
444 error_ =
445 QStringLiteral("Destination changed during publication: %1").arg(record.path);
446 return fail();
447 }
448 }
449 if (!writeJournal(true))
450 return fail();
451 committed_ = true;
452 // Once this marker exists, restart must retain the complete new set.
453 // Cleanup failure is harmless and retried by recovery on the next open.
454 checkpoint(int(records_.size()) + 1);
455 cleanupJournal();
456 return true;
457 }
458
459private:
465 static std::filesystem::path nativePath(const QString &path)
466 {
467#ifdef Q_OS_WIN
468 return std::filesystem::path(path.toStdWString());
469#else
470 return std::filesystem::path(path.toUtf8().constData());
471#endif
472 }
479 static bool sameFile(const QString &a, const QString &b)
480 {
481 std::error_code ec;
482 if (std::filesystem::equivalent(nativePath(a), nativePath(b), ec))
483 return true;
484 const auto ca = std::filesystem::weakly_canonical(nativePath(a), ec);
485 if (!ec) {
486 const auto cb = std::filesystem::weakly_canonical(nativePath(b), ec);
487 if (!ec && ca == cb)
488 return true;
489 }
490 return QDir::cleanPath(QFileInfo(a).absoluteFilePath()) ==
491 QDir::cleanPath(QFileInfo(b).absoluteFilePath());
492 }
498 bool checkDestination(const QString &path)
499 {
500 const QFileInfo info(path);
501 if ((info.isSymLink() && !info.exists()) || (info.exists() && !info.isFile())) {
502 error_ =
503 QStringLiteral("Cannot replace non-regular or dangling destination %1").arg(path);
504 return false;
505 }
506 if (info.exists() &&
507 !(info.permissions() &
508 (QFileDevice::WriteOwner | QFileDevice::WriteGroup | QFileDevice::WriteOther))) {
509 error_ = QStringLiteral("Cannot replace read-only destination %1").arg(path);
510 return false;
511 }
512 return true;
513 }
519 bool checkProtectedDirectories(const QString &path)
520 {
521 const auto within = [](const QString &candidate, const QString &directory) {
522 if (candidate.isEmpty() || directory.isEmpty())
523 return false;
524 const QString prefix = directory.endsWith('/') ? directory : directory + '/';
525#ifdef Q_OS_WIN
526 constexpr auto sensitivity = Qt::CaseInsensitive;
527#else
528 constexpr auto sensitivity = Qt::CaseSensitive;
529#endif
530 return candidate.compare(directory, sensitivity) == 0 ||
531 candidate.startsWith(prefix, sensitivity);
532 };
533 const QString logical = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
534 const QString physical = resolved(path);
535 for (const auto &directory : protectedDirectories_) {
536 // Keep the original identity protected, and also check its current
537 // resolution in case an ancestor symlink changed after registration.
538 if (within(logical, directory.path) || within(physical, directory.resolvedPath) ||
539 within(physical, resolved(directory.path))) {
540 error_ =
541 QStringLiteral("Output %1 would be published inside protected directory %2")
542 .arg(path, directory.path);
543 return false;
544 }
545 }
546 return true;
547 }
548 struct Protected {
549 QString path;
550 int allowedRole;
551 };
552 struct ProtectedDirectory {
553 QString path, resolvedPath;
554 };
555 // A uniquely named staging file with no open handle, removed on destruction.
556 struct StagedFile {
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_; }
579
580 private:
581 QString path_;
582 };
583 struct Entry {
584 QString finalPath;
585 Role role;
586 std::unique_ptr<StagedFile> file;
587 QString physicalPath, oldHash;
588 };
589 struct Record {
590 QString path, oldHash, newHash;
591 int permissions = 0;
592 };
597 static QString absent() { return QStringLiteral("absent"); }
603 static QString resolved(const QString &path)
604 {
605 std::error_code ec;
606 const auto canonical =
607 std::filesystem::weakly_canonical(nativePath(QFileInfo(path).absoluteFilePath()), ec);
608 if (ec)
609 return {};
610#ifdef Q_OS_WIN
611 return QString::fromStdWString(canonical.wstring());
612#else
613 return QString::fromUtf8(canonical.string().c_str());
614#endif
615 }
622 bool fingerprint(const QString &path, QString &hash)
623 {
624 const QFileInfo info(path);
625 if (!info.exists() && !info.isSymLink()) {
626 hash = absent();
627 return true;
628 }
629 QFile file(path);
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());
633 return false;
634 }
635 hash = QString::fromLatin1(digest.result().toHex());
636 return true;
637 }
645 bool acquire(const QString &model, bool recovering)
646 {
647 modelPath_ = resolved(model);
648 if (modelPath_.isEmpty()) {
649 error_ = QStringLiteral("Cannot resolve model destination %1").arg(model);
650 return false;
651 }
652 journalPath_ = modelPath_ + QStringLiteral(".openswmm-save.json");
653 lock_ = std::make_unique<QLockFile>(modelPath_ + QStringLiteral(".openswmm-save.lock"));
654 // A long Save is not a stale lock; QLockFile still detects exited processes.
655 lock_->setStaleLockTime(0);
656 if (!lock_->tryLock()) {
657 error_ = QStringLiteral("Another operation is saving or recovering %1").arg(model);
658 return false;
659 }
660 if (!recovering &&
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_);
665 return false;
666 }
667 return true;
668 }
675 QString dataFile(int index, bool old) const
676 {
677 return QDir(dataPath_).filePath(QString::number(index) +
678 (old ? QStringLiteral(".old") : QStringLiteral(".new")));
679 }
687 bool copyFile(const QString &source, const QString &target, const QString &expected = {})
688 {
689 QFile input(source);
690 QSaveFile output(target);
691 output.setDirectWriteFallback(false);
692 if (!input.open(QIODevice::ReadOnly) || !output.open(QIODevice::WriteOnly)) {
693 error_ = QStringLiteral("Cannot copy %1 to %2: %3")
694 .arg(source, target,
695 input.isOpen() ? output.errorString() : input.errorString());
696 return false;
697 }
698 QCryptographicHash digest(QCryptographicHash::Sha256);
699 char buffer[65536];
700 while (true) {
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();
706 return false;
707 }
708 if (!n)
709 break;
710 if (!expected.isEmpty())
711 digest.addData(QByteArrayView(buffer, n));
712 }
713 if (!expected.isEmpty() && QString::fromLatin1(digest.result().toHex()) != expected) {
714 error_ = QStringLiteral("Save source changed while copying %1").arg(source);
715 output.cancelWriting();
716 return false;
717 }
718 if (!output.commit()) {
719 error_ = QStringLiteral("Cannot publish %1: %2").arg(target, output.errorString());
720 return false;
721 }
722 return true;
723 }
729 bool writeJournal(bool committed)
730 {
731 QJsonArray entries;
732 for (const auto &r : records_)
733 entries.append(QJsonObject{{"path", r.path},
734 {"old", r.oldHash},
735 {"new", r.newHash},
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}})
743 .toJson();
744 QSaveFile file(journalPath_);
745 file.setDirectWriteFallback(false);
746 if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() ||
747 !file.commit()) {
748 error_ = QStringLiteral("Cannot record save recovery journal %1: %2")
749 .arg(journalPath_, file.errorString());
750 return false;
751 }
752 return true;
753 }
759 bool readJournal()
760 {
761 auto invalid = [&]() {
762 error_ = QStringLiteral("Invalid save recovery journal %1").arg(journalPath_);
763 return false;
764 };
765 QFile file(journalPath_);
766 if (QFileInfo(file).isSymLink() || !file.open(QIODevice::ReadOnly) ||
767 file.size() > 16 * 1024 * 1024)
768 return invalid();
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())
779 return invalid();
780 dataPath_ = QFileInfo(journalPath_).absoluteDir().filePath(data);
781 if (!QFileInfo(dataPath_).isDir() || QFileInfo(dataPath_).isSymLink())
782 return invalid();
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;
787 };
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()))
799 return invalid();
800 for (const auto &prior : records_)
801 if (sameFile(prior.path, r.path))
802 return invalid();
803 hasModel |= r.path == modelPath_;
804 records_.push_back(r);
805 }
806 if (!hasModel)
807 return invalid();
808 return true;
809 }
815 bool restoreOrFinish()
816 {
817 // Validate the entire set before the first repair. Unknown changes or
818 // corrupt rollback bytes require intervention, never silent overwrite.
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))) {
825 error_ =
826 QStringLiteral("Save recovery found a changed destination: %1").arg(r.path);
827 return false;
828 }
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));
834 return false;
835 }
836 }
837 if (!committed_)
838 for (int i = int(records_.size()) - 1; i >= 0; --i) {
839 const auto &r = records_[i];
840 QString current;
841 if (resolved(r.path) != r.path || !fingerprint(r.path, current)) {
842 error_ = QStringLiteral("Destination identity changed during recovery: %1")
843 .arg(r.path);
844 return false;
845 }
846 if (current == r.oldHash) {
847 if (current != absent() &&
848 !QFile::setPermissions(r.path, QFile::Permissions(r.permissions))) {
849 error_ =
850 QStringLiteral("Cannot restore file permissions for %1").arg(r.path);
851 return false;
852 }
853 continue;
854 }
855 if (current != r.newHash) {
856 error_ = QStringLiteral("Destination changed during recovery: %1").arg(r.path);
857 return false;
858 }
859 if (r.oldHash == absent()) {
860 if (!QFile::remove(r.path)) {
861 error_ =
862 QStringLiteral("Cannot remove interrupted new output %1").arg(r.path);
863 return false;
864 }
865 } else {
866 if (!copyFile(dataFile(i, true), r.path, r.oldHash))
867 return false;
868 if (!QFile::setPermissions(r.path, QFile::Permissions(r.permissions))) {
869 error_ =
870 QStringLiteral("Cannot restore file permissions for %1").arg(r.path);
871 return false;
872 }
873 }
874 }
875 return cleanupJournal();
876 }
881 bool cleanupJournal()
882 {
883 if (!QFile::remove(journalPath_)) {
884 error_ =
885 QStringLiteral("Cannot remove completed recovery journal %1").arg(journalPath_);
886 return false;
887 }
888 cleanupData();
889 return true;
890 }
895 void cleanupData()
896 {
897 // Never recursively remove a directory or follow journal-provided names.
898 for (int i = 0; i < int(records_.size()); ++i) {
899 QFile::remove(dataFile(i, true));
900 QFile::remove(dataFile(i, false));
901 }
902 QDir().rmdir(dataPath_);
903 }
909 bool checkpoint(int count)
910 {
911 if (!checkpoint_ || checkpoint_(count))
912 return true;
913 error_ = QStringLiteral("Publication interrupted at checkpoint %1").arg(count);
914 return false;
915 }
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_;
926 QString error_;
927 bool publicationStarted_ = false;
928};
Definition projectsaveoutputs.h:35
Role
Definition projectsaveoutputs.h:37
@ Auxiliary
Definition projectsaveoutputs.h:37
@ Model
Definition projectsaveoutputs.h:37
@ Settings
Definition projectsaveoutputs.h:37
@ Mesh
Definition projectsaveoutputs.h:37
@ Component
Definition projectsaveoutputs.h:37
QString stage(const QString &path, Role role, bool reuse=false, const DestinationState *expected=nullptr)
Reserve an owned temporary file for a checked final destination.
Definition projectsaveoutputs.h:189
bool recoveryRequired() const
Inspect the stored publication/rollback state.
Definition projectsaveoutputs.h:135
static bool recover(const QString &model, QString *error, QString *notice=nullptr)
Acquire the project save lock and repair or finish an existing checked recovery journal.
Definition projectsaveoutputs.h:147
bool publicationStarted() const
Inspect whether this transaction attempted any final-file replacement.
Definition projectsaveoutputs.h:130
bool publish()
Validate and publish the complete staged set with a checked rollback journal.
Definition projectsaveoutputs.h:290
const QString & error() const
Access the most recently recorded transaction diagnostic.
Definition projectsaveoutputs.h:125
static bool captureDestination(const QString &path, DestinationState *state, QString *error=nullptr)
Snapshot a destination's physical identity and SHA-256 content, without creating directories.
Definition projectsaveoutputs.h:52
void protectDirectory(const QString &path)
Exclude a directory and all descendants from publication.
Definition projectsaveoutputs.h:115
QList< PreparedOutput > preparedOutputs() const
Snapshot final paths, current staging paths and roles in preparation order.
Definition projectsaveoutputs.h:92
void protect(const QString &path, int allowedRole)
Register an input identity that outputs with other roles must preserve.
Definition projectsaveoutputs.h:104
size_t i
Definition contourjob.cpp:30
ModelFacade * model
Definition extensionmodelaccess.cpp:625
QString path
Populated by the worker only after its own successful atomic creation.
Definition extensiontimeseriesaccess.cpp:32
QString target
Definition initialbedqualitydialog.cpp:51
double s
Scene px per model length unit.
Definition inletdrawingview.cpp:95
SWMM_FilePathRole role
Definition ioportabilitynormalizer.cpp:26
ArrowPlacement p
Definition linesymbollayer.cpp:29
QPointer< OpenSWMMVisLayer > source
Definition maskclipresolver.cpp:34
Definition blocks.hpp:25
QObject * obj
Definition featurestyleeditor.cpp:435
int parent
Definition planning.cpp:199
double value
Definition rasterfluxintegration.cpp:27
std::uint64_t bytes
Definition smsio.cpp:24
std::ifstream input
Definition smsio.cpp:21
Definition projectsaveoutputs.h:38
QString resolvedPath
Definition projectsaveoutputs.h:39
QString fingerprint
Definition projectsaveoutputs.h:40
Definition projectsaveoutputs.h:83
QString stagedPath
Definition projectsaveoutputs.h:85
Role role
Definition projectsaveoutputs.h:86
QString finalPath
Definition projectsaveoutputs.h:84
double a
Definition terrainerrorfield.cpp:42
double b
Definition terrainerrorfield.cpp:42
double error
Definition terrainerrorfield.cpp:42
quint64 count
Definition terrainerrorfield.cpp:43