What is the difference between a stored path and a bookmark, and how do security-scoped bookmarks work in a sandboxed Mac app?

20.8.3 · L3 · Interview

Predict first

What does the bookmark do after a rename and after a delete?

import Foundation
let fm = FileManager.default
let dir = fm.temporaryDirectory.appendingPathComponent("bm-\(UUID().uuidString)")
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let a = dir.appendingPathComponent("report.txt")
try Data("hello".utf8).write(to: a)
let bookmark = try a.bookmarkData()
let b = dir.appendingPathComponent("renamed.txt")
try fm.moveItem(at: a, to: b)
var stale = false
let resolved = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale)
print("old path exists:", fm.fileExists(atPath: a.path))
print("bookmark resolves to:", resolved.lastPathComponent, "| stale:", stale)
print("contents:", String(decoding: try Data(contentsOf: resolved), as: UTF8.self))
try fm.removeItem(at: resolved)
do { _ = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) } catch { print("deleted file: error code", (error as NSError).code) }
let scoped = try dir.bookmarkData(options: .withSecurityScope)
var st2 = false
let r2 = try URL(resolvingBookmarkData: scoped, options: .withSecurityScope, bookmarkDataIsStale: &st2)
print("scoped start accessing (not sandboxed):", r2.startAccessingSecurityScopedResource())
r2.stopAccessingSecurityScopedResource()
  • old path exists: false / resolves to renamed.txt, stale: true / contents: hello / deleted: error code 4 / scoped start: true
  • resolves to report.txt
  • throws immediately

Mental model. A bookmark is a library call number rather than a shelf position: the shelf can be rearranged and the call number still finds the book (and tells you the catalogue entry is out of date).

A path is a string that names a place and breaks when the file moves. A bookmark (URL.bookmarkData()) is an opaque blob that records the file’s identity, so it keeps resolving after a move or rename. The check creates a file, bookmarks it, renames it and resolves the bookmark: the old path no longer exists, the bookmark resolves to renamed.txt with the contents intact, and it reports stale: true, the signal to re-create and re-save the bookmark from the resolved URL. After the file is deleted, resolving throws (Cocoa error 4, “no such file”). A security-scoped bookmark, made with .withSecurityScope (macOS), adds the sandbox’s permission: a sandboxed app that was given a folder through NSOpenPanel stores it as a bookmark and calls startAccessingSecurityScopedResource() after resolving on the next launch to regain access.

Rules to remember: pair every successful start with a stop (the access is counted and leaks otherwise); the bookmark, not the URL, is what you persist; you need the user-selected entitlement (files.user-selected.read-write, or .read-only) and, for a bookmark that outlives launches, com.apple.security.files.bookmarks.app-scope. In an unsandboxed process startAccessing simply returns true, which is what the check printed, so a test outside the sandbox does not prove the sandboxed behaviour; the sandboxed path needs the app bundle from Q840 and a user-selected file, which was not run here. On iOS the same idea appears in document pickers and file providers, where a security-scoped URL is how you keep access to a file outside your container (UIDocumentPickerViewController).

A bookmark follows the file’s identity and marks itself stale when the path changed.

Common traps

  • Storing the path string of a user-chosen file. It breaks on rename and carries no sandbox permission.
  • Ignoring isStale. Re-save the bookmark when it is true, or it will eventually fail.

They might follow up with

  • Why do I need stop? Access is reference counted; leaks hold the permission open.
  • What changes on iOS? Document-picker URLs are security scoped and need the same start and stop calls.

Related questions

  • 20.3.2 What does a Mac app need that an iOS app does not: the menu bar, windows, the App Sandbox, Hardened Runtime…
  • 20.8.1 What does the App Sandbox actually change in a running Mac process, and how do you test it without a paid…

← What do the Hardened Runtime flag and its exception entitlements…   How does a menu item find its target on the responder chain, what do… →