Qt
Internal/Contributor docs for the Qt SDK. Note: These are NOT official API docs; those are found at https://doc.qt.io/
Loading...
Searching...
No Matches
qdarwinsecurityscopedfileengine.mm
Go to the documentation of this file.
1// Copyright (C) 2025 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3// Qt-Security score:significant reason:default
4
6
7#include <QtCore/qloggingcategory.h>
8#include <QtCore/qstandardpaths.h>
9#include <QtCore/qreadwritelock.h>
10#include <QtCore/qscopedvaluerollback.h>
11
12#include <QtCore/private/qcore_mac_p.h>
13#include <QtCore/private/qfsfileengine_p.h>
14#include <QtCore/private/qfilesystemengine_p.h>
15
16#include <thread>
17#include <mutex>
18
19#include <CoreFoundation/CoreFoundation.h>
20#include <Foundation/NSURL.h>
21
22QT_BEGIN_NAMESPACE
23
24using namespace Qt::StringLiterals;
25
26Q_STATIC_LOGGING_CATEGORY(lcSecEngine, "qt.core.io.security-scoped-fileengine", QtCriticalMsg)
27
28template<typename T> class BackgroundLoader;
29
30/*
31 File engine handler for security scoped file paths.
32
33 Installs itself as soon as QtCore is loaded if the application
34 is sandboxed (optionally on macOS, and always on iOS and friends).
35*/
37{
38public:
41
43
44 std::unique_ptr<QAbstractFileEngine> create(const QString &fileName) const override;
45
47
48private:
50
51 void saveBookmark(NSURL *url);
52 void saveBookmarks();
53
54 NSURL *bookmarksFile() const;
55
56 static NSString *cacheKeyForUrl(NSURL *url);
57 static NSString *cacheKeyForPath(const QString &url);
58
59 NSMutableDictionary *m_bookmarks = nullptr;
60 mutable QReadWriteLock m_bookmarkLock;
61
63};
64
65/*
66 Helper class for asynchronous instantiation of types.
67*/
68template<typename T>
69class BackgroundLoader
70{
71public:
72 explicit BackgroundLoader(bool shouldLoad) {
73 if (shouldLoad) {
74 m_thread = std::thread([this]() {
75 m_instance = std::make_unique<T>();
76 });
77 }
78 }
79
81 {
82 std::scoped_lock lock(m_mutex);
83 if (m_thread.joinable())
84 m_thread.join();
85 }
86
87 T* operator->() const
88 {
89 std::scoped_lock lock(m_mutex);
90 if (m_thread.joinable())
91 m_thread.join();
92 return m_instance.get();
93 }
94
95 explicit operator bool() const
96 {
97 std::scoped_lock lock(m_mutex);
98 return m_thread.joinable() || m_instance;
99 }
100
101private:
102 mutable std::mutex m_mutex;
103 mutable std::thread m_thread;
104 std::unique_ptr<T> m_instance;
105};
106
107/*
108 Thread-safe background-loading of optional security scoped handler,
109 with the ability to kick off instantiation early during program load.
110*/
112{
113 using Handler = BackgroundLoader<SecurityScopedFileEngineHandler>;
114 static Handler handler = []() -> Handler {
115 if (!qt_apple_isSandboxed())
116 return Handler{false};
117
118 qCInfo(lcSecEngine) << "Application sandbox is active. Registering security-scoped file engine.";
119 return Handler{true};
120 }();
121 return handler;
122}
123
125{
126 // Kick off loading of bookmarks early in the background
127 std::ignore = SecurityScopedFileEngineHandler::get();
128}
130
131/*
132 Registration function for possibly security scoped URLs.
133
134 Entry points that might provide security scoped URLs such as file
135 dialogs or drag-and-drop should use this function to ensure that
136 the security scoped file engine handler knows about the URL.
137*/
139{
140 if (auto &handler = SecurityScopedFileEngineHandler::get())
141 handler->registerPossiblySecurityScopedURL(url);
142
143 // Note: The URL itself doesn't encode any of the bookmark data,
144 // neither in the scheme or as fragments or query parameters,
145 // as it's all handled by the bookmark cache in the file engine.
146 return QUrl(QString::fromNSString(url.absoluteString)
147 .normalized(QString::NormalizationForm_C));
148}
149
150static bool checkIfResourceIsReachable(NSURL *url)
151{
152 NSError *error = nullptr;
153 if ([url checkResourceIsReachableAndReturnError:&error])
154 return true;
155
156 // Our goal is to check whether the file exists or not, and if
157 // not, defer creating a bookmark for it. If we get any other
158 // error we want to know.
159 if (![error.domain isEqualToString:NSCocoaErrorDomain] || error.code != NSFileReadNoSuchFileError) {
160 qCWarning(lcSecEngine) << "Unexpected" << error
161 << "when resolving reachability for" << url;
162 }
163
164 return false;
165}
166
167/*
168 File engine for maintaining access lifetime of security-scoped
169 resources on sandboxed Apple platforms.
170
171 Note that there isn't necessarily a 1:1 relationship between
172 the file being operated on by the QFSFileEngine and the security
173 scoped resource that allows access to it, for example in the
174 case of a folder giving access to all files (and sub-folders)
175 within it.
176*/
178{
179 Q_DECLARE_PRIVATE(QFSFileEngine)
180public:
187
193
199
201 {
202 // We can't rely on the new entry being accessible under the same
203 // security scope as the original path, or even that the new path
204 // is a security scoped resource, so stop access here, and start
205 // access for the new resource below if needed.
209
210 const QString fileName = entry.filePath();
212
213 // The new path may not be a security scoped resource, but if it is
214 // we need to establish access to it. The only way to do that is to
215 // actually create an engine for it, including resolving bookmarks.
217 if (auto *engine = dynamic_cast<SecurityScopedFileEngine*>(newEngine.get())) {
220 }
221 }
222
223private:
225 {
227 qCDebug(lcSecEngine) << "Started accessing" << m_securityScopedUrl.path
228 << "on behalf of" << fileName(DefaultName);
229
231 } else {
232 qCWarning(lcSecEngine) << "Unexpectedly using security scoped"
233 << "file engine for" << m_securityScopedUrl.path
234 << "on behalf of" << fileName(DefaultName)
235 << "without needing scoped access";
236 }
237 }
238
240 {
242 // The security scoped URL didn't exist when we first started
243 // accessing it, but it does now, so persist a bookmark for it.
244 qCDebug(lcSecEngine) << "Security scoped resource has been created. Saving bookmark.";
246 }
247
248 // Note: Stopping access is a no-op if we didn't have access
250 qCDebug(lcSecEngine) << "Stopped accessing" << m_securityScopedUrl.path
251 << "on behalf of" << fileName(DefaultName);
252 }
253
254 bool securityScopeIsReachable() const
255 {
257 }
258
259 // See note above about relationship to fileName
260 NSURL *m_securityScopedUrl = nullptr;
261 bool m_securityScopeWasReachable = false;
262};
263
264// ----------------------------------------------------------------------
265
267{
268 QMacAutoReleasePool pool;
269
270 NSURL *savedBookmarks = bookmarksFile();
271 if ([NSFileManager.defaultManager fileExistsAtPath:savedBookmarks.path]) {
272 NSError *error = nullptr;
273 m_bookmarks = [[NSDictionary dictionaryWithContentsOfURL:savedBookmarks
274 error:&error] mutableCopy];
275
276 if (error) {
277 qCWarning(lcSecEngine) << "Failed to load bookmarks from"
278 << savedBookmarks << ":" << error;
279 } else {
280 qCInfo(lcSecEngine) << "Loaded existing bookmarks for" << m_bookmarks.allKeys;
281 }
282 }
283
284 if (!m_bookmarks)
285 m_bookmarks = [NSMutableDictionary new];
286}
287
292
293void SecurityScopedFileEngineHandler::registerPossiblySecurityScopedURL(NSURL *url)
294{
295 QMacAutoReleasePool pool;
296
297 // Start accessing the resource, to check if it's security scoped,
298 // and allow us to create a bookmark for it on both macOS and iOS.
299 if (![url startAccessingSecurityScopedResource])
300 return; // All good, not security scoped
301
302 if (checkIfResourceIsReachable(url)) {
303 // We can access the resource, which means it exists, so we can
304 // create a persistent bookmark for it right away. We want to do
305 // this as soon as possible, so that if the app is terminated the
306 // user can continue working on the file without the app needing
307 // to ask for access again via a file dialog.
308 saveBookmark(url);
309 } else {
310 // The file isn't accessible, likely because it doesn't exist.
311 // As we can only create security scoped bookmarks for files
312 // that exist we store the URL itself for now, and save it to
313 // a bookmark later when we detect that the file has been created.
314 qCInfo(lcSecEngine) << "Resource is not reachable."
315 << "Registering URL" << url << "instead";
316 QWriteLocker locker(&m_bookmarkLock);
317 m_bookmarks[cacheKeyForUrl(url)] = url;
318 }
319
320 // Balance access from above
321 [url stopAccessingSecurityScopedResource];
322
323#if defined(Q_OS_MACOS)
324 // On macOS, unlike iOS, URLs from file dialogs, etc, come with implicit
325 // access already, and we are expected to balance this access with an
326 // explicit stopAccessingSecurityScopedResource. We release the last
327 // access here to unify the behavior between macOS and iOS, and then
328 // leave it up to the SecurityScopedFileEngine to regain access, where
329 // we know the lifetime of resource use, and when to release access.
330 [url stopAccessingSecurityScopedResource];
331#endif
332}
333
335{
336 QMacAutoReleasePool pool;
337
338 static thread_local bool recursionGuard = false;
339 if (recursionGuard)
340 return nullptr;
341
342 if (fileName.isEmpty())
343 return nullptr;
344
345 QFileSystemEntry fileSystemEntry(fileName);
346 QFileSystemMetaData metaData;
347
348 {
349 // Check if there's another engine that claims to handle the given file name.
350 // This covers non-QFSFileEngines like QTemporaryFileEngine, and QResourceFileEngine.
351 // If there isn't one, we'll get nullptr back, and know that we can access the
352 // file via our special QFSFileEngine.
353 QScopedValueRollback<bool> rollback(recursionGuard, true);
354 if (auto engine = QFileSystemEngine::createLegacyEngine(fileSystemEntry, metaData)) {
355 // Shortcut the logic of the createLegacyEngine call we're in by
356 // just returning this engine now.
357 qCDebug(lcSecEngine) << "Preferring non-QFSFileEngine engine"
358 << engine.get() << "for" << fileName;
359 return engine;
360 }
361 }
362
363 // We're mapping the file name to existing bookmarks below, so make sure
364 // we use as close as we can get to the canonical path. For files that
365 // do not exist we fall back to the cleaned absolute path.
366 auto canonicalEntry = QFileSystemEngine::canonicalName(fileSystemEntry, metaData);
367 if (canonicalEntry.isEmpty())
368 canonicalEntry = QFileSystemEngine::absoluteName(fileSystemEntry);
369
370 if (canonicalEntry.isRelative()) {
371 // We try to map relative paths to absolute above, but doing so requires
372 // knowledge of the current working directory, which we only have if the
373 // working directory has already started access through other means. We
374 // can't explicitly start access of the working directory here, as doing
375 // so requires its name, which we can't get from getcwd() without access.
376 // Fortunately all of the entry points of security scoped URLs such as
377 // file dialogs or drag-and-drop give us absolute paths, and APIs like
378 // QDir::filePath() will construct absolute URLs without needing the
379 // current working directory.
380 qCWarning(lcSecEngine) << "Could not resolve" << fileSystemEntry.filePath()
381 << "against current working working directory";
382 return nullptr;
383 }
384
385 // Clean the path as well, to remove any trailing slashes for directories
386 QString filePath = QDir::cleanPath(canonicalEntry.filePath());
387
388 // Files inside the sandbox container can always be accessed directly
389 static const QString sandboxRoot = QString::fromNSString(NSHomeDirectory());
390 if (filePath.startsWith(sandboxRoot))
391 return nullptr;
392
393 // The same applies to files inside the application's own bundle
394 static const QString bundleRoot = QString::fromNSString(NSBundle.mainBundle.bundlePath);
395 if (filePath.startsWith(bundleRoot))
396 return nullptr;
397
398 qCDebug(lcSecEngine) << "Looking up bookmark for" << filePath << "based on incoming fileName" << fileName;
399
400 // Check if we have a persisted bookmark for this fileName, or
401 // any of its containing directories (which will give us access
402 // to the file).
403 QReadLocker locker(&m_bookmarkLock);
404 auto *cacheKey = cacheKeyForPath(filePath);
405 NSObject *bookmarkData = nullptr;
406 while (cacheKey.length > 1) {
407 bookmarkData = m_bookmarks[cacheKey];
408 if (bookmarkData)
409 break;
410 cacheKey = [cacheKey stringByDeletingLastPathComponent];
411 }
412
413 // We didn't find a bookmark, so there's no point in trying to manage
414 // this file via a SecurityScopedFileEngine.
415 if (!bookmarkData) {
416 qCDebug(lcSecEngine) << "No bookmark found. Falling back to QFSFileEngine.";
417 return nullptr;
418 }
419
420 NSURL *securityScopedUrl = nullptr;
421 if ([bookmarkData isKindOfClass:NSURL.class]) {
422 securityScopedUrl = static_cast<NSURL*>(bookmarkData);
423 } else {
424 NSError *error = nullptr;
425 BOOL bookmarkDataIsStale = NO;
426 securityScopedUrl = [NSURL URLByResolvingBookmarkData:static_cast<NSData*>(bookmarkData)
427 options:
428 #if defined(Q_OS_MACOS)
429 NSURLBookmarkResolutionWithSecurityScope
430 #else
431 // iOS bookmarks are always security scoped, and we
432 // don't need or want any of the other options.
433 NSURLBookmarkResolutionOptions(0)
434 #endif
435 relativeToURL:nil /* app-scoped bookmark */
436 bookmarkDataIsStale:&bookmarkDataIsStale
437 error:&error];
438
439 if (!securityScopedUrl || error) {
440 qCWarning(lcSecEngine) << "Failed to resolve bookmark data for"
441 << fileName << ":" << error;
442 return nullptr;
443 }
444
445 if (bookmarkDataIsStale) {
446 // This occurs when for example the file has been renamed, moved,
447 // or deleted. Normally this would be the place to update the
448 // bookmark to point to the new location, but Qt clients may not
449 // be prepared for QFiles changing their file-names under their
450 // feet so we treat it as a missing file.
451 qCDebug(lcSecEngine) << "Bookmark for" << cacheKey << "was stale";
452 locker.unlock();
453 QWriteLocker writeLocker(&m_bookmarkLock);
454 [m_bookmarks removeObjectForKey:cacheKey];
455 auto *mutableThis = const_cast<SecurityScopedFileEngineHandler*>(this);
456 mutableThis->saveBookmarks();
457 return nullptr;
458 }
459 }
460
461 qCInfo(lcSecEngine) << "Resolved security scope" << securityScopedUrl
462 << "for path" << filePath;
463 return std::make_unique<SecurityScopedFileEngine>(fileName, securityScopedUrl);
464}
465
466/*
467 Create an app-scoped bookmark, and store it in our persistent cache.
468
469 We do this so that the user can continue accessing the file even after
470 application restarts.
471
472 Storing the bookmarks to disk (inside the sandbox) is safe, as only the
473 app that created the app-scoped bookmarks can obtain access to the file
474 system resource that the URL points to. Specifically, a bookmark created
475 with security scope fails to resolve if the caller does not have the same
476 code signing identity as the caller that created the bookmark.
477*/
478void SecurityScopedFileEngineHandler::saveBookmark(NSURL *url)
479{
480 NSError *error = nullptr;
481 NSData *bookmarkData = [url bookmarkDataWithOptions:
482 #if defined(Q_OS_MACOS)
483 NSURLBookmarkCreationWithSecurityScope
484 #else
485 // iOS bookmarks are always security scoped, and we
486 // don't need or want any of the other options.
487 NSURLBookmarkCreationOptions(0)
488 #endif
489 includingResourceValuesForKeys:nil
490 relativeToURL:nil /* app-scoped bookmark */
491 error:&error];
492
493 if (bookmarkData) {
494 QWriteLocker locker(&m_bookmarkLock);
495 NSString *cacheKey = cacheKeyForUrl(url);
496 qCInfo(lcSecEngine)
497 << (m_bookmarks[cacheKey] ? "Updating" : "Registering")
498 << "bookmark for" << cacheKey;
499 m_bookmarks[cacheKey] = bookmarkData;
500 saveBookmarks();
501 } else {
502 qCWarning(lcSecEngine) << "Failed to create bookmark data for" << url << error;
503 }
504}
505
506/*
507 Saves the bookmarks cache to disk.
508
509 We do this preemptively whenever we create a bookmark, to ensure
510 the file can be accessed later on even if the app crashes.
511*/
512void SecurityScopedFileEngineHandler::saveBookmarks()
513{
514 QMacAutoReleasePool pool;
515
516 NSError *error = nullptr;
517 NSURL *bookmarksFilePath = bookmarksFile();
518 [NSFileManager.defaultManager
519 createDirectoryAtURL:[bookmarksFilePath URLByDeletingLastPathComponent]
520 withIntermediateDirectories:YES attributes:nil error:&error];
521 if (error) {
522 qCWarning(lcSecEngine) << "Failed to create bookmarks path:" << error;
523 return;
524 }
525 [m_bookmarks writeToURL:bookmarksFile() error:&error];
526 if (error) {
527 qCWarning(lcSecEngine) << "Failed to save bookmarks to"
528 << bookmarksFile() << ":" << error;
529 }
530}
531
532NSURL *SecurityScopedFileEngineHandler::bookmarksFile() const
533{
534 NSURL *appSupportDir = [[NSFileManager.defaultManager URLsForDirectory:
535 NSApplicationSupportDirectory inDomains:NSUserDomainMask] firstObject];
536 return [appSupportDir URLByAppendingPathComponent:@"SecurityScopedBookmarks.plist"];
537}
538
539NSString *SecurityScopedFileEngineHandler::cacheKeyForUrl(NSURL *url)
540{
541 return cacheKeyForPath(QString::fromNSString(url.path));
542}
543
544NSString *SecurityScopedFileEngineHandler::cacheKeyForPath(const QString &path)
545{
546 auto normalized = path.normalized(QString::NormalizationForm_D);
547 // We assume the file paths we get via file dialogs and similar
548 // are already canonical, but clean it just in case.
549 return QDir::cleanPath(normalized).toNSString();
550}
551
552QT_END_NAMESPACE
static BackgroundLoader< SecurityScopedFileEngineHandler > & get()
std::unique_ptr< QAbstractFileEngine > create(const QString &fileName) const override
If this file handler can handle fileName, this method creates a file engine and returns it wrapped in...
QUrl qt_apple_urlFromPossiblySecurityScopedURL(NSURL *url)
static bool checkIfResourceIsReachable(NSURL *url)
Q_CONSTRUCTOR_FUNCTION(initializeSecurityScopedFileEngineHandler)
static void initializeSecurityScopedFileEngineHandler()
QT_BEGIN_NAMESPACE Q_STATIC_LOGGING_CATEGORY(lcSynthesizedIterableAccess, "qt.iterable.synthesized", QtWarningMsg)