diff --git a/src/core/qgsarchive.cpp b/src/core/qgsarchive.cpp index 413c8a7f0ae4..539c4c6b9594 100644 --- a/src/core/qgsarchive.cpp +++ b/src/core/qgsarchive.cpp @@ -76,9 +76,18 @@ bool QgsArchive::zip( const QString &filename ) return false; } + QString target {filename}; + // remove existing zip file - if ( QFile::exists( filename ) ) - QFile::remove( filename ); + if ( QFile::exists( target ) ) + { + // If symlink -> we want to write to its target instead + const QFileInfo targetFileInfo( target ); + target = targetFileInfo.canonicalFilePath(); + // If target still exists, remove (might not exist if was a dangling symlink) + if ( QFile::exists( target ) ) + QFile::remove( target ); + } #ifdef Q_OS_WIN // Clear temporary flag (see GH #32118) @@ -94,9 +103,9 @@ bool QgsArchive::zip( const QString &filename ) #endif // Q_OS_WIN // save zip archive - if ( ! tmpFile.rename( filename ) ) + if ( ! tmpFile.rename( target ) ) { - const QString err = QObject::tr( "Unable to save zip file '%1'" ).arg( filename ); + const QString err = QObject::tr( "Unable to save zip file '%1'" ).arg( target ); QgsMessageLog::logMessage( err, QStringLiteral( "QgsArchive" ) ); return false; } diff --git a/src/core/qgspathresolver.cpp b/src/core/qgspathresolver.cpp index 1781d1b78eae..b0b86f8783f2 100644 --- a/src/core/qgspathresolver.cpp +++ b/src/core/qgspathresolver.cpp @@ -130,7 +130,7 @@ QString QgsPathResolver::readPath( const QString &f ) const } else { - return vsiPrefix + fi.canonicalFilePath(); + return vsiPrefix + QDir::cleanPath( fi.absoluteFilePath() ); } } @@ -291,7 +291,8 @@ QString QgsPathResolver::writePath( const QString &s ) const const QFileInfo srcFileInfo( srcPath ); if ( srcFileInfo.exists() ) - srcPath = srcFileInfo.canonicalFilePath(); + // Do NOT resolve symlinks, but do remove '..' and '.' + srcPath = QDir::cleanPath( srcFileInfo.absoluteFilePath() ); // if this is a VSIFILE, remove the VSI prefix and append to final result const QString vsiPrefix = QgsGdalUtils::vsiPrefixForPath( src ); diff --git a/tests/src/core/testqgsproject.cpp b/tests/src/core/testqgsproject.cpp index 4efe6afd7870..b9d1850981a2 100644 --- a/tests/src/core/testqgsproject.cpp +++ b/tests/src/core/testqgsproject.cpp @@ -32,7 +32,8 @@ #include "qgsmarkersymbol.h" #include "qgsrasterlayer.h" #include "qgssettingsregistrycore.h" - +#include "qgsvectorfilewriter.h" +#include "qgsarchive.h" class TestQgsProject : public QObject { @@ -65,6 +66,11 @@ class TestQgsProject : public QObject void testAttachmentIdentifier(); void testEmbeddedGroupWithJoins(); void testAsynchronousLayerLoading(); + void testSymlinks1LayerRasterChange(); + void testSymlinks2LayerFolder(); + void testSymlinks3LayerShapefile(); + void testSymlinks4LayerShapefileBroken(); + void testSymlinks5ProjectFile(); }; void TestQgsProject::init() @@ -1102,6 +1108,372 @@ void TestQgsProject::testAsynchronousLayerLoading() QCOMPARE( project->mapLayers( false ).count(), layersCount ); } +QString getProjectXmlContent( const QString &projectPath ) +{ + if ( projectPath.endsWith( QLatin1String( ".qgz" ) ) ) + { + QgsProjectArchive archive; + if ( !archive.unzip( projectPath ) ) + return QString(); + + const QString qgsFile = archive.projectFile(); + if ( qgsFile.isEmpty() ) + return QString(); + + QFile file( qgsFile ); + if ( !file.open( QIODevice::ReadOnly ) ) + return QString(); + return file.readAll(); + } + + QFile file( projectPath ); + if ( !file.open( QIODevice::ReadOnly ) ) + return QString(); + return file.readAll(); +} + +QString getLayerSourceFromProjectXml( const QString &projectPath, const QString &layerName ) +{ + // Get XML content + const QString xmlContent = getProjectXmlContent( projectPath ); + if ( xmlContent.isEmpty() ) + return QString(); + + // Parse XML + QDomDocument doc; + if ( !doc.setContent( xmlContent ) ) + return QString(); + + // Find layer by name in XML + const QDomNodeList layers = doc.elementsByTagName( QStringLiteral( "maplayer" ) ); + for ( int i = 0; i < layers.count(); ++i ) + { + const QDomElement layerElem = layers.at( i ).toElement(); + if ( layerElem.firstChildElement( QStringLiteral( "layername" ) ).text() == layerName ) + { + return layerElem.firstChildElement( QStringLiteral( "datasource" ) ).text(); + } + } + return QString(); +} + +void TestQgsProject::testSymlinks1LayerRasterChange() +{ + // Verify that symlinked raster layer behaves well when target is changed + + // ++SETUP++ + // Create directory structure + QTemporaryDir tempDir; + const QString rootPath = tempDir.path(); + const QString projectDir = rootPath + "/projects/qgis/test1"; + const QString dataDir = rootPath + "/data"; + const QString projectPath = projectDir + "/proj.qgs"; + QDir().mkpath( projectDir ); + QDir().mkpath( dataDir ); + + // Copy test rasters to data dir + const QString testDataDir( TEST_DATA_DIR ); + const QStringList rasters = { "rnd_percentile_raster1_byte.tif", "rnd_percentile_raster2_byte.tif", "rnd_percentile_raster3_byte.tif" }; + for ( const QString &raster : rasters ) + { + QVERIFY( QFile::copy( testDataDir + "/raster/" + raster, dataDir + "/" + raster ) ); + } + + // Create symlink pointing to raster1 + QVERIFY( QFile::link( dataDir + "/" + rasters[0], projectDir + "/latest.tif" ) ); + + // Create project with layer pointing to symlink + std::unique_ptr project = std::make_unique(); + std::unique_ptr layer = std::make_unique( "./latest.tif", QStringLiteral( "Latest" ), QStringLiteral( "gdal" ) ); + project->addMapLayer( layer.release() ); + project->write( projectPath ); + project.reset(); + + // ++Verify symlink changes are detected++ + // Initial state - points to raster1 + project = std::make_unique(); + project->read( projectPath ); + QgsRasterLayer *loadedLayer = qobject_cast( project->mapLayersByName( QStringLiteral( "Latest" ) ).at( 0 ) ); + QCOMPARE( QFileInfo( loadedLayer->source() ).canonicalFilePath(), dataDir + "/" + rasters[0] ); + project->write( projectPath ); + // Change to raster2 + QFile::remove( projectDir + "/latest.tif" ); + QVERIFY( QFile::link( dataDir + "/" + rasters[1], projectDir + "/latest.tif" ) ); + project->read( projectPath ); + QCOMPARE( QFileInfo( loadedLayer->source() ).canonicalFilePath(), dataDir + "/" + rasters[1] ); + project->write( projectPath ); + // Change to raster3 + QFile::remove( projectDir + "/latest.tif" ); + QVERIFY( QFile::link( dataDir + "/" + rasters[2], projectDir + "/latest.tif" ) ); + project->read( projectPath ); + QCOMPARE( QFileInfo( loadedLayer->source() ).canonicalFilePath(), dataDir + "/" + rasters[2] ); +} + +void TestQgsProject::testSymlinks2LayerFolder() +{ + // Verify that shapefile layer added via symlinked data folder + // maintains correct relative paths in .qgz on save + + // ++SETUP++ + // Create directory structure (QGZ file) + QTemporaryDir tempDir; + const QString rootPath = tempDir.path(); + const QString testDataDir( TEST_DATA_DIR ); + const QString projectDir = rootPath + "/projects/qgis/test1"; + const QString dataDir = rootPath + "/data"; + const QString projectPath = projectDir + "/proj.qgz"; + QDir().mkpath( projectDir ); + QDir().mkpath( dataDir ); + + // Copy shapefile components + const QStringList components = { "dbf", "prj", "shp", "shx" }; + for ( const QString &ext : components ) + { + QVERIFY( QFile::copy( testDataDir + "/points." + ext, dataDir + "/points." + ext ) ); + } + + // Symlink data folder + QVERIFY( QFile::link( dataDir, projectDir + "/data" ) ); + + // Create project with relative layer + std::unique_ptr project = std::make_unique(); + std::unique_ptr layer = std::make_unique( "./data/points.shp", QStringLiteral( "Points" ), QStringLiteral( "ogr" ) ); + project->addMapLayer( layer.release() ); + project->write( projectPath ); + project.reset(); + + // ++Verify paths after re-opening++ + // XML datasource is "./data/points.shp" NOT "../../../data/points.shp" + const QString layerSource = getLayerSourceFromProjectXml( projectPath, QStringLiteral( "Points" ) ); + QCOMPARE( layerSource, QStringLiteral( "./data/points.shp" ) ); + + // Absolute layer source still in projectDir + project = std::make_unique(); + project->read( projectPath ); + QgsVectorLayer *loadedLayer = qobject_cast( project->mapLayersByName( QStringLiteral( "Points" ) ).at( 0 ) ); + QCOMPARE( loadedLayer->source(), projectDir + "/data/points.shp" ); +} + +void TestQgsProject::testSymlinks3LayerShapefile() +{ + // Verify that individually symlinked shapefile components + // maintain correct relative paths in .qgs on save and shapefile edit + + // ++SETUP++ + // Create directory structure (QGS file) + QTemporaryDir tempDir; + const QString rootPath = tempDir.path(); + const QString testDataDir( TEST_DATA_DIR ); + const QString projectDir = rootPath + "/projects/qgis/test2"; + const QString dataDir = rootPath + "/data"; + const QString projectPath = projectDir + "/proj.qgs"; + QDir().mkpath( projectDir ); + QDir().mkpath( dataDir ); + + // Copy and symlink shapefile components + const QStringList components = { "dbf", "prj", "shp", "shx" }; + for ( const QString &ext : components ) + { + QVERIFY( QFile::copy( testDataDir + "/points." + ext, dataDir + "/points." + ext ) ); + QVERIFY( QFile::link( dataDir + "/points." + ext, projectDir + "/points." + ext ) ); + } + + // Create project with relative layer + std::unique_ptr project = std::make_unique(); + std::unique_ptr layer = std::make_unique( "./points.shp", QStringLiteral( "Points" ), QStringLiteral( "ogr" ) ); + project->addMapLayer( layer.release() ); + project->write( projectPath ); + project.reset(); + + // ++Verify paths after re-opening++ + // XML datasource is "./points.shp" NOT "../../../data/points.shp" + const QString layerSource = getLayerSourceFromProjectXml( projectPath, QStringLiteral( "Points" ) ); + QCOMPARE( layerSource, QStringLiteral( "./points.shp" ) ); + + // Absolute layer source still in projectDir + project = std::make_unique(); + project->read( projectPath ); + QgsVectorLayer *loadedLayer = qobject_cast( project->mapLayersByName( QStringLiteral( "Points" ) ).at( 0 ) ); + QCOMPARE( loadedLayer->source(), projectDir + "/points.shp" ); + + // ++Verify that layer edit follows symlinks++ + const long initialCount = loadedLayer->featureCount(); + + // Add new feature + loadedLayer->startEditing(); + QgsFeature feat( loadedLayer->fields() ); + QgsGeometry geom = QgsGeometry::fromWkt( "POINT(1 2)" ); + feat.setGeometry( geom ); + loadedLayer->addFeature( feat ); + loadedLayer->commitChanges(); + project.reset(); + + // Symlinks still exist and point to correct files + for ( const QString &ext : components ) + { + const QString symlink = projectDir + "/points." + ext; + const QString target = dataDir + "/points." + ext; + // Check symlink exists + QVERIFY( QFileInfo( symlink ).isSymLink() ); + // Check canonical paths match + QFileInfo symlinkInfo( symlink ); + QFileInfo targetInfo( target ); + QCOMPARE( symlinkInfo.canonicalFilePath(), targetInfo.canonicalFilePath() ); + } + + // Feature count has increased + project = std::make_unique(); + project->read( projectPath ); + loadedLayer = qobject_cast( project->mapLayersByName( QStringLiteral( "Points" ) ).at( 0 ) ); + QCOMPARE( loadedLayer->featureCount(), initialCount + 1 ); +} + +void TestQgsProject::testSymlinks4LayerShapefileBroken() +{ + // Verify that saving a new layer to location with existing broken + // shapefile symlinks maintains the symlinks and properly saves the data + + // ++SETUP++ + // Create directory structure (QGS file) + QTemporaryDir tempDir; + const QString rootPath = tempDir.path(); + const QString projectDir = rootPath + "/projects/qgis/test3"; + const QString dataDir = rootPath + "/data"; + const QString projectPath = projectDir + "/proj.qgz"; + QDir().mkpath( projectDir ); + QDir().mkpath( dataDir ); + + // Create broken symlinks for shapefile components, also symlink ".cpg" since it WILL be created + const QStringList components = { "dbf", "prj", "shp", "shx", "cpg" }; + for ( const QString &ext : components ) + { + QVERIFY( QFile::link( dataDir + "/points." + ext, projectDir + "/points." + ext ) ); + } + + // ++Verify that layer creation follows the (broken) symlink++ + // Create memory layer with single point + std::unique_ptr memLayer = std::make_unique( "Point", QStringLiteral( "Points" ), QStringLiteral( "memory" ) ); + QgsFeature feat( memLayer->fields() ); + feat.setGeometry( QgsGeometry::fromWkt( "POINT(1 2)" ) ); + memLayer->startEditing(); + memLayer->addFeature( feat ); + memLayer->commitChanges(); + + // Save memory layer to shapefile at symlink location + QgsVectorFileWriter::SaveVectorOptions options; + options.driverName = QStringLiteral( "ESRI Shapefile" ); + QgsVectorFileWriter::writeAsVectorFormatV3( memLayer.get(), projectDir + "/points.shp", QgsCoordinateTransformContext(), options ); + + // Create project with the layer + std::unique_ptr project = std::make_unique(); + std::unique_ptr layer = std::make_unique( "./points.shp", QStringLiteral( "Points" ), QStringLiteral( "ogr" ) ); + project->addMapLayer( layer.release() ); + project->write( projectPath ); + project.reset(); + + // Verify symlinks and data + for ( const QString &ext : components ) + { + const QString symlink = projectDir + "/points." + ext; + const QString target = dataDir + "/points." + ext; + // Check symlink exists + QVERIFY( QFileInfo( symlink ).isSymLink() ); + // Check canonical paths match + QFileInfo symlinkInfo( symlink ); + QFileInfo targetInfo( target ); + QCOMPARE( symlinkInfo.canonicalFilePath(), targetInfo.canonicalFilePath() ); + } + + // Verify layer has 1 feature + project = std::make_unique(); + project->read( projectPath ); + QgsVectorLayer *loadedLayer = qobject_cast( project->mapLayersByName( QStringLiteral( "Points" ) ).at( 0 ) ); + QCOMPARE( loadedLayer->featureCount(), 1L ); +} + +void TestQgsProject::testSymlinks5ProjectFile() +{ + // Verify that symlinked project file maintains relative paths + // and test writing broken project links + + // ++SETUP++ + // Create directory structure + QTemporaryDir tempDir; + const QString rootPath = tempDir.path(); + const QString projectDir = rootPath + "/projects/qgis/test4"; + const QString symlinkprojDir = rootPath + "/symlinkproj"; + QDir().mkpath( projectDir ); + QDir().mkpath( symlinkprojDir ); + + // Copy shapefile components to project dir + const QString testDataDir( TEST_DATA_DIR ); + const QStringList components = { "dbf", "prj", "shp", "shx" }; + for ( const QString &ext : components ) + { + QFile::copy( testDataDir + "/points." + ext, projectDir + "/points." + ext ); + } + + // Create initial project in project dir + const QString originalPath = projectDir + "/project.qgs"; + const QString originalAttachPath = projectDir + "/project_attachments.zip"; + std::unique_ptr project = std::make_unique(); + std::unique_ptr layer = std::make_unique( "./points.shp", QStringLiteral( "Points" ), QStringLiteral( "ogr" ) ); + project->addMapLayer( layer.release() ); + project->write( originalPath ); + project.reset(); + + // ++Verify that moved project behaves well++ + // Move project file and create symlink + QVERIFY( QFile::rename( originalPath, symlinkprojDir + "/project.qgs" ) ); + QVERIFY( QFile::rename( originalAttachPath, symlinkprojDir + "/project_attachments.zip" ) ); + QVERIFY( QFile::link( symlinkprojDir + "/project.qgs", originalPath ) ); + QVERIFY( QFile::link( symlinkprojDir + "/project_attachments.zip", originalAttachPath ) ); + + // Open symlinked project and verify paths + project = std::make_unique(); + project->read( originalPath ); + QgsVectorLayer *loadedLayer = qobject_cast( project->mapLayersByName( QStringLiteral( "Points" ) ).at( 0 ) ); + QCOMPARE( loadedLayer->source(), projectDir + "/points.shp" ); + + // Save and verify XML content + project->write( originalPath ); + const QString layerSource = getLayerSourceFromProjectXml( originalPath, QStringLiteral( "Points" ) ); + QCOMPARE( layerSource, QStringLiteral( "./points.shp" ) ); + + // ++Change project settings, verify symlinks still good++ + project->setDistanceUnits( Qgis::DistanceUnit::NauticalMiles ); + project->write( originalPath ); + + // Verify symlinks and canonical paths + const QStringList symlinks = { originalPath, originalAttachPath }; + for ( const QString &symlink : symlinks ) + { + QVERIFY( QFileInfo( symlink ).isSymLink() ); + QFileInfo symlinkInfo( symlink ); + QFileInfo targetInfo( symlinkprojDir + "/" + QFileInfo( symlink ).fileName() ); + QCOMPARE( symlinkInfo.canonicalFilePath(), targetInfo.canonicalFilePath() ); + } + + // ++Break symlinks and create new project++ + // Remove symlink destinations + QVERIFY( QFile::remove( symlinkprojDir + "/project.qgs" ) ); + QVERIFY( QFile::remove( symlinkprojDir + "/project_attachments.zip" ) ); + + // Create a new project, writing to the broken symlink + project = std::make_unique(); + layer = std::make_unique( "./points.shp", QStringLiteral( "Points" ), QStringLiteral( "ogr" ) ); + project->addMapLayer( layer.release() ); + project->write( originalPath ); + + // Verify symlinks are now active and well-behaved + for ( const QString &symlink : symlinks ) + { + QVERIFY( QFileInfo( symlink ).isSymLink() ); + QFileInfo symlinkInfo( symlink ); + QFileInfo targetInfo( symlinkprojDir + "/" + QFileInfo( symlink ).fileName() ); + QCOMPARE( symlinkInfo.canonicalFilePath(), targetInfo.canonicalFilePath() ); + } +} QGSTEST_MAIN( TestQgsProject ) #include "testqgsproject.moc"