14. August 2026

Vier Fehler, die mir bei security-scoped bookmarks unterlaufen sind

Divecut ist eine gesandboxte macOS-App, die auf einen Ordner voller Material zeigt — und das auch nach dem Beenden und Neustarten weiterhin tut. Der Mechanismus dafür ist ein security-scoped bookmark: Du fragst den Nutzer einmal nach einem Ordner, speicherst einen Blob und löst ihn beim nächsten Start wieder auf.

Die API ist klein. Trotzdem habe ich sie viermal falsch gemacht. Jeder Bug hatte dieselbe Form — er blieb unsichtbar, bis sich etwas völlig Unabhängiges änderte, und dann kam er auf einmal zum Vorschein. Dieses Muster ist der eigentlich nützliche Teil, mehr als die Fixes selbst.

1. Ein Bookmark zu halten ist nicht dasselbe wie Zugriff zu haben

Beim Start habe ich nur geprüft, ob ein Bookmark existiert, und weitergemacht. Miniaturen luden, das Raster füllte sich, alles sah gut aus.

Dann habe ich die Version des Miniatur-Caches hochgezählt. Jede Zelle wurde auf einen Schlag zu einem „defekte Datei"-Symbol.

Das Bookmark war nie aufgelöst worden. resolve() und startAccessingSecurityScopedResource() wurden nie aufgerufen, also hatte die App keine Berechtigung, die Originale zu lesen. Solange der Cache warm war, spielte das keine Rolle, weil nichts die Quelldateien anfasste. Das Invalidieren des Caches entfernte genau das, was den Bug versteckt hatte.

Ein Cache vor einem Berechtigungsbug hält diesen so lange unsichtbar, wie der Cache warm bleibt.

Der Fix ist unspektakulär: beim Start konsequent durch resolve → start accessing gehen und diesen Zugriff halten, solange die App läuft.

2. /var und /private/var sind sich uneinig

Der Import eines Ordners unterhalb des System-Temp-Verzeichnisses ergab null Clips. Kein Fehler, kein Absturz — nur ein leeres Raster.

FileManager.enumerator liefert Pfade mit bereits aufgelösten Symlinks zurück, also kamen Dateien als /private/var/… zurück. Der Ordnerpfad, mit dem ich verglich, war /var/…. Ein Präfixvergleich zwischen den beiden trifft nie.

Was das hier besonders fies macht: Die naheliegenden Normalisierer beheben es nicht. Weder standardizedFileURL noch resolvingSymlinksInPath() löst den /var-Sonderlink auf — wenn überhaupt, wirken sie in die entgegengesetzte Richtung und entfernen /private. Was funktioniert, ist der canonicalPath- Resource-Wert:

let canonical = try? standardized
    .resourceValues(forKeys: [.canonicalPathKey])
    .canonicalPath

Die Falle nach dem Fix: Die Normalisierung muss bei jedem Pfad passieren, der verglichen wird. Ich habe beim Import normalisiert, aber nicht bei der Prüfung auf verschobene Ordner — und der Mismatch war sofort wieder da.

3. Ein veraltetes Bookmark lässt sich nicht von außen auffrischen

Wenn ein Nutzer den Ordner im Finder verschiebt, löst sich das Bookmark zwar noch auf, kommt aber als veraltet (stale) markiert zurück, und man soll ein frisches schreiben. Genau das habe ich getan: isStale erkannt, bookmarkData() erneut aufgerufen, gespeichert.

Das erneuert in Wirklichkeit gar nichts. Ein Bookmark, das aus einer rekonstituierten URL erstellt wird, auf die du gerade nicht zugreifst, wird einfach wieder veraltet — das ist in den Apple Developer Forums so berichtet, und es deckt sich mit dem, was ich gesehen habe.

Die Auffrischung muss innerhalb eines Zugriffs-Scopes passieren: start accessing, das neue Bookmark schreiben, stop accessing — alles, bevor resolve zurückkehrt.

4. Zwei Schreibvorgänge, ein Absturz, falscher Ordner

Um zu erkennen, dass „der Nutzer den Ordner verschoben hat", habe ich den zuletzt erfolgreich aufgelösten Pfad neben dem Bookmark gespeichert, unter einem eigenen Schlüssel. Zwei Werte, zwei Schreibvorgänge.

Stirbt die App dazwischen, hast du ein Bookmark, das auf den neuen Ort zeigt, und einen Pfadhinweis, der noch auf den alten zeigt. Beim nächsten Start liest sich diese Kombination als der Ordner wurde verschoben, und die App schreibt Clip-Pfade um, die nie falsch waren. Ein Absturz im falschen Moment beschädigt die Bibliothek.

Start N Schreiben 1: Bookmark = neuer Ort Absturz Schreiben 2: Pfadhinweis läuft nie Start N+1 Bookmark: neu Pfadhinweis: alt liest sich als „Ordner verschoben" schreibt Clip-Pfade um die nie falsch waren
Das Zeitfenster ist klein, aber es existiert bei jeder Ordneränderung — und der Schaden trifft Clips, die mit dem Verschieben nichts zu tun hatten.

Der Fix bestand darin, die zwei Schreibvorgänge abzuschaffen. Beide Werte gehen in einen einzigen Blob, gespeichert unter einem Schlüssel. Ein einzelner UserDefaults-Schreibvorgang ist nicht zerreißbar, also sieht ein Leser entweder den vollständigen alten oder den vollständigen neuen Wert — nie eine Mischung.

Das Gegenstück, das die banale Version davon verhindert

Jeder temporäre Zugriff läuft über einen einzigen Helper, damit ein geworfener Fehler den Scope nicht durchsickern lassen kann:

public func withAccess<T>(to url: URL, _ body: (URL) throws -> T) throws -> T {
    let granted = url.startAccessingSecurityScopedResource()
    defer {
        if granted { url.stopAccessingSecurityScopedResource() }
    }
    guard granted else { throw StoreError.accessDenied }
    return try body(url)
}

Der Bibliotheksordner ist die bewusste Ausnahme. Er bleibt für die gesamte Lebensdauer der App geöffnet, läuft also nicht über diesen Helper — ihn per defer zu schließen wäre dort genau das falsche Verhalten.

Was ich mir vor dem Start gesagt hätte

BugWas ihn versteckt hat
Bookmark nie aufgelöstEin warmer Miniatur-Cache
/var vs /private/varNur Temp-Verzeichnisse nutzen diesen Symlink
Stale-Auffrischung, die nicht auffrischtWährend der Entwicklung verschiebt niemand Ordner
Zwei Schreibvorgänge, ein AbsturzDie App muss in einer bestimmten Millisekunde sterben

Keiner dieser Bugs wurde dadurch gefunden, dass ich beim ersten Mal sorgfältiger programmiert hätte. Sie wurden gefunden, indem sich etwas anderes änderte — eine Cache-Version, ein Testordner, ein Code-Review — und ich beobachtet habe, was dabei umfiel. Wenn du auf dieser API aufbaust, lohnt es sich, deine Caches einmal absichtlich zu invalidieren, nur um zu sehen, was auf nichts stand.

Divecut ist ein Materialbrowser für macOS und iPad: Log-Clips in der richtigen Farbe, durchsuchbar mit normalen Wörtern, übergeben an DaVinci Resolve — Log unangetastet. Sieh dir an, was es kann.