A provider-backed document's reload ran with no security-scoped access, because the only grant was the caller's transient scope around the initial read. DocumentSession now owns a reference-counted lease for its whole lifetime. Reviewed against origin/main (PR #391).
startAccessingSecurityScopedResource() is reference-counted and access survives until the last balanced stop — so the session's start, made while the caller's scope is still live, keeps the file reachable after the caller's defer fires.DocumentFlowCoordinator.openFile) and recent-file bookmark (RecentFileEntry.resolveBookmark) both construct the session inside their own scope, so the ordering is always outer-start → session-start → outer-stop.DocumentLayoutCoordinator.reloadDocument destructures session.source; didSave updates source, fileObserver and the lease in one block, so the three can never disagree.handleFileSave carries a comment saying the exporter's grant expires with the callback — which is why it creates bookmark data eagerly. didSave starts its lease well after that. The two beliefs contradict each other and the tests cannot arbitrate.currentSession is the sole strong owner and PendingSaveBookmark.session is weak — but the invariant is sharper than it was.Services/ files), and no test covers the file→file re-save that the code comment explicitly reasons about.Ready to push — one claim to verify
The core fix is sound and independently verified. Apple's documentation confirms the premise the whole design rests on — "when you make the last balanced call to stopAccessingSecurityScopedResource(), you immediately lose access" — so nesting the session's own start inside the caller's live scope does hold access past the caller's stop. Both open paths nest correctly, the lease is on exactly the URL the reload reads, ownership is single and the release path is real. Lint is clean, both platforms build with zero warnings, WebKit test-isolation passes, and the 15 targeted tests pass (verified from the result bundle, not taken on trust).
The one thing to settle before pushing is a claim, not a defect. The second commit's Save-As lease starts access on the file-exporter URL long after that callback returned — and this same codebase already asserts, in a comment that exists precisely to work around it, that the exporter's grant expires when the callback returns. Both statements cannot be true. If the existing comment is right, that half of the fix is inert and the CHANGELOG promises something the code does not deliver. The mock-based tests cannot tell the difference, because the mock always grants. Nothing here regresses: the worst case is a no-op. But the CHANGELOG sentence should not ship until the author has confirmed it on a device.
8ec03ba5 Fix T-1849: File reload loses its security-scoped source access 615f7f07 Fix T-1849: Save As also needs a security-scoped access lease working-tree No changes applied by this review On iPhone and iPad, apps live in a sandbox: they cannot read arbitrary files. When you pick a markdown file out of iCloud Drive, Dropbox, or any other Files provider, the system hands the app a temporary key to that one file. The app has to say "I am starting to use this key" before reading, and "I am done" afterwards.
Prism was returning the key too early. It said "starting" just long enough to read the file the first time, then "done". That worked for opening the document. But Prism keeps watching the file: if you edit it in another app, a banner offers to reload. By then the key had been handed back, so the reload could fail on a document that had opened perfectly a minute earlier.
It is the confusing kind of failure. The document is right there on screen, so nothing looks broken until you try to refresh it — and then the app cannot reach a file it clearly just read.
The open document now takes out a key of its own, at the moment it is created, and holds it for as long as the document stays open. It hands it back automatically when you close the document or open a different one. Because the system counts keys rather than tracking a single one, the document's key and the original opener's key coexist happily — the file stays reachable until the last one is handed back.
Two pieces. SecurityScopedResourceLease (63 lines, prism/Services/) is a small RAII-style class: it calls startAccessingSecurityScopedResource() in init, records whether the grant succeeded in isActive, and calls the matching stop from deinit — with an idempotent explicit stop() so replacement and deallocation cannot double-release. DocumentSession stores one in a new private fileAccessLease, populated by the file initializer and by didSave(to:), nil for every non-file source.
The codebase already had two shapes for security-scoped access, both scoped-closure: DocumentFlowCoordinator.openFile uses defer, RecentFileEntry.resolveBookmark wraps a work closure, and DirectoryAccessManager keeps a keyed dictionary of long-lived directory grants. What was missing was "one file, held for one object's lifetime" — which is what this adds. It does not refactor the other three, and it should not: they encode genuinely different lifetimes.
Real security-scoped URLs cannot be manufactured from a unit-test host, so the class is parameterized over a two-method SecurityScopedResourceAccessing protocol that URL conforms to via an empty extension. This mirrors the existing KeyValueStoreProtocol/NSUbiquitousKeyValueStore seam almost exactly. DocumentSession exposes the seam only through #if DEBUG initializers plus a hasActiveFileAccessLeaseForTests flag — the same ForTests convention DocumentFlowCoordinator and DirectoryAccessManager already use.
false, which is a model of the sandbox, not the sandbox.source, fileObserver and fileAccessLease must move together. They do, at all seven sites — but the invariant is convention, enforced only by source being private(set) and every mutation living in one file.The design rests on one documented guarantee: "You must balance each call to startAccessingSecurityScopedResource() for a given security-scoped URL with a call to stopAccessingSecurityScopedResource(). When you make the last balanced call, you immediately lose access." That is reference counting stated outright, and it makes the nesting legitimate rather than lucky. Both production paths honour the ordering:
DocumentFlowCoordinator.openFile:369-383 — start, then MarkdownDocument(contentsOf:), then DocumentSession(url:content:) (which starts its own), and only then the defer'd stop.RecentFileEntry.resolveBookmark:261-270 — the same shape through a closure; the session is constructed inside work(url).The count is per-URL-object, and the token travels with the URL's shared storage. Copying the struct into source, into the existential, and into PendingSave all preserve it. Nothing on these paths reconstructs the URL from a path string, which is the classic way to silently drop a sandbox extension.
The isActive-guarded stop is what makes the false case safe. Where the outer start returns false, the session's start returns false too, the lease is inert, and nothing is unbalanced — the two are symmetric because they are the same URL, so there is no configuration where the outer succeeds and the inner spuriously fails. On macOS ENABLE_APP_SANDBOX = YES, so this is not a platform no-op there; bookmarks resolve .withSecurityScope and the lease is load-bearing on both platforms.
A file→file re-save evaluates the RHS before releasing the old value: the new lease's start() runs, then the old lease is released and its deinit stops. The count never touches zero mid-transition, so access does not blink even momentarily. @Observable's withMutation wrapper does not change that evaluation order. This is correct and undocumented — worth a line in the comment, since the obvious reading ("assigning releases the old one first") is what the existing comment says, and it is backwards.
The app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so the explicit nonisolated on both the protocol and the class is necessary, not decorative — without it the type would be MainActor-isolated and could not be touched from DocumentSession's (always nonisolated) deinit. SWIFT_VERSION = 5.0 with SWIFT_APPROACHABLE_CONCURRENCY, so there is no strict-concurrency Sendable check to satisfy. The mutable isActive is not synchronized, but every production mutation happens either on the MainActor call stack or during deallocation when the lease is uniquely referenced; stopAccessingSecurityScopedResource() itself is thread-safe. The asymmetry with FileChangeObserver (which is @MainActor) is deliberate and explained in the doc comment.
didSave(to:) is the interesting case, and the reason is that it runs on the far side of an await. ClipboardSaveFlow migrates notes asynchronously and only then calls didSave(to: attempt.url). By that point the exporter callback returned long ago — and handleFileSave:437-440 states, as its justification for creating bookmark data eagerly, that "the temporary access grant expires when the callback returns." If that is accurate, the lease started in didSave gets false and does nothing; if it is not accurate, the eager bookmark was never needed. The codebase holds both beliefs simultaneously. The tests cannot arbitrate because MockSecurityScopedResource grants unconditionally. The structurally sound version starts the lease inside handleFileSave, where access is stipulated to be live, and hands it to the session — or leases the URL resolved from the bookmark that path already creates for exactly this reason.
init(persisted:) always restores .clipboard, so there is no lease-less file session — the fileAccessLease = nil there is correct, not a gap.navigationPath appends session.id (a UUID), not the session, so it is not a retain path.PendingSaveBookmark.session is weak — the one place that could plausibly outlive a replacement does not.currentSession is the sole strong owner, and both activateSession and closeDocument drop it. wireSearchClosures() captures [weak self] throughout, and WebDocumentController stores a sessionID: String rather than the session — so neither of the two obvious cycle candidates is one.ClipboardSaveFlow's in-flight Task does capture the session strongly across the note migration, but it is bounded and self-terminating — intended, not a leak.FileChangeObserver (no presentedItemDidMove; presentedItemURL returns the fileURL captured at init). That is T-1881, still open in Transit at idea status. The lease follows the stale URL exactly as faithfully as source and the observer already do — neither improved nor worsened.SecurityScopedResourceLease.swift
Why it matters. This is the whole fix in 63 lines. It is correct in the ways that matter: the stop is guarded on isActive so a never-granted resource is never unbalanced, stop() is idempotent so replacement and deinit can both call it, and deinit is the only release path production actually uses.
What to look at. prism/Services/SecurityScopedResourceLease.swift:20-62
DocumentSession.swift
Why it matters. This is where the bug actually was. The session outlives every caller's transient scope — it owns a FileChangeObserver that fires minutes later and a reloadContent(from:) that runs on that signal — so it is the right lifetime to own access. Verify the nesting claim holds at both construction sites before accepting it.
What to look at. prism/Models/DocumentSession.swift:140-160, 318-346
DocumentSession.swift
Why it matters. The load-bearing question of this review. Unlike the init path, there is no outer scope to nest under here — didSave runs on the far side of ClipboardSaveFlow's async note migration. Whether start() returns true on that URL at that moment decides whether the second commit does anything at all.
What to look at. prism/Models/DocumentSession.swift:547-577; contrast DocumentFlowCoordinator.swift:437-440
DocumentSession.swift
Why it matters. Subtle and correct, but the comment describes it backwards. Swift evaluates the RHS first, so the new lease's start() runs before the old lease is released and stopped — the count never reaches zero. The comment says 'assigning the fresh lease here releases the old one first', which is the opposite, and a future reader trusting it would think there is a gap where access drops.
What to look at. prism/Models/DocumentSession.swift:561-576
DocumentSession.swift
Why it matters. Redundant to the compiler — Optional stored properties default to nil — but it is what makes the source/observer/lease invariant readable at every site. Worth confirming the count: seven sites touch source, and all seven touch the lease.
What to look at. prism/Models/DocumentSession.swift:358, 375, 391, 409
DocumentSession is @MainActor, and its deinit is necessarily nonisolated — so releasing a resource from it directly means either actor-isolation ceremony or an unsafe hop. Extracting a nonisolated class gives the release a natural home in that class's own deinit, reached by ordinary ARC when the session's stored property is released.
Stated in both the source and the test-file header: the sandbox behaviour that grants security-scoped access cannot be exercised from a macOS unit-test host. The seam mirrors the existing KeyValueStoreProtocol/NSUbiquitousKeyValueStore pattern — protocol over a system type, real type conforming via an empty extension, fake conforming in tests.
The cost is real and should be named: every negative case in the suite is a mock returning false, which tests the code's handling of a refusal, not the conditions under which the OS refuses.
Unlike ExportCounter's KeyValueStoreProtocol or StoreManager's EntitlementSource — both injected through ordinary default-parameter initializers available in all configurations — the lease's seam is reachable only from a #if DEBUG convenience init.
Defensible: there is no plausible production use of a non-URL resource, so hiding the seam entirely is tighter than an always-present parameter nobody should pass. Confirmed stripped from Release: SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)" appears exactly once in project.pbxproj, inside the Debug configuration only.
The alternative — acquiring and releasing around each reloadContent(from:) — would be a narrower blast radius and would sidestep the kernel-resource concern entirely. It was not taken, and the session-lifetime model is what document-based apps normally use.
The trade is stated honestly nowhere in the diff: a leaked DocumentSession now leaks a sandbox extension, which Apple explicitly warns can cost the app its ability to extend its sandbox at all until relaunch. The ownership audit finds no leak today, so this is a note about the invariant rather than a defect.
No rationale is given anywhere, and it is the choice most in need of one — see the Open Questions section. handleFileSave is where the codebase says access is still live; didSave is on the far side of an async note migration.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | DocumentSession.didSave(to:) / DocumentFlowCoordinator.handleFileSave | The Save-As lease (commit 615f7f07) starts access on the file-exporter URL after ClipboardSaveFlow's async note migration — long after the exporter callback returned. DocumentFlowCoordinator.swift:437-440 asserts, as the justification for creating bookmark data eagerly, that 'the temporary access grant expires when the callback returns'. If that comment is right, startAccessingSecurityScopedResource() in didSave returns false, the lease is inert, and the CHANGELOG's 'including after a Save As turns a pasted document into a file-backed one' is not delivered. If it is wrong, the eager bookmark creation is unnecessary. The two beliefs cannot both hold. MockSecurityScopedResource grants unconditionally, so no test in the suite can distinguish the cases. | Raised for the author — not fixed, and not a regression (worst case is a no-op, correctly balanced either way). Needs a device check on an iOS provider-backed Save As. If the grant really does expire, the structural fix is to start the lease inside handleFileSave where access is stipulated live and hand it to the session at didSave, or to lease the URL resolved from the bookmark that path already creates for exactly this reason. Until then the CHANGELOG's Save-As clause should not ship as stated. |
| minor | CLAUDE.md | prism/Services/SecurityScopedResourceLease.swift is not listed in CLAUDE.md's Source Structure, and neither the Document Flow section nor anywhere else in CLAUDE.md mentions security-scoped access lifetime (grep for 'security-scoped' returns zero hits). This project follows that convention consistently for new Services files — ef96b0ce (ImageMemoryGuard, BoundedFileRead) and f238a89a (PaywallPresenter) both updated the file list and the surrounding prose at length. | Raised. One line in the Services list plus a sentence in Document Flow noting that a file-backed session owns its own security-scoped lease for its lifetime. |
| minor | prismTests/SecurityScopedResourceLeaseTests.swift | No test covers a file→file re-save via didSave(to:) — the exact path the new code comment reasons about ('a re-save of an already-file-backed session does: assigning the fresh lease here releases the old one first'). Coverage is clipboard→file only. The path is genuinely reachable, not hypothetical: handleFileSave calls session.prepareSave(to:) on the .fileExporter result unconditionally, for whatever the current session is, so a re-Save-As of an already file-backed document lands here. This is also the path where the comment's stated ordering is inverted relative to what Swift actually does, so a test asserting both resources' call counts would pin the real behaviour and correct the comment at the same time. | Raised. A test constructing a file session with resource A, calling didSave with resource B, and asserting A.stopCallCount == 1 and B.startCallCount == 1 would close it. |
| minor | DocumentSession.swift:561-576 (comment) | The didSave doc comment says 'assigning the fresh lease here releases the old one first, via SecurityScopedResourceLease.deinit'. The order is the opposite: Swift evaluates the RHS before the assignment, so the new lease's start() runs first and the old one is released after. The code's behaviour is the better of the two (access never drops to zero mid-swap) but the comment describes the worse one. | Raised. Editorial only — reword to state the acquire-then-release order, which is the property worth documenting. |
| nit | specs/bugfixes/ | No bugfix report folder, which CLAUDE.md and the /fix-bug workflow nominally require. In practice only 1 of the last 30 bugfix commits on origin/main shipped one — including directly comparable fixes (T-2213, T-2219/T-2096, T-1812). | Not raised as a gap. Consistent with actual project practice; flagged only so the divergence from the written convention is on record. |
| nit | SecurityScopedResourceLease.swift | The lease is nonisolated while its sibling per-session file resource, FileChangeObserver, is @MainActor. Asymmetric between two types with the same role and lifecycle. | No change wanted. The asymmetry is necessary — the lease must be releasable from DocumentSession's nonisolated deinit — and the doc comment already explains it. |
| nit | Code reuse / conventions | Checked for duplication against DirectoryAccessManager, RecentFileEntry.withResolvedURL, and DocumentFlowCoordinator.openFile. All three encode different lifetimes (keyed long-lived directories, scoped closure, defer-scoped read); none can absorb a per-object lease without changing its contract. The protocol seam matches KeyValueStoreProtocol and the ForTests convention matches DocumentFlowCoordinator and DirectoryAccessManager. | Nothing to change. Recorded as a clean result rather than a finding. |
Click to expand.
diff --git a/prism/Services/SecurityScopedResourceLease.swift b/prism/Services/SecurityScopedResourceLease.swiftnew file mode 100644index 00000000..69a48a59--- /dev/null+++ b/prism/Services/SecurityScopedResourceLease.swift@@ -0,0 +1,63 @@+//+// SecurityScopedResourceLease.swift+// prism+//++import Foundation++/// Abstraction over `URL`'s security-scoped resource access, decoupled from+/// `Foundation.URL` itself so the start/stop balancing in+/// `SecurityScopedResourceLease` is unit-testable without a real+/// security-scoped URL — the sandbox behaviour that grants one cannot be+/// exercised from a macOS unit-test host (T-1849). Production always passes+/// a real `URL`; tests inject a fake that records calls.+///+/// `nonisolated`, matching `URL`'s own methods: neither is bound to the+/// MainActor, so a lease can be started, stopped, and torn down (`deinit`)+/// without the actor-isolation ceremony a MainActor-isolated type would need+/// to release resources from its (always-nonisolated) `deinit`.+nonisolated protocol SecurityScopedResourceAccessing {+ func startAccessingSecurityScopedResource() -> Bool+ func stopAccessingSecurityScopedResource()+}++extension URL: SecurityScopedResourceAccessing {}++/// Balances one security-scoped resource's access across a lifetime longer+/// than a single read.+///+/// `URL.startAccessingSecurityScopedResource()` is reference-counted: a+/// caller that has already started its own access can start a second,+/// independent access on the same URL and the resource stays available+/// until BOTH are stopped. `DocumentSession` uses this to hold its own+/// lease on a file source for as long as the session lives — covering its+/// `FileChangeObserver` and any later `reloadContent(from:)` call — separate+/// from whatever transient scope the caller that produced the URL (the file+/// importer, or a resolved recent-file bookmark) held only around its own+/// initial read and released as soon as that read returned (T-1849).+///+/// Started once, at `init`; `isActive` records whether the start actually+/// succeeded, since a URL that was never security-scoped to begin with+/// (an app-sandbox file, or an unsandboxed macOS path) returns `false` and+/// needs no matching stop. `stop()` is idempotent so replacement, explicit+/// close, and `deinit` can all call it without double-releasing.+nonisolated final class SecurityScopedResourceLease {+ private let resource: SecurityScopedResourceAccessing+ private(set) var isActive: Bool++ init(resource: SecurityScopedResourceAccessing) {+ self.resource = resource+ self.isActive = resource.startAccessingSecurityScopedResource()+ }++ /// Releases this lease's share of the access, if it holds one.+ func stop() {+ guard isActive else { return }+ resource.stopAccessingSecurityScopedResource()+ isActive = false+ }++ deinit {+ stop()+ }+}
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex e49218ff..da7a082f 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -137,6 +137,30 @@ final class DocumentSession: Identifiable { /// Requirement 2.4: No FileChangeObserver for clipboard-sourced content. var fileObserver: FileChangeObserver? + /// This session's own security-scoped access lease on its file source+ /// (nil for non-file sources).+ ///+ /// The caller that produced the URL — the file importer in+ /// `DocumentFlowCoordinator.openFile`, or a bookmark resolved by+ /// `RecentFileEntry.withResolvedURL` — only holds a transient scope+ /// around its own initial read and releases it as soon as that read+ /// returns. Without this, `fileObserver`'s external-change monitoring+ /// and any later `reloadContent(from:)` call ran with no security-scoped+ /// access at all, and could fail even though the initial open succeeded+ /// (T-1849). Starting a second, independent lease here — while the+ /// caller's own scope is still active — nests safely underneath it+ /// (`startAccessingSecurityScopedResource()` is reference-counted), so+ /// this session ends up owning access for as long as it lives, stopped+ /// via `SecurityScopedResourceLease.deinit` when the session itself+ /// deallocates (on close or replacement).+ ///+ /// `didSave(to:)` starts one too, for the same reason: a Save-As'd+ /// session runs well after the file exporter's own transient scope has+ /// ended, with no outer scope left to nest under (T-1849 through the+ /// clipboard-to-file transition). `revertToClipboard()` clears it back+ /// to nil.+ private var fileAccessLease: SecurityScopedResourceLease?+ // MARK: - Parse Generation Tracking (T-718) /// Monotonic counter incremented at the start of each parse cycle.@@ -287,14 +311,40 @@ final class DocumentSession: Identifiable { /// - Parameters: /// - url: The URL of the file. /// - content: The markdown content of the file.- init(url: URL, content: String) {+ convenience init(url: URL, content: String) {+ self.init(url: url, content: content, fileAccessResource: url)+ }++ /// Designated file initializer, parameterized over the security-scoped+ /// resource so tests can substitute a fake in place of the real `URL`+ /// (see the `#if DEBUG` initializer below). Production always calls+ /// this via `init(url:content:)`, which passes `url` itself.+ private init(url: URL, content: String, fileAccessResource: SecurityScopedResourceAccessing) { self.id = UUID() self.source = .file(url: url) self.content = content self.fileObserver = FileChangeObserver(fileURL: url)+ self.fileAccessLease = SecurityScopedResourceLease(resource: fileAccessResource) wireSearchClosures() } + #if DEBUG+ /// Test-only initializer that injects a fake security-scoped resource in+ /// place of the real `URL`, so `fileAccessLease`'s start/stop balancing+ /// is unit-testable without a real sandboxed URL — the sandbox behaviour+ /// that grants one cannot be exercised from a macOS unit-test host+ /// (T-1849). Stripped from release builds.+ convenience init(url: URL, content: String, fileAccessResourceForTests resource: SecurityScopedResourceAccessing) {+ self.init(url: url, content: content, fileAccessResource: resource)+ }++ /// Test-only view of whether this session still holds its own+ /// security-scoped access lease. Stripped from release builds.+ var hasActiveFileAccessLeaseForTests: Bool {+ fileAccessLease?.isActive ?? false+ }+ #endif+ /// Creates a session from clipboard content. /// /// Initializes with a clipboard source. No FileChangeObserver is created@@ -306,6 +356,7 @@ final class DocumentSession: Identifiable { self.source = .clipboard self.content = clipboardContent self.fileObserver = nil+ self.fileAccessLease = nil wireSearchClosures() } @@ -322,6 +373,7 @@ final class DocumentSession: Identifiable { self.source = .bundled(name: name) self.content = content self.fileObserver = nil+ self.fileAccessLease = nil wireSearchClosures() } @@ -337,6 +389,7 @@ final class DocumentSession: Identifiable { self.content = persisted.content self.scrollPositionID = persisted.scrollPositionID self.fileObserver = nil+ self.fileAccessLease = nil wireSearchClosures() } @@ -353,6 +406,7 @@ final class DocumentSession: Identifiable { self.source = .url(remote: remoteURL, display: displayURL) self.content = content self.fileObserver = nil+ self.fileAccessLease = nil wireSearchClosures() } @@ -491,8 +545,33 @@ final class DocumentSession: Identifiable { /// /// - Parameter url: The URL where the file was saved. func didSave(to url: URL) {+ didSave(to: url, fileAccessResource: url)+ }++ #if DEBUG+ /// Test-only variant of `didSave(to:)` that injects a fake+ /// security-scoped resource in place of the real `URL`, mirroring+ /// `init(url:content:fileAccessResourceForTests:)`. Stripped from+ /// release builds.+ func didSave(to url: URL, fileAccessResourceForTests resource: SecurityScopedResourceAccessing) {+ didSave(to: url, fileAccessResource: resource)+ }+ #endif++ /// Shared implementation behind `didSave(to:)`, parameterized over the+ /// security-scoped resource so tests can substitute a fake (mirroring+ /// the designated file initializer above).+ ///+ /// A save-as from a clipboard session has no lease to replace+ /// (`fileAccessLease` is nil until this point). A re-save of an+ /// already-file-backed session (e.g. re-exporting to a new location)+ /// does: assigning the fresh lease here releases the old one first, via+ /// `SecurityScopedResourceLease.deinit`, once this is the only strong+ /// reference to it.+ private func didSave(to url: URL, fileAccessResource: SecurityScopedResourceAccessing) { source = .file(url: url) fileObserver = FileChangeObserver(fileURL: url)+ fileAccessLease = SecurityScopedResourceLease(resource: fileAccessResource) pendingSave = nil } @@ -504,6 +583,7 @@ final class DocumentSession: Identifiable { func revertToClipboard() { source = .clipboard fileObserver = nil+ fileAccessLease = nil pendingSave = nil }
diff --git a/prismTests/SecurityScopedResourceLeaseTests.swift b/prismTests/SecurityScopedResourceLeaseTests.swiftnew file mode 100644index 00000000..102339e8--- /dev/null+++ b/prismTests/SecurityScopedResourceLeaseTests.swift@@ -0,0 +1,255 @@+//+// SecurityScopedResourceLeaseTests.swift+// prismTests+//+// Regression coverage for T-1849: a file reload run after the URL's+// initial security-scoped access grant had already been released (by+// DocumentFlowCoordinator.openFile or RecentFileEntry.withResolvedURL, both+// of which stop their own scope as soon as their initial read returns).+//+// DocumentSession now starts its own, independent security-scoped access+// lease for the whole session lifetime, covering both its FileChangeObserver+// and any later reloadContent(from:) call. The sandbox behaviour that grants+// real security-scoped access cannot be exercised from a macOS unit-test+// host, so these tests exercise the lease-balancing logic itself (and its+// DocumentSession wiring) through the SecurityScopedResourceAccessing seam,+// with a fake resource standing in for the real URL.+//++import Foundation+import Testing+@testable import prism++/// Records start/stop calls in place of a real security-scoped `URL`.+private final class MockSecurityScopedResource: SecurityScopedResourceAccessing {+ var startCallCount = 0+ var stopCallCount = 0+ var startReturnValue: Bool++ init(startReturnValue: Bool = true) {+ self.startReturnValue = startReturnValue+ }++ func startAccessingSecurityScopedResource() -> Bool {+ startCallCount += 1+ return startReturnValue+ }++ func stopAccessingSecurityScopedResource() {+ stopCallCount += 1+ }+}++// MARK: - SecurityScopedResourceLease++@Suite("SecurityScopedResourceLease")+struct SecurityScopedResourceLeaseTests {++ @Test("init starts access and records it active when the resource grants it")+ func initStartsAccess() {+ let resource = MockSecurityScopedResource(startReturnValue: true)+ let lease = SecurityScopedResourceLease(resource: resource)++ #expect(resource.startCallCount == 1)+ #expect(lease.isActive)+ }++ @Test("init records no active lease when the resource was never security-scoped")+ func initWithoutGrantIsInactive() {+ let resource = MockSecurityScopedResource(startReturnValue: false)+ let lease = SecurityScopedResourceLease(resource: resource)++ #expect(resource.startCallCount == 1)+ #expect(!lease.isActive)+ }++ @Test("stop releases an active lease exactly once")+ func stopReleasesActiveLease() {+ let resource = MockSecurityScopedResource(startReturnValue: true)+ let lease = SecurityScopedResourceLease(resource: resource)++ lease.stop()++ #expect(resource.stopCallCount == 1)+ #expect(!lease.isActive)+ }++ @Test("stop is idempotent and never double-releases")+ func stopIsIdempotent() {+ let resource = MockSecurityScopedResource(startReturnValue: true)+ let lease = SecurityScopedResourceLease(resource: resource)++ lease.stop()+ lease.stop()+ lease.stop()++ #expect(resource.stopCallCount == 1)+ }++ @Test("stop on a lease that never activated does not call the underlying stop")+ func stopWithoutActiveGrantIsNoOp() {+ let resource = MockSecurityScopedResource(startReturnValue: false)+ let lease = SecurityScopedResourceLease(resource: resource)++ lease.stop()++ #expect(resource.stopCallCount == 0)+ }++ @Test("deinit releases an active lease")+ func deinitReleasesActiveLease() {+ let resource = MockSecurityScopedResource(startReturnValue: true)+ var lease: SecurityScopedResourceLease? = SecurityScopedResourceLease(resource: resource)+ _ = lease // silence "never used" before the reassignment below++ lease = nil++ #expect(resource.stopCallCount == 1)+ }++ @Test("deinit on a never-activated lease does not call the underlying stop")+ func deinitWithoutActiveGrantIsNoOp() {+ let resource = MockSecurityScopedResource(startReturnValue: false)+ var lease: SecurityScopedResourceLease? = SecurityScopedResourceLease(resource: resource)+ _ = lease++ lease = nil++ #expect(resource.stopCallCount == 0)+ }+}++// MARK: - DocumentSession wiring++@Suite("DocumentSession file access lease")+struct DocumentSessionFileAccessLeaseTests {++ @Test("Opening a file session starts its own security-scoped access lease")+ @MainActor+ func openingFileSessionStartsLease() {+ let resource = MockSecurityScopedResource(startReturnValue: true)+ let url = URL(fileURLWithPath: "/tmp/T-1849-test.md")++ let session = DocumentSession(url: url, content: "# Test", fileAccessResourceForTests: resource)++ #expect(resource.startCallCount == 1)+ #expect(session.hasActiveFileAccessLeaseForTests)+ }++ @Test("A file session's lease is not reacquired across a reload")+ @MainActor+ func leaseIsHeldNotReacquiredOnReload() async throws {+ let tempURL = FileManager.default.temporaryDirectory+ .appendingPathComponent("T-1849-reload-\(UUID().uuidString).md")+ try "# Original".write(to: tempURL, atomically: true, encoding: .utf8)+ defer { try? FileManager.default.removeItem(at: tempURL) }++ let resource = MockSecurityScopedResource(startReturnValue: true)+ let session = DocumentSession(url: tempURL, content: "# Original", fileAccessResourceForTests: resource)+ #expect(resource.startCallCount == 1)++ // Simulate the file having changed externally (FileChangeObserver's+ // whole reason for existing) and reload — the scenario T-1849+ // reported failing once the caller's transient access had ended.+ try "# Updated".write(to: tempURL, atomically: true, encoding: .utf8)+ try await session.reloadContent(from: tempURL)++ // The reload must not need (and must not trigger) a fresh start —+ // the session's lease from init is still covering it.+ #expect(resource.startCallCount == 1)+ #expect(session.hasActiveFileAccessLeaseForTests)+ #expect(session.documentTitle == "Updated")+ }++ @Test("A file session releases its lease when deallocated")+ @MainActor+ func sessionReleasesLeaseOnDeinit() {+ let resource = MockSecurityScopedResource(startReturnValue: true)+ let url = URL(fileURLWithPath: "/tmp/T-1849-deinit-test.md")++ var session: DocumentSession? = DocumentSession(+ url: url, content: "# Test", fileAccessResourceForTests: resource+ )+ _ = session++ session = nil++ #expect(resource.stopCallCount == 1)+ }++ @Test("Non-file sessions never touch security-scoped access")+ @MainActor+ func nonFileSessionsHaveNoLease() {+ let clipboardSession = DocumentSession(clipboardContent: "# Clipboard")+ #expect(clipboardSession.hasActiveFileAccessLeaseForTests == false)++ let bundledSession = DocumentSession(bundledResource: "onboarding", content: "# Bundled")+ #expect(bundledSession.hasActiveFileAccessLeaseForTests == false)+ }++ // MARK: - Save As (clipboard → file) transition (T-1849 follow-up)++ @Test("didSave(to:) starts a security-scoped access lease for a clipboard session becoming file-backed")+ @MainActor+ func didSaveStartsLease() {+ let clipboardSession = DocumentSession(clipboardContent: "# Clipboard")+ #expect(clipboardSession.hasActiveFileAccessLeaseForTests == false)++ let resource = MockSecurityScopedResource(startReturnValue: true)+ let savedURL = URL(fileURLWithPath: "/tmp/T-1849-saveas-test.md")+ clipboardSession.didSave(to: savedURL, fileAccessResourceForTests: resource)++ #expect(resource.startCallCount == 1)+ #expect(clipboardSession.hasActiveFileAccessLeaseForTests)+ }++ @Test("revertToClipboard releases the lease didSave(to:) started")+ @MainActor+ func revertToClipboardReleasesLease() {+ let clipboardSession = DocumentSession(clipboardContent: "# Clipboard")+ let resource = MockSecurityScopedResource(startReturnValue: true)+ let savedURL = URL(fileURLWithPath: "/tmp/T-1849-revert-test.md")+ clipboardSession.didSave(to: savedURL, fileAccessResourceForTests: resource)+ #expect(clipboardSession.hasActiveFileAccessLeaseForTests)++ clipboardSession.revertToClipboard()++ #expect(resource.stopCallCount == 1)+ #expect(clipboardSession.hasActiveFileAccessLeaseForTests == false)+ }++ @Test("didSave(to:) tolerates a resource that declines the grant and never stops it")+ @MainActor+ func didSaveToleratesDeclinedGrant() {+ let clipboardSession = DocumentSession(clipboardContent: "# Clipboard")+ let resource = MockSecurityScopedResource(startReturnValue: false)+ let savedURL = URL(fileURLWithPath: "/tmp/T-1849-declined-test.md")+ clipboardSession.didSave(to: savedURL, fileAccessResourceForTests: resource)++ #expect(resource.startCallCount == 1)+ #expect(clipboardSession.hasActiveFileAccessLeaseForTests == false)++ clipboardSession.revertToClipboard()++ // isActive was already false, so releasing must not call the+ // underlying stop — there is nothing to balance.+ #expect(resource.stopCallCount == 0)+ }++ @Test("Construction tolerates a resource that declines the grant and never stops it")+ @MainActor+ func constructionToleratesDeclinedGrant() {+ let resource = MockSecurityScopedResource(startReturnValue: false)+ let url = URL(fileURLWithPath: "/tmp/T-1849-declined-init-test.md")++ var session: DocumentSession? = DocumentSession(+ url: url, content: "# Test", fileAccessResourceForTests: resource+ )+ #expect(resource.startCallCount == 1)+ #expect(session?.hasActiveFileAccessLeaseForTests == false)++ session = nil++ #expect(resource.stopCallCount == 0)+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..1eafcb70 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A provider-backed document's reload (external-change banner or a later re-read) could fail even though the initial open succeeded, because security-scoped access was released as soon as the open finished; the document session now holds its own access lease for as long as it stays open, including after a Save As turns a pasted document into a file-backed one (T-1849). - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.
The one item that cannot be settled by reading. Do a Save As from a pasted document onto an iCloud Drive or third-party provider location on iOS, then edit that file from another app and take the reload banner. If the reload fails, the exporter's grant did expire and the lease is inert; if it succeeds, the handleFileSave comment is wrong and the eager bookmark is belt-and-braces. Either answer is useful — one of the two comments in the codebase should be corrected afterwards.
Apple's warning is unusually specific: "If you fail to relinquish your access to file-system resources when you no longer need them, your app leaks kernel resources. If sufficient kernel resources leak, your app loses its ability to add file-system locations to its sandbox… until relaunched."
The audit found no leak: currentSession is the sole strong owner, navigationPath holds only the UUID, PendingSaveBookmark.session is weak, no session registry or controller cache exists, and both activateSession and closeDocument drop the reference. But before this change a retained session was merely wasted memory. Anything that later caches sessions — a tab model, a back-stack, an undo history — now has to be weighed against this.
FileChangeObserver implements no presentedItemDidMove, so session.source keeps pointing at the old location when a file is moved out from under an open document. The lease inherits that unchanged — it is neither improved nor worsened here. Worth a ticket of its own rather than scope creep on this one.
make lint — 0 violations across 557 files.make build-macos and make build-ios — both succeeded, 0 warnings.make verify-test-isolation — passed, plus its own 43 self-tests.T-1849-6.xcresult inspected directly: 15 passed, 0 failed, 0 skipped, on macOS 26.5.1.startAccessingSecurityScopedResource() documentation read to confirm the reference-counting premise rather than assuming it.