Skip to content
37 changes: 37 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions docs/adr/0001-versioned-macos-bookmarks.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 54 additions & 5 deletions docs/core/bookmark-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -131,13 +149,44 @@ 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.

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
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
```

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)
}
```

- **JVM**: Stores the file's absolute path. This is usually sufficient for desktop apps which are not typically sandboxed.
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 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.

<Warning>
**Always handle restoration failures gracefully.** A bookmark can become invalid if:
Expand Down Expand Up @@ -171,4 +220,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.
139 changes: 139 additions & 0 deletions docs/plans/issue-590-macos-security-scoped-bookmarks.md
Original file line number Diff line number Diff line change
@@ -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 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.
- 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.
66 changes: 66 additions & 0 deletions docs/plans/issue-590-platform-file-jvm-abi.txt
Original file line number Diff line number Diff line change
@@ -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<io.github.vinceglb.filekit.PlatformFile> 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<? super io.github.vinceglb.filekit.BookmarkData>);
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;
}
Loading
Loading