···11+# Ladder
22+33+Swift PhotoKit export helper for iCloud Photos backup. Part of the photo-cloud system (companion: [attic](https://github.com/tijs/attic)).
44+55+## Commands
66+77+```bash
88+swift build -c release | xcsift # Build release binary
99+swift test | xcsift # Run tests (19 tests)
1010+swiftlint --fix # Auto-fix lint issues
1111+```
1212+1313+## Architecture
1414+1515+- **LadderKit** (library): `PhotoExporter`, `StreamingHasher`, `FileHasher`, model types, path safety
1616+- **CLI** (executable): Reads JSON request from stdin, calls PhotoExporter, writes JSON response to stdout
1717+1818+## Conventions
1919+2020+- Use `debugPrint()` instead of `print()` (stripped by compiler in release)
2121+- Dependencies are injected for testability — no real PhotoKit calls in tests
2222+- `StreamingHasher` uses `@unchecked Sendable` with `NSLock` — documented safety invariant in `Hasher.swift`. TODO: migrate to `Mutex<CC_SHA256_CTX>` when targeting macOS 15+
2323+- Pipe all xcodebuild/swift commands through `xcsift` for clean output
2424+- Files should stay under 500 lines
+114
README.md
···11+# ladder
22+33+A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
44+55+## What it does
66+77+ladder takes a JSON request on stdin (or from a file argument), exports the requested assets from the Photos library to a staging directory, computes a SHA-256 hash for each file during export, and writes a JSON response to stdout. It is designed to be called as a subprocess by [attic](../attic), the Deno/TypeScript component of the photo-cloud backup system.
88+99+## How it works
1010+1111+1. Reads a JSON `ExportRequest` from stdin (or from a file path passed as the first argument)
1212+2. Requests Photos library authorization via PhotoKit
1313+3. Fetches assets by their local identifiers (UUIDs)
1414+4. Exports each asset's original resource to the staging directory with bounded concurrency (default: 6)
1515+5. Computes SHA-256 inline while streaming data to disk (no second pass over the file)
1616+6. Writes a JSON `ExportResponse` to stdout
1717+1818+## Building
1919+2020+Requires macOS 13+ and Swift 5.9+.
2121+2222+```
2323+swift build -c release
2424+```
2525+2626+The binary is at `.build/release/ladder`.
2727+2828+## Usage
2929+3030+### Input (ExportRequest)
3131+3232+```json
3333+{
3434+ "uuids": [
3535+ "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001",
3636+ "3FA8GH5M-BMMH-H123-ABCD-1234567890AB/L0/001"
3737+ ],
3838+ "stagingDir": "/tmp/photo-export"
3939+}
4040+```
4141+4242+- `uuids` -- Photos library local identifiers to export
4343+- `stagingDir` -- absolute path where exported files will be written (must not be inside system directories like `/System`, `/Library`, `/usr`, etc.)
4444+4545+### Running
4646+4747+```bash
4848+# From stdin
4949+echo '{"uuids":["..."],"stagingDir":"/tmp/staging"}' | .build/release/ladder
5050+5151+# From a file
5252+.build/release/ladder request.json
5353+```
5454+5555+### Output (ExportResponse)
5656+5757+Written to stdout:
5858+5959+```json
6060+{
6161+ "errors": [
6262+ {
6363+ "message": "Asset not found in Photos library",
6464+ "uuid": "missing-uuid"
6565+ }
6666+ ],
6767+ "results": [
6868+ {
6969+ "path": "/tmp/photo-export/B84E8479-475C-4727_IMG_0001.HEIC",
7070+ "sha256": "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447",
7171+ "size": 3158112,
7272+ "uuid": "B84E8479-475C-4727-A7F4-B3D5E5D71923/L0/001"
7373+ }
7474+ ]
7575+}
7676+```
7777+7878+Each result includes the file path, size in bytes, and SHA-256 hash. Assets that could not be found or exported appear in the `errors` array.
7979+8080+## Required permissions
8181+8282+- **Photos access** -- ladder requests read/write authorization on launch. Grant access in System Settings > Privacy & Security > Photos.
8383+- **Full Disk Access** -- may be required depending on how the Photos library is stored. Grant in System Settings > Privacy & Security > Full Disk Access.
8484+8585+## Testing
8686+8787+```
8888+swift test
8989+```
9090+9191+Tests use protocol-based dependency injection (`PhotoLibrary` / `AssetHandle` protocols) with mock implementations, so they run without Photos library access.
9292+9393+## Project structure
9494+9595+```
9696+Sources/
9797+ CLI/
9898+ Main.swift -- entry point, stdin parsing, authorization
9999+ LadderKit/
100100+ Models.swift -- ExportRequest, ExportResponse, ExportResult, ExportError
101101+ PhotoExporter.swift -- concurrent export orchestration
102102+ PhotoLibrary.swift -- PhotoKit abstraction (protocol + real implementation)
103103+ Hasher.swift -- streaming SHA-256 (inline with export)
104104+ PathSafety.swift -- filename sanitization and path traversal prevention
105105+Tests/
106106+ ModelsTests.swift
107107+ PhotoExporterTests.swift
108108+ HasherTests.swift
109109+ PathSafetyTests.swift
110110+```
111111+112112+## How it fits with attic
113113+114114+In the photo-cloud system, **attic** (Deno/TypeScript) is the orchestrator that determines which photos need backing up and manages cloud storage. It spawns **ladder** as a subprocess to handle the macOS-specific part: accessing the Photos library via PhotoKit and exporting original files to a staging directory. attic sends a JSON request with the asset UUIDs, ladder exports them and reports back with file paths and hashes, and attic takes it from there.