From 8fd0730fe4e1a5f1f315563f05e4bdab901c3886 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 20 Jul 2026 18:19:23 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=93=9D=20Document=20macOS=20bookmark?= =?UTF-8?q?=20persistence=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 37 +++++ docs/adr/0001-versioned-macos-bookmarks.md | 20 +++ ...sue-590-macos-security-scoped-bookmarks.md | 139 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-versioned-macos-bookmarks.md create mode 100644 docs/plans/issue-590-macos-security-scoped-bookmarks.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..d1fecef6 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,37 @@ +# File Access + +FileKit provides cross-platform references to user-selected files while respecting each platform's access model. + +## Language + +**Bookmark Data**: +An opaque, platform-specific persistent reference to a file or directory. It may also preserve an access grant when the platform supports that capability, and must not be assumed to be portable between platforms. +_Avoid_: Saved path, serialized file + +**Security-Scoped Bookmark**: +A macOS bookmark that preserves sandbox access to a user-selected file or directory across application launches. +_Avoid_: Apple bookmark, iOS security-scoped bookmark + +**Stale Bookmark**: +A bookmark that the platform reports as still resolvable but in need of replacement with newly created bookmark data. Staleness is not a FileKit version marker and is not the same as an invalid bookmark. +_Avoid_: Invalid bookmark, missing file + +**Legacy Bookmark Data**: +Bookmark data created by an older FileKit behavior that does not preserve the access guarantees of the current platform implementation. FileKit attempts to resolve it permissively and only requires user reselection after an actual access or refresh failure. +_Avoid_: Stale bookmark + +**Bookmark Refresh**: +Advisory replacement of successfully resolved bookmark data because the platform reports it as stale or FileKit recognizes it as legacy. Refresh preserves or upgrades a reference; it does not mean the old bookmark is unusable and does not recover access that the operating system has already revoked. +_Avoid_: Permission recovery, bookmark resolution + +**Bookmark Resolution**: +Interpretation of stored bookmark data to recover a `PlatformFile` and information about the stored representation. Successful resolution identifies the resource but does not guarantee that every later file operation will succeed. +_Avoid_: File access, permission grant + +**Access Capability**: +Platform-granted authority carried by a `PlatformFile` to access a user-selected resource. A directory's capability also covers files derived within that directory. +_Avoid_: Path permission, permanent access + +**Scoped Access**: +A bounded period during which an application activates the access represented by a security-scoped resource. +_Avoid_: Bookmark lifetime, permanent access diff --git a/docs/adr/0001-versioned-macos-bookmarks.md b/docs/adr/0001-versioned-macos-bookmarks.md new file mode 100644 index 00000000..36374247 --- /dev/null +++ b/docs/adr/0001-versioned-macos-bookmarks.md @@ -0,0 +1,20 @@ +--- +status: accepted +--- + +# Use Versioned Native Bookmarks for macOS Persistence + +FileKit will store new macOS bookmark data in a versioned FileKit envelope containing a native macOS bookmark. Bookmark creation will automatically use a security scope when the running application adopts App Sandbox and a regular native bookmark otherwise. Unwrapped data remains supported through platform-specific legacy resolution, and successfully resolved legacy data will be recommended for refresh. + +`PlatformFile` remains the capability-carrying abstraction: a file restored from a security-scoped bookmark retains that capability, descendants within a bookmarked directory inherit it, and FileKit balances scoped access around actual resource use. This preserves the existing API instead of introducing a separate access-session type. + +## Considered Options + +- Keeping JVM paths and unscoped native bookmarks was rejected because it cannot persist access for sandboxed macOS applications. +- Always requesting a security-scoped bookmark was rejected because security scope is specific to App Sandbox and can fail for resources that remain valid regular bookmarks. +- Rejecting or eagerly migrating all legacy data was rejected because old references may still be usable and FileKit cannot recreate access that macOS has already revoked. +- Introducing an explicit scoped-file type was rejected because it would disrupt existing `PlatformFile` usage and weaken backward compatibility. + +## Consequences + +The bookmark envelope becomes a persistent compatibility contract and must be decoded defensively. Existing JVM-facing `PlatformFile` constructor, copy/component methods, equality, and binary signatures must remain compatible even if the implementation stops being a Kotlin data class internally. A fix is only complete after signed, sandboxed, cross-launch verification on both JVM and Kotlin/Native macOS. diff --git a/docs/plans/issue-590-macos-security-scoped-bookmarks.md b/docs/plans/issue-590-macos-security-scoped-bookmarks.md new file mode 100644 index 00000000..df080643 --- /dev/null +++ b/docs/plans/issue-590-macos-security-scoped-bookmarks.md @@ -0,0 +1,139 @@ +# Issue 590: macOS Security-Scoped Bookmarks + +## Objective + +Make bookmark persistence work across application launches for sandboxed macOS applications on JVM and Kotlin/Native while preserving existing bookmark bytes and the published `PlatformFile` API. + +## Public API + +Add a successful resolution value: + +```kotlin +public class BookmarkResolution( + public val file: PlatformFile, + public val isStale: Boolean, + public val shouldRefresh: Boolean, +) +``` + +Add `PlatformFile.resolveBookmarkData(BookmarkData)` and its `ByteArray` convenience overload. Keep both existing `fromBookmarkData` overloads and delegate them to the new resolver while discarding the additional metadata. + +Keep resolution exception-based. Add `BookmarkResolutionException : FileKitException` with a reason that distinguishes invalid data, unsupported envelope versions, incompatible platforms, and unavailable resources. Existing callers that catch `FileKitException` remain compatible. + +`isStale` reflects the platform resolver's stale flag. `shouldRefresh` is advisory and is true for a stale bookmark or any successfully resolved legacy macOS representation. Refresh can fail and does not recover permission already revoked by macOS. + +## Bookmark Format + +Define a compact binary envelope with: + +- A FileKit bookmark magic value. +- A format version. +- A payload kind identifying a regular or security-scoped macOS native bookmark. +- The opaque native bookmark payload. + +Reject recognized envelopes with unknown versions or incompatible payload kinds explicitly. When the magic value is absent, use the existing runtime-specific legacy decoder: + +- JVM macOS interprets the legacy bytes as the previously stored path. +- Kotlin/Native macOS resolves the previous unwrapped Foundation bookmark. +- Other platforms retain their current bookmark formats and behavior. + +Do not promise that bookmark data is portable between operating systems or applications. + +## macOS Bookmark Creation and Resolution + +Introduce a small platform abstraction shared by the macOS implementations: + +1. Detect the running process's `com.apple.security.app-sandbox` entitlement. +2. In a sandboxed application, create and resolve bookmarks with the macOS security-scope options. Do not silently fall back to an unscoped bookmark if scoped creation fails. +3. In an unsandboxed application, create and resolve regular native bookmarks. +4. Capture the native stale flag during resolution. +5. Preserve the resolved native URL as the access capability owned by the resulting `PlatformFile`. + +For Kotlin/Native, move the macOS-specific option selection out of the shared Apple behavior so iOS keeps its supported bookmark semantics. The macOS implementation uses Foundation APIs directly. + +For JVM macOS, add a focused JNA bridge to the stable CoreFoundation and Security C APIs. FileKit Core already depends on JNA Platform, so this must not add a bundled native library or JNI component. Guard all native loading behind a macOS runtime check. + +## Capability Lifetime + +Represent a restored macOS capability as a shared internal object containing its scope root and retained native URL. Derived files inherit it only when their normalized location is the root or a descendant of it. + +Balance every successful start with a stop. Support nested access safely and release retained native objects when no longer reachable. + +Audit all filesystem entry points. In particular: + +- Apply scoped access to currently uncovered operations such as existence checks and directory creation. +- Keep access active until returned `RawSource` and `RawSink` instances are closed rather than stopping immediately after constructing them. +- Preserve the capability through child resolution, applicable parent/absolute transformations, and JVM `copy()`. +- Do not propagate a directory capability to a location outside its scope. + +`withScopedAccess` remains the explicit bridge for third-party libraries that operate on paths or handles outside FileKit's own operations. + +## JVM Compatibility + +If the JVM actual class can no longer remain a Kotlin data class, reproduce its published surface manually: + +- `PlatformFile(File)` constructor. +- `component1()`. +- `copy(File)` and its default-argument JVM helper. +- Existing equality, hash-code, and string behavior. + +Capture the current JVM signatures before changing the class and compare the built artifact against that baseline. Add regression coverage for copying the same file, copying within a bookmarked directory, and copying outside the capability root. + +## Legacy Behavior + +Legacy resolution is permissive: + +1. Attempt the old decoder when no envelope is present. +2. Return the file with `shouldRefresh = true` when resolution succeeds. +3. Allow normal file access to proceed. +4. Let the application regenerate and persist bookmark data. +5. Require user reselection only after an actual access or refresh failure. + +Old sandboxed bookmarks may identify an external resource without carrying a persistent access grant. FileKit cannot upgrade those bytes after macOS has revoked access; this limitation must be documented explicitly. + +## Documentation + +Update the bookmark guide to: + +- Separate iOS, Kotlin/Native macOS, JVM macOS, and other JVM behavior. +- Define `isStale`, `shouldRefresh`, legacy data, and refresh failure. +- Show the new resolver and an opportunistic persistence refresh. +- Explain that `fromBookmarkData` remains the simple compatibility API but does not expose refresh metadata. +- Document App Sandbox, user-selected read/write access, and app-scoped bookmark entitlements for persistent external access. +- Explain balanced scoped access and the `withScopedAccess` escape hatch. +- Include the unavoidable reselection path for legacy bookmarks that never contained a persistent grant. + +## Verification + +Add automated coverage for: + +- Envelope round trips and malformed input. +- Unknown versions and incompatible payload kinds. +- Both JVM-path and Kotlin/Native Foundation legacy formats. +- Current, stale, and legacy resolution metadata. +- Automatic sandbox-mode selection through an injectable entitlement reader. +- Balanced and nested capability access. +- Capability propagation within a directory and rejection outside it. +- `RawSource` and `RawSink` lifetime behavior. +- JVM public and binary compatibility. + +Run the repository gates required by `AGENTS.md`, including `./gradlew :filekit-core:check` and `./gradlew assemble`. + +Before closing the issue, manually verify signed sandboxed applications on JVM macOS and Kotlin/Native macOS: + +1. Pick an external directory. +2. Persist its bookmark and write a file. +3. Terminate the process completely. +4. Relaunch and resolve the bookmark. +5. Write again without presenting a picker. +6. Confirm access is relinquished after use. +7. Repeat with accessible and inaccessible legacy bookmark data. + +Record the operating system version, entitlements, packaging method, and results in the pull request description. + +## Out of Scope + +- Configurable read-only versus read/write bookmark modes. +- Changing Android, iOS, Windows, or Linux bookmark formats. +- Guaranteeing cross-platform or cross-application bookmark portability. +- Silently replacing bookmark bytes in application-owned storage. From 6ae8ad9890ea3640fd3153e28ab061dfddd70d73 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 20 Jul 2026 18:20:54 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=93=9D=20Capture=20JVM=20PlatformFile?= =?UTF-8?q?=20ABI=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/issue-590-platform-file-jvm-abi.txt | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/plans/issue-590-platform-file-jvm-abi.txt diff --git a/docs/plans/issue-590-platform-file-jvm-abi.txt b/docs/plans/issue-590-platform-file-jvm-abi.txt new file mode 100644 index 00000000..59f30d8d --- /dev/null +++ b/docs/plans/issue-590-platform-file-jvm-abi.txt @@ -0,0 +1,66 @@ +Issue #590 JVM PlatformFile ABI baseline +======================================== + +Source commit: 8fd0730f +Parent production commit: 941c90b5 +Build command: ./gradlew :filekit-core:jvmJar --no-daemon +Artifact: filekit-core/build/libs/filekit-core-jvm.jar +Artifact SHA-256: 22c502c0dafc22bcd03ea52dc2e6fe3bc792b23c54c37d05d8a8bc79b0b115ca +PlatformFile.class SHA-256: 57e82d716a93e27d946eac8357d7157d1363a3e2ecf001cb95c8a956a9596f45 + +Captured with: + + javap -classpath filekit-core/build/libs/filekit-core-jvm.jar -p -s \ + io.github.vinceglb.filekit.PlatformFile \ + 'io.github.vinceglb.filekit.PlatformFile$Companion' \ + io.github.vinceglb.filekit.PlatformFile_jvmKt + +Compiled from "PlatformFile.jvm.kt" +public final class io.github.vinceglb.filekit.PlatformFile { + public static final io.github.vinceglb.filekit.PlatformFile$Companion Companion; + descriptor: Lio/github/vinceglb/filekit/PlatformFile$Companion; + private final java.io.File file; + descriptor: Ljava/io/File; + public io.github.vinceglb.filekit.PlatformFile(java.io.File); + descriptor: (Ljava/io/File;)V + public final java.io.File getFile(); + descriptor: ()Ljava/io/File; + public java.lang.String toString(); + descriptor: ()Ljava/lang/String; + public final java.io.File component1(); + descriptor: ()Ljava/io/File; + public final io.github.vinceglb.filekit.PlatformFile copy(java.io.File); + descriptor: (Ljava/io/File;)Lio/github/vinceglb/filekit/PlatformFile; + public static io.github.vinceglb.filekit.PlatformFile copy$default(io.github.vinceglb.filekit.PlatformFile, java.io.File, int, java.lang.Object); + descriptor: (Lio/github/vinceglb/filekit/PlatformFile;Ljava/io/File;ILjava/lang/Object;)Lio/github/vinceglb/filekit/PlatformFile; + public int hashCode(); + descriptor: ()I + public boolean equals(java.lang.Object); + descriptor: (Ljava/lang/Object;)Z + static {}; + descriptor: ()V +} + +Compiled from "PlatformFile.jvm.kt" +public final class io.github.vinceglb.filekit.PlatformFile$Companion { + private io.github.vinceglb.filekit.PlatformFile$Companion(); + descriptor: ()V + public final kotlinx.serialization.KSerializer serializer(); + descriptor: ()Lkotlinx/serialization/KSerializer; + public io.github.vinceglb.filekit.PlatformFile$Companion(kotlin.jvm.internal.DefaultConstructorMarker); + descriptor: (Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +Compiled from "PlatformFile.jvm.kt" +public final class io.github.vinceglb.filekit.PlatformFile_jvmKt { + public static final boolean startAccessingSecurityScopedResource(io.github.vinceglb.filekit.PlatformFile); + descriptor: (Lio/github/vinceglb/filekit/PlatformFile;)Z + public static final void stopAccessingSecurityScopedResource(io.github.vinceglb.filekit.PlatformFile); + descriptor: (Lio/github/vinceglb/filekit/PlatformFile;)V + public static final java.lang.Object bookmarkData(io.github.vinceglb.filekit.PlatformFile, kotlin.coroutines.Continuation); + descriptor: (Lio/github/vinceglb/filekit/PlatformFile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final void releaseBookmark(io.github.vinceglb.filekit.PlatformFile); + descriptor: (Lio/github/vinceglb/filekit/PlatformFile;)V + public static final io.github.vinceglb.filekit.PlatformFile fromBookmarkData(io.github.vinceglb.filekit.PlatformFile$Companion, io.github.vinceglb.filekit.BookmarkData); + descriptor: (Lio/github/vinceglb/filekit/PlatformFile$Companion;Lio/github/vinceglb/filekit/BookmarkData;)Lio/github/vinceglb/filekit/PlatformFile; +} From 2f90823d07d304653fdffa5e1e924e0d4507375b Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 20 Jul 2026 19:07:21 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20Add=20macOS=20secur?= =?UTF-8?q?ity-scoped=20bookmark=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/core/bookmark-data.mdx | 57 +++- .../vinceglb/filekit/PlatformFile.android.kt | 12 +- .../AppleBookmarkConfiguration.apple.kt | 21 ++ .../vinceglb/filekit/PlatformFile.apple.kt | 97 +++++- .../filekit/AppleBookmarkConfiguration.ios.kt | 12 + .../filekit/PlatformFile.jvmAndNative.kt | 91 +++++- .../vinceglb/filekit/MacOsBookmark.jvm.kt | 277 ++++++++++++++++++ .../vinceglb/filekit/PlatformFile.jvm.kt | 100 ++++++- .../vinceglb/filekit/PlatformFileJvmTest.kt | 207 +++++++++++++ .../AppleBookmarkConfiguration.macos.kt | 90 ++++++ .../filekit/PlatformFileMacOsBookmarkTest.kt | 76 +++++ .../vinceglb/filekit/PlatformFile.mingw.kt | 15 +- .../github/vinceglb/filekit/BookmarkData.kt | 13 + .../vinceglb/filekit/MacOsBookmarkEnvelope.kt | 62 ++++ .../vinceglb/filekit/PlatformFile.nonWeb.kt | 9 + .../exceptions/BookmarkResolutionException.kt | 29 ++ .../AppleBookmarkConfiguration.watchos.kt | 12 + 17 files changed, 1134 insertions(+), 46 deletions(-) create mode 100644 filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt create mode 100644 filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt create mode 100644 filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt create mode 100644 filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt create mode 100644 filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt create mode 100644 filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt create mode 100644 filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt create mode 100644 filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt diff --git a/docs/core/bookmark-data.mdx b/docs/core/bookmark-data.mdx index b42fb5ae..e3633d84 100644 --- a/docs/core/bookmark-data.mdx +++ b/docs/core/bookmark-data.mdx @@ -37,6 +37,24 @@ if (savedBytes != null) { } ``` +`fromBookmarkData()` remains the simplest restoration API. When your application can update its stored bookmark, use `resolveBookmarkData()` to inspect the persisted representation: + +```kotlin +val resolution = PlatformFile.resolveBookmarkData(savedBytes) +val restoredFile = resolution.file + +if (resolution.shouldRefresh) { + // Replace the bytes in your own storage. + val refreshedBookmark = restoredFile.bookmarkData() + MyPreferences.save("last_file_bookmark", refreshedBookmark.bytes) +} +``` + +- `isStale` means the operating system resolved the bookmark but reported that its native representation should be recreated. +- `shouldRefresh` is FileKit's broader recommendation. It is also `true` when FileKit successfully resolves bookmark data written by an older FileKit version. + +Refreshing is advisory. The old bookmark may still work, and refresh can fail if the resource disappeared or macOS already revoked access. In that case, ask the user to select the file or directory again. + ## Complete Example Here’s a more complete, practical example using a simple object to manage the bookmark. @@ -131,13 +149,42 @@ FileKit abstracts away the details, but here's what happens on each platform: - **Android**: For standard file paths, the path itself is stored. For `content://` URIs from the system picker, FileKit requests persistent URI permissions and stores the URI string. This ensures long-term access. -- **iOS & macOS**: Uses the native security-scoped bookmark system. This is crucial for sandboxed apps. FileKit automatically starts and stops access to the security-scoped resource when you use the restored `PlatformFile`. +- **iOS**: Uses Foundation bookmark data and keeps the security-scoped URL behavior supplied by the system document picker. The explicit persistent bookmark flags described below are macOS-only. + +- **Kotlin/Native macOS**: Uses a native macOS bookmark. FileKit automatically creates an explicit security-scoped bookmark when the running application adopts App Sandbox and a regular native bookmark otherwise. + +- **JVM macOS**: Uses the same versioned native bookmark format through CoreFoundation. A `PlatformFile` restored from a security-scoped bookmark retains its access capability, including for children inside a bookmarked directory. + +- **JVM Linux and Windows**: Stores the file path. These platforms keep their existing bookmark representation. -- **JVM**: Stores the file's absolute path. This is usually sufficient for desktop apps which are not typically sandboxed. +New macOS bookmark data is wrapped in a versioned FileKit format. Existing unwrapped Kotlin/Native bookmarks and JVM path bytes remain readable. Bookmark bytes are platform-specific and must not be treated as portable between operating systems or applications. + +## macOS App Sandbox Setup + +Persistent access to user-selected locations outside a macOS application's container requires appropriate signing entitlements. Configure the packaged application with: + +```xml +com.apple.security.app-sandbox + +com.apple.security.files.user-selected.read-write + +com.apple.security.files.bookmarks.app-scope + +``` + +FileKit reads the signed App Sandbox entitlement at runtime and selects security-scoped bookmark creation automatically. When sandboxing is enabled, failure to create scoped bookmark data is reported instead of silently falling back to a bookmark that cannot restore access across launches. + +Keep security-scoped access balanced. FileKit does this around its own file operations and keeps access active until returned sources and sinks are closed. Use `withScopedAccess` when passing a restored file to another library: + +```kotlin +restoredFile.withScopedAccess { file -> + thirdPartyLibrary.open(file.path) +} +``` -## Handling Invalid Bookmarks +## Handling Invalid and Legacy Bookmarks -A bookmark is not a guarantee. It can become invalid if the original file is deleted, moved, or if permissions change. +A bookmark is not a guarantee. It can become invalid if the original file is deleted, moved, or if permissions change. A stale bookmark is different: it resolved successfully but should be recreated. Legacy bookmark data is also different: FileKit resolved an older representation and recommends replacing it. **Always handle restoration failures gracefully.** A bookmark can become invalid if: @@ -171,4 +218,4 @@ suspend fun loadFileSafely(): PlatformFile? { } } ``` -This defensive approach ensures your app doesn't crash from a stale bookmark and can self-heal by clearing invalid data. +This defensive approach ensures your app doesn't crash from an invalid bookmark and can self-heal by clearing invalid data. diff --git a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt index bfb876e7..4b865392 100644 --- a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt +++ b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt @@ -533,7 +533,11 @@ private fun Uri.getUriToRelease(isDirectory: Boolean): Uri = if (isDirectory) { public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, -): PlatformFile { +): PlatformFile = resolveBookmarkData(bookmarkData).file + +public actual fun PlatformFile.Companion.resolveBookmarkData( + bookmarkData: BookmarkData, +): BookmarkResolution { val str = bookmarkData.bytes.decodeToString() val platformFile = when { @@ -575,7 +579,11 @@ public actual fun PlatformFile.Companion.fromBookmarkData( throw FileKitException("Bookmark target is no longer accessible") } - return platformFile + return BookmarkResolution( + file = platformFile, + isStale = false, + shouldRefresh = false, + ) } private fun getUriFileSize(uri: Uri): Long? = UriMetadataResolver.size(uri) diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt new file mode 100644 index 00000000..e9d67cbb --- /dev/null +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt @@ -0,0 +1,21 @@ +package io.github.vinceglb.filekit + +internal data class AppleBookmarkCreationConfiguration( + val options: ULong, + val kind: MacOsBookmarkKind?, +) + +internal data class AppleBookmarkPayload( + val bytes: ByteArray, + val resolutionOptions: ULong, + val isLegacy: Boolean, +) + +internal expect fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration + +internal expect fun encodeAppleBookmarkPayload( + payload: ByteArray, + configuration: AppleBookmarkCreationConfiguration, +): ByteArray + +internal expect fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index fd2090dd..c2d3f21b 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -1,11 +1,14 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.exceptions.FileKitException import io.github.vinceglb.filekit.mimeType.MimeType import io.github.vinceglb.filekit.utils.toByteArray import io.github.vinceglb.filekit.utils.toKotlinxPath import io.github.vinceglb.filekit.utils.toNSData import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.BooleanVar import kotlinx.cinterop.ByteVar import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi @@ -13,6 +16,7 @@ import kotlinx.cinterop.ObjCObjectVar import kotlinx.cinterop.UnsafeNumber import kotlinx.cinterop.alloc import kotlinx.cinterop.allocArray +import kotlinx.cinterop.convert import kotlinx.cinterop.memScoped import kotlinx.cinterop.pointed import kotlinx.cinterop.ptr @@ -53,11 +57,34 @@ import kotlin.time.Instant * @property nsUrl The underlying [NSURL] object. */ @Serializable(with = PlatformFileSerializer::class) -public actual data class PlatformFile( - val nsUrl: NSURL, +public actual class PlatformFile internal constructor( + public val nsUrl: NSURL, + internal val securityScopeUrl: NSURL, + internal val securityScopeRootPath: String, ) { + public constructor(nsUrl: NSURL) : this( + nsUrl = nsUrl, + securityScopeUrl = nsUrl, + securityScopeRootPath = nsUrl.standardizedPath, + ) + public actual override fun toString(): String = path + public operator fun component1(): NSURL = nsUrl + + public fun copy(nsUrl: NSURL = this.nsUrl): PlatformFile { + val candidatePath = nsUrl.standardizedPath + return if (candidatePath.isWithin(securityScopeRootPath)) { + PlatformFile( + nsUrl = nsUrl, + securityScopeUrl = securityScopeUrl, + securityScopeRootPath = securityScopeRootPath, + ) + } else { + PlatformFile(nsUrl) + } + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is PlatformFile) return false @@ -80,6 +107,26 @@ public actual fun PlatformFile(path: Path): PlatformFile = public actual fun PlatformFile.toKotlinxIoPath(): Path = nsUrl.toKotlinxPath() +@PublishedApi +internal actual fun PlatformFile.withPath(path: Path): PlatformFile = copy(PlatformFile(path).nsUrl) + +private fun String.isWithin(rootPath: String): Boolean { + val root = rootPath.trimEnd('/') + return when { + root.isEmpty() && rootPath.startsWith('/') -> startsWith('/') + this == root -> true + root.isNotEmpty() -> startsWith("$root/") + else -> false + } +} + +private val NSURL.standardizedPath: String + get() = runCatching { + SystemFileSystem.resolve(toKotlinxPath()).toString() + }.getOrElse { + URLByStandardizingPath?.path.orEmpty() + } + public actual val PlatformFile.extension: String get() = nsUrl.pathExtension ?: "" @@ -93,7 +140,7 @@ public actual inline fun PlatformFile.list(block: (List) -> Unit): withScopedAccess { val directoryFiles = SystemFileSystem .list(toKotlinxIoPath()) - .map { PlatformFile(NSURL.fileURLWithPath(it.toString())) } + .map(::withPath) block(directoryFiles) } @@ -101,7 +148,7 @@ public actual fun PlatformFile.list(): List = withScopedAccess { SystemFileSystem .list(toKotlinxIoPath()) - .map { PlatformFile(NSURL.fileURLWithPath(it.toString())) } + .map(::withPath) } @OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) @@ -209,23 +256,29 @@ private fun cfStringToKString(cfString: CFStringRef?): String? { } public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = - nsUrl.startAccessingSecurityScopedResource() + securityScopeUrl.startAccessingSecurityScopedResource() public actual fun PlatformFile.stopAccessingSecurityScopedResource(): Unit = - nsUrl.stopAccessingSecurityScopedResource() + securityScopeUrl.stopAccessingSecurityScopedResource() @OptIn(ExperimentalForeignApi::class, UnsafeNumber::class, BetaInteropApi::class) public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContext(Dispatchers.IO) { withScopedAccess { memScoped { + val configuration = appleBookmarkCreationConfiguration() val errorPtr = alloc>() val bookmarkData = nsUrl.bookmarkDataWithOptions( - options = 0u, + options = configuration.options.convert(), includingResourceValuesForKeys = null, relativeToURL = null, error = errorPtr.ptr, ) ?: throw FileKitException("Failed to create bookmark data: ${errorPtr.ptr.pointed.value}") - BookmarkData(bookmarkData.toByteArray()) + BookmarkData( + encodeAppleBookmarkPayload( + payload = bookmarkData.toByteArray(), + configuration = configuration, + ), + ) } } } @@ -235,17 +288,31 @@ public actual fun PlatformFile.releaseBookmark() {} @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class) public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, -): PlatformFile = memScoped { - val nsData = bookmarkData.bytes.toNSData() +): PlatformFile = resolveBookmarkData(bookmarkData).file + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class) +public actual fun PlatformFile.Companion.resolveBookmarkData( + bookmarkData: BookmarkData, +): BookmarkResolution = memScoped { + val payload = decodeAppleBookmarkPayload(bookmarkData.bytes) + val nsData = payload.bytes.toNSData() val error: CPointer> = alloc>().ptr + val isStale = alloc() val restoredUrl = NSURL.URLByResolvingBookmarkData( bookmarkData = nsData, - options = 0u, + options = payload.resolutionOptions.convert(), relativeToURL = null, - bookmarkDataIsStale = null, + bookmarkDataIsStale = isStale.ptr, error = error, - ) ?: throw FileKitException("Failed to resolve bookmark data: ${error.pointed.value}") - - PlatformFile(restoredUrl) + ) ?: throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, + message = "Failed to resolve bookmark data: ${error.pointed.value}", + ) + + BookmarkResolution( + file = PlatformFile(restoredUrl), + isStale = isStale.value, + shouldRefresh = isStale.value || payload.isLegacy, + ) } diff --git a/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt b/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt new file mode 100644 index 00000000..c5cf75cd --- /dev/null +++ b/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt @@ -0,0 +1,12 @@ +package io.github.vinceglb.filekit + +internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration = + AppleBookmarkCreationConfiguration(options = 0u, kind = null) + +internal actual fun encodeAppleBookmarkPayload( + payload: ByteArray, + configuration: AppleBookmarkCreationConfiguration, +): ByteArray = payload + +internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload = + AppleBookmarkPayload(bytes = bytes, resolutionOptions = 0u, isLegacy = false) diff --git a/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt b/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt index 9b17183f..0da7dd8f 100644 --- a/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt +++ b/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt @@ -4,6 +4,7 @@ import io.github.vinceglb.filekit.utils.div import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext +import kotlinx.io.Buffer import kotlinx.io.RawSink import kotlinx.io.RawSource import kotlinx.io.files.Path @@ -19,7 +20,7 @@ public actual fun PlatformFile(path: String): PlatformFile = PlatformFile(Path(path)) public actual fun PlatformFile(base: PlatformFile, child: String): PlatformFile = - PlatformFile(base.toKotlinxIoPath() / child) + base.withPath(base.toKotlinxIoPath() / child) public actual fun PlatformFile.isRegularFile(): Boolean = withScopedAccess { SystemFileSystem.metadataOrNull(toKotlinxIoPath())?.isRegularFile ?: false @@ -32,30 +33,51 @@ public actual fun PlatformFile.isDirectory(): Boolean = withScopedAccess { public actual fun PlatformFile.isAbsolute(): Boolean = toKotlinxIoPath().isAbsolute -public actual fun PlatformFile.exists(): Boolean = +public actual fun PlatformFile.exists(): Boolean = withScopedAccess { SystemFileSystem.exists(toKotlinxIoPath()) +} public actual fun PlatformFile.size(): Long = withScopedAccess { SystemFileSystem.metadataOrNull(toKotlinxIoPath())?.size ?: -1 } public actual fun PlatformFile.parent(): PlatformFile? = - toKotlinxIoPath().parent?.let(::PlatformFile) + toKotlinxIoPath().parent?.let(::withPath) public actual fun PlatformFile.absoluteFile(): PlatformFile = withScopedAccess { - PlatformFile(SystemFileSystem.resolve(toKotlinxIoPath())) + withPath(SystemFileSystem.resolve(toKotlinxIoPath())) } -public actual fun PlatformFile.source(): RawSource = withScopedAccess { - SystemFileSystem.source(toKotlinxIoPath()) +public actual fun PlatformFile.source(): RawSource { + val accessGranted = startAccessingSecurityScopedResource() + try { + return ScopedRawSource( + delegate = SystemFileSystem.source(toKotlinxIoPath()), + onClose = { if (accessGranted) stopAccessingSecurityScopedResource() }, + ) + } catch (error: Throwable) { + if (accessGranted) stopAccessingSecurityScopedResource() + throw error + } } -public actual fun PlatformFile.sink(append: Boolean): RawSink = withScopedAccess { - SystemFileSystem.sink(toKotlinxIoPath(), append) +public actual fun PlatformFile.sink(append: Boolean): RawSink { + val accessGranted = startAccessingSecurityScopedResource() + try { + return ScopedRawSink( + delegate = SystemFileSystem.sink(toKotlinxIoPath(), append), + onClose = { if (accessGranted) stopAccessingSecurityScopedResource() }, + ) + } catch (error: Throwable) { + if (accessGranted) stopAccessingSecurityScopedResource() + throw error + } } public actual fun PlatformFile.createDirectories(mustCreate: Boolean): Unit = - SystemFileSystem.createDirectories(toKotlinxIoPath(), mustCreate) + withScopedAccess { + SystemFileSystem.createDirectories(toKotlinxIoPath(), mustCreate) + } public actual suspend fun PlatformFile.delete(mustExist: Boolean): Unit = withScopedAccess { @@ -81,7 +103,7 @@ public actual suspend fun PlatformFile.atomicMove(destination: PlatformFile): Un private fun PlatformFile.resolveAtomicMoveDestination(source: PlatformFile): PlatformFile { if (isDirectory()) { - return PlatformFile(toKotlinxIoPath() / source.name) + return withPath(toKotlinxIoPath() / source.name) } return this } @@ -90,8 +112,55 @@ internal actual suspend fun PlatformFile.prepareDestinationForWrite( source: PlatformFile, ): PlatformFile = withScopedAccess { if (isDirectory()) { - PlatformFile(toKotlinxIoPath() / source.name) + withPath(toKotlinxIoPath() / source.name) } else { this } } + +@PublishedApi +internal expect fun PlatformFile.withPath(path: Path): PlatformFile + +private class ScopedRawSource( + private val delegate: RawSource, + private val onClose: () -> Unit, +) : RawSource { + private var closed = false + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = delegate.readAtMostTo(sink, byteCount) + + override fun close() { + if (closed) return + closed = true + try { + delegate.close() + } finally { + onClose() + } + } +} + +private class ScopedRawSink( + private val delegate: RawSink, + private val onClose: () -> Unit, +) : RawSink { + private var closed = false + + override fun write(source: Buffer, byteCount: Long) { + delegate.write(source, byteCount) + } + + override fun flush() { + delegate.flush() + } + + override fun close() { + if (closed) return + closed = true + try { + delegate.close() + } finally { + onClose() + } + } +} diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt new file mode 100644 index 00000000..dbfdbc33 --- /dev/null +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt @@ -0,0 +1,277 @@ +package io.github.vinceglb.filekit + +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Platform +import com.sun.jna.Pointer +import com.sun.jna.PointerType +import com.sun.jna.platform.mac.CoreFoundation +import com.sun.jna.ptr.ByteByReference +import com.sun.jna.ptr.PointerByReference +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure +import java.io.File +import java.lang.ref.Cleaner + +internal data class ResolvedMacOsBookmark( + val file: File, + val isStale: Boolean, + val access: MacOsBookmarkAccess?, +) + +internal interface MacOsBookmarkAccess { + fun covers(file: File): Boolean + + fun start(): Boolean + + fun stop() +} + +private class NativeMacOsBookmarkAccess( + root: File, + private val url: CFUrlRef, +) : MacOsBookmarkAccess { + private val rootPath = root.canonicalFile.toPath() + + @Suppress("unused") + private val cleanable = cleaner.register(this, NativeUrlReleaser(url)) + + override fun covers(file: File): Boolean = file.canonicalFile.toPath().startsWith(rootPath) + + override fun start(): Boolean = + CoreFoundationBookmarkApi.instance.CFURLStartAccessingSecurityScopedResource(url) != 0.toByte() + + override fun stop() { + CoreFoundationBookmarkApi.instance.CFURLStopAccessingSecurityScopedResource(url) + } + + companion object { + private val cleaner = Cleaner.create() + } + + private class NativeUrlReleaser( + private val url: CFUrlRef, + ) : Runnable { + override fun run() { + url.release() + } + } +} + +internal object MacOsSandbox { + internal var entitlementReader: () -> Boolean = ::readAppSandboxEntitlement + + fun isSandboxed(): Boolean = entitlementReader() + + private fun readAppSandboxEntitlement(): Boolean { + check(Platform.isMac()) + val task = SecurityApi.instance.SecTaskCreateFromSelf(null) ?: return false + try { + val entitlement = CoreFoundation.CFStringRef.createCFString(APP_SANDBOX_ENTITLEMENT) + try { + val value = SecurityApi.instance.SecTaskCopyValueForEntitlement(task, entitlement, null) ?: return false + try { + if (!value.isTypeID(CoreFoundation.BOOLEAN_TYPE_ID)) return false + return CoreFoundation.INSTANCE.CFBooleanGetValue( + CoreFoundation.CFBooleanRef(value.pointer), + ) != 0.toByte() + } finally { + value.release() + } + } finally { + entitlement.release() + } + } finally { + task.release() + } + } +} + +internal fun macOsBookmarkKindForCurrentProcess(): MacOsBookmarkKind = if (MacOsSandbox.isSandboxed()) { + MacOsBookmarkKind.SecurityScoped +} else { + MacOsBookmarkKind.Regular +} + +internal object MacOsBookmarks { + fun create(file: File, kind: MacOsBookmarkKind): ByteArray { + check(Platform.isMac()) + val path = CoreFoundation.CFStringRef.createCFString(file.canonicalPath) + try { + val url = CoreFoundationBookmarkApi.instance.CFURLCreateWithFileSystemPath( + allocator = null, + filePath = path, + pathStyle = POSIX_PATH_STYLE, + isDirectory = if (file.isDirectory) 1 else 0, + ) ?: throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, + message = "Could not create a native URL for ${file.path}", + ) + try { + val bookmark = CoreFoundationBookmarkApi.instance.CFURLCreateBookmarkData( + allocator = null, + url = url, + options = NativeLong(kind.creationOptions), + resourcePropertiesToInclude = null, + relativeToUrl = null, + error = null, + ) ?: throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, + message = "Could not create bookmark data for ${file.path}", + ) + try { + return bookmark.bytePtr.getByteArray(0, bookmark.length) + } finally { + bookmark.release() + } + } finally { + url.release() + } + } finally { + path.release() + } + } + + fun resolve(payload: ByteArray, kind: MacOsBookmarkKind): ResolvedMacOsBookmark { + check(Platform.isMac()) + val payloadMemory = Memory(payload.size.toLong()).apply { write(0, payload, 0, payload.size) } + val bookmarkData = CoreFoundation.INSTANCE.CFDataCreate( + null, + payloadMemory, + CoreFoundation.CFIndex(payload.size.toLong()), + ) + try { + val isStale = ByteByReference() + val url = CoreFoundationBookmarkApi.instance.CFURLCreateByResolvingBookmarkData( + allocator = null, + bookmark = bookmarkData, + options = NativeLong(kind.resolutionOptions), + relativeToUrl = null, + resourcePropertiesToInclude = null, + isStale = isStale, + error = null, + ) ?: throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, + message = "Could not resolve macOS bookmark data", + ) + var releaseUrl = true + try { + val path = CoreFoundationBookmarkApi.instance.CFURLCopyFileSystemPath(url, POSIX_PATH_STYLE) + ?: throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, + message = "The resolved macOS bookmark has no file-system path", + ) + try { + val file = File(path.stringValue()) + val access = if (kind == MacOsBookmarkKind.SecurityScoped) { + releaseUrl = false + NativeMacOsBookmarkAccess(file, url) + } else { + null + } + return ResolvedMacOsBookmark( + file = file, + isStale = isStale.value != 0.toByte(), + access = access, + ) + } finally { + path.release() + } + } finally { + if (releaseUrl) url.release() + } + } finally { + bookmarkData.release() + } + } +} + +private val MacOsBookmarkKind.creationOptions: Long + get() = when (this) { + MacOsBookmarkKind.Regular -> 0 + MacOsBookmarkKind.SecurityScoped -> 1L shl 11 + } + +private val MacOsBookmarkKind.resolutionOptions: Long + get() = when (this) { + MacOsBookmarkKind.Regular -> 0 + MacOsBookmarkKind.SecurityScoped -> 1L shl 10 + } + +private const val POSIX_PATH_STYLE = 0 + +internal class CFUrlRef : PointerType { + constructor() : super() + constructor(pointer: Pointer) : super(pointer) + + fun release() { + CoreFoundation.INSTANCE.CFRelease(CoreFoundation.CFTypeRef(pointer)) + } +} + +@Suppress("ktlint:standard:function-naming", "FunctionName") +internal interface CoreFoundationBookmarkApi : Library { + fun CFURLCreateWithFileSystemPath( + allocator: CoreFoundation.CFAllocatorRef?, + filePath: CoreFoundation.CFStringRef, + pathStyle: Int, + isDirectory: Byte, + ): CFUrlRef? + + fun CFURLCreateBookmarkData( + allocator: CoreFoundation.CFAllocatorRef?, + url: CFUrlRef, + options: NativeLong, + resourcePropertiesToInclude: CoreFoundation.CFArrayRef?, + relativeToUrl: CFUrlRef?, + error: PointerByReference?, + ): CoreFoundation.CFDataRef? + + fun CFURLCreateByResolvingBookmarkData( + allocator: CoreFoundation.CFAllocatorRef?, + bookmark: CoreFoundation.CFDataRef, + options: NativeLong, + relativeToUrl: CFUrlRef?, + resourcePropertiesToInclude: CoreFoundation.CFArrayRef?, + isStale: ByteByReference, + error: PointerByReference?, + ): CFUrlRef? + + fun CFURLCopyFileSystemPath(url: CFUrlRef, pathStyle: Int): CoreFoundation.CFStringRef? + + fun CFURLStartAccessingSecurityScopedResource(url: CFUrlRef): Byte + + fun CFURLStopAccessingSecurityScopedResource(url: CFUrlRef) + + companion object { + val instance: CoreFoundationBookmarkApi = Native.load("CoreFoundation", CoreFoundationBookmarkApi::class.java) + } +} + +internal class SecTaskRef : PointerType { + constructor() : super() + constructor(pointer: Pointer) : super(pointer) + + fun release() { + CoreFoundation.INSTANCE.CFRelease(CoreFoundation.CFTypeRef(pointer)) + } +} + +@Suppress("ktlint:standard:function-naming", "FunctionName") +internal interface SecurityApi : Library { + fun SecTaskCreateFromSelf(allocator: CoreFoundation.CFAllocatorRef?): SecTaskRef? + + fun SecTaskCopyValueForEntitlement( + task: SecTaskRef, + entitlement: CoreFoundation.CFStringRef, + error: PointerByReference?, + ): CoreFoundation.CFTypeRef? + + companion object { + val instance: SecurityApi = Native.load("Security", SecurityApi::class.java) + } +} + +private const val APP_SANDBOX_ENTITLEMENT = "com.apple.security.app-sandbox" diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index 9bcaeefd..05374a4d 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -1,5 +1,8 @@ package io.github.vinceglb.filekit +import com.sun.jna.Platform +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.mimeType.MimeType import io.github.vinceglb.filekit.utils.toFile import io.github.vinceglb.filekit.utils.toKotlinxIoPath @@ -20,12 +23,40 @@ import kotlin.time.Instant * @property file The underlying [java.io.File] object. */ @Serializable(with = PlatformFileSerializer::class) -public actual data class PlatformFile( - val file: File, +public actual class PlatformFile private constructor( + public val file: File, + private val macOsBookmarkAccess: MacOsBookmarkAccess?, ) { + public constructor(file: File) : this(file, null) + public actual override fun toString(): String = path - public actual companion object + public operator fun component1(): File = file + + public fun copy(file: File = this.file): PlatformFile = PlatformFile( + file = file, + macOsBookmarkAccess = macOsBookmarkAccess?.takeIf { it.covers(file) }, + ) + + override fun equals(other: Any?): Boolean = this === other || (other is PlatformFile && file == other.file) + + override fun hashCode(): Int = file.hashCode() + + @JvmSynthetic + internal fun startMacOsBookmarkAccess(): Boolean = macOsBookmarkAccess?.start() ?: true + + @JvmSynthetic + internal fun stopMacOsBookmarkAccess() { + macOsBookmarkAccess?.stop() + } + + public actual companion object { + @JvmSynthetic + internal fun withMacOsBookmarkAccess( + file: File, + access: MacOsBookmarkAccess, + ): PlatformFile = PlatformFile(file, access) + } } public actual fun PlatformFile(path: Path): PlatformFile = @@ -34,6 +65,9 @@ public actual fun PlatformFile(path: Path): PlatformFile = public actual fun PlatformFile.toKotlinxIoPath(): Path = file.toKotlinxIoPath() +@PublishedApi +internal actual fun PlatformFile.withPath(path: Path): PlatformFile = copy(path.toFile()) + public actual val PlatformFile.extension: String get() = file.extension @@ -45,13 +79,13 @@ public actual fun PlatformFile.absolutePath(): String = public actual inline fun PlatformFile.list(block: (List) -> Unit): Unit = withScopedAccess { - val directoryFiles = SystemFileSystem.list(toKotlinxIoPath()).map(::PlatformFile) + val directoryFiles = SystemFileSystem.list(toKotlinxIoPath()).map(::withPath) block(directoryFiles) } public actual fun PlatformFile.list(): List = withScopedAccess { - SystemFileSystem.list(toKotlinxIoPath()).map(::PlatformFile) + SystemFileSystem.list(toKotlinxIoPath()).map(::withPath) } @OptIn(ExperimentalTime::class) @@ -77,19 +111,63 @@ public actual fun PlatformFile.mimeType(): MimeType? { return mimeTypeValue?.let(MimeType::parse) } -public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true +public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = startMacOsBookmarkAccess() -public actual fun PlatformFile.stopAccessingSecurityScopedResource() {} +public actual fun PlatformFile.stopAccessingSecurityScopedResource() { + stopMacOsBookmarkAccess() +} public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContext(Dispatchers.IO) { - BookmarkData(file.path.encodeToByteArray()) + if (Platform.isMac()) { + val kind = macOsBookmarkKindForCurrentProcess() + BookmarkData( + MacOsBookmarkEnvelope( + kind = kind, + payload = MacOsBookmarks.create(file, kind), + ).encode(), + ) + } else { + BookmarkData(file.path.encodeToByteArray()) + } } public actual fun PlatformFile.releaseBookmark() {} public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, -): PlatformFile { - val path = bookmarkData.bytes.decodeToString() - return PlatformFile(Path(path)) +): PlatformFile = resolveBookmarkData(bookmarkData).file + +public actual fun PlatformFile.Companion.resolveBookmarkData( + bookmarkData: BookmarkData, +): BookmarkResolution { + val envelope = MacOsBookmarkEnvelope.decodeOrNull(bookmarkData.bytes) + if (envelope != null) { + if (!Platform.isMac()) { + throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM, + message = "macOS bookmark data cannot be resolved on this platform", + ) + } + val resolved = MacOsBookmarks.resolve(envelope.payload, envelope.kind) + return BookmarkResolution( + file = resolved.access?.let { PlatformFile.withMacOsBookmarkAccess(resolved.file, it) } + ?: PlatformFile(resolved.file), + isStale = resolved.isStale, + shouldRefresh = resolved.isStale, + ) + } + val path = try { + bookmarkData.bytes.decodeToString(throwOnInvalidSequence = true) + } catch (cause: CharacterCodingException) { + throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.INVALID_DATA, + message = "Legacy JVM bookmark data is not a valid UTF-8 path", + cause = cause, + ) + } + return BookmarkResolution( + file = PlatformFile(Path(path)), + isStale = false, + shouldRefresh = true, + ) } diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt index 81a00869..5b616203 100644 --- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt +++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt @@ -1,9 +1,20 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + package io.github.vinceglb.filekit +import com.sun.jna.Platform +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path +import java.io.File +import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class PlatformFileJvmTest { private val resourceDirectory = PlatformFile(Path("src/nonWebTest/resources")) @@ -35,4 +46,200 @@ class PlatformFileJvmTest { actual = resourceDirectory.mimeType(), ) } + + @Test + fun PlatformFile_resolveLegacyBookmarkData_recommendsRefresh() { + val legacyPath = "src/nonWebTest/resources/hello.txt" + val bookmarkData = BookmarkData(legacyPath.encodeToByteArray()) + + val resolution = PlatformFile.resolveBookmarkData(bookmarkData) + + assertEquals(expected = legacyPath, actual = resolution.file.path) + assertFalse(resolution.isStale) + assertTrue(resolution.shouldRefresh) + assertEquals(expected = resolution.file, actual = PlatformFile.fromBookmarkData(bookmarkData)) + } + + @Test + fun PlatformFile_bookmarkDataOnMacos_roundTripsCurrentBookmark() = runTest { + if (!Platform.isMac()) return@runTest + + val bookmarkData = textFile.bookmarkData() + val resolution = PlatformFile.resolveBookmarkData(bookmarkData) + + assertEquals(expected = textFile.file.canonicalPath, actual = resolution.file.file.canonicalPath) + assertFalse(resolution.isStale) + assertFalse(resolution.shouldRefresh) + assertFalse(bookmarkData.bytes.contentEquals(textFile.path.encodeToByteArray())) + } + + @Test + fun PlatformFile_resolveUnknownBookmarkVersion_throwsTypedFailure() { + val unknownVersion = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 99, 1) + + val error = assertFailsWith { + PlatformFile.resolveBookmarkData(unknownVersion) + } + + assertEquals(expected = BookmarkResolutionFailure.UNSUPPORTED_VERSION, actual = error.reason) + } + + @Test + fun PlatformFile_resolveUnknownBookmarkKind_throwsTypedFailure() { + val unknownKind = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 1, 99, 1) + + val error = assertFailsWith { + PlatformFile.resolveBookmarkData(unknownKind) + } + + assertEquals(expected = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM, actual = error.reason) + } + + @Test + fun MacOsBookmarkEnvelope_encodeAndDecode_roundTripsPayload() { + val expected = MacOsBookmarkEnvelope( + kind = MacOsBookmarkKind.SecurityScoped, + payload = byteArrayOf(1, 2, 3), + ) + + val actual = requireNotNull(MacOsBookmarkEnvelope.decodeOrNull(expected.encode())) + + assertEquals(expected = expected.kind, actual = actual.kind) + assertTrue(expected.payload.contentEquals(actual.payload)) + } + + @Test + fun PlatformFile_resolveEmptyBookmarkEnvelope_throwsTypedFailure() { + val emptyEnvelope = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 1, 1) + + val error = assertFailsWith { + PlatformFile.resolveBookmarkData(emptyEnvelope) + } + + assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason) + } + + @Test + fun PlatformFile_resolveInvalidLegacyPath_throwsTypedFailure() { + val error = assertFailsWith { + PlatformFile.resolveBookmarkData(byteArrayOf(0xC3.toByte())) + } + + assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason) + } + + @Test + fun PlatformFile_copy_preservesJvmDataClassSurface() { + val original = PlatformFile(textFile.file) + + val copied = original.copy() + + assertEquals(expected = original, actual = copied) + assertEquals(expected = original.file, actual = copied.component1()) + } + + @Test + fun PlatformFile_copy_preservesCapabilityOnlyWithinRoot() { + val root = createTempDirectory("filekit-bookmark-root").toFile() + try { + val access = RecordingBookmarkAccess(root) + val original = PlatformFile.withMacOsBookmarkAccess(root, access) + + val child = original.copy(File(root, "child")) + val outside = original.copy(File(root.parentFile, "outside")) + + child.startAccessingSecurityScopedResource() + child.stopAccessingSecurityScopedResource() + assertEquals(expected = 1, actual = access.startCount) + assertEquals(expected = 1, actual = access.stopCount) + + outside.startAccessingSecurityScopedResource() + outside.stopAccessingSecurityScopedResource() + assertEquals(expected = 1, actual = access.startCount) + assertEquals(expected = 1, actual = access.stopCount) + } finally { + root.deleteRecursively() + } + } + + @Test + fun PlatformFile_nestedScopedAccess_balancesEverySuccessfulStart() { + val file = createTempDirectory("filekit-bookmark-access").toFile() + try { + val access = RecordingBookmarkAccess(file) + val platformFile = PlatformFile.withMacOsBookmarkAccess(file, access) + + platformFile.withScopedAccess { + platformFile.withScopedAccess {} + } + + assertEquals(expected = 2, actual = access.startCount) + assertEquals(expected = 2, actual = access.stopCount) + } finally { + file.deleteRecursively() + } + } + + @Test + fun PlatformFile_sourceAndSink_keepAccessUntilClose() { + val directory = createTempDirectory("filekit-bookmark-stream").toFile() + try { + val nativeFile = File(directory, "content.txt").apply { writeText("hello") } + val access = RecordingBookmarkAccess(directory) + val platformFile = PlatformFile.withMacOsBookmarkAccess(nativeFile, access) + + val source = platformFile.source() + assertEquals(expected = 1, actual = access.startCount) + assertEquals(expected = 0, actual = access.stopCount) + source.close() + source.close() + assertEquals(expected = 1, actual = access.stopCount) + + val sink = platformFile.sink() + assertEquals(expected = 2, actual = access.startCount) + assertEquals(expected = 1, actual = access.stopCount) + sink.close() + sink.close() + assertEquals(expected = 2, actual = access.stopCount) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun MacOsBookmarkKind_usesInjectedSandboxEntitlement() { + val originalReader = MacOsSandbox.entitlementReader + try { + MacOsSandbox.entitlementReader = { true } + assertEquals(expected = MacOsBookmarkKind.SecurityScoped, actual = macOsBookmarkKindForCurrentProcess()) + + MacOsSandbox.entitlementReader = { false } + assertEquals(expected = MacOsBookmarkKind.Regular, actual = macOsBookmarkKindForCurrentProcess()) + } finally { + MacOsSandbox.entitlementReader = originalReader + } + } + + private class RecordingBookmarkAccess( + root: File, + ) : MacOsBookmarkAccess { + private val rootPath = root.canonicalFile.toPath() + + var startCount: Int = 0 + private set + + var stopCount: Int = 0 + private set + + override fun covers(file: File): Boolean = file.canonicalFile.toPath().startsWith(rootPath) + + override fun start(): Boolean { + startCount += 1 + return true + } + + override fun stop() { + stopCount += 1 + } + } } diff --git a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt new file mode 100644 index 00000000..eabf0dbd --- /dev/null +++ b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt @@ -0,0 +1,90 @@ +package io.github.vinceglb.filekit + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.reinterpret +import platform.CoreFoundation.CFBooleanGetValue +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFStringCreateWithCString +import platform.CoreFoundation.kCFAllocatorDefault +import platform.CoreFoundation.kCFStringEncodingUTF8 +import platform.Foundation.NSURLBookmarkCreationWithSecurityScope +import platform.Foundation.NSURLBookmarkResolutionWithSecurityScope +import platform.Security.SecTaskCopyValueForEntitlement +import platform.Security.SecTaskCreateFromSelf + +@OptIn(ExperimentalForeignApi::class) +internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration = + if (AppleBookmarkEnvironment.isAppSandboxEnabled()) { + AppleBookmarkCreationConfiguration( + options = NSURLBookmarkCreationWithSecurityScope, + kind = MacOsBookmarkKind.SecurityScoped, + ) + } else { + AppleBookmarkCreationConfiguration( + options = 0u, + kind = MacOsBookmarkKind.Regular, + ) + } + +internal actual fun encodeAppleBookmarkPayload( + payload: ByteArray, + configuration: AppleBookmarkCreationConfiguration, +): ByteArray = MacOsBookmarkEnvelope( + kind = requireNotNull(configuration.kind), + payload = payload, +).encode() + +internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload { + val envelope = MacOsBookmarkEnvelope.decodeOrNull(bytes) + if (envelope == null) { + return AppleBookmarkPayload( + bytes = bytes, + resolutionOptions = 0u, + isLegacy = true, + ) + } + return AppleBookmarkPayload( + bytes = envelope.payload, + resolutionOptions = envelope.kind.resolutionOptions, + isLegacy = false, + ) +} + +private val MacOsBookmarkKind?.resolutionOptions: ULong + get() = when (this) { + MacOsBookmarkKind.SecurityScoped -> NSURLBookmarkResolutionWithSecurityScope + MacOsBookmarkKind.Regular, null -> 0u + } + +@OptIn(ExperimentalForeignApi::class) +internal object AppleBookmarkEnvironment { + internal var entitlementReader: () -> Boolean = ::readAppSandboxEntitlement + + fun isAppSandboxEnabled(): Boolean = entitlementReader() +} + +@OptIn(ExperimentalForeignApi::class) +private fun readAppSandboxEntitlement(): Boolean { + val task = SecTaskCreateFromSelf(kCFAllocatorDefault) ?: return false + val entitlement = CFStringCreateWithCString( + alloc = kCFAllocatorDefault, + cStr = APP_SANDBOX_ENTITLEMENT, + encoding = kCFStringEncodingUTF8, + ) ?: run { + CFRelease(task) + return false + } + return try { + val value = SecTaskCopyValueForEntitlement(task, entitlement, null) ?: return false + try { + CFBooleanGetValue(value.reinterpret()) + } finally { + CFRelease(value) + } + } finally { + CFRelease(entitlement) + CFRelease(task) + } +} + +private const val APP_SANDBOX_ENTITLEMENT = "com.apple.security.app-sandbox" diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt new file mode 100644 index 00000000..7d04aad9 --- /dev/null +++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt @@ -0,0 +1,76 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.utils.toByteArray +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class PlatformFileMacOsBookmarkTest { + @Test + fun AppleBookmarkConfiguration_usesInjectedSandboxEntitlement() { + val originalReader = AppleBookmarkEnvironment.entitlementReader + try { + AppleBookmarkEnvironment.entitlementReader = { true } + assertEquals( + expected = MacOsBookmarkKind.SecurityScoped, + actual = appleBookmarkCreationConfiguration().kind, + ) + + AppleBookmarkEnvironment.entitlementReader = { false } + assertEquals( + expected = MacOsBookmarkKind.Regular, + actual = appleBookmarkCreationConfiguration().kind, + ) + } finally { + AppleBookmarkEnvironment.entitlementReader = originalReader + } + } + + @Test + fun PlatformFile_derivedPaths_inheritScopeOnlyWithinRoot() { + val root = FileKit.projectDir.absoluteFile() + + val child = root / "child" + val escaped = root / "../outside" + + assertEquals(expected = root.securityScopeUrl, actual = child.securityScopeUrl) + assertEquals(expected = escaped.nsUrl, actual = escaped.securityScopeUrl) + } + + @Test + fun PlatformFile_bookmarkData_roundTripsCurrentBookmark() = runTest { + val file = FileKit.projectDir / "src/nonWebTest/resources/hello.txt" + + val bookmarkData = file.bookmarkData() + val resolution = PlatformFile.resolveBookmarkData(bookmarkData) + + assertEquals(expected = file.path, actual = resolution.file.path) + assertFalse(resolution.isStale) + assertFalse(resolution.shouldRefresh) + assertFalse(bookmarkData.bytes.contentEquals(file.path.encodeToByteArray())) + } + + @OptIn(ExperimentalForeignApi::class) + @Test + fun PlatformFile_resolveLegacyBookmarkData_recommendsRefresh() { + val file = FileKit.projectDir / "src/nonWebTest/resources/hello.txt" + val legacyData = requireNotNull( + file.nsUrl.bookmarkDataWithOptions( + options = 0u, + includingResourceValuesForKeys = null, + relativeToURL = null, + error = null, + ), + ) + + val resolution = PlatformFile.resolveBookmarkData(BookmarkData(legacyData.toByteArray())) + + assertEquals(expected = file.path, actual = resolution.file.path) + assertFalse(resolution.isStale) + kotlin.test.assertTrue(resolution.shouldRefresh) + } +} diff --git a/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt b/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt index 130b602c..fc0fad6c 100644 --- a/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt +++ b/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt @@ -68,6 +68,9 @@ public actual fun PlatformFile(path: Path): PlatformFile = public actual fun PlatformFile.toKotlinxIoPath(): Path = windowsPath.path +@PublishedApi +internal actual fun PlatformFile.withPath(path: Path): PlatformFile = PlatformFile(path) + public actual val PlatformFile.extension: String get() = name.substringAfterLast('.', "") @@ -193,9 +196,17 @@ public actual fun PlatformFile.releaseBookmark() {} public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, -): PlatformFile { +): PlatformFile = resolveBookmarkData(bookmarkData).file + +public actual fun PlatformFile.Companion.resolveBookmarkData( + bookmarkData: BookmarkData, +): BookmarkResolution { val restoredPath = bookmarkData.bytes.decodeToString() - return PlatformFile(windowsPath = WindowsPath(Path(restoredPath))) + return BookmarkResolution( + file = PlatformFile(windowsPath = WindowsPath(Path(restoredPath))), + isStale = false, + shouldRefresh = false, + ) } @OptIn(ExperimentalForeignApi::class) diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt index d3cd317e..af0e7dd2 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt @@ -8,3 +8,16 @@ package io.github.vinceglb.filekit public class BookmarkData( public val bytes: ByteArray, ) + +/** + * The result of resolving persisted [BookmarkData]. + * + * @property file The file identified by the bookmark. + * @property isStale Whether the platform reported that the bookmark data is stale. + * @property shouldRefresh Whether FileKit recommends replacing the persisted bookmark data. + */ +public class BookmarkResolution( + public val file: PlatformFile, + public val isStale: Boolean, + public val shouldRefresh: Boolean, +) diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt new file mode 100644 index 00000000..1a377f89 --- /dev/null +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt @@ -0,0 +1,62 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure + +internal enum class MacOsBookmarkKind( + val encodedValue: Byte, +) { + Regular(1), + SecurityScoped(2), + ; + + companion object { + fun fromEncodedValue(value: Byte): MacOsBookmarkKind? = entries.firstOrNull { it.encodedValue == value } + } +} + +internal data class MacOsBookmarkEnvelope( + val kind: MacOsBookmarkKind, + val payload: ByteArray, +) { + fun encode(): ByteArray = MAGIC + byteArrayOf(VERSION, kind.encodedValue) + payload + + companion object { + private val MAGIC = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte()) + private const val VERSION: Byte = 1 + private const val HEADER_SIZE = 6 + + fun decodeOrNull(bytes: ByteArray): MacOsBookmarkEnvelope? { + if (bytes.size < MAGIC.size || !bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) { + return null + } + if (bytes.size < HEADER_SIZE) { + throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.INVALID_DATA, + message = "The FileKit bookmark envelope is incomplete", + ) + } + if (bytes[MAGIC.size] != VERSION) { + throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.UNSUPPORTED_VERSION, + message = "Unsupported FileKit bookmark version: ${bytes[MAGIC.size]}", + ) + } + val kind = MacOsBookmarkKind.fromEncodedValue(bytes[MAGIC.size + 1]) + ?: throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM, + message = "Unsupported FileKit bookmark payload: ${bytes[MAGIC.size + 1]}", + ) + if (bytes.size == HEADER_SIZE) { + throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.INVALID_DATA, + message = "The FileKit bookmark payload is empty", + ) + } + return MacOsBookmarkEnvelope( + kind = kind, + payload = bytes.copyOfRange(HEADER_SIZE, bytes.size), + ) + } + } +} diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt index a108e86e..c313c52a 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt @@ -259,6 +259,11 @@ public expect fun PlatformFile.releaseBookmark() */ public expect fun PlatformFile.Companion.fromBookmarkData(bookmarkData: BookmarkData): PlatformFile +/** + * Resolves [bookmarkData] and reports whether the persisted representation should be refreshed. + */ +public expect fun PlatformFile.Companion.resolveBookmarkData(bookmarkData: BookmarkData): BookmarkResolution + /** * Creates a [PlatformFile] from a byte array representing bookmark data. * @@ -267,3 +272,7 @@ public expect fun PlatformFile.Companion.fromBookmarkData(bookmarkData: Bookmark */ public fun PlatformFile.Companion.fromBookmarkData(bytes: ByteArray): PlatformFile = fromBookmarkData(BookmarkData(bytes)) + +/** Resolves bookmark data stored as a byte array. */ +public fun PlatformFile.Companion.resolveBookmarkData(bytes: ByteArray): BookmarkResolution = + resolveBookmarkData(BookmarkData(bytes)) diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt new file mode 100644 index 00000000..cac633dd --- /dev/null +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt @@ -0,0 +1,29 @@ +package io.github.vinceglb.filekit.exceptions + +/** The reason persisted bookmark data could not be resolved. */ +public enum class BookmarkResolutionFailure { + INVALID_DATA, + UNSUPPORTED_VERSION, + INCOMPATIBLE_PLATFORM, + RESOURCE_UNAVAILABLE, +} + +/** Thrown when persisted bookmark data cannot be resolved. */ +public class BookmarkResolutionException : FileKitException { + public val reason: BookmarkResolutionFailure + + public constructor( + reason: BookmarkResolutionFailure, + message: String, + ) : super(message) { + this.reason = reason + } + + public constructor( + reason: BookmarkResolutionFailure, + message: String, + cause: Throwable, + ) : super(message, cause) { + this.reason = reason + } +} diff --git a/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt b/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt new file mode 100644 index 00000000..c5cf75cd --- /dev/null +++ b/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt @@ -0,0 +1,12 @@ +package io.github.vinceglb.filekit + +internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration = + AppleBookmarkCreationConfiguration(options = 0u, kind = null) + +internal actual fun encodeAppleBookmarkPayload( + payload: ByteArray, + configuration: AppleBookmarkCreationConfiguration, +): ByteArray = payload + +internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload = + AppleBookmarkPayload(bytes = bytes, resolutionOptions = 0u, isLegacy = false) From 79aba87ddf387d858882d817e3eb08e632948696 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 20 Jul 2026 19:36:16 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9C=85=20Address=20bookmark=20persistenc?= =?UTF-8?q?e=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppleBookmarkConfiguration.apple.kt | 5 ++ .../vinceglb/filekit/PlatformFile.apple.kt | 27 +++---- .../filekit/AppleBookmarkConfiguration.ios.kt | 6 ++ .../vinceglb/filekit/MacOsBookmark.jvm.kt | 44 +++++++++-- .../vinceglb/filekit/PlatformFile.jvm.kt | 42 +++++----- .../vinceglb/filekit/PlatformFileJvmTest.kt | 78 +++++++++++++++---- .../AppleBookmarkConfiguration.macos.kt | 11 ++- .../filekit/PlatformFileMacOsBookmarkTest.kt | 17 ++++ .../github/vinceglb/filekit/BookmarkData.kt | 4 + .../filekit/MacOsBookmarkEnvelopeTest.kt | 22 ++++++ .../AppleBookmarkConfiguration.watchos.kt | 6 ++ .../shared/util/BookmarkStorage.nonWeb.kt | 14 +++- 12 files changed, 217 insertions(+), 59 deletions(-) create mode 100644 filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt index e9d67cbb..4b441250 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt @@ -1,5 +1,8 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure +import platform.Foundation.NSError + internal data class AppleBookmarkCreationConfiguration( val options: ULong, val kind: MacOsBookmarkKind?, @@ -19,3 +22,5 @@ internal expect fun encodeAppleBookmarkPayload( ): ByteArray internal expect fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload + +internal expect fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index c2d3f21b..8e32e128 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -1,7 +1,6 @@ package io.github.vinceglb.filekit import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException -import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.exceptions.FileKitException import io.github.vinceglb.filekit.mimeType.MimeType import io.github.vinceglb.filekit.utils.toByteArray @@ -88,11 +87,11 @@ public actual class PlatformFile internal constructor( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is PlatformFile) return false - if (nsUrl.path != other.nsUrl.path) return false + if (nsUrl != other.nsUrl) return false return true } - override fun hashCode(): Int = nsUrl.path.hashCode() + override fun hashCode(): Int = nsUrl.hashCode() public actual companion object } @@ -152,17 +151,17 @@ public actual fun PlatformFile.list(): List = } @OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) -public actual fun PlatformFile.createdAt(): Instant? { - val values = this.nsUrl.resourceValuesForKeys(listOf(NSURLCreationDateKey), null) +public actual fun PlatformFile.createdAt(): Instant? = withScopedAccess { + val values = nsUrl.resourceValuesForKeys(listOf(NSURLCreationDateKey), null) val date = values?.get(NSURLCreationDateKey) as? NSDate - return Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L) + Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L) } @OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) -public actual fun PlatformFile.lastModified(): Instant { - val values = this.nsUrl.resourceValuesForKeys(listOf(NSURLContentModificationDateKey), null) +public actual fun PlatformFile.lastModified(): Instant = withScopedAccess { + val values = nsUrl.resourceValuesForKeys(listOf(NSURLContentModificationDateKey), null) val date = values?.get(NSURLContentModificationDateKey) as? NSDate - return Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L) + Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L) } public actual fun PlatformFile.mimeType(): MimeType? = withScopedAccess { file -> @@ -305,10 +304,7 @@ public actual fun PlatformFile.Companion.resolveBookmarkData( relativeToURL = null, bookmarkDataIsStale = isStale.ptr, error = error, - ) ?: throw BookmarkResolutionException( - reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, - message = "Failed to resolve bookmark data: ${error.pointed.value}", - ) + ) ?: throw error.pointed.value.toBookmarkResolutionException() BookmarkResolution( file = PlatformFile(restoredUrl), @@ -316,3 +312,8 @@ public actual fun PlatformFile.Companion.resolveBookmarkData( shouldRefresh = isStale.value || payload.isLegacy, ) } + +private fun NSError?.toBookmarkResolutionException(): BookmarkResolutionException = BookmarkResolutionException( + reason = classifyAppleBookmarkResolutionError(this), + message = "Failed to resolve bookmark data: $this", +) diff --git a/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt b/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt index c5cf75cd..00d71cd8 100644 --- a/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt +++ b/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt @@ -1,5 +1,8 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure +import platform.Foundation.NSError + internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration = AppleBookmarkCreationConfiguration(options = 0u, kind = null) @@ -10,3 +13,6 @@ internal actual fun encodeAppleBookmarkPayload( internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload = AppleBookmarkPayload(bytes = bytes, resolutionOptions = 0u, isLegacy = false) + +internal actual fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure = + BookmarkResolutionFailure.RESOURCE_UNAVAILABLE diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt index dbfdbc33..835ea167 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt @@ -144,6 +144,7 @@ internal object MacOsBookmarks { ) try { val isStale = ByteByReference() + val error = PointerByReference() val url = CoreFoundationBookmarkApi.instance.CFURLCreateByResolvingBookmarkData( allocator = null, bookmark = bookmarkData, @@ -151,11 +152,8 @@ internal object MacOsBookmarks { relativeToUrl = null, resourcePropertiesToInclude = null, isStale = isStale, - error = null, - ) ?: throw BookmarkResolutionException( - reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, - message = "Could not resolve macOS bookmark data", - ) + error = error, + ) ?: throw error.toBookmarkResolutionException() var releaseUrl = true try { val path = CoreFoundationBookmarkApi.instance.CFURLCopyFileSystemPath(url, POSIX_PATH_STYLE) @@ -188,6 +186,34 @@ internal object MacOsBookmarks { } } +private fun PointerByReference.toBookmarkResolutionException(): BookmarkResolutionException { + val errorPointer = value ?: return BookmarkResolutionException( + reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE, + message = "Could not resolve macOS bookmark data", + ) + val error = CoreFoundation.CFTypeRef(errorPointer) + try { + val domain = CoreFoundationBookmarkApi.instance.CFErrorGetDomain(error)?.stringValue() + val code = CoreFoundationBookmarkApi.instance.CFErrorGetCode(error).toLong() + val description = CoreFoundationBookmarkApi.instance.CFErrorCopyDescription(error) + val message = try { + description?.stringValue() ?: "Could not resolve macOS bookmark data" + } finally { + description?.release() + } + return BookmarkResolutionException( + reason = if (domain == COCOA_ERROR_DOMAIN && code == NS_FILE_READ_CORRUPT_ERROR_CODE) { + BookmarkResolutionFailure.INVALID_DATA + } else { + BookmarkResolutionFailure.RESOURCE_UNAVAILABLE + }, + message = message, + ) + } finally { + error.release() + } +} + private val MacOsBookmarkKind.creationOptions: Long get() = when (this) { MacOsBookmarkKind.Regular -> 0 @@ -245,6 +271,12 @@ internal interface CoreFoundationBookmarkApi : Library { fun CFURLStopAccessingSecurityScopedResource(url: CFUrlRef) + fun CFErrorGetCode(error: CoreFoundation.CFTypeRef): CoreFoundation.CFIndex + + fun CFErrorGetDomain(error: CoreFoundation.CFTypeRef): CoreFoundation.CFStringRef? + + fun CFErrorCopyDescription(error: CoreFoundation.CFTypeRef): CoreFoundation.CFStringRef? + companion object { val instance: CoreFoundationBookmarkApi = Native.load("CoreFoundation", CoreFoundationBookmarkApi::class.java) } @@ -273,5 +305,3 @@ internal interface SecurityApi : Library { val instance: SecurityApi = Native.load("Security", SecurityApi::class.java) } } - -private const val APP_SANDBOX_ENTITLEMENT = "com.apple.security.app-sandbox" diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index 05374a4d..fa371f19 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -89,26 +89,26 @@ public actual fun PlatformFile.list(): List = } @OptIn(ExperimentalTime::class) -public actual fun PlatformFile.createdAt(): Instant? = runCatching { - val attributes = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) - val timestamp = attributes.creationTime().toMillis() - return Instant.fromEpochMilliseconds(timestamp) -}.getOrNull() +public actual fun PlatformFile.createdAt(): Instant? = withScopedAccess { + runCatching { + val attributes = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) + Instant.fromEpochMilliseconds(attributes.creationTime().toMillis()) + }.getOrNull() +} @OptIn(ExperimentalTime::class) -public actual fun PlatformFile.lastModified(): Instant { - val timestamp = this.file.lastModified() - return Instant.fromEpochMilliseconds(timestamp) +public actual fun PlatformFile.lastModified(): Instant = withScopedAccess { + Instant.fromEpochMilliseconds(file.lastModified()) } -public actual fun PlatformFile.mimeType(): MimeType? { +public actual fun PlatformFile.mimeType(): MimeType? = withScopedAccess { val mimeTypeValue = try { Files.probeContentType(file.toPath()) } catch (_: Exception) { null } - return mimeTypeValue?.let(MimeType::parse) + mimeTypeValue?.let(MimeType::parse) } public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = startMacOsBookmarkAccess() @@ -118,16 +118,18 @@ public actual fun PlatformFile.stopAccessingSecurityScopedResource() { } public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContext(Dispatchers.IO) { - if (Platform.isMac()) { - val kind = macOsBookmarkKindForCurrentProcess() - BookmarkData( - MacOsBookmarkEnvelope( - kind = kind, - payload = MacOsBookmarks.create(file, kind), - ).encode(), - ) - } else { - BookmarkData(file.path.encodeToByteArray()) + withScopedAccess { + if (Platform.isMac()) { + val kind = macOsBookmarkKindForCurrentProcess() + BookmarkData( + MacOsBookmarkEnvelope( + kind = kind, + payload = MacOsBookmarks.create(file, kind), + ).encode(), + ) + } else { + BookmarkData(file.path.encodeToByteArray()) + } } } diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt index 5b616203..7982d6d7 100644 --- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt +++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt @@ -9,6 +9,7 @@ import io.github.vinceglb.filekit.mimeType.MimeType import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path import java.io.File +import kotlin.coroutines.Continuation import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals @@ -95,19 +96,6 @@ class PlatformFileJvmTest { assertEquals(expected = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM, actual = error.reason) } - @Test - fun MacOsBookmarkEnvelope_encodeAndDecode_roundTripsPayload() { - val expected = MacOsBookmarkEnvelope( - kind = MacOsBookmarkKind.SecurityScoped, - payload = byteArrayOf(1, 2, 3), - ) - - val actual = requireNotNull(MacOsBookmarkEnvelope.decodeOrNull(expected.encode())) - - assertEquals(expected = expected.kind, actual = actual.kind) - assertTrue(expected.payload.contentEquals(actual.payload)) - } - @Test fun PlatformFile_resolveEmptyBookmarkEnvelope_throwsTypedFailure() { val emptyEnvelope = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 1, 1) @@ -128,6 +116,21 @@ class PlatformFileJvmTest { assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason) } + @Test + fun PlatformFile_resolveCorruptNativeBookmark_throwsInvalidDataFailure() { + if (!Platform.isMac()) return + val corruptBookmark = MacOsBookmarkEnvelope( + kind = MacOsBookmarkKind.Regular, + payload = byteArrayOf(1, 2, 3), + ).encode() + + val error = assertFailsWith { + PlatformFile.resolveBookmarkData(corruptBookmark) + } + + assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason) + } + @Test fun PlatformFile_copy_preservesJvmDataClassSurface() { val original = PlatformFile(textFile.file) @@ -138,6 +141,35 @@ class PlatformFileJvmTest { assertEquals(expected = original.file, actual = copied.component1()) } + @Test + fun PlatformFile_preservesCapturedJvmAbi() { + val platformFileClass = PlatformFile::class.java + platformFileClass.getConstructor(File::class.java) + platformFileClass.getMethod("getFile") + platformFileClass.getMethod("toString") + platformFileClass.getMethod("component1") + platformFileClass.getMethod("copy", File::class.java) + platformFileClass.getMethod( + "copy\$default", + platformFileClass, + File::class.java, + Int::class.javaPrimitiveType, + Any::class.java, + ) + platformFileClass.getMethod("hashCode") + platformFileClass.getMethod("equals", Any::class.java) + + val companionClass = Class.forName("io.github.vinceglb.filekit.PlatformFile\$Companion") + companionClass.getMethod("serializer") + + val facadeClass = Class.forName("io.github.vinceglb.filekit.PlatformFile_jvmKt") + facadeClass.getMethod("startAccessingSecurityScopedResource", platformFileClass) + facadeClass.getMethod("stopAccessingSecurityScopedResource", platformFileClass) + facadeClass.getMethod("bookmarkData", platformFileClass, Continuation::class.java) + facadeClass.getMethod("releaseBookmark", platformFileClass) + facadeClass.getMethod("fromBookmarkData", companionClass, BookmarkData::class.java) + } + @Test fun PlatformFile_copy_preservesCapabilityOnlyWithinRoot() { val root = createTempDirectory("filekit-bookmark-root").toFile() @@ -206,6 +238,26 @@ class PlatformFileJvmTest { } } + @Test + fun PlatformFile_metadataAndBookmarkOperations_balanceScopedAccess() = runTest { + val directory = createTempDirectory("filekit-bookmark-metadata").toFile() + try { + val nativeFile = File(directory, "content.txt").apply { writeText("hello") } + val access = RecordingBookmarkAccess(directory) + val platformFile = PlatformFile.withMacOsBookmarkAccess(nativeFile, access) + + platformFile.createdAt() + platformFile.lastModified() + platformFile.mimeType() + platformFile.bookmarkData() + + assertEquals(expected = 4, actual = access.startCount) + assertEquals(expected = 4, actual = access.stopCount) + } finally { + directory.deleteRecursively() + } + } + @Test fun MacOsBookmarkKind_usesInjectedSandboxEntitlement() { val originalReader = MacOsSandbox.entitlementReader diff --git a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt index eabf0dbd..7f8ab502 100644 --- a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt +++ b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt @@ -1,5 +1,6 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.reinterpret import platform.CoreFoundation.CFBooleanGetValue @@ -7,6 +8,7 @@ import platform.CoreFoundation.CFRelease import platform.CoreFoundation.CFStringCreateWithCString import platform.CoreFoundation.kCFAllocatorDefault import platform.CoreFoundation.kCFStringEncodingUTF8 +import platform.Foundation.NSError import platform.Foundation.NSURLBookmarkCreationWithSecurityScope import platform.Foundation.NSURLBookmarkResolutionWithSecurityScope import platform.Security.SecTaskCopyValueForEntitlement @@ -50,6 +52,13 @@ internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkP ) } +internal actual fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure = + if (error?.domain == COCOA_ERROR_DOMAIN && error.code == NS_FILE_READ_CORRUPT_ERROR_CODE) { + BookmarkResolutionFailure.INVALID_DATA + } else { + BookmarkResolutionFailure.RESOURCE_UNAVAILABLE + } + private val MacOsBookmarkKind?.resolutionOptions: ULong get() = when (this) { MacOsBookmarkKind.SecurityScoped -> NSURLBookmarkResolutionWithSecurityScope @@ -86,5 +95,3 @@ private fun readAppSandboxEntitlement(): Boolean { CFRelease(task) } } - -private const val APP_SANDBOX_ENTITLEMENT = "com.apple.security.app-sandbox" diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt index 7d04aad9..39741135 100644 --- a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt +++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt @@ -2,11 +2,14 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.utils.toByteArray import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse class PlatformFileMacOsBookmarkTest { @@ -73,4 +76,18 @@ class PlatformFileMacOsBookmarkTest { assertFalse(resolution.isStale) kotlin.test.assertTrue(resolution.shouldRefresh) } + + @Test + fun PlatformFile_resolveCorruptNativeBookmark_throwsInvalidDataFailure() { + val corruptBookmark = MacOsBookmarkEnvelope( + kind = MacOsBookmarkKind.Regular, + payload = byteArrayOf(1, 2, 3), + ).encode() + + val error = assertFailsWith { + PlatformFile.resolveBookmarkData(corruptBookmark) + } + + assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason) + } } diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt index af0e7dd2..eaa2251f 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt @@ -21,3 +21,7 @@ public class BookmarkResolution( public val isStale: Boolean, public val shouldRefresh: Boolean, ) + +internal const val APP_SANDBOX_ENTITLEMENT: String = "com.apple.security.app-sandbox" +internal const val COCOA_ERROR_DOMAIN: String = "NSCocoaErrorDomain" +internal const val NS_FILE_READ_CORRUPT_ERROR_CODE: Long = 259 diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt new file mode 100644 index 00000000..3a8708da --- /dev/null +++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt @@ -0,0 +1,22 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MacOsBookmarkEnvelopeTest { + @Test + fun MacOsBookmarkEnvelope_encodeAndDecode_roundTripsPayload() { + val expected = MacOsBookmarkEnvelope( + kind = MacOsBookmarkKind.SecurityScoped, + payload = byteArrayOf(1, 2, 3), + ) + + val actual = requireNotNull(MacOsBookmarkEnvelope.decodeOrNull(expected.encode())) + + assertEquals(expected = expected.kind, actual = actual.kind) + assertTrue(expected.payload.contentEquals(actual.payload)) + } +} diff --git a/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt b/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt index c5cf75cd..00d71cd8 100644 --- a/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt +++ b/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt @@ -1,5 +1,8 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure +import platform.Foundation.NSError + internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration = AppleBookmarkCreationConfiguration(options = 0u, kind = null) @@ -10,3 +13,6 @@ internal actual fun encodeAppleBookmarkPayload( internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload = AppleBookmarkPayload(bytes = bytes, resolutionOptions = 0u, isLegacy = false) + +internal actual fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure = + BookmarkResolutionFailure.RESOURCE_UNAVAILABLE diff --git a/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt b/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt index d625e13f..9c1b8900 100644 --- a/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt +++ b/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt @@ -8,9 +8,9 @@ import io.github.vinceglb.filekit.delete import io.github.vinceglb.filekit.div import io.github.vinceglb.filekit.exists import io.github.vinceglb.filekit.filesDir -import io.github.vinceglb.filekit.fromBookmarkData import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.releaseBookmark +import io.github.vinceglb.filekit.resolveBookmarkData import io.github.vinceglb.filekit.write internal actual class BookmarkStorage actual constructor() { @@ -28,7 +28,13 @@ internal actual class BookmarkStorage actual constructor() { return try { val bytes = bookmarkFile.readBytes() - PlatformFile.fromBookmarkData(bytes) + val resolution = PlatformFile.resolveBookmarkData(bytes) + if (resolution.shouldRefresh) { + runCatching { + bookmarkFile write resolution.file.bookmarkData().bytes + } + } + resolution.file } catch (_: Throwable) { bookmarkFile.delete(mustExist = false) null @@ -54,10 +60,10 @@ internal actual class BookmarkStorage actual constructor() { try { val bytes = bookmarkFile.readBytes() - val bookmarkedFile = PlatformFile.fromBookmarkData(bytes) + val bookmarkedFile = PlatformFile.resolveBookmarkData(bytes).file bookmarkedFile.releaseBookmark() } catch (_: Throwable) { - // Ignore failures from stale bookmarks. + // Ignore failures from invalid or unavailable bookmarks. } bookmarkFile.delete(mustExist = false) From 1ab039c6606a6ebde668efe0d41b85922251beff Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 20 Jul 2026 19:40:16 +0200 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=85=20Test=20stale=20macOS=20bookmark?= =?UTF-8?q?=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/PlatformFile.apple.kt | 40 +++++++++++++++---- .../filekit/PlatformFileMacOsBookmarkTest.kt | 23 ++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index 8e32e128..501ada38 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -292,24 +292,48 @@ public actual fun PlatformFile.Companion.fromBookmarkData( @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class) public actual fun PlatformFile.Companion.resolveBookmarkData( bookmarkData: BookmarkData, -): BookmarkResolution = memScoped { +): BookmarkResolution { val payload = decodeAppleBookmarkPayload(bookmarkData.bytes) - val nsData = payload.bytes.toNSData() + val nativeResolution = resolveAppleBookmark( + payload.bytes, + payload.resolutionOptions, + ) + return appleBookmarkResolution(payload, nativeResolution) +} + +internal fun appleBookmarkResolution( + payload: AppleBookmarkPayload, + nativeResolution: AppleBookmarkNativeResolution, +): BookmarkResolution = + BookmarkResolution( + file = PlatformFile(nativeResolution.url), + isStale = nativeResolution.isStale, + shouldRefresh = nativeResolution.isStale || payload.isLegacy, + ) + +internal data class AppleBookmarkNativeResolution( + val url: NSURL, + val isStale: Boolean, +) + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class) +private fun resolveAppleBookmark( + bytes: ByteArray, + resolutionOptions: ULong, +): AppleBookmarkNativeResolution = memScoped { val error: CPointer> = alloc>().ptr val isStale = alloc() - val restoredUrl = NSURL.URLByResolvingBookmarkData( - bookmarkData = nsData, - options = payload.resolutionOptions.convert(), + bookmarkData = bytes.toNSData(), + options = resolutionOptions.convert(), relativeToURL = null, bookmarkDataIsStale = isStale.ptr, error = error, ) ?: throw error.pointed.value.toBookmarkResolutionException() - BookmarkResolution( - file = PlatformFile(restoredUrl), + AppleBookmarkNativeResolution( + url = restoredUrl, isStale = isStale.value, - shouldRefresh = isStale.value || payload.isLegacy, ) } diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt index 39741135..e0611650 100644 --- a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt +++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt @@ -11,6 +11,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertTrue class PlatformFileMacOsBookmarkTest { @Test @@ -74,7 +75,27 @@ class PlatformFileMacOsBookmarkTest { assertEquals(expected = file.path, actual = resolution.file.path) assertFalse(resolution.isStale) - kotlin.test.assertTrue(resolution.shouldRefresh) + assertTrue(resolution.shouldRefresh) + } + + @Test + fun PlatformFile_resolveStaleBookmarkData_recommendsRefresh() { + val file = FileKit.projectDir / "src/nonWebTest/resources/hello.txt" + val resolution = appleBookmarkResolution( + payload = AppleBookmarkPayload( + bytes = byteArrayOf(1, 2, 3), + resolutionOptions = 0u, + isLegacy = false, + ), + nativeResolution = AppleBookmarkNativeResolution( + url = file.nsUrl, + isStale = true, + ), + ) + + assertEquals(expected = file.path, actual = resolution.file.path) + assertTrue(resolution.isStale) + assertTrue(resolution.shouldRefresh) } @Test From eda7dc5816e262144e10c136ba8f6c065ed83298 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 20 Jul 2026 20:24:51 +0200 Subject: [PATCH 6/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20bookmark=20?= =?UTF-8?q?compatibility=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...sue-590-macos-security-scoped-bookmarks.md | 2 +- .../vinceglb/filekit/PlatformFile.apple.kt | 4 +- .../vinceglb/filekit/MacOsBookmark.jvm.kt | 39 +++++++++---------- .../vinceglb/filekit/PlatformFile.jvm.kt | 2 +- .../vinceglb/filekit/PlatformFileJvmTest.kt | 34 +++++++++------- .../AppleBookmarkConfiguration.macos.kt | 12 ++---- .../filekit/PlatformFileMacOsBookmarkTest.kt | 37 ++++++++++-------- .../vinceglb/filekit/MacOsBookmarkEnvelope.kt | 4 +- .../filekit/MacOsBookmarkEnvelopeTest.kt | 8 ++++ 9 files changed, 75 insertions(+), 67 deletions(-) diff --git a/docs/plans/issue-590-macos-security-scoped-bookmarks.md b/docs/plans/issue-590-macos-security-scoped-bookmarks.md index df080643..97d8c3a2 100644 --- a/docs/plans/issue-590-macos-security-scoped-bookmarks.md +++ b/docs/plans/issue-590-macos-security-scoped-bookmarks.md @@ -111,7 +111,7 @@ Add automated coverage for: - Unknown versions and incompatible payload kinds. - Both JVM-path and Kotlin/Native Foundation legacy formats. - Current, stale, and legacy resolution metadata. -- Automatic sandbox-mode selection through an injectable entitlement reader. +- Automatic sandbox-mode selection through pure selectors fed by the platform entitlement readers. - Balanced and nested capability access. - Capability propagation within a directory and rejection outside it. - `RawSource` and `RawSink` lifetime behavior. diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index 501ada38..0575e581 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -87,11 +87,11 @@ public actual class PlatformFile internal constructor( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is PlatformFile) return false - if (nsUrl != other.nsUrl) return false + if (nsUrl.path != other.nsUrl.path) return false return true } - override fun hashCode(): Int = nsUrl.hashCode() + override fun hashCode(): Int = nsUrl.path.hashCode() public actual companion object } diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt index 835ea167..682815e1 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt @@ -60,41 +60,38 @@ private class NativeMacOsBookmarkAccess( } } -internal object MacOsSandbox { - internal var entitlementReader: () -> Boolean = ::readAppSandboxEntitlement - - fun isSandboxed(): Boolean = entitlementReader() - - private fun readAppSandboxEntitlement(): Boolean { - check(Platform.isMac()) - val task = SecurityApi.instance.SecTaskCreateFromSelf(null) ?: return false +private fun readAppSandboxEntitlement(): Boolean { + check(Platform.isMac()) + val task = SecurityApi.instance.SecTaskCreateFromSelf(null) ?: return false + try { + val entitlement = CoreFoundation.CFStringRef.createCFString(APP_SANDBOX_ENTITLEMENT) try { - val entitlement = CoreFoundation.CFStringRef.createCFString(APP_SANDBOX_ENTITLEMENT) + val value = SecurityApi.instance.SecTaskCopyValueForEntitlement(task, entitlement, null) ?: return false try { - val value = SecurityApi.instance.SecTaskCopyValueForEntitlement(task, entitlement, null) ?: return false - try { - if (!value.isTypeID(CoreFoundation.BOOLEAN_TYPE_ID)) return false - return CoreFoundation.INSTANCE.CFBooleanGetValue( - CoreFoundation.CFBooleanRef(value.pointer), - ) != 0.toByte() - } finally { - value.release() - } + if (!value.isTypeID(CoreFoundation.BOOLEAN_TYPE_ID)) return false + return CoreFoundation.INSTANCE.CFBooleanGetValue( + CoreFoundation.CFBooleanRef(value.pointer), + ) != 0.toByte() } finally { - entitlement.release() + value.release() } } finally { - task.release() + entitlement.release() } + } finally { + task.release() } } -internal fun macOsBookmarkKindForCurrentProcess(): MacOsBookmarkKind = if (MacOsSandbox.isSandboxed()) { +internal fun macOsBookmarkKind(isSandboxed: Boolean): MacOsBookmarkKind = if (isSandboxed) { MacOsBookmarkKind.SecurityScoped } else { MacOsBookmarkKind.Regular } +internal fun macOsBookmarkKindForCurrentProcess(): MacOsBookmarkKind = + macOsBookmarkKind(isSandboxed = readAppSandboxEntitlement()) + internal object MacOsBookmarks { fun create(file: File, kind: MacOsBookmarkKind): ByteArray { check(Platform.isMac()) diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index fa371f19..a371370c 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -170,6 +170,6 @@ public actual fun PlatformFile.Companion.resolveBookmarkData( return BookmarkResolution( file = PlatformFile(Path(path)), isStale = false, - shouldRefresh = true, + shouldRefresh = Platform.isMac(), ) } diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt index 7982d6d7..91414342 100644 --- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt +++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt @@ -49,7 +49,7 @@ class PlatformFileJvmTest { } @Test - fun PlatformFile_resolveLegacyBookmarkData_recommendsRefresh() { + fun PlatformFile_resolveLegacyBookmarkData_recommendsRefreshOnlyOnMacos() { val legacyPath = "src/nonWebTest/resources/hello.txt" val bookmarkData = BookmarkData(legacyPath.encodeToByteArray()) @@ -57,7 +57,7 @@ class PlatformFileJvmTest { assertEquals(expected = legacyPath, actual = resolution.file.path) assertFalse(resolution.isStale) - assertTrue(resolution.shouldRefresh) + assertEquals(expected = Platform.isMac(), actual = resolution.shouldRefresh) assertEquals(expected = resolution.file, actual = PlatformFile.fromBookmarkData(bookmarkData)) } @@ -76,7 +76,9 @@ class PlatformFileJvmTest { @Test fun PlatformFile_resolveUnknownBookmarkVersion_throwsTypedFailure() { - val unknownVersion = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 99, 1) + val unknownVersion = bookmarkEnvelopeHeader().apply { + this[lastIndex - 1] = 99 + } val error = assertFailsWith { PlatformFile.resolveBookmarkData(unknownVersion) @@ -87,7 +89,9 @@ class PlatformFileJvmTest { @Test fun PlatformFile_resolveUnknownBookmarkKind_throwsTypedFailure() { - val unknownKind = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 1, 99, 1) + val unknownKind = bookmarkEnvelopeHeader().apply { + this[lastIndex] = 99 + } val error = assertFailsWith { PlatformFile.resolveBookmarkData(unknownKind) @@ -98,7 +102,7 @@ class PlatformFileJvmTest { @Test fun PlatformFile_resolveEmptyBookmarkEnvelope_throwsTypedFailure() { - val emptyEnvelope = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte(), 1, 1) + val emptyEnvelope = bookmarkEnvelopeHeader() val error = assertFailsWith { PlatformFile.resolveBookmarkData(emptyEnvelope) @@ -259,17 +263,17 @@ class PlatformFileJvmTest { } @Test - fun MacOsBookmarkKind_usesInjectedSandboxEntitlement() { - val originalReader = MacOsSandbox.entitlementReader - try { - MacOsSandbox.entitlementReader = { true } - assertEquals(expected = MacOsBookmarkKind.SecurityScoped, actual = macOsBookmarkKindForCurrentProcess()) + fun MacOsBookmarkKind_selectsUsingSandboxEntitlement() { + assertEquals(expected = MacOsBookmarkKind.SecurityScoped, actual = macOsBookmarkKind(isSandboxed = true)) + assertEquals(expected = MacOsBookmarkKind.Regular, actual = macOsBookmarkKind(isSandboxed = false)) + } - MacOsSandbox.entitlementReader = { false } - assertEquals(expected = MacOsBookmarkKind.Regular, actual = macOsBookmarkKindForCurrentProcess()) - } finally { - MacOsSandbox.entitlementReader = originalReader - } + private fun bookmarkEnvelopeHeader(): ByteArray { + val envelope = MacOsBookmarkEnvelope( + kind = MacOsBookmarkKind.Regular, + payload = byteArrayOf(1), + ).encode() + return envelope.copyOf(envelope.size - 1) } private class RecordingBookmarkAccess( diff --git a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt index 7f8ab502..038534bc 100644 --- a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt +++ b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt @@ -16,7 +16,10 @@ import platform.Security.SecTaskCreateFromSelf @OptIn(ExperimentalForeignApi::class) internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration = - if (AppleBookmarkEnvironment.isAppSandboxEnabled()) { + appleBookmarkCreationConfiguration(isAppSandboxEnabled = readAppSandboxEntitlement()) + +internal fun appleBookmarkCreationConfiguration(isAppSandboxEnabled: Boolean): AppleBookmarkCreationConfiguration = + if (isAppSandboxEnabled) { AppleBookmarkCreationConfiguration( options = NSURLBookmarkCreationWithSecurityScope, kind = MacOsBookmarkKind.SecurityScoped, @@ -65,13 +68,6 @@ private val MacOsBookmarkKind?.resolutionOptions: ULong MacOsBookmarkKind.Regular, null -> 0u } -@OptIn(ExperimentalForeignApi::class) -internal object AppleBookmarkEnvironment { - internal var entitlementReader: () -> Boolean = ::readAppSandboxEntitlement - - fun isAppSandboxEnabled(): Boolean = entitlementReader() -} - @OptIn(ExperimentalForeignApi::class) private fun readAppSandboxEntitlement(): Boolean { val task = SecTaskCreateFromSelf(kCFAllocatorDefault) ?: return false diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt index e0611650..083916a4 100644 --- a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt +++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt @@ -7,6 +7,7 @@ import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import io.github.vinceglb.filekit.utils.toByteArray import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.test.runTest +import platform.Foundation.NSURL import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -15,23 +16,25 @@ import kotlin.test.assertTrue class PlatformFileMacOsBookmarkTest { @Test - fun AppleBookmarkConfiguration_usesInjectedSandboxEntitlement() { - val originalReader = AppleBookmarkEnvironment.entitlementReader - try { - AppleBookmarkEnvironment.entitlementReader = { true } - assertEquals( - expected = MacOsBookmarkKind.SecurityScoped, - actual = appleBookmarkCreationConfiguration().kind, - ) - - AppleBookmarkEnvironment.entitlementReader = { false } - assertEquals( - expected = MacOsBookmarkKind.Regular, - actual = appleBookmarkCreationConfiguration().kind, - ) - } finally { - AppleBookmarkEnvironment.entitlementReader = originalReader - } + fun AppleBookmarkConfiguration_selectsUsingSandboxEntitlement() { + assertEquals( + expected = MacOsBookmarkKind.SecurityScoped, + actual = appleBookmarkCreationConfiguration(isAppSandboxEnabled = true).kind, + ) + assertEquals( + expected = MacOsBookmarkKind.Regular, + actual = appleBookmarkCreationConfiguration(isAppSandboxEnabled = false).kind, + ) + } + + @Test + fun PlatformFile_equality_usesUrlPath() { + val first = PlatformFile(requireNotNull(NSURL(string = "file:///tmp/filekit-equality?version=1"))) + val second = PlatformFile(requireNotNull(NSURL(string = "file:///tmp/filekit-equality?version=2"))) + + assertEquals(expected = first.nsUrl.path, actual = second.nsUrl.path) + assertEquals(expected = first, actual = second) + assertEquals(expected = first.hashCode(), actual = second.hashCode()) } @Test diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt index 1a377f89..a22c0a4e 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt @@ -22,9 +22,9 @@ internal data class MacOsBookmarkEnvelope( fun encode(): ByteArray = MAGIC + byteArrayOf(VERSION, kind.encodedValue) + payload companion object { - private val MAGIC = byteArrayOf('F'.code.toByte(), 'K'.code.toByte(), 'B'.code.toByte(), 'K'.code.toByte()) + private val MAGIC = byteArrayOf(0) + "FileKitBookmark".encodeToByteArray() private const val VERSION: Byte = 1 - private const val HEADER_SIZE = 6 + private val HEADER_SIZE = MAGIC.size + 2 fun decodeOrNull(bytes: ByteArray): MacOsBookmarkEnvelope? { if (bytes.size < MAGIC.size || !bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) { diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt index 3a8708da..453f7943 100644 --- a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt +++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt @@ -4,6 +4,7 @@ package io.github.vinceglb.filekit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class MacOsBookmarkEnvelopeTest { @@ -19,4 +20,11 @@ class MacOsBookmarkEnvelopeTest { assertEquals(expected = expected.kind, actual = actual.kind) assertTrue(expected.payload.contentEquals(actual.payload)) } + + @Test + fun MacOsBookmarkEnvelope_decodeLegacyPathStartingWithOldMagic_returnsNull() { + val legacyPath = "FKBK-project/file.txt".encodeToByteArray() + + assertNull(MacOsBookmarkEnvelope.decodeOrNull(legacyPath)) + } } From f63c9ddf7da08a434679534bf18b68efbb0d55e7 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 27 Jul 2026 19:21:59 +0200 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=90=9B=20Harden=20macOS=20bookmark=20?= =?UTF-8?q?lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/core/bookmark-data.mdx | 2 + .../AppleBookmarkConfiguration.apple.kt | 1 + .../vinceglb/filekit/PlatformFile.apple.kt | 93 ++++++++++++------- .../vinceglb/filekit/MacOsBookmark.jvm.kt | 60 ++++++++++-- .../vinceglb/filekit/PlatformFile.jvm.kt | 4 +- .../vinceglb/filekit/PlatformFileJvmTest.kt | 49 ++++++++++ .../AppleBookmarkConfiguration.macos.kt | 31 +++++-- .../filekit/PlatformFileMacOsBookmarkTest.kt | 25 +++-- .../vinceglb/filekit/MacOsBookmarkEnvelope.kt | 9 +- .../filekit/MacOsBookmarkEnvelopeTest.kt | 14 +++ 10 files changed, 234 insertions(+), 54 deletions(-) diff --git a/docs/core/bookmark-data.mdx b/docs/core/bookmark-data.mdx index e3633d84..33c8234d 100644 --- a/docs/core/bookmark-data.mdx +++ b/docs/core/bookmark-data.mdx @@ -182,6 +182,8 @@ restoredFile.withScopedAccess { file -> } ``` +Call `releaseBookmark()` when the restored bookmark is no longer needed. It prevents new scoped operations while allowing already-open sources and sinks to close cleanly. + ## Handling Invalid and Legacy Bookmarks A bookmark is not a guarantee. It can become invalid if the original file is deleted, moved, or if permissions change. A stale bookmark is different: it resolved successfully but should be recreated. Legacy bookmark data is also different: FileKit resolved an older representation and recommends replacing it. diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt index 4b441250..a97ea15c 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt @@ -12,6 +12,7 @@ internal data class AppleBookmarkPayload( val bytes: ByteArray, val resolutionOptions: ULong, val isLegacy: Boolean, + val kind: MacOsBookmarkKind? = null, ) internal expect fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt index 0575e581..cc3e6505 100644 --- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt +++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt @@ -56,44 +56,58 @@ import kotlin.time.Instant * @property nsUrl The underlying [NSURL] object. */ @Serializable(with = PlatformFileSerializer::class) -public actual class PlatformFile internal constructor( +public actual data class PlatformFile( public val nsUrl: NSURL, - internal val securityScopeUrl: NSURL, - internal val securityScopeRootPath: String, ) { - public constructor(nsUrl: NSURL) : this( - nsUrl = nsUrl, - securityScopeUrl = nsUrl, - securityScopeRootPath = nsUrl.standardizedPath, - ) + internal var macOsBookmarkLease: MacOsBookmarkLease? = null public actual override fun toString(): String = path - public operator fun component1(): NSURL = nsUrl + public actual companion object { + internal fun withMacOsBookmarkLease(url: NSURL): PlatformFile = PlatformFile(url).also { + it.macOsBookmarkLease = MacOsBookmarkLease(url) + } + } +} - public fun copy(nsUrl: NSURL = this.nsUrl): PlatformFile { - val candidatePath = nsUrl.standardizedPath - return if (candidatePath.isWithin(securityScopeRootPath)) { - PlatformFile( - nsUrl = nsUrl, - securityScopeUrl = securityScopeUrl, - securityScopeRootPath = securityScopeRootPath, - ) - } else { - PlatformFile(nsUrl) +@OptIn(ExperimentalForeignApi::class) +internal class MacOsBookmarkLease( + url: NSURL, +) { + private val rootPath = url.standardizedPath + private var scopeUrl: NSURL? = url + private var activeAccesses = 0 + private var released = false + + fun covers(url: NSURL): Boolean = url.standardizedPath.isWithin(rootPath) + + fun start(): Boolean { + check(!released) { "This security-scoped bookmark has been released" } + val granted = requireNotNull(scopeUrl).startAccessingSecurityScopedResource() + if (granted) { + activeAccesses += 1 } + return granted } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is PlatformFile) return false - if (nsUrl.path != other.nsUrl.path) return false - return true + fun stop() { + if (activeAccesses == 0) return + requireNotNull(scopeUrl).stopAccessingSecurityScopedResource() + activeAccesses -= 1 + releaseNativeUrlIfDrained() } - override fun hashCode(): Int = nsUrl.path.hashCode() + fun release() { + if (released) return + released = true + releaseNativeUrlIfDrained() + } - public actual companion object + private fun releaseNativeUrlIfDrained() { + if (released && activeAccesses == 0) { + scopeUrl = null + } + } } public actual fun PlatformFile(path: Path): PlatformFile = @@ -107,7 +121,12 @@ public actual fun PlatformFile.toKotlinxIoPath(): Path = nsUrl.toKotlinxPath() @PublishedApi -internal actual fun PlatformFile.withPath(path: Path): PlatformFile = copy(PlatformFile(path).nsUrl) +internal actual fun PlatformFile.withPath(path: Path): PlatformFile = + PlatformFile(path).copyMacOsBookmarkLeaseFrom(this) + +private fun PlatformFile.copyMacOsBookmarkLeaseFrom(source: PlatformFile): PlatformFile = apply { + macOsBookmarkLease = source.macOsBookmarkLease?.takeIf { it.covers(nsUrl) } +} private fun String.isWithin(rootPath: String): Boolean { val root = rootPath.trimEnd('/') @@ -255,10 +274,10 @@ private fun cfStringToKString(cfString: CFStringRef?): String? { } public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = - securityScopeUrl.startAccessingSecurityScopedResource() + macOsBookmarkLease?.start() ?: nsUrl.startAccessingSecurityScopedResource() public actual fun PlatformFile.stopAccessingSecurityScopedResource(): Unit = - securityScopeUrl.stopAccessingSecurityScopedResource() + macOsBookmarkLease?.stop() ?: nsUrl.stopAccessingSecurityScopedResource() @OptIn(ExperimentalForeignApi::class, UnsafeNumber::class, BetaInteropApi::class) public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContext(Dispatchers.IO) { @@ -282,7 +301,9 @@ public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContex } } -public actual fun PlatformFile.releaseBookmark() {} +public actual fun PlatformFile.releaseBookmark() { + macOsBookmarkLease?.release() +} @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class) public actual fun PlatformFile.Companion.fromBookmarkData( @@ -304,12 +325,18 @@ public actual fun PlatformFile.Companion.resolveBookmarkData( internal fun appleBookmarkResolution( payload: AppleBookmarkPayload, nativeResolution: AppleBookmarkNativeResolution, -): BookmarkResolution = - BookmarkResolution( - file = PlatformFile(nativeResolution.url), +): BookmarkResolution { + val file = if (payload.kind == MacOsBookmarkKind.SecurityScoped) { + PlatformFile.withMacOsBookmarkLease(nativeResolution.url) + } else { + PlatformFile(nativeResolution.url) + } + return BookmarkResolution( + file = file, isStale = nativeResolution.isStale, shouldRefresh = nativeResolution.isStale || payload.isLegacy, ) +} internal data class AppleBookmarkNativeResolution( val url: NSURL, diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt index 682815e1..256e0353 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt @@ -27,6 +27,8 @@ internal interface MacOsBookmarkAccess { fun start(): Boolean fun stop() + + fun release() } private class NativeMacOsBookmarkAccess( @@ -34,17 +36,40 @@ private class NativeMacOsBookmarkAccess( private val url: CFUrlRef, ) : MacOsBookmarkAccess { private val rootPath = root.canonicalFile.toPath() + private var activeAccesses = 0 + private var released = false @Suppress("unused") private val cleanable = cleaner.register(this, NativeUrlReleaser(url)) override fun covers(file: File): Boolean = file.canonicalFile.toPath().startsWith(rootPath) - override fun start(): Boolean = - CoreFoundationBookmarkApi.instance.CFURLStartAccessingSecurityScopedResource(url) != 0.toByte() + override fun start(): Boolean = synchronized(this) { + check(!released) { "This security-scoped bookmark has been released" } + val granted = CoreFoundationBookmarkApi.instance.CFURLStartAccessingSecurityScopedResource(url) != 0.toByte() + if (granted) { + activeAccesses += 1 + } + granted + } - override fun stop() { + override fun stop() = synchronized(this) { + if (activeAccesses == 0) return CoreFoundationBookmarkApi.instance.CFURLStopAccessingSecurityScopedResource(url) + activeAccesses -= 1 + releaseNativeUrlIfDrained() + } + + override fun release() = synchronized(this) { + if (released) return + released = true + releaseNativeUrlIfDrained() + } + + private fun releaseNativeUrlIfDrained() { + if (released && activeAccesses == 0) { + cleanable.clean() + } } companion object { @@ -62,13 +87,22 @@ private class NativeMacOsBookmarkAccess( private fun readAppSandboxEntitlement(): Boolean { check(Platform.isMac()) - val task = SecurityApi.instance.SecTaskCreateFromSelf(null) ?: return false + val task = SecurityApi.instance.SecTaskCreateFromSelf(null) + ?: throw IllegalStateException("Could not inspect the App Sandbox entitlement") try { val entitlement = CoreFoundation.CFStringRef.createCFString(APP_SANDBOX_ENTITLEMENT) + ?: throw IllegalStateException("Could not create the App Sandbox entitlement name") try { - val value = SecurityApi.instance.SecTaskCopyValueForEntitlement(task, entitlement, null) ?: return false + val error = PointerByReference() + val value = SecurityApi.instance.SecTaskCopyValueForEntitlement(task, entitlement, error) + if (value == null) { + error.value?.let { throw CoreFoundation.CFTypeRef(it).toEntitlementException() } + return false + } try { - if (!value.isTypeID(CoreFoundation.BOOLEAN_TYPE_ID)) return false + if (!value.isTypeID(CoreFoundation.BOOLEAN_TYPE_ID)) { + throw IllegalStateException("The App Sandbox entitlement is not a Boolean") + } return CoreFoundation.INSTANCE.CFBooleanGetValue( CoreFoundation.CFBooleanRef(value.pointer), ) != 0.toByte() @@ -83,6 +117,20 @@ private fun readAppSandboxEntitlement(): Boolean { } } +private fun CoreFoundation.CFTypeRef.toEntitlementException(): IllegalStateException { + try { + val description = CoreFoundationBookmarkApi.instance.CFErrorCopyDescription(this) + val message = try { + description?.stringValue() ?: "Could not inspect the App Sandbox entitlement" + } finally { + description?.release() + } + return IllegalStateException(message) + } finally { + release() + } +} + internal fun macOsBookmarkKind(isSandboxed: Boolean): MacOsBookmarkKind = if (isSandboxed) { MacOsBookmarkKind.SecurityScoped } else { diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index a371370c..1cd656f0 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -133,7 +133,9 @@ public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContex } } -public actual fun PlatformFile.releaseBookmark() {} +public actual fun PlatformFile.releaseBookmark() { + macOsBookmarkAccess?.release() +} public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt index 91414342..accc62fd 100644 --- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt +++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt @@ -242,6 +242,33 @@ class PlatformFileJvmTest { } } + @Test + fun PlatformFile_releaseBookmark_waitsForOpenStreamsAndRejectsNewAccess() { + val directory = createTempDirectory("filekit-bookmark-release").toFile() + try { + val nativeFile = File(directory, "content.txt").apply { writeText("hello") } + val access = RecordingBookmarkAccess(directory) + val platformFile = PlatformFile.withMacOsBookmarkAccess(nativeFile, access) + + val source = platformFile.source() + platformFile.releaseBookmark() + + assertEquals(expected = 0, actual = access.releaseCount) + assertFailsWith { + platformFile.startAccessingSecurityScopedResource() + } + + source.close() + + assertEquals(expected = 1, actual = access.stopCount) + assertEquals(expected = 1, actual = access.releaseCount) + platformFile.releaseBookmark() + assertEquals(expected = 1, actual = access.releaseCount) + } finally { + directory.deleteRecursively() + } + } + @Test fun PlatformFile_metadataAndBookmarkOperations_balanceScopedAccess() = runTest { val directory = createTempDirectory("filekit-bookmark-metadata").toFile() @@ -287,15 +314,37 @@ class PlatformFileJvmTest { var stopCount: Int = 0 private set + var releaseCount: Int = 0 + private set + + private var released = false + private var activeAccesses = 0 + override fun covers(file: File): Boolean = file.canonicalFile.toPath().startsWith(rootPath) override fun start(): Boolean { + check(!released) { "This security-scoped bookmark has been released" } startCount += 1 + activeAccesses += 1 return true } override fun stop() { stopCount += 1 + activeAccesses -= 1 + releaseIfDrained() + } + + override fun release() { + if (released) return + released = true + releaseIfDrained() + } + + private fun releaseIfDrained() { + if (released && activeAccesses == 0) { + releaseCount += 1 + } } } } diff --git a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt index 038534bc..c0b61657 100644 --- a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt +++ b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt @@ -2,12 +2,19 @@ package io.github.vinceglb.filekit import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr import kotlinx.cinterop.reinterpret import platform.CoreFoundation.CFBooleanGetValue +import platform.CoreFoundation.CFBooleanGetTypeID +import platform.CoreFoundation.CFGetTypeID import platform.CoreFoundation.CFRelease import platform.CoreFoundation.CFStringCreateWithCString import platform.CoreFoundation.kCFAllocatorDefault import platform.CoreFoundation.kCFStringEncodingUTF8 +import platform.CoreFoundation.__CFError import platform.Foundation.NSError import platform.Foundation.NSURLBookmarkCreationWithSecurityScope import platform.Foundation.NSURLBookmarkResolutionWithSecurityScope @@ -52,6 +59,7 @@ internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkP bytes = envelope.payload, resolutionOptions = envelope.kind.resolutionOptions, isLegacy = false, + kind = envelope.kind, ) } @@ -69,19 +77,28 @@ private val MacOsBookmarkKind?.resolutionOptions: ULong } @OptIn(ExperimentalForeignApi::class) -private fun readAppSandboxEntitlement(): Boolean { - val task = SecTaskCreateFromSelf(kCFAllocatorDefault) ?: return false +private fun readAppSandboxEntitlement(): Boolean = memScoped { + val task = SecTaskCreateFromSelf(kCFAllocatorDefault) + ?: throw IllegalStateException("Could not inspect the App Sandbox entitlement") val entitlement = CFStringCreateWithCString( alloc = kCFAllocatorDefault, cStr = APP_SANDBOX_ENTITLEMENT, encoding = kCFStringEncodingUTF8, - ) ?: run { - CFRelease(task) - return false - } + ) ?: throw IllegalStateException("Could not create the App Sandbox entitlement name") return try { - val value = SecTaskCopyValueForEntitlement(task, entitlement, null) ?: return false + val error = alloc>() + val value = SecTaskCopyValueForEntitlement(task, entitlement, error.ptr) + if (value == null) { + error.value?.let { failure -> + CFRelease(failure.reinterpret()) + throw IllegalStateException("Could not inspect the App Sandbox entitlement") + } + return false + } try { + if (CFGetTypeID(value) != CFBooleanGetTypeID()) { + throw IllegalStateException("The App Sandbox entitlement is not a Boolean") + } CFBooleanGetValue(value.reinterpret()) } finally { CFRelease(value) diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt index 083916a4..ab7b64fb 100644 --- a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt +++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt @@ -12,6 +12,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class PlatformFileMacOsBookmarkTest { @@ -28,24 +30,35 @@ class PlatformFileMacOsBookmarkTest { } @Test - fun PlatformFile_equality_usesUrlPath() { + fun PlatformFile_equality_preservesUrlIdentity() { val first = PlatformFile(requireNotNull(NSURL(string = "file:///tmp/filekit-equality?version=1"))) val second = PlatformFile(requireNotNull(NSURL(string = "file:///tmp/filekit-equality?version=2"))) assertEquals(expected = first.nsUrl.path, actual = second.nsUrl.path) - assertEquals(expected = first, actual = second) - assertEquals(expected = first.hashCode(), actual = second.hashCode()) + assertNotEquals(illegal = first, actual = second) } @Test fun PlatformFile_derivedPaths_inheritScopeOnlyWithinRoot() { - val root = FileKit.projectDir.absoluteFile() + val root = PlatformFile.withMacOsBookmarkLease(FileKit.projectDir.absoluteFile().nsUrl) val child = root / "child" val escaped = root / "../outside" - assertEquals(expected = root.securityScopeUrl, actual = child.securityScopeUrl) - assertEquals(expected = escaped.nsUrl, actual = escaped.securityScopeUrl) + assertEquals(expected = root.macOsBookmarkLease, actual = child.macOsBookmarkLease) + assertNull(actual = escaped.macOsBookmarkLease) + } + + @Test + fun PlatformFile_releaseBookmark_rejectsNewScopedAccessForDerivedFiles() { + val root = PlatformFile.withMacOsBookmarkLease(FileKit.projectDir.absoluteFile().nsUrl) + val child = root / "child" + + root.releaseBookmark() + + assertFailsWith { + child.startAccessingSecurityScopedResource() + } } @Test diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt index a22c0a4e..ed48ca30 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt @@ -27,9 +27,16 @@ internal data class MacOsBookmarkEnvelope( private val HEADER_SIZE = MAGIC.size + 2 fun decodeOrNull(bytes: ByteArray): MacOsBookmarkEnvelope? { - if (bytes.size < MAGIC.size || !bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) { + val magicPrefixSize = minOf(bytes.size, MAGIC.size) + if (!bytes.copyOfRange(0, magicPrefixSize).contentEquals(MAGIC.copyOfRange(0, magicPrefixSize))) { return null } + if (bytes.size < MAGIC.size) { + throw BookmarkResolutionException( + reason = BookmarkResolutionFailure.INVALID_DATA, + message = "The FileKit bookmark envelope is incomplete", + ) + } if (bytes.size < HEADER_SIZE) { throw BookmarkResolutionException( reason = BookmarkResolutionFailure.INVALID_DATA, diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt index 453f7943..1c438bf8 100644 --- a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt +++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt @@ -2,8 +2,11 @@ package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException +import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue @@ -27,4 +30,15 @@ class MacOsBookmarkEnvelopeTest { assertNull(MacOsBookmarkEnvelope.decodeOrNull(legacyPath)) } + + @Test + fun MacOsBookmarkEnvelope_decodeTruncatedCurrentMagic_throwsInvalidData() { + val truncatedMagic = byteArrayOf(0) + "FileKit".encodeToByteArray() + + val error = assertFailsWith { + MacOsBookmarkEnvelope.decodeOrNull(truncatedMagic) + } + + assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason) + } } From 582a15164084da00b0e7aaab78697e2b5298155d Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 27 Jul 2026 19:28:10 +0200 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A9=B9=20Fix=20macOS=20entitlement=20?= =?UTF-8?q?error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/AppleBookmarkConfiguration.macos.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt index c0b61657..0ae34ac4 100644 --- a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt +++ b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt @@ -2,19 +2,20 @@ package io.github.vinceglb.filekit import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.CPointerVar import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped +import kotlinx.cinterop.pointed import kotlinx.cinterop.ptr import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.value import platform.CoreFoundation.CFBooleanGetValue import platform.CoreFoundation.CFBooleanGetTypeID +import platform.CoreFoundation.CFErrorRefVar import platform.CoreFoundation.CFGetTypeID import platform.CoreFoundation.CFRelease import platform.CoreFoundation.CFStringCreateWithCString import platform.CoreFoundation.kCFAllocatorDefault import platform.CoreFoundation.kCFStringEncodingUTF8 -import platform.CoreFoundation.__CFError import platform.Foundation.NSError import platform.Foundation.NSURLBookmarkCreationWithSecurityScope import platform.Foundation.NSURLBookmarkResolutionWithSecurityScope @@ -86,7 +87,7 @@ private fun readAppSandboxEntitlement(): Boolean = memScoped { encoding = kCFStringEncodingUTF8, ) ?: throw IllegalStateException("Could not create the App Sandbox entitlement name") return try { - val error = alloc>() + val error = alloc() val value = SecTaskCopyValueForEntitlement(task, entitlement, error.ptr) if (value == null) { error.value?.let { failure -> From d0fdbe8de34871b8b3a6daf696d59f94bca3a2bb Mon Sep 17 00:00:00 2001 From: vinceglb Date: Mon, 27 Jul 2026 19:30:14 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A9=B9=20Preserve=20bookmark=20releas?= =?UTF-8?q?e=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/MacOsBookmark.jvm.kt | 22 +++++++++++-------- .../vinceglb/filekit/PlatformFile.jvm.kt | 7 +++++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt index 256e0353..ab25fceb 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt @@ -53,17 +53,21 @@ private class NativeMacOsBookmarkAccess( granted } - override fun stop() = synchronized(this) { - if (activeAccesses == 0) return - CoreFoundationBookmarkApi.instance.CFURLStopAccessingSecurityScopedResource(url) - activeAccesses -= 1 - releaseNativeUrlIfDrained() + override fun stop() { + synchronized(this) { + if (activeAccesses == 0) return + CoreFoundationBookmarkApi.instance.CFURLStopAccessingSecurityScopedResource(url) + activeAccesses -= 1 + releaseNativeUrlIfDrained() + } } - override fun release() = synchronized(this) { - if (released) return - released = true - releaseNativeUrlIfDrained() + override fun release() { + synchronized(this) { + if (released) return + released = true + releaseNativeUrlIfDrained() + } } private fun releaseNativeUrlIfDrained() { diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt index 1cd656f0..d2938987 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt @@ -50,6 +50,11 @@ public actual class PlatformFile private constructor( macOsBookmarkAccess?.stop() } + @JvmSynthetic + internal fun releaseMacOsBookmarkAccess() { + macOsBookmarkAccess?.release() + } + public actual companion object { @JvmSynthetic internal fun withMacOsBookmarkAccess( @@ -134,7 +139,7 @@ public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContex } public actual fun PlatformFile.releaseBookmark() { - macOsBookmarkAccess?.release() + releaseMacOsBookmarkAccess() } public actual fun PlatformFile.Companion.fromBookmarkData(