Skip to content

Commit 64e319d

Browse files
committed
Add file content read/write utilities for QML extensions; add tests
1 parent f18475f commit 64e319d

3 files changed

Lines changed: 492 additions & 2 deletions

File tree

src/core/utils/fileutils.cpp

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626
#include <QMimeDatabase>
2727
#include <QPainter>
2828
#include <QPainterPath>
29+
#include <QStandardPaths>
2930
#include <qgis.h>
3031
#include <qgsexiftools.h>
3132
#include <qgsfileutils.h>
33+
#include <qgsproject.h>
3234
#include <qgsrendercontext.h>
3335
#include <qgstextformat.h>
3436
#include <qgstextrenderer.h>
3537

38+
3639
FileUtils::FileUtils( QObject *parent )
3740
: QObject( parent )
3841
{
@@ -318,3 +321,233 @@ void FileUtils::addImageStamp( const QString &imagePath, const QString &text )
318321
}
319322
}
320323
}
324+
325+
bool FileUtils::isWithinProjectDirectory( const QString &filePath )
326+
{
327+
// Get the project instance
328+
QgsProject *project = QgsProject::instance();
329+
if ( !project || project->fileName().isEmpty() )
330+
return false;
331+
332+
QFileInfo projectFileInfo( project->fileName() );
333+
if ( !projectFileInfo.exists() )
334+
return false;
335+
336+
// Get the canonical path for the project directory
337+
QString projectDirCanonical = QFileInfo( projectFileInfo.dir().absolutePath() ).canonicalFilePath();
338+
if ( projectDirCanonical.isEmpty() )
339+
{
340+
// Fallback to absolutePath() if canonicalFilePath() is empty
341+
projectDirCanonical = QFileInfo( projectFileInfo.dir().absolutePath() ).absoluteFilePath();
342+
if ( projectDirCanonical.isEmpty() )
343+
return false;
344+
}
345+
346+
// Get target file info and its canonical path
347+
QFileInfo targetInfo( filePath );
348+
QString targetCanonical;
349+
350+
if ( targetInfo.exists() || targetInfo.isSymLink() )
351+
{
352+
targetCanonical = targetInfo.canonicalFilePath();
353+
if ( targetCanonical.isEmpty() )
354+
{
355+
targetCanonical = targetInfo.absoluteFilePath();
356+
}
357+
}
358+
else
359+
{
360+
// Walk up the directory tree until we find an existing parent
361+
QDir dir = targetInfo.dir();
362+
QStringList pendingSegments;
363+
364+
while ( !dir.exists() && dir.cdUp() )
365+
{
366+
pendingSegments.prepend( QFileInfo( dir.path() ).fileName() );
367+
}
368+
369+
QString existingCanonical = QFileInfo( dir.path() ).canonicalFilePath();
370+
if ( existingCanonical.isEmpty() )
371+
{
372+
existingCanonical = QFileInfo( dir.path() ).absoluteFilePath();
373+
if ( existingCanonical.isEmpty() )
374+
return false;
375+
}
376+
377+
// Rebuild the target path from existing directories
378+
QDir rebuiltDir( existingCanonical );
379+
for ( const QString &segment : pendingSegments )
380+
{
381+
rebuiltDir.cd( segment );
382+
}
383+
384+
targetCanonical = rebuiltDir.absoluteFilePath( targetInfo.fileName() );
385+
}
386+
387+
// Normalize paths for Windows (case-insensitive check)
388+
#ifdef Q_OS_WIN
389+
projectDirCanonical = projectDirCanonical.toLower();
390+
targetCanonical = targetCanonical.toLower();
391+
#endif
392+
393+
// Check if the target path is equal to or within the project directory
394+
return targetCanonical == projectDirCanonical || targetCanonical.startsWith( projectDirCanonical + QDir::separator() );
395+
}
396+
397+
QByteArray FileUtils::readFileContent( const QString &filePath )
398+
{
399+
QByteArray content;
400+
401+
if ( !isWithinProjectDirectory( filePath ) )
402+
{
403+
qWarning() << QStringLiteral( "Security warning: Attempted to read file outside project directory: %1" ).arg( filePath );
404+
return QByteArray();
405+
}
406+
407+
QFile file( filePath );
408+
409+
if ( file.exists() )
410+
{
411+
if ( file.open( QIODevice::ReadOnly ) )
412+
{
413+
content = file.readAll();
414+
file.close();
415+
}
416+
else
417+
{
418+
qDebug() << QStringLiteral( "Failed to read file content: %1" ).arg( file.errorString() );
419+
}
420+
}
421+
else
422+
{
423+
qDebug() << QStringLiteral( "File does not exist: %1" ).arg( filePath );
424+
}
425+
426+
return content;
427+
}
428+
429+
bool FileUtils::writeFileContent( const QString &filePath, const QByteArray &content )
430+
{
431+
if ( !isWithinProjectDirectory( filePath ) )
432+
{
433+
qWarning() << QStringLiteral( "Security warning: Attempted to write file outside project directory: %1" ).arg( filePath );
434+
return false;
435+
}
436+
437+
QFile file( filePath );
438+
QFileInfo fileInfo( filePath );
439+
QDir directory = fileInfo.dir();
440+
441+
bool isLikelyWritable = false;
442+
443+
#ifdef Q_OS_ANDROID
444+
QString appStorage = QStandardPaths::writableLocation( QStandardPaths::AppDataLocation );
445+
isLikelyWritable = filePath.startsWith( appStorage );
446+
#elif defined( Q_OS_IOS )
447+
QString appStorage = QStandardPaths::writableLocation( QStandardPaths::AppDataLocation );
448+
isLikelyWritable = filePath.startsWith( appStorage );
449+
#else
450+
QFileInfo dirInfo( directory.absolutePath() );
451+
isLikelyWritable = dirInfo.isWritable();
452+
#endif
453+
454+
if ( !isLikelyWritable )
455+
{
456+
qWarning() << QStringLiteral( "Writing to %1 may fail due to platform restrictions. Use PlatformUtilities.applicationDirectory() for a safe location." ).arg( filePath );
457+
}
458+
459+
if ( !directory.exists() )
460+
{
461+
if ( !directory.mkpath( "." ) )
462+
{
463+
qDebug() << QStringLiteral( "Failed to create directory for file: %1. This may be due to permission restrictions." ).arg( filePath );
464+
return false;
465+
}
466+
}
467+
468+
if ( file.open( QIODevice::WriteOnly ) )
469+
{
470+
qint64 bytesWritten = file.write( content );
471+
file.close();
472+
473+
if ( bytesWritten != content.size() )
474+
{
475+
qDebug() << QStringLiteral( "Failed to write all data to file: %1" ).arg( filePath );
476+
return false;
477+
}
478+
479+
return true;
480+
}
481+
else
482+
{
483+
QString errorMsg = file.errorString();
484+
if ( file.error() == QFile::PermissionsError )
485+
{
486+
errorMsg += QStringLiteral( " - This may be due to platform security restrictions." );
487+
}
488+
qDebug() << QStringLiteral( "Failed to open file for writing: %1" ).arg( errorMsg );
489+
return false;
490+
}
491+
}
492+
493+
QVariantMap FileUtils::getFileInfo( const QString &filePath )
494+
{
495+
QVariantMap info;
496+
497+
if ( !isWithinProjectDirectory( filePath ) )
498+
{
499+
qWarning() << QStringLiteral( "Security warning: Attempted to access file info outside project directory: %1" ).arg( filePath );
500+
info["exists"] = false;
501+
info["error"] = QStringLiteral( "Access denied: File is outside the current project directory" );
502+
return info;
503+
}
504+
505+
QFile file( filePath );
506+
QFileInfo fileInfo( filePath );
507+
508+
if ( fileInfo.exists() )
509+
{
510+
info["exists"] = true;
511+
info["fileName"] = fileInfo.fileName();
512+
info["filePath"] = fileInfo.absoluteFilePath();
513+
info["fileSize"] = fileInfo.size();
514+
info["lastModified"] = fileInfo.lastModified();
515+
info["suffix"] = fileInfo.suffix();
516+
517+
QMimeDatabase db;
518+
info["mimeType"] = db.mimeTypeForFile( filePath ).name();
519+
520+
QByteArray md5Hash = fileChecksum( filePath, QCryptographicHash::Md5 );
521+
if ( !md5Hash.isEmpty() )
522+
{
523+
info["md5"] = md5Hash.toHex();
524+
}
525+
else
526+
{
527+
info["md5Error"] = QStringLiteral( "Could not calculate MD5 hash - possibly due to permission restrictions" );
528+
}
529+
530+
if ( file.open( QIODevice::ReadOnly ) )
531+
{
532+
info["content"] = file.readAll();
533+
info["readable"] = true;
534+
file.close();
535+
}
536+
else
537+
{
538+
info["readable"] = false;
539+
info["readError"] = file.errorString();
540+
541+
if ( file.error() == QFile::PermissionsError )
542+
{
543+
info["readError"] = info["readError"].toString() + QStringLiteral( " - This may be due to platform security restrictions" );
544+
}
545+
}
546+
}
547+
else
548+
{
549+
info["exists"] = false;
550+
}
551+
552+
return info;
553+
}

src/core/utils/fileutils.h

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
#include <QCryptographicHash>
2323
#include <QObject>
24+
#include <QVariantMap>
2425
#include <qgsfeedback.h>
2526

2627
class GnssPositionInformation;
@@ -48,9 +49,53 @@ class QFIELD_CORE_EXPORT FileUtils : public QObject
4849
Q_INVOKABLE static QString fileSuffix( const QString &filePath );
4950
//! Returns a human-friendly size from bytes
5051
Q_INVOKABLE static QString representFileSize( qint64 bytes );
51-
//! Returns the absolute path of tghe folder containing the \a filePath.
52+
//! Returns the absolute path of the folder containing the \a filePath.
5253
Q_INVOKABLE static QString absolutePath( const QString &filePath );
5354

55+
/**
56+
* Checks if a file path is securely within the current project directory.
57+
* Security measures:
58+
* - Prevents directory traversal attacks using path normalization
59+
* - Handles symbolic links that could escape project boundaries
60+
* - Supports cross-platform path comparisons (Windows, macOS, Linux, Android, iOS)
61+
* - Validates non-existent files safely for write operations
62+
* - Prevents partial directory matching by enforcing complete path containment
63+
*
64+
* \param filePath The path to check
65+
* \return True if the path is safely within the current project directory
66+
*/
67+
static bool isWithinProjectDirectory( const QString &filePath );
68+
69+
/**
70+
* Reads the entire content of a file and returns it as a byte array.
71+
* \param filePath The path to the file to be read
72+
* \return The file content as a QByteArray
73+
*/
74+
Q_INVOKABLE static QByteArray readFileContent( const QString &filePath );
75+
76+
/**
77+
* Writes content to a file.
78+
* \param filePath The path to the file to be written
79+
* \param content The content to write to the file
80+
* \return True if the write operation was successful, false otherwise
81+
*
82+
* \note Platform restrictions apply:
83+
* - On Android: Writing is only permitted within the app's internal storage or
84+
* properly requested scoped storage locations.
85+
* - On iOS: Writing is restricted to the app's sandbox.
86+
* - Use PlatformUtilities.applicationDirectory() to get a safe write location.
87+
*/
88+
Q_INVOKABLE static bool writeFileContent( const QString &filePath, const QByteArray &content );
89+
90+
/**
91+
* Gets detailed information about a file including content, MD5 hash and metadata.
92+
* This is useful for file validation, caching, and efficient file handling in QML.
93+
* \param filePath The path to the file
94+
* \return A map containing file metadata and optionally its content
95+
*/
96+
Q_INVOKABLE static QVariantMap getFileInfo( const QString &filePath );
97+
98+
5499
/**
55100
* Insures that a given image's width and height are restricted to a maximum size.
56101
* \param imagePath the image file path

0 commit comments

Comments
 (0)