From 1aa62c7c04b8e0296db54cef674901a040454add Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 5 Feb 2026 09:36:21 +0200 Subject: [PATCH 01/10] Fix for all platforms --- app/inpututils.cpp | 32 +++++++++++++++----- app/inpututils.h | 2 +- app/qml/form/editors/MMFormGalleryEditor.qml | 2 +- app/qml/form/editors/MMFormPhotoEditor.qml | 6 ++-- app/qml/form/editors/MMFormPhotoViewer.qml | 5 ++- app/test/testutilsfunctions.cpp | 2 +- gallery/qml/Main.qml | 6 ++-- 7 files changed, 36 insertions(+), 19 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 47b37b021..1d96b232b 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -91,9 +91,10 @@ bool InputUtils::removeFile( const QString &filePath ) bool InputUtils::copyFile( const QString &srcPath, const QString &dstPath ) { QString modSrcPath = srcPath; - if ( srcPath.startsWith( "file://" ) ) + QUrl url( srcPath ); + if ( url.isValid() && url.isLocalFile() ) { - modSrcPath = modSrcPath.replace( "file://", "" ); + modSrcPath = url.toLocalFile(); } QFileInfo fi( dstPath ); @@ -1044,7 +1045,7 @@ QString InputUtils::resolvePath( const QString &path, const QString &homePath, c QString InputUtils::getRelativePath( const QString &path, const QString &prefixPath ) { QString modPath = path; - QString filePrefix( "file://" ); + QString filePrefix( "file:///" ); if ( path.startsWith( filePrefix ) ) { @@ -1983,11 +1984,15 @@ void InputUtils::sanitizeFileName( QString &fileName ) void InputUtils::sanitizePath( QString &path ) { - const bool pathStartsWithFileURL = path.startsWith( "file://" ); + const bool pathStartsWithThreeSlashesFileUrl = path.startsWith( "file:///" ); + const bool pathStartsWithTwoSlashesFileURL = path.startsWith( "file://" ); - if ( pathStartsWithFileURL ) + if ( pathStartsWithThreeSlashesFileUrl ) + { + path.remove( 0, 8 ); + } + else if ( pathStartsWithTwoSlashesFileURL ) { - // remove file:// prefix before sanitization path.remove( 0, 7 ); } @@ -1999,7 +2004,15 @@ void InputUtils::sanitizePath( QString &path ) for ( int i = 0; i < parts.size(); ++i ) { QString part = parts.at( i ); + +#ifdef Q_OS_WIN + if ( !part.endsWith( ':' ) ) + { + sanitizeFileName( part ); + } +#else sanitizeFileName( part ); +#endif if ( i > 0 ) { @@ -2015,7 +2028,12 @@ void InputUtils::sanitizePath( QString &path ) sanitizedPath = '/' + sanitizedPath; } - if ( pathStartsWithFileURL ) + if ( pathStartsWithThreeSlashesFileUrl ) + { + // restore file:/// prefix + sanitizedPath = "file:///" + sanitizedPath; + } + else if ( pathStartsWithTwoSlashesFileURL ) { // restore file:// prefix sanitizedPath = "file://" + sanitizedPath; diff --git a/app/inpututils.h b/app/inpututils.h index 00791f0f1..5ad2bf48d 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -367,7 +367,7 @@ class InputUtils: public QObject /** * Returns relative path of the file to given prefixPath. If prefixPath does not match a path parameter, - * returns an empty string. If a path starts with "file://", this prefix is ignored. + * returns an empty string. If a path starts with "file:///" this prefix is ignored. * \param path Absolute path to file * \param prefixPath */ diff --git a/app/qml/form/editors/MMFormGalleryEditor.qml b/app/qml/form/editors/MMFormGalleryEditor.qml index 656c51ead..c0afb2407 100644 --- a/app/qml/form/editors/MMFormGalleryEditor.qml +++ b/app/qml/form/editors/MMFormGalleryEditor.qml @@ -57,7 +57,7 @@ MMPrivateComponents.MMBaseInput { let absolutePath = model.PhotoPath if ( absolutePath !== '' && __inputUtils.fileExists( absolutePath ) ) { - return "file://" + absolutePath + return "file:///" + absolutePath } return '' } diff --git a/app/qml/form/editors/MMFormPhotoEditor.qml b/app/qml/form/editors/MMFormPhotoEditor.qml index bae20c91f..9f8eaf135 100644 --- a/app/qml/form/editors/MMFormPhotoEditor.qml +++ b/app/qml/form/editors/MMFormPhotoEditor.qml @@ -173,10 +173,10 @@ MMFormPhotoViewer { target: root.sketchingController function onTempPhotoSourceChanged( newPath ){ - if ( internal.tempSketchedImageSource === "file://" + newPath ) { + if ( internal.tempSketchedImageSource === "file:///" + newPath ) { internal.tempSketchedImageSource = "" } - internal.tempSketchedImageSource = "file://" + newPath + internal.tempSketchedImageSource = "file:///" + newPath } function onSketchesSavingError(){ @@ -269,7 +269,7 @@ MMFormPhotoViewer { if ( __inputUtils.fileExists( absolutePath ) ) { root.photoState = "valid" - resolvedImageSource = "file://" + absolutePath + resolvedImageSource = "file:///" + absolutePath tempSketchedImageSource = "" } else if ( __inputUtils.isValidUrl( absolutePath ) ) { diff --git a/app/qml/form/editors/MMFormPhotoViewer.qml b/app/qml/form/editors/MMFormPhotoViewer.qml index 0b4afc595..f8796c044 100644 --- a/app/qml/form/editors/MMFormPhotoViewer.qml +++ b/app/qml/form/editors/MMFormPhotoViewer.qml @@ -75,7 +75,7 @@ MMPrivateComponents.MMBaseInput { visible: photoStateGroup.state !== "notSet" photoUrl: root.photoUrl - isLocalFile: root.photoUrl.startsWith( "file://" ) + isLocalFile: root.photoUrl.startsWith( "file:///") cache: false fillMode: Image.PreserveAspectCrop @@ -125,8 +125,7 @@ MMPrivateComponents.MMBaseInput { iconSource: __style.drawIcon iconColor: __style.forestColor - visible: root.editState === "enabled" && photoStateGroup.state !== "notSet" && __activeProject.photoSketchingEnabled && root.photoUrl.startsWith("file://") - + visible: root.editState === "enabled" && photoStateGroup.state !== "notSet" && __activeProject.photoSketchingEnabled && root.photoUrl.startsWith("file:///") onClicked: { sketchingLoader.active = true sketchingLoader.focus = true diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 1c949f4e7..e5a1a48af 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -274,7 +274,7 @@ void TestUtilsFunctions::getRelativePath() QString relativePath2 = mUtils->getRelativePath( path2, prefixPath ); QCOMPARE( fileName2, relativePath2 ); - QString path3 = QStringLiteral( "file://" ) + path2; + QString path3 = QStringLiteral( "file:///" ) + path2; QString relativePath3 = mUtils->getRelativePath( path3, prefixPath ); QCOMPARE( fileName2, relativePath3 ); diff --git a/gallery/qml/Main.qml b/gallery/qml/Main.qml index 854b5d21b..878a1bf5e 100644 --- a/gallery/qml/Main.qml +++ b/gallery/qml/Main.qml @@ -31,7 +31,7 @@ ApplicationWindow { function onWatchedSourceChanged() { mainLoader.active = false _hotReload.clearCache() - mainLoader.setSource("file://" + _qmlWrapperPath + currentPageSource) + mainLoader.setSource("file:///" + _qmlWrapperPath + currentPageSource) mainLoader.active = true console.log( new Date().toLocaleTimeString().split(' ')[0] + " ------ App reloaded 🔥 ------ ") } @@ -162,7 +162,7 @@ ApplicationWindow { if (__isMobile) stackView.push("qrc:/qml/pages/" + model.source) else - stackView.push("file://" + _qmlWrapperPath + model.source) + stackView.push("file:///" + _qmlWrapperPath + model.source) stackView.pop() drawer.close() } @@ -269,7 +269,7 @@ ApplicationWindow { initialItem: Loader { id: mainLoader - source: (__isMobile ? "qrc:/qml/pages/" : ("file://" + _qmlWrapperPath)) + currentPageSource + source: (__isMobile ? "qrc:/qml/pages/" : ("file:///" + _qmlWrapperPath)) + currentPageSource scale: 1.0 } } From 7b46f5515688450c7c039d2a0ed43009503d3b54 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Thu, 5 Feb 2026 10:10:31 +0200 Subject: [PATCH 02/10] Minor changes --- app/inpututils.cpp | 2 ++ app/inpututils.h | 2 +- app/qml/form/editors/MMFormPhotoViewer.qml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 1d96b232b..d9d3c3c87 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -1989,10 +1989,12 @@ void InputUtils::sanitizePath( QString &path ) if ( pathStartsWithThreeSlashesFileUrl ) { + // remove file:/// prefix before sanitization path.remove( 0, 8 ); } else if ( pathStartsWithTwoSlashesFileURL ) { + // remove file:// prefix before sanitization path.remove( 0, 7 ); } diff --git a/app/inpututils.h b/app/inpututils.h index 5ad2bf48d..66d0e02b8 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -367,7 +367,7 @@ class InputUtils: public QObject /** * Returns relative path of the file to given prefixPath. If prefixPath does not match a path parameter, - * returns an empty string. If a path starts with "file:///" this prefix is ignored. + * returns an empty string. If a path starts with "file:///", this prefix is ignored. * \param path Absolute path to file * \param prefixPath */ diff --git a/app/qml/form/editors/MMFormPhotoViewer.qml b/app/qml/form/editors/MMFormPhotoViewer.qml index f8796c044..f5e02b085 100644 --- a/app/qml/form/editors/MMFormPhotoViewer.qml +++ b/app/qml/form/editors/MMFormPhotoViewer.qml @@ -126,6 +126,7 @@ MMPrivateComponents.MMBaseInput { iconColor: __style.forestColor visible: root.editState === "enabled" && photoStateGroup.state !== "notSet" && __activeProject.photoSketchingEnabled && root.photoUrl.startsWith("file:///") + onClicked: { sketchingLoader.active = true sketchingLoader.focus = true From 8146774f3903765973e3f9f475d772facf3f16c9 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Mon, 9 Feb 2026 11:36:49 +0200 Subject: [PATCH 03/10] Modified input functions Added test cases --- app/inpututils.cpp | 169 +++++++++++++++++++------------- app/test/testutilsfunctions.cpp | 15 ++- 2 files changed, 116 insertions(+), 68 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index d9d3c3c87..81fa22ab0 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -1045,37 +1045,46 @@ QString InputUtils::resolvePath( const QString &path, const QString &homePath, c QString InputUtils::getRelativePath( const QString &path, const QString &prefixPath ) { QString modPath = path; - QString filePrefix( "file:///" ); - if ( path.startsWith( filePrefix ) ) + // handle file:// and file:/// prefixes + if ( modPath.startsWith( QLatin1String( "file:///" ), Qt::CaseInsensitive ) ) { - modPath = modPath.replace( filePrefix, QString() ); +#ifdef Q_OS_WIN + // remove the partition letter as well + modPath.remove( 0, 8 ); +#else + modPath.remove( 0, 7 ); +#endif + } + else if ( modPath.startsWith( QLatin1String( "file://" ), Qt::CaseInsensitive ) ) + { + modPath.remove( 0, 7 ); } if ( prefixPath.isEmpty() ) return modPath; + // use QDir to calculate the relative path + QDir prefixDir( prefixPath ); + QString relativePath; + // Do not use a canonical path for non-existing path - if ( !QFileInfo::exists( path ) ) + if ( QFileInfo::exists( modPath ) && QFileInfo::exists( prefixPath ) ) { - if ( !prefixPath.isEmpty() && modPath.startsWith( prefixPath ) ) - { - return modPath.replace( prefixPath, QString() ); - } + QDir canonicalPrefix( prefixDir.canonicalPath() ); + relativePath = canonicalPrefix.relativeFilePath( QFileInfo( modPath ).canonicalFilePath() ); } else { - QDir absoluteDir( modPath ); - QDir prefixDir( prefixPath ); - QString canonicalPath = absoluteDir.canonicalPath(); - QString prefixCanonicalPath = prefixDir.canonicalPath() + "/"; + relativePath = prefixDir.relativeFilePath( modPath ); + } - if ( prefixCanonicalPath.length() > 1 && canonicalPath.startsWith( prefixCanonicalPath ) ) - { - return canonicalPath.replace( prefixCanonicalPath, QString() ); - } + // check that the file is actually a child of the prefix + if ( relativePath.startsWith( QLatin1String( ".." ) ) || QFileInfo( relativePath ).isAbsolute() ) + { + return QString(); } - return QString(); + return relativePath; } void InputUtils::logMessage( const QString &message, const QString &tag, Qgis::MessageLevel level ) @@ -1967,81 +1976,107 @@ QUrl InputUtils::iconFromGeometry( const Qgis::GeometryType &geometry ) void InputUtils::sanitizeFileName( QString &fileName ) { - // regex captures ascii codes 0 to 31 and windows path forbidden characters <>:|?*" - const thread_local QRegularExpression illegalChars( QStringLiteral( "[\x00-\x19<>:|?*\"]" ) ); - fileName.replace( illegalChars, QStringLiteral( "_" ) ); - fileName = fileName.trimmed(); + if ( fileName.isEmpty() ) + return; - // Trim whitespace immediately before the final extension, e.g. "name .jpg" -> "name.jpg" - const int lastDot = fileName.lastIndexOf( QChar( '.' ) ); - if ( lastDot > 0 ) - { - const QString base = fileName.first( lastDot ).trimmed(); - const QString ext = fileName.sliced( lastDot ); - fileName = base + ext; - } -} + // unify separators to '/' to handle splitting easily + QString unifiedPath = fileName; + unifiedPath.replace( QLatin1Char( '\\' ), QLatin1Char( '/' ) ); -void InputUtils::sanitizePath( QString &path ) -{ - const bool pathStartsWithThreeSlashesFileUrl = path.startsWith( "file:///" ); - const bool pathStartsWithTwoSlashesFileURL = path.startsWith( "file://" ); + // split into segments (folders + filename) + QStringList parts = unifiedPath.split( QLatin1Char( '/' ), Qt::KeepEmptyParts ); - if ( pathStartsWithThreeSlashesFileUrl ) - { - // remove file:/// prefix before sanitization - path.remove( 0, 8 ); - } - else if ( pathStartsWithTwoSlashesFileURL ) + // regex captures ascii codes 0 to 31 and windows path forbidden characters <>:|?*" + const thread_local QRegularExpression illegalChars( QStringLiteral( "[\x00-\x1f<>:|?*\"\\\\]" ) ); + +#ifdef Q_OS_WIN + const static QRegularExpression reservedNames( + QStringLiteral( "^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$" ), + QRegularExpression::CaseInsensitiveOption + ); +#endif + + // process each segment + for ( QString &segment : parts ) { - // remove file:// prefix before sanitization - path.remove( 0, 7 ); - } + if ( segment.isEmpty() ) + continue; - const bool pathStartsWithSlash = path.startsWith( '/' ); + // skip Windows drive letters + if ( segment.length() == 2 && segment.at( 1 ) == QLatin1Char( ':' ) && segment.at( 0 ).isLetter() ) + { + continue; + } - const QStringList parts = path.split( '/', Qt::SkipEmptyParts ); - QString sanitizedPath; + // replace illegal characters + segment.replace( illegalChars, QStringLiteral( "_" ) ); - for ( int i = 0; i < parts.size(); ++i ) - { - QString part = parts.at( i ); + // handle whitespace logic + QFileInfo info( segment ); + QString baseName = info.completeBaseName(); + QString suffix = info.suffix(); + + // trim whitespace from the base name + baseName = baseName.trimmed(); + // windows reserved names check #ifdef Q_OS_WIN - if ( !part.endsWith( ':' ) ) + if ( reservedNames.match( baseName ).hasMatch() ) { - sanitizeFileName( part ); + baseName.append( QStringLiteral( "_" ) ); } -#else - sanitizeFileName( part ); #endif - if ( i > 0 ) + // reassemble segment + if ( !suffix.isEmpty() ) + { + segment = baseName + QLatin1Char( '.' ) + suffix; + } + else { - sanitizedPath += '/'; + segment = baseName; } - sanitizedPath += part; + // final trim of the segment + segment = segment.trimmed(); } - if ( pathStartsWithSlash ) + // rejoin the fileName + fileName = parts.join( QLatin1Char( '/' ) ); +} + +void InputUtils::sanitizePath( QString &path ) +{ + if ( path.isEmpty() ) + return; + + QString prefix; + QString cleanPath = path; + + // detect and strip the file prefix + if ( path.startsWith( QStringLiteral( "file:///" ), Qt::CaseInsensitive ) ) + { + prefix = QStringLiteral( "file:///" ); + cleanPath = path.mid( 8 ); + } + else if ( path.startsWith( QStringLiteral( "file://" ), Qt::CaseInsensitive ) ) { - // restore leading slash - sanitizedPath = '/' + sanitizedPath; + prefix = QStringLiteral( "file://" ); + cleanPath = path.mid( 7 ); } - if ( pathStartsWithThreeSlashesFileUrl ) + // sanitize the path content + sanitizeFileName( cleanPath ); + + // reconstruct + if ( !prefix.isEmpty() ) { - // restore file:/// prefix - sanitizedPath = "file:///" + sanitizedPath; + path = prefix + cleanPath; } - else if ( pathStartsWithTwoSlashesFileURL ) + else { - // restore file:// prefix - sanitizedPath = "file://" + sanitizedPath; + path = cleanPath; } - - path = sanitizedPath; } QSet InputUtils::referencedAttributeIndexes( QgsVectorLayer *layer, const QString &expression ) diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index e5a1a48af..701b85d18 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -355,7 +355,9 @@ void TestUtilsFunctions::resolveTargetDir() config.insert( QStringLiteral( "PropertyCollection" ), collection ); QString resultDir3 = mUtils->resolveTargetDir( homePath, config, pair, QgsProject::instance() ); - QCOMPARE( resultDir3, QStringLiteral( "%1/photos" ).arg( projectDir ) ); + QString expectedDir = QStringLiteral( "%1/photos" ).arg( projectDir ); + mUtils->sanitizeFileName( expectedDir ); + QCOMPARE( resultDir3, expectedDir ); } void TestUtilsFunctions::testExtractPointFromFeature() @@ -1000,6 +1002,7 @@ void TestUtilsFunctions::testSanitizeFileName() str = QStringLiteral( "/sa ni*tized/.fl?n\"a:m|e .co .ext " ); InputUtils::sanitizeFileName( str ); QCOMPARE( str, QStringLiteral( "/sa ni_tized/.f_i_l_n_a_m_e .co.ext" ) ); + // add some guard and tests for windows } void TestUtilsFunctions::testSanitizePath() @@ -1029,6 +1032,11 @@ void TestUtilsFunctions::testSanitizePath() InputUtils::sanitizePath( str ); QCOMPARE( str, QStringLiteral( "/complex/valid/Φ!l@#äme$%^&()-_=+[]{}`~;',.ext" ) ); + // unchanged with partition letter on Windows + str = QStringLiteral( "C:/Users/simple/valid/filename.ext" ); + InputUtils::sanitizePath( str ); + QCOMPARE( str, QStringLiteral( "C:/Users/simple/valid/filename.ext" ) ); + // sanitized str = QStringLiteral( "/sa ni*tized/fl?n\"a:m|e.ext " ); InputUtils::sanitizePath( str ); @@ -1048,4 +1056,9 @@ void TestUtilsFunctions::testSanitizePath() str = QStringLiteral( "project name / project .qgz " ); InputUtils::sanitizePath( str ); QCOMPARE( str, QStringLiteral( "project name/project.qgz" ) ); + + // sanitized with partition letter + str = QStringLiteral( "C:/project name / project .qgz " ); + InputUtils::sanitizePath( str ); + QCOMPARE( str, QStringLiteral( "C:/project name/project.qgz" ) ); } From 27d75663ccf28db234a45ec83cf06c42fcf42a72 Mon Sep 17 00:00:00 2001 From: "gabriel.bolbotina" Date: Mon, 9 Feb 2026 12:30:45 +0200 Subject: [PATCH 04/10] Modified test for windows path --- app/test/testsketching.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/test/testsketching.cpp b/app/test/testsketching.cpp index 76901751a..940bbade2 100644 --- a/app/test/testsketching.cpp +++ b/app/test/testsketching.cpp @@ -169,7 +169,11 @@ void TestSketching::testLoadBackupSketch() sketchingController.mPhotoSource = path; sketchingController.mProjectName = QStringLiteral( "/this/is/long/path/to/image/test_sketching" ); sketchingController.prepareController(); + #ifdef Q_OS_WIN32 + QCOMPARE( sketchingController.mPhotoSource, "file:///" + QDir::tempPath() + QStringLiteral( "/test_sketching" ) + QStringLiteral( "/MM_test_image.jpg" ) ); +#else QCOMPARE( sketchingController.mPhotoSource, "file://" + QDir::tempPath() + QStringLiteral( "/test_sketching" ) + QStringLiteral( "/MM_test_image.jpg" ) ); +#endif QCOMPARE( sketchingController.mOriginalPhotoSource, QUrl( path ).toLocalFile() ); QCOMPARE( spy.count(), 1 ); auto signalArgs = spy.takeLast(); From 43bbae046f46e70623f353ac0a75239a8af5f6e9 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Mon, 9 Feb 2026 12:34:23 +0200 Subject: [PATCH 05/10] Small modifications --- app/test/testsketching.cpp | 2 +- app/test/testutilsfunctions.cpp | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/test/testsketching.cpp b/app/test/testsketching.cpp index 940bbade2..de4fddb55 100644 --- a/app/test/testsketching.cpp +++ b/app/test/testsketching.cpp @@ -169,7 +169,7 @@ void TestSketching::testLoadBackupSketch() sketchingController.mPhotoSource = path; sketchingController.mProjectName = QStringLiteral( "/this/is/long/path/to/image/test_sketching" ); sketchingController.prepareController(); - #ifdef Q_OS_WIN32 +#ifdef Q_OS_WIN32 QCOMPARE( sketchingController.mPhotoSource, "file:///" + QDir::tempPath() + QStringLiteral( "/test_sketching" ) + QStringLiteral( "/MM_test_image.jpg" ) ); #else QCOMPARE( sketchingController.mPhotoSource, "file://" + QDir::tempPath() + QStringLiteral( "/test_sketching" ) + QStringLiteral( "/MM_test_image.jpg" ) ); diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 701b85d18..ccb67e8a5 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -1002,7 +1002,6 @@ void TestUtilsFunctions::testSanitizeFileName() str = QStringLiteral( "/sa ni*tized/.fl?n\"a:m|e .co .ext " ); InputUtils::sanitizeFileName( str ); QCOMPARE( str, QStringLiteral( "/sa ni_tized/.f_i_l_n_a_m_e .co.ext" ) ); - // add some guard and tests for windows } void TestUtilsFunctions::testSanitizePath() From 21ceeb923a38678cae4d25b53c492d9a1df479bf Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Mon, 9 Feb 2026 14:46:02 +0200 Subject: [PATCH 06/10] Modified comment --- app/inpututils.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/inpututils.h b/app/inpututils.h index 66d0e02b8..b2744ae15 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -367,7 +367,7 @@ class InputUtils: public QObject /** * Returns relative path of the file to given prefixPath. If prefixPath does not match a path parameter, - * returns an empty string. If a path starts with "file:///", this prefix is ignored. + * returns an empty string. If a path starts with "file:///" or "file://", this prefix is ignored. * \param path Absolute path to file * \param prefixPath */ From 138043763385fd8c39fb656294f412e52be996c7 Mon Sep 17 00:00:00 2001 From: "gabriel.bolbotina" Date: Wed, 18 Feb 2026 12:09:28 +0200 Subject: [PATCH 07/10] Implemented code findings --- app/inpututils.cpp | 232 ++++++++++++++++---------------- app/inpututils.h | 10 +- app/test/testsketching.cpp | 8 +- app/test/testutilsfunctions.cpp | 38 ++++-- 4 files changed, 150 insertions(+), 138 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 81fa22ab0..0614bbb2d 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -1044,47 +1044,27 @@ QString InputUtils::resolvePath( const QString &path, const QString &homePath, c QString InputUtils::getRelativePath( const QString &path, const QString &prefixPath ) { - QString modPath = path; - - // handle file:// and file:/// prefixes - if ( modPath.startsWith( QLatin1String( "file:///" ), Qt::CaseInsensitive ) ) - { -#ifdef Q_OS_WIN - // remove the partition letter as well - modPath.remove( 0, 8 ); -#else - modPath.remove( 0, 7 ); -#endif - } - else if ( modPath.startsWith( QLatin1String( "file://" ), Qt::CaseInsensitive ) ) - { - modPath.remove( 0, 7 ); - } - - if ( prefixPath.isEmpty() ) return modPath; - - // use QDir to calculate the relative path - QDir prefixDir( prefixPath ); - QString relativePath; + // clean the file prefix + QString cleanPath = path; + QUrl url(path); + if (url.isValid() && url.isLocalFile()) { + cleanPath = url.toLocalFile(); + } - // Do not use a canonical path for non-existing path - if ( QFileInfo::exists( modPath ) && QFileInfo::exists( prefixPath ) ) - { - QDir canonicalPrefix( prefixDir.canonicalPath() ); - relativePath = canonicalPrefix.relativeFilePath( QFileInfo( modPath ).canonicalFilePath() ); - } - else - { - relativePath = prefixDir.relativeFilePath( modPath ); - } + // if no prefix is provided, return the cleaned absolute path + if ( prefixPath.isEmpty() ) { + return cleanPath; + } - // check that the file is actually a child of the prefix - if ( relativePath.startsWith( QLatin1String( ".." ) ) || QFileInfo( relativePath ).isAbsolute() ) - { - return QString(); - } + // use QDir to calculate the relative path + const QDir prefixDir(prefixPath); + QString relativePath = prefixDir.relativeFilePath(cleanPath); + + if ( relativePath == cleanPath && !cleanPath.isEmpty() ) { + return {}; + } - return relativePath; + return relativePath; } void InputUtils::logMessage( const QString &message, const QString &tag, Qgis::MessageLevel level ) @@ -1974,109 +1954,123 @@ QUrl InputUtils::iconFromGeometry( const Qgis::GeometryType &geometry ) } } -void InputUtils::sanitizeFileName( QString &fileName ) +QString InputUtils::sanitizeNode( const QString &input ) { - if ( fileName.isEmpty() ) - return; + if (input.isEmpty()) return input; + + // trim the whitespace at the beginning and the end + QString cleanOutput = input; + cleanOutput = cleanOutput.trimmed(); - // unify separators to '/' to handle splitting easily - QString unifiedPath = fileName; - unifiedPath.replace( QLatin1Char( '\\' ), QLatin1Char( '/' ) ); + // remove illegal characters before using QFileInfo + const static QRegularExpression illegalChars(QStringLiteral("[\x00-\x1f<>:|?*\"/\\\\]")); + cleanOutput.replace(illegalChars, QStringLiteral("_")); - // split into segments (folders + filename) - QStringList parts = unifiedPath.split( QLatin1Char( '/' ), Qt::KeepEmptyParts ); + // if name has multiple extensions, clear the space for the second extension + // e.g., .tar .gz --> .tar.gz + const static QRegularExpression multipleExtensions(QStringLiteral("(\\s)(\\.[^.]+)\\s+(?=\\.)")); + cleanOutput.replace(multipleExtensions, QStringLiteral("\\1\\2")); - // regex captures ascii codes 0 to 31 and windows path forbidden characters <>:|?*" - const thread_local QRegularExpression illegalChars( QStringLiteral( "[\x00-\x1f<>:|?*\"\\\\]" ) ); + // split base and suffix to trim whitespace correctly. + QFileInfo fi(cleanOutput); + QString base = fi.completeBaseName(); + QString suffix = fi.suffix().trimmed(); + // handle Windows Reserved Names (CON, PRN, etc.) #ifdef Q_OS_WIN - const static QRegularExpression reservedNames( - QStringLiteral( "^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$" ), - QRegularExpression::CaseInsensitiveOption - ); + if (base.trimmed().length() == 3 || base.trimmed().length() == 4) { + const static QRegularExpression reservedNames( + QStringLiteral("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$"), + QRegularExpression::CaseInsensitiveOption + ); + if (reservedNames.match(base.trimmed()).hasMatch()) { + base = base.trimmed() + QLatin1Char('_'); + } + } #endif - // process each segment - for ( QString &segment : parts ) - { - if ( segment.isEmpty() ) - continue; - - // skip Windows drive letters - if ( segment.length() == 2 && segment.at( 1 ) == QLatin1Char( ':' ) && segment.at( 0 ).isLetter() ) - { - continue; + // reassemble + if (!suffix.isEmpty()) { + return base + QLatin1Char('.') + suffix; } + return base; +} - // replace illegal characters - segment.replace( illegalChars, QStringLiteral( "_" ) ); - - // handle whitespace logic - QFileInfo info( segment ); - QString baseName = info.completeBaseName(); - QString suffix = info.suffix(); +void InputUtils::sanitizeFileName( QString &fileName ) +{ + if (fileName.isEmpty()) return; - // trim whitespace from the base name - baseName = baseName.trimmed(); + // separate directory from file name + QFileInfo fileInfo(fileName); + QString dirPath = fileInfo.path(); + QString rawName = fileInfo.fileName(); - // windows reserved names check -#ifdef Q_OS_WIN - if ( reservedNames.match( baseName ).hasMatch() ) - { - baseName.append( QStringLiteral( "_" ) ); - } -#endif + // sanitize only the file name part + QString cleanName = sanitizeNode(rawName); - // reassemble segment - if ( !suffix.isEmpty() ) - { - segment = baseName + QLatin1Char( '.' ) + suffix; + // re-attach Directory + if (dirPath == QLatin1String(".") && !fileName.contains(QLatin1Char('/')) && !fileName.contains(QLatin1Char('\\'))) { + fileName = cleanName; + } + else { + // Use QDir to join safely + QString fullPath = QDir(dirPath).filePath(cleanName); + fileName = QDir::cleanPath(fullPath); } - else - { - segment = baseName; - } - - // final trim of the segment - segment = segment.trimmed(); - } - - // rejoin the fileName - fileName = parts.join( QLatin1Char( '/' ) ); } void InputUtils::sanitizePath( QString &path ) { - if ( path.isEmpty() ) - return; + if (path.isEmpty()) return; + QString cleanPath = path; - QString prefix; - QString cleanPath = path; + // QUrl treats ? as a query start, # as a fragment + cleanPath.replace(QLatin1Char('?'), QLatin1Char('_')); + cleanPath.replace(QLatin1Char('#'), QLatin1Char('_')); - // detect and strip the file prefix - if ( path.startsWith( QStringLiteral( "file:///" ), Qt::CaseInsensitive ) ) - { - prefix = QStringLiteral( "file:///" ); - cleanPath = path.mid( 8 ); - } - else if ( path.startsWith( QStringLiteral( "file://" ), Qt::CaseInsensitive ) ) - { - prefix = QStringLiteral( "file://" ); - cleanPath = path.mid( 7 ); - } + // parse file prefix and path + QUrl url = QUrl::fromUserInput(cleanPath); + bool isUrl = path.startsWith(QLatin1String("file:"), Qt::CaseInsensitive); - // sanitize the path content - sanitizeFileName( cleanPath ); + if (isUrl && (url.isLocalFile() || url.hasQuery())) { + cleanPath = url.toLocalFile(); + } else { + cleanPath = path; + } - // reconstruct - if ( !prefix.isEmpty() ) - { - path = prefix + cleanPath; - } - else - { - path = cleanPath; - } + // normalize separators + // convert all backslashes to forward slashes to ensure consistent splitting + cleanPath.replace(QLatin1Char('\\'), QLatin1Char('/')); + + // split and sanitize each segment + QStringList parts = cleanPath.split(QLatin1Char('/'), Qt::SkipEmptyParts); + QStringList sanitizedParts; + + for (int i = 0; i < parts.size(); ++i) { + QString part = parts[i]; + + // keep Windows Drive Letters (e.g. "C:") + if (i == 0 && part.endsWith(QLatin1Char(':'))) { + sanitizedParts << part; + continue; + } + sanitizedParts << sanitizeNode(part); + } + + // reconstruct with Unix-style separators (/) + QString result = sanitizedParts.join(QLatin1Char('/')); + + // handle Absolute Paths + if (cleanPath.startsWith(QLatin1Char('/'))) { + result.prepend(QLatin1Char('/')); + } + + // restore file prefix rotocol + if (isUrl) { + path = QUrl::fromLocalFile(result).toString(); + } else { + path = result; + } } QSet InputUtils::referencedAttributeIndexes( QgsVectorLayer *layer, const QString &expression ) diff --git a/app/inpututils.h b/app/inpututils.h index 66d0e02b8..ded2c6b81 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -626,15 +626,23 @@ class InputUtils: public QObject */ Q_INVOKABLE static bool isValidEmail( const QString &email ); + /** + * Replaces invalid path related characters with underscores '_' + * It can be used as standalone for any string to be sanitized + */ + + static QString sanitizeNode(const QString &input); + /** * Replaces invalid filename characters with underscores (_) * Also trims whitespaces at the start and end of \a filename. If \a filename has an extension and * last character before the . is a whitespace, it does not get trimmed. + * it only sanitizes the file name not the entire path */ static void sanitizeFileName( QString &fileName ); /** - * Splits path into components and sanitizes each component using sanitizeFileName(). + * Splits path into components and sanitizes each component using sanitizeNode(). */ static void sanitizePath( QString &path ); diff --git a/app/test/testsketching.cpp b/app/test/testsketching.cpp index 940bbade2..544571381 100644 --- a/app/test/testsketching.cpp +++ b/app/test/testsketching.cpp @@ -169,11 +169,9 @@ void TestSketching::testLoadBackupSketch() sketchingController.mPhotoSource = path; sketchingController.mProjectName = QStringLiteral( "/this/is/long/path/to/image/test_sketching" ); sketchingController.prepareController(); - #ifdef Q_OS_WIN32 - QCOMPARE( sketchingController.mPhotoSource, "file:///" + QDir::tempPath() + QStringLiteral( "/test_sketching" ) + QStringLiteral( "/MM_test_image.jpg" ) ); -#else - QCOMPARE( sketchingController.mPhotoSource, "file://" + QDir::tempPath() + QStringLiteral( "/test_sketching" ) + QStringLiteral( "/MM_test_image.jpg" ) ); -#endif + const QString localPath = QDir::tempPath() + QStringLiteral( "/test_sketching/MM_test_image.jpg" ); + const QString expectedUrl = QUrl::fromLocalFile( localPath ).toString(); + QCOMPARE( sketchingController.mPhotoSource, expectedUrl); QCOMPARE( sketchingController.mOriginalPhotoSource, QUrl( path ).toLocalFile() ); QCOMPARE( spy.count(), 1 ); auto signalArgs = spy.takeLast(); diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 701b85d18..8b9540b95 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -274,7 +274,7 @@ void TestUtilsFunctions::getRelativePath() QString relativePath2 = mUtils->getRelativePath( path2, prefixPath ); QCOMPARE( fileName2, relativePath2 ); - QString path3 = QStringLiteral( "file:///" ) + path2; + QString path3 = QUrl (path2).toString(); QString relativePath3 = mUtils->getRelativePath( path3, prefixPath ); QCOMPARE( fileName2, relativePath3 ); @@ -355,9 +355,7 @@ void TestUtilsFunctions::resolveTargetDir() config.insert( QStringLiteral( "PropertyCollection" ), collection ); QString resultDir3 = mUtils->resolveTargetDir( homePath, config, pair, QgsProject::instance() ); - QString expectedDir = QStringLiteral( "%1/photos" ).arg( projectDir ); - mUtils->sanitizeFileName( expectedDir ); - QCOMPARE( resultDir3, expectedDir ); + QCOMPARE( resultDir3, QStringLiteral( "%1/photos" ).arg( projectDir ) ); } void TestUtilsFunctions::testExtractPointFromFeature() @@ -993,16 +991,15 @@ void TestUtilsFunctions::testSanitizeFileName() InputUtils::sanitizeFileName( str ); QCOMPARE( str, QStringLiteral( "/complex/valid/Φ!l@#äme$%^&()-_=+[]{}`~;',.ext" ) ); - // sanitized + // sanitized file name, we expect the rest of the path to remain unsanitized str = QStringLiteral( "/sa ni*tized/fl?n\"a:m|e .ext " ); InputUtils::sanitizeFileName( str ); - QCOMPARE( str, QStringLiteral( "/sa ni_tized/f_i_l_n_a_m_e.ext" ) ); + QCOMPARE( str, QStringLiteral( "/sa ni*tized/f_i_l_n_a_m_e .ext" ) ); - // sanitized + // sanitized file name, we expect the rest of the path to remain unsanitized str = QStringLiteral( "/sa ni*tized/.fl?n\"a:m|e .co .ext " ); InputUtils::sanitizeFileName( str ); - QCOMPARE( str, QStringLiteral( "/sa ni_tized/.f_i_l_n_a_m_e .co.ext" ) ); - // add some guard and tests for windows + QCOMPARE( str, QStringLiteral( "/sa ni*tized/.f_i_l_n_a_m_e .co.ext" ) ); } void TestUtilsFunctions::testSanitizePath() @@ -1020,7 +1017,12 @@ void TestUtilsFunctions::testSanitizePath() // unchanged - url prefix str = QStringLiteral( "file://simple/valid/filename.ext" ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "file://simple/valid/filename.ext" ) ); + +#ifdef Q_OS_WIN + QCOMPARE( str, QStringLiteral( "file:///simple/valid/filename.ext" ) ); +#else + QCOMPARE( str, QStringLiteral( "file://simple/valid/filename.ext" ) ); +#endif // unchanged - url prefix with slash str = QStringLiteral( "file:///simple/valid/filename.ext" ); @@ -1037,6 +1039,16 @@ void TestUtilsFunctions::testSanitizePath() InputUtils::sanitizePath( str ); QCOMPARE( str, QStringLiteral( "C:/Users/simple/valid/filename.ext" ) ); + // unchanged with partition letter on Windows and file prefix + str = QStringLiteral( "file:///C:/Users/simple/valid/filename.ext" ); + InputUtils::sanitizePath( str ); + +#ifdef Q_OS_WIN + QCOMPARE( str, QStringLiteral( "file:///C:/Users/simple/valid/filename.ext" ) ); +#else + QCOMPARE( str, QStringLiteral( "file://C:/Users/simple/valid/filename.ext" ) ); +#endif + // sanitized str = QStringLiteral( "/sa ni*tized/fl?n\"a:m|e.ext " ); InputUtils::sanitizePath( str ); @@ -1050,15 +1062,15 @@ void TestUtilsFunctions::testSanitizePath() // sanitized str = QStringLiteral( "file:/// sa ni*tized /fl?n\"a:m|e .ext " ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "file:///sa ni_tized/f_i_l_n_a_m_e.ext" ) ); + QCOMPARE( str, QStringLiteral( "file:///sa ni_tized/f_i_l_n_a_m_e .ext" ) ); // sanitized str = QStringLiteral( "project name / project .qgz " ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "project name/project.qgz" ) ); + QCOMPARE( str, QStringLiteral( "project name/project .qgz" ) ); // sanitized with partition letter str = QStringLiteral( "C:/project name / project .qgz " ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "C:/project name/project.qgz" ) ); + QCOMPARE( str, QStringLiteral( "C:/project name/project .qgz" ) ); } From 052cb72675717ca2b7aa905e4ac3569b770d73d7 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 18 Feb 2026 12:14:04 +0200 Subject: [PATCH 08/10] Formatted code --- app/inpututils.cpp | 225 +++++++++++++++++--------------- app/inpututils.h | 2 +- app/test/testsketching.cpp | 2 +- app/test/testutilsfunctions.cpp | 8 +- 4 files changed, 127 insertions(+), 110 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index 0614bbb2d..f5988d450 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -1044,27 +1044,30 @@ QString InputUtils::resolvePath( const QString &path, const QString &homePath, c QString InputUtils::getRelativePath( const QString &path, const QString &prefixPath ) { - // clean the file prefix - QString cleanPath = path; - QUrl url(path); - if (url.isValid() && url.isLocalFile()) { - cleanPath = url.toLocalFile(); - } + // clean the file prefix + QString cleanPath = path; + QUrl url( path ); + if ( url.isValid() && url.isLocalFile() ) + { + cleanPath = url.toLocalFile(); + } - // if no prefix is provided, return the cleaned absolute path - if ( prefixPath.isEmpty() ) { - return cleanPath; - } + // if no prefix is provided, return the cleaned absolute path + if ( prefixPath.isEmpty() ) + { + return cleanPath; + } - // use QDir to calculate the relative path - const QDir prefixDir(prefixPath); - QString relativePath = prefixDir.relativeFilePath(cleanPath); - - if ( relativePath == cleanPath && !cleanPath.isEmpty() ) { - return {}; - } + // use QDir to calculate the relative path + const QDir prefixDir( prefixPath ); + QString relativePath = prefixDir.relativeFilePath( cleanPath ); + + if ( relativePath == cleanPath && !cleanPath.isEmpty() ) + { + return {}; + } - return relativePath; + return relativePath; } void InputUtils::logMessage( const QString &message, const QString &tag, Qgis::MessageLevel level ) @@ -1956,121 +1959,135 @@ QUrl InputUtils::iconFromGeometry( const Qgis::GeometryType &geometry ) QString InputUtils::sanitizeNode( const QString &input ) { - if (input.isEmpty()) return input; + if ( input.isEmpty() ) return input; - // trim the whitespace at the beginning and the end - QString cleanOutput = input; - cleanOutput = cleanOutput.trimmed(); + // trim the whitespace at the beginning and the end + QString cleanOutput = input; + cleanOutput = cleanOutput.trimmed(); - // remove illegal characters before using QFileInfo - const static QRegularExpression illegalChars(QStringLiteral("[\x00-\x1f<>:|?*\"/\\\\]")); - cleanOutput.replace(illegalChars, QStringLiteral("_")); + // remove illegal characters before using QFileInfo + const static QRegularExpression illegalChars( QStringLiteral( "[\x00-\x1f<>:|?*\"/\\\\]" ) ); + cleanOutput.replace( illegalChars, QStringLiteral( "_" ) ); - // if name has multiple extensions, clear the space for the second extension - // e.g., .tar .gz --> .tar.gz - const static QRegularExpression multipleExtensions(QStringLiteral("(\\s)(\\.[^.]+)\\s+(?=\\.)")); - cleanOutput.replace(multipleExtensions, QStringLiteral("\\1\\2")); + // if name has multiple extensions, clear the space for the second extension + // e.g., .tar .gz --> .tar.gz + const static QRegularExpression multipleExtensions( QStringLiteral( "(\\s)(\\.[^.]+)\\s+(?=\\.)" ) ); + cleanOutput.replace( multipleExtensions, QStringLiteral( "\\1\\2" ) ); - // split base and suffix to trim whitespace correctly. - QFileInfo fi(cleanOutput); - QString base = fi.completeBaseName(); - QString suffix = fi.suffix().trimmed(); + // split base and suffix to trim whitespace correctly. + QFileInfo fi( cleanOutput ); + QString base = fi.completeBaseName(); + QString suffix = fi.suffix().trimmed(); - // handle Windows Reserved Names (CON, PRN, etc.) + // handle Windows Reserved Names (CON, PRN, etc.) #ifdef Q_OS_WIN - if (base.trimmed().length() == 3 || base.trimmed().length() == 4) { + if ( base.trimmed().length() == 3 || base.trimmed().length() == 4 ) + { const static QRegularExpression reservedNames( - QStringLiteral("^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$"), - QRegularExpression::CaseInsensitiveOption - ); - if (reservedNames.match(base.trimmed()).hasMatch()) { - base = base.trimmed() + QLatin1Char('_'); - } + QStringLiteral( "^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$" ), + QRegularExpression::CaseInsensitiveOption + ); + if ( reservedNames.match( base.trimmed() ).hasMatch() ) + { + base = base.trimmed() + QLatin1Char( '_' ); + } } #endif - // reassemble - if (!suffix.isEmpty()) { - return base + QLatin1Char('.') + suffix; - } - return base; + // reassemble + if ( !suffix.isEmpty() ) + { + return base + QLatin1Char( '.' ) + suffix; + } + return base; } void InputUtils::sanitizeFileName( QString &fileName ) { - if (fileName.isEmpty()) return; + if ( fileName.isEmpty() ) return; - // separate directory from file name - QFileInfo fileInfo(fileName); - QString dirPath = fileInfo.path(); - QString rawName = fileInfo.fileName(); + // separate directory from file name + QFileInfo fileInfo( fileName ); + QString dirPath = fileInfo.path(); + QString rawName = fileInfo.fileName(); - // sanitize only the file name part - QString cleanName = sanitizeNode(rawName); + // sanitize only the file name part + QString cleanName = sanitizeNode( rawName ); - // re-attach Directory - if (dirPath == QLatin1String(".") && !fileName.contains(QLatin1Char('/')) && !fileName.contains(QLatin1Char('\\'))) { - fileName = cleanName; - } - else { - // Use QDir to join safely - QString fullPath = QDir(dirPath).filePath(cleanName); - fileName = QDir::cleanPath(fullPath); - } + // re-attach Directory + if ( dirPath == QLatin1String( "." ) && !fileName.contains( QLatin1Char( '/' ) ) && !fileName.contains( QLatin1Char( '\\' ) ) ) + { + fileName = cleanName; + } + else + { + // Use QDir to join safely + QString fullPath = QDir( dirPath ).filePath( cleanName ); + fileName = QDir::cleanPath( fullPath ); + } } void InputUtils::sanitizePath( QString &path ) { - if (path.isEmpty()) return; - QString cleanPath = path; + if ( path.isEmpty() ) return; + QString cleanPath = path; - // QUrl treats ? as a query start, # as a fragment - cleanPath.replace(QLatin1Char('?'), QLatin1Char('_')); - cleanPath.replace(QLatin1Char('#'), QLatin1Char('_')); + // QUrl treats ? as a query start, # as a fragment + cleanPath.replace( QLatin1Char( '?' ), QLatin1Char( '_' ) ); + cleanPath.replace( QLatin1Char( '#' ), QLatin1Char( '_' ) ); - // parse file prefix and path - QUrl url = QUrl::fromUserInput(cleanPath); - bool isUrl = path.startsWith(QLatin1String("file:"), Qt::CaseInsensitive); + // parse file prefix and path + QUrl url = QUrl::fromUserInput( cleanPath ); + bool isUrl = path.startsWith( QLatin1String( "file:" ), Qt::CaseInsensitive ); - if (isUrl && (url.isLocalFile() || url.hasQuery())) { - cleanPath = url.toLocalFile(); - } else { - cleanPath = path; - } + if ( isUrl && ( url.isLocalFile() || url.hasQuery() ) ) + { + cleanPath = url.toLocalFile(); + } + else + { + cleanPath = path; + } - // normalize separators - // convert all backslashes to forward slashes to ensure consistent splitting - cleanPath.replace(QLatin1Char('\\'), QLatin1Char('/')); - - // split and sanitize each segment - QStringList parts = cleanPath.split(QLatin1Char('/'), Qt::SkipEmptyParts); - QStringList sanitizedParts; - - for (int i = 0; i < parts.size(); ++i) { - QString part = parts[i]; - - // keep Windows Drive Letters (e.g. "C:") - if (i == 0 && part.endsWith(QLatin1Char(':'))) { - sanitizedParts << part; - continue; - } - sanitizedParts << sanitizeNode(part); - } + // normalize separators + // convert all backslashes to forward slashes to ensure consistent splitting + cleanPath.replace( QLatin1Char( '\\' ), QLatin1Char( '/' ) ); - // reconstruct with Unix-style separators (/) - QString result = sanitizedParts.join(QLatin1Char('/')); + // split and sanitize each segment + QStringList parts = cleanPath.split( QLatin1Char( '/' ), Qt::SkipEmptyParts ); + QStringList sanitizedParts; - // handle Absolute Paths - if (cleanPath.startsWith(QLatin1Char('/'))) { - result.prepend(QLatin1Char('/')); - } + for ( int i = 0; i < parts.size(); ++i ) + { + QString part = parts[i]; - // restore file prefix rotocol - if (isUrl) { - path = QUrl::fromLocalFile(result).toString(); - } else { - path = result; + // keep Windows Drive Letters (e.g. "C:") + if ( i == 0 && part.endsWith( QLatin1Char( ':' ) ) ) + { + sanitizedParts << part; + continue; } + sanitizedParts << sanitizeNode( part ); + } + + // reconstruct with Unix-style separators (/) + QString result = sanitizedParts.join( QLatin1Char( '/' ) ); + + // handle Absolute Paths + if ( cleanPath.startsWith( QLatin1Char( '/' ) ) ) + { + result.prepend( QLatin1Char( '/' ) ); + } + + // restore file prefix rotocol + if ( isUrl ) + { + path = QUrl::fromLocalFile( result ).toString(); + } + else + { + path = result; + } } QSet InputUtils::referencedAttributeIndexes( QgsVectorLayer *layer, const QString &expression ) diff --git a/app/inpututils.h b/app/inpututils.h index b26d538b8..09e6ea1fe 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -631,7 +631,7 @@ class InputUtils: public QObject * It can be used as standalone for any string to be sanitized */ - static QString sanitizeNode(const QString &input); + static QString sanitizeNode( const QString &input ); /** * Replaces invalid filename characters with underscores (_) diff --git a/app/test/testsketching.cpp b/app/test/testsketching.cpp index 544571381..189bec5bd 100644 --- a/app/test/testsketching.cpp +++ b/app/test/testsketching.cpp @@ -171,7 +171,7 @@ void TestSketching::testLoadBackupSketch() sketchingController.prepareController(); const QString localPath = QDir::tempPath() + QStringLiteral( "/test_sketching/MM_test_image.jpg" ); const QString expectedUrl = QUrl::fromLocalFile( localPath ).toString(); - QCOMPARE( sketchingController.mPhotoSource, expectedUrl); + QCOMPARE( sketchingController.mPhotoSource, expectedUrl ); QCOMPARE( sketchingController.mOriginalPhotoSource, QUrl( path ).toLocalFile() ); QCOMPARE( spy.count(), 1 ); auto signalArgs = spy.takeLast(); diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 8b9540b95..b1592602f 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -274,7 +274,7 @@ void TestUtilsFunctions::getRelativePath() QString relativePath2 = mUtils->getRelativePath( path2, prefixPath ); QCOMPARE( fileName2, relativePath2 ); - QString path3 = QUrl (path2).toString(); + QString path3 = QUrl( path2 ).toString(); QString relativePath3 = mUtils->getRelativePath( path3, prefixPath ); QCOMPARE( fileName2, relativePath3 ); @@ -1019,9 +1019,9 @@ void TestUtilsFunctions::testSanitizePath() InputUtils::sanitizePath( str ); #ifdef Q_OS_WIN - QCOMPARE( str, QStringLiteral( "file:///simple/valid/filename.ext" ) ); + QCOMPARE( str, QStringLiteral( "file:///simple/valid/filename.ext" ) ); #else - QCOMPARE( str, QStringLiteral( "file://simple/valid/filename.ext" ) ); + QCOMPARE( str, QStringLiteral( "file://simple/valid/filename.ext" ) ); #endif // unchanged - url prefix with slash @@ -1042,7 +1042,7 @@ void TestUtilsFunctions::testSanitizePath() // unchanged with partition letter on Windows and file prefix str = QStringLiteral( "file:///C:/Users/simple/valid/filename.ext" ); InputUtils::sanitizePath( str ); - + #ifdef Q_OS_WIN QCOMPARE( str, QStringLiteral( "file:///C:/Users/simple/valid/filename.ext" ) ); #else From 3342e3f32515b477e20d9a77d43f20f533ead9a4 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Wed, 18 Feb 2026 12:37:10 +0200 Subject: [PATCH 09/10] Modified getRelativePath function --- app/inpututils.cpp | 6 ++++++ app/test/testutilsfunctions.cpp | 10 ---------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index f5988d450..b10e028e4 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -1062,6 +1062,12 @@ QString InputUtils::getRelativePath( const QString &path, const QString &prefixP const QDir prefixDir( prefixPath ); QString relativePath = prefixDir.relativeFilePath( cleanPath ); + // check if the path starts with ".." or is absolute (on Windows/different drives), it's not a "child" + if ( relativePath.startsWith( QLatin1String( ".." ) ) || QDir::isAbsolutePath( relativePath ) ) + { + return {}; + } + if ( relativePath == cleanPath && !cleanPath.isEmpty() ) { return {}; diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index b1592602f..c52f8c9b5 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -1017,12 +1017,7 @@ void TestUtilsFunctions::testSanitizePath() // unchanged - url prefix str = QStringLiteral( "file://simple/valid/filename.ext" ); InputUtils::sanitizePath( str ); - -#ifdef Q_OS_WIN QCOMPARE( str, QStringLiteral( "file:///simple/valid/filename.ext" ) ); -#else - QCOMPARE( str, QStringLiteral( "file://simple/valid/filename.ext" ) ); -#endif // unchanged - url prefix with slash str = QStringLiteral( "file:///simple/valid/filename.ext" ); @@ -1042,12 +1037,7 @@ void TestUtilsFunctions::testSanitizePath() // unchanged with partition letter on Windows and file prefix str = QStringLiteral( "file:///C:/Users/simple/valid/filename.ext" ); InputUtils::sanitizePath( str ); - -#ifdef Q_OS_WIN QCOMPARE( str, QStringLiteral( "file:///C:/Users/simple/valid/filename.ext" ) ); -#else - QCOMPARE( str, QStringLiteral( "file://C:/Users/simple/valid/filename.ext" ) ); -#endif // sanitized str = QStringLiteral( "/sa ni*tized/fl?n\"a:m|e.ext " ); From 38a6fb6e568d0bf97b5a34382d4baa8c68d6c678 Mon Sep 17 00:00:00 2001 From: Gabriel Bolbotina Date: Fri, 20 Feb 2026 15:25:15 +0200 Subject: [PATCH 10/10] Implemented code findings --- app/inpututils.cpp | 70 ++++++++------------------------- app/inpututils.h | 8 ---- app/projectwizard.cpp | 2 +- app/test/testutilsfunctions.cpp | 32 ++------------- app/test/testutilsfunctions.h | 1 - 5 files changed, 22 insertions(+), 91 deletions(-) diff --git a/app/inpututils.cpp b/app/inpututils.cpp index b10e028e4..382a7165e 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -995,7 +995,7 @@ QString InputUtils::resolveTargetDir( const QString &homePath, const QVariantMap if ( !expression.isEmpty() ) { QString result = evaluateExpression( pair, activeProject, expression ); - sanitizeFileName( result ); + sanitizePath( result ); return result; } else @@ -1046,7 +1046,7 @@ QString InputUtils::getRelativePath( const QString &path, const QString &prefixP { // clean the file prefix QString cleanPath = path; - QUrl url( path ); + const QUrl url( path ); if ( url.isValid() && url.isLocalFile() ) { cleanPath = url.toLocalFile(); @@ -1068,11 +1068,6 @@ QString InputUtils::getRelativePath( const QString &path, const QString &prefixP return {}; } - if ( relativePath == cleanPath && !cleanPath.isEmpty() ) - { - return {}; - } - return relativePath; } @@ -1975,14 +1970,9 @@ QString InputUtils::sanitizeNode( const QString &input ) const static QRegularExpression illegalChars( QStringLiteral( "[\x00-\x1f<>:|?*\"/\\\\]" ) ); cleanOutput.replace( illegalChars, QStringLiteral( "_" ) ); - // if name has multiple extensions, clear the space for the second extension - // e.g., .tar .gz --> .tar.gz - const static QRegularExpression multipleExtensions( QStringLiteral( "(\\s)(\\.[^.]+)\\s+(?=\\.)" ) ); - cleanOutput.replace( multipleExtensions, QStringLiteral( "\\1\\2" ) ); - // split base and suffix to trim whitespace correctly. QFileInfo fi( cleanOutput ); - QString base = fi.completeBaseName(); + QString base = fi.completeBaseName().trimmed(); QString suffix = fi.suffix().trimmed(); // handle Windows Reserved Names (CON, PRN, etc.) @@ -2008,67 +1998,41 @@ QString InputUtils::sanitizeNode( const QString &input ) return base; } -void InputUtils::sanitizeFileName( QString &fileName ) -{ - if ( fileName.isEmpty() ) return; - - // separate directory from file name - QFileInfo fileInfo( fileName ); - QString dirPath = fileInfo.path(); - QString rawName = fileInfo.fileName(); - - // sanitize only the file name part - QString cleanName = sanitizeNode( rawName ); - - // re-attach Directory - if ( dirPath == QLatin1String( "." ) && !fileName.contains( QLatin1Char( '/' ) ) && !fileName.contains( QLatin1Char( '\\' ) ) ) - { - fileName = cleanName; - } - else - { - // Use QDir to join safely - QString fullPath = QDir( dirPath ).filePath( cleanName ); - fileName = QDir::cleanPath( fullPath ); - } -} - void InputUtils::sanitizePath( QString &path ) { if ( path.isEmpty() ) return; QString cleanPath = path; + // check if the path has a file prefix // QUrl treats ? as a query start, # as a fragment - cleanPath.replace( QLatin1Char( '?' ), QLatin1Char( '_' ) ); - cleanPath.replace( QLatin1Char( '#' ), QLatin1Char( '_' ) ); + // so we modify the cleanPath for the file prefix check + cleanPath.replace( QStringLiteral( "?" ), QStringLiteral( "_" ) ); + cleanPath.replace( QStringLiteral( "#" ), QStringLiteral( "_" ) ); // parse file prefix and path QUrl url = QUrl::fromUserInput( cleanPath ); bool isUrl = path.startsWith( QLatin1String( "file:" ), Qt::CaseInsensitive ); - if ( isUrl && ( url.isLocalFile() || url.hasQuery() ) ) + // if it has the file prefix, we will get rid of it + if ( isUrl ) { cleanPath = url.toLocalFile(); } - else - { - cleanPath = path; - } // normalize separators // convert all backslashes to forward slashes to ensure consistent splitting - cleanPath.replace( QLatin1Char( '\\' ), QLatin1Char( '/' ) ); + cleanPath.replace( QStringLiteral( "\\" ), QStringLiteral( "/" ) ); // split and sanitize each segment - QStringList parts = cleanPath.split( QLatin1Char( '/' ), Qt::SkipEmptyParts ); + QStringList parts = cleanPath.split( QStringLiteral( "/" ), Qt::SkipEmptyParts ); QStringList sanitizedParts; for ( int i = 0; i < parts.size(); ++i ) { - QString part = parts[i]; + const QString &part = parts[i]; // keep Windows Drive Letters (e.g. "C:") - if ( i == 0 && part.endsWith( QLatin1Char( ':' ) ) ) + if ( i == 0 && part.endsWith( QStringLiteral( ":" ) ) && part.size() == 2 ) { sanitizedParts << part; continue; @@ -2077,15 +2041,15 @@ void InputUtils::sanitizePath( QString &path ) } // reconstruct with Unix-style separators (/) - QString result = sanitizedParts.join( QLatin1Char( '/' ) ); + QString result = sanitizedParts.join( QStringLiteral( "/" ) ); // handle Absolute Paths - if ( cleanPath.startsWith( QLatin1Char( '/' ) ) ) + if ( cleanPath.startsWith( QStringLiteral( "/" ) ) ) { - result.prepend( QLatin1Char( '/' ) ); + result.prepend( QStringLiteral( "/" ) ); } - // restore file prefix rotocol + // restore file prefix protocol if ( isUrl ) { path = QUrl::fromLocalFile( result ).toString(); diff --git a/app/inpututils.h b/app/inpututils.h index 09e6ea1fe..6ec78ecef 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -633,14 +633,6 @@ class InputUtils: public QObject static QString sanitizeNode( const QString &input ); - /** - * Replaces invalid filename characters with underscores (_) - * Also trims whitespaces at the start and end of \a filename. If \a filename has an extension and - * last character before the . is a whitespace, it does not get trimmed. - * it only sanitizes the file name not the entire path - */ - static void sanitizeFileName( QString &fileName ); - /** * Splits path into components and sanitizes each component using sanitizeNode(). */ diff --git a/app/projectwizard.cpp b/app/projectwizard.cpp index c149ca85a..4339558bc 100644 --- a/app/projectwizard.cpp +++ b/app/projectwizard.cpp @@ -163,7 +163,7 @@ void ProjectWizard::createProject( QString const &projectNameRaw, FieldsModel *f } QString projectName( projectNameRaw ); - InputUtils::sanitizeFileName( projectName ); + projectName = InputUtils::sanitizeNode( projectName ); QString projectDir = CoreUtils::createUniqueProjectDirectory( mDataDir, projectName ); QString projectFilepath( QString( "%1/%2.qgz" ).arg( projectDir ).arg( projectName ) ); diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index c52f8c9b5..2a665031c 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -978,30 +978,6 @@ void TestUtilsFunctions::testIsValidEmail() QVERIFY( !InputUtils::isValidEmail( "brokenemail" ) ); QVERIFY( !InputUtils::isValidEmail( "" ) ); } - -void TestUtilsFunctions::testSanitizeFileName() -{ - // unchanged - QString str = QStringLiteral( "/simple/valid/filename.ext" ); - InputUtils::sanitizeFileName( str ); - QCOMPARE( str, QStringLiteral( "/simple/valid/filename.ext" ) ); - - // unchanged - str = QStringLiteral( "/complex/valid/Φ!l@#äme$%^&()-_=+[]{}`~;',.ext" ); - InputUtils::sanitizeFileName( str ); - QCOMPARE( str, QStringLiteral( "/complex/valid/Φ!l@#äme$%^&()-_=+[]{}`~;',.ext" ) ); - - // sanitized file name, we expect the rest of the path to remain unsanitized - str = QStringLiteral( "/sa ni*tized/fl?n\"a:m|e .ext " ); - InputUtils::sanitizeFileName( str ); - QCOMPARE( str, QStringLiteral( "/sa ni*tized/f_i_l_n_a_m_e .ext" ) ); - - // sanitized file name, we expect the rest of the path to remain unsanitized - str = QStringLiteral( "/sa ni*tized/.fl?n\"a:m|e .co .ext " ); - InputUtils::sanitizeFileName( str ); - QCOMPARE( str, QStringLiteral( "/sa ni*tized/.f_i_l_n_a_m_e .co.ext" ) ); -} - void TestUtilsFunctions::testSanitizePath() { // unchanged @@ -1027,7 +1003,7 @@ void TestUtilsFunctions::testSanitizePath() // unchanged str = QStringLiteral( "/complex/valid/Φ!l@#äme$%^&()-_=+[]{}`~;',.ext" ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "/complex/valid/Φ!l@#äme$%^&()-_=+[]{}`~;',.ext" ) ); + QCOMPARE( str, QStringLiteral( "/complex/valid/Φ!l@_äme$%^&()-_=+[]{}`~;',.ext" ) ); // unchanged with partition letter on Windows str = QStringLiteral( "C:/Users/simple/valid/filename.ext" ); @@ -1052,15 +1028,15 @@ void TestUtilsFunctions::testSanitizePath() // sanitized str = QStringLiteral( "file:/// sa ni*tized /fl?n\"a:m|e .ext " ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "file:///sa ni_tized/f_i_l_n_a_m_e .ext" ) ); + QCOMPARE( str, QStringLiteral( "file:///sa ni_tized/f_i_l_n_a_m_e.ext" ) ); // sanitized str = QStringLiteral( "project name / project .qgz " ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "project name/project .qgz" ) ); + QCOMPARE( str, QStringLiteral( "project name/project.qgz" ) ); // sanitized with partition letter str = QStringLiteral( "C:/project name / project .qgz " ); InputUtils::sanitizePath( str ); - QCOMPARE( str, QStringLiteral( "C:/project name/project .qgz" ) ); + QCOMPARE( str, QStringLiteral( "C:/project name/project.qgz" ) ); } diff --git a/app/test/testutilsfunctions.h b/app/test/testutilsfunctions.h index c37f87bb4..affe9437d 100644 --- a/app/test/testutilsfunctions.h +++ b/app/test/testutilsfunctions.h @@ -51,7 +51,6 @@ class TestUtilsFunctions: public QObject void testFormatAreaInProjectUnit(); void testRelevantGeometryCenterToScreenCoordinates(); void testIsValidEmail(); - void testSanitizeFileName(); void testSanitizePath(); private: