diff --git a/src/libsync/filesystem.cpp b/src/libsync/filesystem.cpp index 9e5f6ef10ca47..4fb2172e57b7d 100644 --- a/src/libsync/filesystem.cpp +++ b/src/libsync/filesystem.cpp @@ -16,11 +16,14 @@ #include #include #include +#include #include +#include #include #include #include +#include #ifdef Q_OS_WIN #include @@ -80,6 +83,96 @@ bool autoCADSiblingLockFileExists(const QString &lockFilePath) }); } +// Unlike Office (`~$…`) and LibreOffice (`.~lock.…#`) lock files, Adobe lock file +// names do not encode the guarded document's own extension, only its base name. +// The guarded document has to be located among the lock file's siblings. +// - `idlk`: InDesign documents (`indd`) and InCopy stories (`icml`). +// - `prlock`: Premiere Pro projects (`prproj`). +static const std::map> adobeLockFileDocumentExtensions = { + {"idlk", {"indd", "icml"}}, + {"prlock", {"prproj"}}, +}; + +bool isAdobeLockFileExtension(const QString &path) +{ + const auto suffix = QFileInfo{path}.suffix().toLower().toStdString(); + return adobeLockFileDocumentExtensions.contains(suffix); +} + +// Returns the candidate document extensions guarded by the given Adobe lock file +// extension (lowercased, without the dot), in lookup order. Returns std::nullopt +// if the extension is not a recognised Adobe lock file extension. +std::optional> adobeDocumentExtensionsFor(const QString &lockExtension) +{ + const auto lockExt = lockExtension.toStdString(); + const auto it = adobeLockFileDocumentExtensions.find(lockExt); + if (it == adobeLockFileDocumentExtensions.cend()) { + return std::nullopt; + } + QList result; + result.reserve(static_cast(it->second.size())); + for (const auto &documentExtension : it->second) { + result.append(QString::fromStdString(std::string(documentExtension))); + } + return result; +} + +// Parses the document base name embedded in an Adobe lock file name. +// - InDesign / InCopy (.idlk): `Test` is extracted from `~Test~0kjyv(.idlk`. +// - Premiere Pro (.prlock): `Test` is extracted from `Test.prlock`. +// Adobe lock file names drop the guarded document's own extension, so only the +// base name can be recovered here. Returns std::nullopt if \a lockExtension is +// not a recognised Adobe lock file extension, or if \a lockFileName does not +// match the naming pattern expected for it. +std::optional adobeLockFileDocumentBaseName(const QString &lockFileName, const QString &lockExtension) +{ + static const QRegularExpression idlkPattern(QStringLiteral(R"(^~(?.+)~[^~]*\(\.idlk$)"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression prlockPattern(QStringLiteral(R"(^(?.+)\.prlock$)"), + QRegularExpression::CaseInsensitiveOption); + + const auto pattern = lockExtension == QLatin1String("idlk") ? &idlkPattern + : lockExtension == QLatin1String("prlock") ? &prlockPattern + : nullptr; + if (!pattern) { + return std::nullopt; + } + + const auto match = pattern->match(lockFileName); + if (!match.hasMatch()) { + return std::nullopt; + } + return match.captured(QStringLiteral("base")); +} + +// Resolve the document guarded by an Adobe lock file by matching a sibling file +// in the same directory by base name and expected document extension. Returns +// the guarded document's absolute file path, or an empty string if no matching +// document is found. +std::optional adobeLockFileTargetFilePath(const QString &lockFilePath) +{ + const QFileInfo lockFileInfo{lockFilePath}; + const auto lockExtension = QFileInfo{lockFileInfo.fileName()}.suffix().toLower(); + const auto documentExtensions = adobeDocumentExtensionsFor(lockExtension); + if (!documentExtensions || documentExtensions->isEmpty()) { + return std::nullopt; + } + + const auto baseName = adobeLockFileDocumentBaseName(lockFileInfo.fileName(), lockExtension); + if (!baseName || baseName->isEmpty()) { + return std::nullopt; + } + + const QDir dir = lockFileInfo.dir(); + for (const auto &documentExtension : *documentExtensions) { + const auto candidatePath = dir.absoluteFilePath(*baseName + QLatin1Char('.') + documentExtension); + if (QFileInfo::exists(candidatePath)) { + return candidatePath; + } + } + return std::nullopt; +} + // iterates through the dirPath to find the matching fileName QString findMatchingUnlockedFileInDir(const QString &dirPath, const QString &lockFileName) { @@ -129,6 +222,14 @@ QString FileSystem::filePathLockFilePatternMatch(const QString &path) return QStringLiteral(".") + suffix; } + // Adobe lock files (.idlk / .prlock) are identified by extension, not prefix. + const auto suffix = QFileInfo{pathSplit.last()}.suffix().toLower().toStdString(); + if (adobeLockFileDocumentExtensions.contains(suffix)) { + const auto pattern = QStringLiteral(".") + QString::fromStdString(suffix); + qCDebug(OCC::lcFileSystem) << "Found an Adobe lock file with extension:" << pattern << "in path:" << path; + return pattern; + } + return {}; } @@ -144,6 +245,15 @@ bool FileSystem::isMatchingAutoCADDocumentExtension(const QString &path) return QFileInfo{path}.suffix().toLower().toStdString() == autoCADDocumentExtension; } +bool FileSystem::isMatchingAdobeDocumentExtension(const QString &path) +{ + const auto pathSplit = path.split(QLatin1Char('.')); + const auto extension = pathSplit.size() > 1 ? pathSplit.last().toLower().toStdString() : std::string{}; + return std::ranges::any_of(adobeLockFileDocumentExtensions, [&extension](const auto &entry) { + return std::ranges::any_of(entry.second, [&extension](const auto &documentExtension) { return documentExtension == extension; }); + }); +} + FileSystem::FileLockingInfo FileSystem::lockFileTargetFilePath(const QString &lockFilePath, const QString &lockFileNamePattern) { FileLockingInfo result; @@ -166,6 +276,18 @@ FileSystem::FileLockingInfo FileSystem::lockFileTargetFilePath(const QString &lo return result; } + // Adobe lock files (.idlk / .prlock) are resolved by sibling lookup — the lock + // file name carries only the document base name, not its extension. + if (isAdobeLockFileExtension(lockFilePath)) { + const auto adobeResolvedPath = adobeLockFileTargetFilePath(lockFilePath); + if (adobeResolvedPath) { + result.path = *adobeResolvedPath; + if (!result.path.isEmpty()) + result.type = QFile::exists(lockFilePath) ? FileLockingInfo::Type::Locked : FileLockingInfo::Type::Unlocked; + } + return result; + } + if (lockFileNamePattern.isEmpty()) { return result; } diff --git a/src/libsync/filesystem.h b/src/libsync/filesystem.h index 736f371a3c6bd..7b1d9f920c02f 100644 --- a/src/libsync/filesystem.h +++ b/src/libsync/filesystem.h @@ -58,6 +58,8 @@ namespace FileSystem { bool OWNCLOUDSYNC_EXPORT isMatchingOfficeFileExtension(const QString &path); // check if it is an AutoCAD document (by .dwg extension), ONLY call it for files bool OWNCLOUDSYNC_EXPORT isMatchingAutoCADDocumentExtension(const QString &path); + // check if it is an Adobe document guarded by an Adobe lock file (by extension), ONLY call it for files + bool OWNCLOUDSYNC_EXPORT isMatchingAdobeDocumentExtension(const QString &path); // finds and fetches FileLockingInfo for the corresponding file that we are locking/unlocking FileLockingInfo OWNCLOUDSYNC_EXPORT lockFileTargetFilePath(const QString &lockFilePath, const QString &lockFileNamePattern); // lists all files matching a lockfile pattern in dirPath diff --git a/src/libsync/syncengine.cpp b/src/libsync/syncengine.cpp index 936e0ec54912a..7371d5b260043 100644 --- a/src/libsync/syncengine.cpp +++ b/src/libsync/syncengine.cpp @@ -871,7 +871,7 @@ void SyncEngine::detectFileLock(const SyncFileItemPtr &item) item->_direction == SyncFileItem::Up && item->_status == SyncFileItem::Success; if (isNewlyUploadedFile && item->_locked != SyncFileItem::LockStatus::LockedItem && _account->capabilities().filesLockAvailable() && - (FileSystem::isMatchingOfficeFileExtension(item->_file) || FileSystem::isMatchingAutoCADDocumentExtension(item->_file))) { + (FileSystem::isMatchingOfficeFileExtension(item->_file) || FileSystem::isMatchingAutoCADDocumentExtension(item->_file) || FileSystem::isMatchingAdobeDocumentExtension(item->_file))) { { SyncJournalFileRecord rec; if (!_journal->getFileRecord(item->_file, &rec) || !rec.isValid()) { diff --git a/sync-exclude.lst b/sync-exclude.lst index dfc3c09713ae2..d4a70e88127e7 100644 --- a/sync-exclude.lst +++ b/sync-exclude.lst @@ -5,6 +5,13 @@ .~lock.* ~*.tmp +# Adobe lock files: InDesign/InCopy (.idlk) and Premiere Pro (.prlock) +# lock files. Unlike Office/LibreOffice lock files they do not encode the +# guarded document's extension, but excluding them by extension is safe as +# no legitimate user document uses it. +*.idlk +*.prlock + # AutoCAD lock files (.dwl / .dwl2) created when opening a .dwg drawing. # Both share the document's base name and are deleted when it is closed. *.dwl diff --git a/test/testexcludedfiles.cpp b/test/testexcludedfiles.cpp index acc8e97bff820..79f28d85aeaa4 100644 --- a/test/testexcludedfiles.cpp +++ b/test/testexcludedfiles.cpp @@ -182,6 +182,18 @@ private slots: QCOMPARE(check_file_full("my.~directory"), CSYNC_FILE_EXCLUDE_AND_REMOVE); QCOMPARE(check_file_full("/a_folder/my.~directory"), CSYNC_FILE_EXCLUDE_AND_REMOVE); + /* Adobe lock files are excluded by extension (.idlk/.prlock) */ + QCOMPARE(check_file_full("Test.idlk"), CSYNC_FILE_EXCLUDE_LIST); + QCOMPARE(check_file_full("~Test~0kjyv(.idlk"), CSYNC_FILE_EXCLUDE_LIST); + QCOMPARE(check_file_full("subdir/Test.idlk"), CSYNC_FILE_EXCLUDE_LIST); + QCOMPARE(check_file_full("Test.prlock"), CSYNC_FILE_EXCLUDE_LIST); + QCOMPARE(check_file_full("subdir/Test.prlock"), CSYNC_FILE_EXCLUDE_LIST); + + /* Adobe documents themselves are NOT excluded */ + QCOMPARE(check_file_full("Test.indd"), CSYNC_NOT_EXCLUDED); + QCOMPARE(check_file_full("Test.icml"), CSYNC_NOT_EXCLUDED); + QCOMPARE(check_file_full("Test.prproj"), CSYNC_NOT_EXCLUDED); + /* Not excluded because the pattern .netscape/cache requires directory. */ QCOMPARE(check_file_full(".netscape/cache"), CSYNC_NOT_EXCLUDED); diff --git a/test/testlockfile.cpp b/test/testlockfile.cpp index 1a9b906566bfc..40385eca27f87 100644 --- a/test/testlockfile.cpp +++ b/test/testlockfile.cpp @@ -828,6 +828,33 @@ private slots: QCOMPARE(lockFileDetectedNewlyUploadedSpy.count(), 1); } + void testLockFile_lockFile_detect_newly_uploaded_adobe_idlk() + { + // InDesign: opening a .indd creates `~{base}~{token}(.idlk`, which carries + // only the document's base name. The guarded document is resolved by + // matching a sibling .indd/.icml file in the same directory. + const auto testFileName = QStringLiteral("document.indd"); + const auto testLockFileName = QStringLiteral("~document~0kjyv(.idlk"); + + const auto testDocumentsDirName = "documents"; + + FakeFolder fakeFolder{FileInfo{}}; + fakeFolder.localModifier().mkdir(testDocumentsDirName); + // Adobe lock files are excluded from sync by the default exclude list; + // the FakeFolder does not load it, so replicate the exclusion here. + fakeFolder.syncEngine().excludedFiles().addManualExclude(QStringLiteral("*.idlk")); + + fakeFolder.syncEngine().account()->setCapabilities({{"files", QVariantMap{{"locking", QByteArray{"1.0"}}}}}); + QSignalSpy lockFileDetectedNewlyUploadedSpy(&fakeFolder.syncEngine(), &OCC::SyncEngine::lockFileDetected); + + fakeFolder.localModifier().insert(testDocumentsDirName + QStringLiteral("/") + testLockFileName); + fakeFolder.localModifier().insert(testDocumentsDirName + QStringLiteral("/") + testFileName); + + QVERIFY(fakeFolder.syncOnce()); + + QCOMPARE(lockFileDetectedNewlyUploadedSpy.count(), 1); + } + void testLockFile_autoCADLockFileTargetFilePath_resolution() { QTemporaryDir dir; @@ -883,6 +910,105 @@ private slots: QVERIFY(orphan.path.isEmpty()); } + void testLockFile_lockFile_detect_newly_uploaded_adobe_prlock() + { + // Premiere Pro: opening a .prproj creates `{base}.prlock`. + const auto testFileName = QStringLiteral("project.prproj"); + const auto testLockFileName = QStringLiteral("project.prlock"); + + const auto testDocumentsDirName = "documents"; + + FakeFolder fakeFolder{FileInfo{}}; + fakeFolder.localModifier().mkdir(testDocumentsDirName); + fakeFolder.syncEngine().excludedFiles().addManualExclude(QStringLiteral("*.prlock")); + + fakeFolder.syncEngine().account()->setCapabilities({{"files", QVariantMap{{"locking", QByteArray{"1.0"}}}}}); + QSignalSpy lockFileDetectedNewlyUploadedSpy(&fakeFolder.syncEngine(), &OCC::SyncEngine::lockFileDetected); + + fakeFolder.localModifier().insert(testDocumentsDirName + QStringLiteral("/") + testLockFileName); + fakeFolder.localModifier().insert(testDocumentsDirName + QStringLiteral("/") + testFileName); + + QVERIFY(fakeFolder.syncOnce()); + + QCOMPARE(lockFileDetectedNewlyUploadedSpy.count(), 1); + } + + void testLockFile_adobeLockFileTargetFilePath_resolution() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const auto dirPath = dir.path() + QLatin1Char('/'); + + // InDesign: `~{base}~{token}(.idlk` guards `{base}.indd`. + const auto inddPath = dirPath + QStringLiteral("Test.indd"); + const auto idlkPath = dirPath + QStringLiteral("~Test~0kjyv(.idlk"); + QVERIFY(QFile{inddPath}.open(QIODevice::WriteOnly)); + QVERIFY(QFile{idlkPath}.open(QIODevice::WriteOnly)); + + const auto idlkResolved = OCC::FileSystem::lockFileTargetFilePath(idlkPath, QString{}); + QCOMPARE(idlkResolved.path, inddPath); + QCOMPARE(idlkResolved.type, OCC::FileSystem::FileLockingInfo::Type::Locked); + + // Removing the lock file marks the document as unlocked (sibling still present). + QVERIFY(QFile::remove(idlkPath)); + const auto idlkUnlocked = OCC::FileSystem::lockFileTargetFilePath(idlkPath, QString{}); + QCOMPARE(idlkUnlocked.path, inddPath); + QCOMPARE(idlkUnlocked.type, OCC::FileSystem::FileLockingInfo::Type::Unlocked); + + // Premiere Pro: `{base}.prlock` guards `{base}.prproj`. + const auto prprojPath = dirPath + QStringLiteral("project.prproj"); + const auto prlockPath = dirPath + QStringLiteral("project.prlock"); + QVERIFY(QFile{prprojPath}.open(QIODevice::WriteOnly)); + QVERIFY(QFile{prlockPath}.open(QIODevice::WriteOnly)); + + const auto prlockResolved = OCC::FileSystem::lockFileTargetFilePath(prlockPath, QString{}); + QCOMPARE(prlockResolved.path, prprojPath); + QCOMPARE(prlockResolved.type, OCC::FileSystem::FileLockingInfo::Type::Locked); + + // InCopy story: `.idlk` may guard an `.icml` when no `.indd` sibling exists. + const auto icmlPath = dirPath + QStringLiteral("story.icml"); + const auto idlk2Path = dirPath + QStringLiteral("~story~ab12cd(.idlk"); + QVERIFY(QFile{icmlPath}.open(QIODevice::WriteOnly)); + QVERIFY(QFile{idlk2Path}.open(QIODevice::WriteOnly)); + const auto idlk2Resolved = OCC::FileSystem::lockFileTargetFilePath(idlk2Path, QString{}); + QCOMPARE(idlk2Resolved.path, icmlPath); + QCOMPARE(idlk2Resolved.type, OCC::FileSystem::FileLockingInfo::Type::Locked); + + // A non-lock file resolves to nothing. + const auto nonLock = OCC::FileSystem::lockFileTargetFilePath(dirPath + QStringLiteral("notes.txt"), QString{}); + QVERIFY(nonLock.path.isEmpty()); + QCOMPARE(nonLock.type, OCC::FileSystem::FileLockingInfo::Type::Unset); + + // A lock file with no matching sibling document resolves to nothing. + const auto orphanIdlkPath = dirPath + QStringLiteral("~orphan~zz(.idlk"); + QVERIFY(QFile{orphanIdlkPath}.open(QIODevice::WriteOnly)); + const auto orphan = OCC::FileSystem::lockFileTargetFilePath(orphanIdlkPath, QString{}); + QVERIFY(orphan.path.isEmpty()); + + // An .idlk file whose name does not match the InDesign `~base~token(.idlk` + // pattern resolves to nothing — the regex fails, base name is nullopt. + const QStringList malformedIdlkNames = { + QStringLiteral("Test.idlk"), // missing leading ~ and token + QStringLiteral("~Test.idlk"), // missing ~token( + QStringLiteral("~Test~token.idlk"), // missing trailing ( + QStringLiteral("Test~token(.idlk"), // missing leading ~ + }; + for (const auto &malformedName : malformedIdlkNames) { + const auto malformedPath = dirPath + malformedName; + QVERIFY(QFile{malformedPath}.open(QIODevice::WriteOnly)); + const auto resolved = OCC::FileSystem::lockFileTargetFilePath(malformedPath, QString{}); + QVERIFY2(resolved.path.isEmpty(), qPrintable(QStringLiteral("Expected empty path for malformed idlk name: %1").arg(malformedName))); + QFile::remove(malformedPath); + } + + // A non-Adobe, non-Office extension resolves to nothing. + const auto unknownExtPath = dirPath + QStringLiteral("file.unknown"); + QVERIFY(QFile{unknownExtPath}.open(QIODevice::WriteOnly)); + const auto unknownExt = OCC::FileSystem::lockFileTargetFilePath(unknownExtPath, QString{}); + QVERIFY(unknownExt.path.isEmpty()); + QCOMPARE(unknownExt.type, OCC::FileSystem::FileLockingInfo::Type::Unset); + } + void testLockFile_verifyE2eeFilesUseCorrectPath() { const auto e2eeRoot = QStringLiteral("encrypted");