Skip to content

Commit

Permalink
Proper symlink handling for layers and projects
Browse files Browse the repository at this point in the history
- Prevent QGIS from silently resolving symlinks in qgspathresolver.cpp
- Respect symlink for “%_attachments.zip” companion file in .qgs project
- Ensure consistent resolution of the project path
- Add new tests to verify good behaviours in common symlink scenarios
- Fix formatting
vsydorov authored and nyalldawson committed Jan 25, 2025
1 parent f286a99 commit a0f7b87
Showing 3 changed files with 450 additions and 8 deletions.
17 changes: 13 additions & 4 deletions src/core/qgsarchive.cpp
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 5 additions & 3 deletions src/core/qgspathresolver.cpp
Original file line number Diff line number Diff line change
@@ -130,7 +130,7 @@ QString QgsPathResolver::readPath( const QString &f ) const
}
else
{
return vsiPrefix + fi.canonicalFilePath();
return vsiPrefix + QDir::cleanPath( fi.absoluteFilePath() );
}
}

@@ -266,7 +266,8 @@ QString QgsPathResolver::writePath( const QString &s ) const

// Get projPath even if project has not been created yet
const QFileInfo pfi( QFileInfo( mBaseFileName ).path() );
QString projPath = pfi.canonicalFilePath();
// readPath does not resolve symlink, so writePath should not either
QString projPath = pfi.absoluteFilePath();

// If project directory doesn't exit, fallback to absoluteFilePath : symbolic
// links won't be handled correctly, but that's OK as the path is "virtual".
@@ -291,7 +292,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 );
433 changes: 432 additions & 1 deletion tests/src/core/testqgsproject.cpp
Original file line number Diff line number Diff line change
@@ -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,12 @@ class TestQgsProject : public QObject
void testAttachmentIdentifier();
void testEmbeddedGroupWithJoins();
void testAsynchronousLayerLoading();
void testSymlinks1LayerRasterChange();
void testSymlinks2LayerFolder();
void testSymlinks3LayerShapefile();
void testSymlinks4LayerShapefileBroken();
void testSymlinks5ProjectFile();
void testSymlinks6ProjectFolder();
};

void TestQgsProject::init()
@@ -1102,6 +1109,430 @@ 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<QgsProject> project = std::make_unique<QgsProject>();
std::unique_ptr<QgsRasterLayer> layer = std::make_unique<QgsRasterLayer>( "./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<QgsProject>();
project->read( projectPath );
QgsRasterLayer *loadedLayer = qobject_cast<QgsRasterLayer *>( project->mapLayersByName( QStringLiteral( "Latest" ) ).at( 0 ) );
QCOMPARE( QFileInfo( loadedLayer->source() ).canonicalFilePath(), dataDir + "/" + rasters[0] );
project->write( projectPath );
project.reset();
// Change to raster2
QFile::remove( projectDir + "/latest.tif" );
QVERIFY( QFile::link( dataDir + "/" + rasters[1], projectDir + "/latest.tif" ) );
project = std::make_unique<QgsProject>();
project->read( projectPath );
loadedLayer = qobject_cast<QgsRasterLayer *>( project->mapLayersByName( QStringLiteral( "Latest" ) ).at( 0 ) );
QCOMPARE( QFileInfo( loadedLayer->source() ).canonicalFilePath(), dataDir + "/" + rasters[1] );
project->write( projectPath );
project.reset();
// Change to raster3
QFile::remove( projectDir + "/latest.tif" );
QVERIFY( QFile::link( dataDir + "/" + rasters[2], projectDir + "/latest.tif" ) );
project = std::make_unique<QgsProject>();
project->read( projectPath );
loadedLayer = qobject_cast<QgsRasterLayer *>( project->mapLayersByName( QStringLiteral( "Latest" ) ).at( 0 ) );
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<QgsProject> project = std::make_unique<QgsProject>();
std::unique_ptr<QgsVectorLayer> layer = std::make_unique<QgsVectorLayer>( "./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<QgsProject>();
project->read( projectPath );
QgsVectorLayer *loadedLayer = qobject_cast<QgsVectorLayer *>( 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<QgsProject> project = std::make_unique<QgsProject>();
std::unique_ptr<QgsVectorLayer> layer = std::make_unique<QgsVectorLayer>( "./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<QgsProject>();
project->read( projectPath );
QgsVectorLayer *loadedLayer = qobject_cast<QgsVectorLayer *>( 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<QgsProject>();
project->read( projectPath );
loadedLayer = qobject_cast<QgsVectorLayer *>( 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<QgsVectorLayer> memLayer = std::make_unique<QgsVectorLayer>( "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<QgsProject> project = std::make_unique<QgsProject>();
std::unique_ptr<QgsVectorLayer> layer = std::make_unique<QgsVectorLayer>( "./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<QgsProject>();
project->read( projectPath );
QgsVectorLayer *loadedLayer = qobject_cast<QgsVectorLayer *>( 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<QgsProject> project = std::make_unique<QgsProject>();
std::unique_ptr<QgsVectorLayer> layer = std::make_unique<QgsVectorLayer>( "./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<QgsProject>();
project->read( originalPath );
QgsVectorLayer *loadedLayer = qobject_cast<QgsVectorLayer *>( 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<QgsProject>();
layer = std::make_unique<QgsVectorLayer>( "./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() );
}
}

void TestQgsProject::testSymlinks6ProjectFolder()
{
// Replicate this test: python/test_qgsproject.py:testSymbolicLinkInProjectPath
// Check functionality if the immediate parent is a symlink

// ++SETUP++
// Create directory structure
QTemporaryDir tempDir;
const QString rootPath = tempDir.path();
const QString projectDir = rootPath + "/projects/qgis/test4";
const QString symlinkprojparentDir = rootPath + "/another/directory";
QDir().mkpath( projectDir );
QDir().mkpath( symlinkprojparentDir );

// 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";
std::unique_ptr<QgsProject> project = std::make_unique<QgsProject>();
std::unique_ptr<QgsVectorLayer> layer = std::make_unique<QgsVectorLayer>( "./points.shp", QStringLiteral( "Points" ), QStringLiteral( "ogr" ) );
project->addMapLayer( layer.release() );
project->write( originalPath );
project.reset();

// Create a new temporary directory and a symbolic link to the original folder
const QString symlinkprojDir = symlinkprojparentDir + "/symlink_projdir";
QVERIFY( QFile::link( projectDir, symlinkprojDir ) );
const QString symlinkprojPath = symlinkprojDir + "/project.qgs";

// ++Open the project through a symlink and re-save++
project = std::make_unique<QgsProject>();
QVERIFY( project->read( symlinkprojPath ) );
QVERIFY( project->write( symlinkprojPath ) );
project.reset();

// ++Verify paths after re-opening++
// XML datasource is still "./points.shp"
const QString layerSource = getLayerSourceFromProjectXml( symlinkprojPath, QStringLiteral( "Points" ) );
QCOMPARE( layerSource, QStringLiteral( "./points.shp" ) );
// Absolute layer source does NOT resolve the symlink
project = std::make_unique<QgsProject>();
project->read( symlinkprojPath );
QgsVectorLayer *loadedLayer = qobject_cast<QgsVectorLayer *>( project->mapLayersByName( QStringLiteral( "Points" ) ).at( 0 ) );
QCOMPARE( loadedLayer->source(), symlinkprojDir + "/points.shp" );
}

QGSTEST_MAIN( TestQgsProject )
#include "testqgsproject.moc"

0 comments on commit a0f7b87

Please sign in to comment.