feat: publish read-only iOS music export skill
This commit is contained in:
commit
1f1e0e604d
15 changed files with 2872 additions and 0 deletions
42
.codex-plugin/plugin.json
Normal file
42
.codex-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "rodgers-ios-music-export",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Read-only export of selected music from a paired USB iOS device to a Mac.",
|
||||||
|
"author": {
|
||||||
|
"name": "Rodger Werkhoven",
|
||||||
|
"url": "https://git.dimensionlab.net/Rodger"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.dimensionlab.net/Rodger/rodgers-ios-music-export",
|
||||||
|
"repository": "https://git.dimensionlab.net/Rodger/rodgers-ios-music-export.git",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"ios",
|
||||||
|
"iphone",
|
||||||
|
"ipad",
|
||||||
|
"music",
|
||||||
|
"export",
|
||||||
|
"recovery",
|
||||||
|
"macos"
|
||||||
|
],
|
||||||
|
"skills": "./skills/",
|
||||||
|
"interface": {
|
||||||
|
"displayName": "Rodger's iOS Music Export",
|
||||||
|
"shortDescription": "Export iOS music safely.",
|
||||||
|
"longDescription": "In local Codex on macOS, export locally stored tracks from a USB iOS device without Finder or Music sync. Use an existing pairing or stop before Apple's persistent Trust action when pairing is absent. Select an exact genre, preserve existing files, resume interrupted copies, and verify results with sizes and SHA-256 hashes. Proven on iPad mini 2 with iOS 12.5.8; other schemas fail closed.",
|
||||||
|
"developerName": "Rodger Werkhoven",
|
||||||
|
"category": "Productivity",
|
||||||
|
"capabilities": [
|
||||||
|
"Read-only iOS device inventory",
|
||||||
|
"Exact genre export",
|
||||||
|
"Resumable local copy",
|
||||||
|
"SHA-256 verification"
|
||||||
|
],
|
||||||
|
"brandColor": "#151515",
|
||||||
|
"logo": "./assets/logo.svg",
|
||||||
|
"composerIcon": "./assets/composer-icon.svg",
|
||||||
|
"websiteURL": "https://git.dimensionlab.net/Rodger/rodgers-ios-music-export",
|
||||||
|
"privacyPolicyURL": "https://git.dimensionlab.net/Rodger/rodgers-ios-music-export/src/branch/main/PRIVACY.md",
|
||||||
|
"termsOfServiceURL": "https://git.dimensionlab.net/Rodger/rodgers-ios-music-export/src/branch/main/TERMS.md",
|
||||||
|
"defaultPrompt": "Use $rodgers-ios-music-export to export matching music from a USB iOS device without syncing it."
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.partial
|
||||||
|
*.tsv
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Rodger Werkhoven
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
30
PRIVACY.md
Normal file
30
PRIVACY.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Effective date: 10 August 2026
|
||||||
|
|
||||||
|
Rodger's iOS Music Export is a local, skills-only Codex plugin. The developer operates no service for it. The plugin contains no analytics, advertising, developer account, telemetry endpoint, or developer-controlled server.
|
||||||
|
|
||||||
|
## Data handled locally
|
||||||
|
|
||||||
|
At the user's direction, the workflow may process:
|
||||||
|
|
||||||
|
- device name, product type, device class, iOS version and build, and battery fields when available;
|
||||||
|
- a one-way SHA-256 fingerprint derived from the device's usbmux identifier, used only to prevent an export manifest from being reused with another device;
|
||||||
|
- music titles, artists, albums, genre labels, source locations, filenames, sizes, modification signatures, and database snapshot hashes;
|
||||||
|
- locally stored audio bytes selected for export;
|
||||||
|
- destination paths, SHA-256 file hashes, progress, verification results, and a local append-only TSV audit manifest with per-event checksums;
|
||||||
|
- the Mac's existing pairing record in memory, including host and device certificate material required to establish Apple's local TLS session.
|
||||||
|
|
||||||
|
The raw device identifier, pairing record, certificates, and private key are never written to logs or the manifest. Python's TLS API requires certificate paths, so host credentials are written only inside a mode-0700 temporary directory as mode-0600 files, loaded locally, and immediately removed. TLS peer identity is pinned to the existing pair record.
|
||||||
|
|
||||||
|
## Purpose and recipients
|
||||||
|
|
||||||
|
The data is used only to identify the attached device, select exact matching locally stored tracks, copy them to a user-chosen Mac folder, support safe resume, and verify the local result. The script communicates only with Apple's local macOS `usbmuxd` service and local filesystem tools. It sends no data to the developer or another developer-controlled recipient.
|
||||||
|
|
||||||
|
Command output and files that an agent reads may become part of the user's Codex task context and are therefore also subject to OpenAI's applicable product and workspace terms. macOS, iOS, and separately installed tools such as `ffprobe` remain subject to their own terms.
|
||||||
|
|
||||||
|
## Retention and control
|
||||||
|
|
||||||
|
The plugin itself retains nothing remotely. Audio files, the TSV manifest, and its empty advisory `.lock` file remain on the user's Mac until the user moves or deletes them. Temporary database snapshots and certificate files are scoped to temporary directories and removed when the command exits normally; the operating system may clean remnants after an abnormal termination. Users control the destination and may delete local outputs with ordinary Finder or command-line tools after confirming the exact paths.
|
||||||
|
|
||||||
|
Privacy questions can be filed through the public repository's issue tracker listed in [SUPPORT.md](SUPPORT.md). Never attach pairing material, private media, or a raw device identifier to an issue.
|
||||||
34
README.md
Normal file
34
README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Rodger's iOS Music Export
|
||||||
|
|
||||||
|
A read-only skill and skills-only plugin for local Codex on macOS. It exports locally stored music from a USB-connected iOS device without Finder or Music sync. It uses an existing Apple pairing when one is present; if pairing is absent, it stops before the persistent Trust action and asks for explicit permission.
|
||||||
|
|
||||||
|
It was created from a proven recovery of 295 exact `Electro` tracks from an iPad mini 2 running iOS 12.5.8. That is the current proven baseline; other models and schemas fail closed unless validated. It performs exact case-insensitive genre matching, preserves collisions, resumes completed or hash-proven tracks, and verifies local copies with sizes and SHA-256 hashes. When `ffprobe` is installed it also checks audio streams and embedded genre tags.
|
||||||
|
|
||||||
|
## Install as a Codex plugin
|
||||||
|
|
||||||
|
Install it through the Codex plugin directory once OpenAI has approved the listing. Until then, clone the public source and link the included skill directory into the user skill scope:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.dimensionlab.net/Rodger/rodgers-ios-music-export.git \
|
||||||
|
"$HOME/plugins/rodgers-ios-music-export"
|
||||||
|
mkdir -p "$HOME/.agents/skills"
|
||||||
|
ln -s "$HOME/plugins/rodgers-ios-music-export/skills/rodgers-ios-music-export" \
|
||||||
|
"$HOME/.agents/skills/rodgers-ios-music-export"
|
||||||
|
```
|
||||||
|
|
||||||
|
Start a new Codex task after installation so discovery refreshes.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
The bundled script enforces read-only usbmuxd, lockdownd, and AFC allowlists and never pairs, unpairs, syncs, uploads, deletes, renames, or writes on the device. Local exports, append-only checksum-bound manifests, and empty manifest-lock sidecars are created only at disclosed user-authorized paths. Existing untrusted files, symlinked paths, changing databases, source mutations, and late collisions cause a hard stop.
|
||||||
|
|
||||||
|
Read the skill's [compatibility reference](skills/rodgers-ios-music-export/references/compatibility.md) before using it with a new iOS version.
|
||||||
|
|
||||||
|
## Support and policies
|
||||||
|
|
||||||
|
- [Privacy](PRIVACY.md)
|
||||||
|
- [Terms](TERMS.md)
|
||||||
|
- [Support](SUPPORT.md)
|
||||||
|
- [OpenAI directory submission notes](submission/openai-directory-listing.md)
|
||||||
|
|
||||||
|
Licensed under the [MIT License](LICENSE).
|
||||||
9
SUPPORT.md
Normal file
9
SUPPORT.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Support
|
||||||
|
|
||||||
|
Report reproducible bugs and compatibility findings in the public Forgejo repository:
|
||||||
|
|
||||||
|
<https://git.dimensionlab.net/Rodger/rodgers-ios-music-export/issues>
|
||||||
|
|
||||||
|
Include the Mac model, macOS version, iOS device model, iOS version and build, command name, and sanitized error text. Never attach a pairing record, UDID, host certificate, private key, exported music file, or other credential/private media.
|
||||||
|
|
||||||
|
For a new MediaLibrary schema, include only the relevant table and column names unless the data owner explicitly authorizes more.
|
||||||
9
TERMS.md
Normal file
9
TERMS.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Terms of Use
|
||||||
|
|
||||||
|
Effective date: 10 August 2026
|
||||||
|
|
||||||
|
Rodger's iOS Music Export is provided under the MIT License and without warranty. It is an independent utility and is not affiliated with or endorsed by Apple or OpenAI.
|
||||||
|
|
||||||
|
Users must have authorization to access the connected device and must comply with copyright, license, subscription, and other legal restrictions that apply to the media they export. The plugin does not remove digital rights management, download cloud-only media, bypass device trust, or grant rights to copied content.
|
||||||
|
|
||||||
|
The workflow is designed to keep the iOS source read-only, but users remain responsible for maintaining backups, reviewing the proposed destination, and confirming that the environment and device are appropriate before running an export.
|
||||||
7
assets/composer-icon.svg
Normal file
7
assets/composer-icon.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||||
|
<title id="title">iOS music export</title>
|
||||||
|
<rect x="9" y="5" width="34" height="54" rx="6" fill="none" stroke="#151515" stroke-width="4"/>
|
||||||
|
<circle cx="26" cy="53" r="2.5" fill="#151515"/>
|
||||||
|
<path d="M25 39c-5 0-9 3-9 7s4 7 9 7 9-3 9-7V25l14-3v12c-2-1-4-2-6-2-5 0-9 3-9 7s4 7 9 7 9-3 9-7V16l-26 6v17z" fill="#151515"/>
|
||||||
|
<path d="M42 52h17m0 0-7-7m7 7-7 7" fill="none" stroke="#151515" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
9
assets/logo.svg
Normal file
9
assets/logo.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Rodger's iOS Music Export</title>
|
||||||
|
<desc id="desc">A tablet, music note, and outward transfer arrow.</desc>
|
||||||
|
<rect width="512" height="512" rx="112" fill="#151515"/>
|
||||||
|
<rect x="104" y="64" width="244" height="384" rx="42" fill="none" stroke="#F6F2E8" stroke-width="24"/>
|
||||||
|
<circle cx="226" cy="411" r="12" fill="#F6F2E8"/>
|
||||||
|
<path d="M191 289c-31 0-56 18-56 42s25 42 56 42 56-18 56-42V190l86-20v84c-9-5-20-8-33-8-31 0-56 18-56 42s25 42 56 42 56-18 56-42V119l-165 39v131z" fill="#61D6C6"/>
|
||||||
|
<path d="M330 365h82m0 0-34-34m34 34-34 34" fill="none" stroke="#FFB85C" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 763 B |
110
skills/rodgers-ios-music-export/SKILL.md
Normal file
110
skills/rodgers-ios-music-export/SKILL.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
---
|
||||||
|
name: rodgers-ios-music-export
|
||||||
|
description: Safely export music from a USB-connected iOS device in a local Codex session on macOS without Finder or Music sync, when an existing pairing is present or pairing status is unknown. Use for reverse-transfer, recovery, exact genre filtering such as Electro versus Electronic, local destination-folder creation, resumable copies, or integrity verification. Proven on iPad mini 2 with iOS 12.5.8; unknown schemas fail closed.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rodger's iOS Music Export
|
||||||
|
|
||||||
|
Export selected, locally stored tracks from one paired iOS device while keeping the device read-only. Use the bundled standard-library Python client instead of Finder or Music sync.
|
||||||
|
|
||||||
|
## Safety contract
|
||||||
|
|
||||||
|
- Treat the iOS device as read-only. Never invoke Pair, Unpair, sync, upload, delete, rename, database mutation, or AFC write operations.
|
||||||
|
- Do not click Finder's or Music's `Sync` button. Sync can reconcile the device against the Mac library and remove device content.
|
||||||
|
- Require exactly one USB-connected iOS device and an existing valid macOS pairing. If pairing is absent, stop and explain that approving Apple's visible Trust prompt creates persistent pairing state on the Mac and device. Obtain explicit permission before that visible action; never create pairing records programmatically.
|
||||||
|
- Write only to the destination and manifest paths the user authorized. Preserve unexpected or untrusted existing files and stop on collisions.
|
||||||
|
- Interpret a requested genre as an exact, case-insensitive database match. `Electro` matches `electro`; it does not match `Electronic`, `Elektronisch`, or `Electro House`.
|
||||||
|
- Verify count, byte total, SHA-256 hashes, and—when `ffprobe` is present—audio streams and embedded genres before claiming completion.
|
||||||
|
- Never expose the device UDID, pairing record, host certificate, private key, or other credentials in output.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Establish live state
|
||||||
|
|
||||||
|
Confirm that this is local Codex on macOS with direct USB access, the intended device is attached, and no Finder/Music sync is running. Proceed when the device is charging; when it is not charging and below 30%, pause and ask the user to connect power. Inspect Finder or Music visibly for battery and sync state when the read-only probe reports that those values are unavailable. Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/ios_music_export.py probe
|
||||||
|
```
|
||||||
|
|
||||||
|
The probe returns only the device name, model identifiers, iOS version, build, and class. If local sandboxing denies access to `/var/run/usbmuxd`, rerun the same bounded command with the user's normal Codex approval mechanism.
|
||||||
|
|
||||||
|
### 2. Resolve the exact genre label
|
||||||
|
|
||||||
|
If the requested label is uncertain or adjacent labels could be confused, inventory the device's exact labels and counts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/ios_music_export.py genres
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not silently broaden the user's genre. Ask only if no exact label exists or their wording genuinely permits multiple labels.
|
||||||
|
|
||||||
|
### 3. Build a read-only plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/ios_music_export.py plan \
|
||||||
|
--genre "Electro" \
|
||||||
|
--destination "/Users/name/Downloads/RODGER's MUSIX"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--show-files` when filenames or metadata need review. The plan takes two byte-identical database snapshots, runs SQLite integrity checks, consolidates them locally, validates the schema, stats every selected source file, and reports total exact matches, transferable and excluded counts, bytes, free space, and headroom. Stop if snapshots differ repeatedly, the schema is unsupported, a source is missing, or database and device signatures disagree.
|
||||||
|
|
||||||
|
Before copying, state the exact genre, destination, track count, byte total, available space, and any known limitations. A direct user request to export is sufficient authorization for destination creation and local copies; do not add an unnecessary confirmation pause.
|
||||||
|
|
||||||
|
### 4. Export and verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/ios_music_export.py export \
|
||||||
|
--genre "Electro" \
|
||||||
|
--destination "/Users/name/Downloads/RODGER's MUSIX"
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the audit manifest is a hidden TSV beside—not inside—the music folder. A persistent empty `<manifest>.lock` sidecar serializes exports to that manifest and contains no device or music data. The exporter:
|
||||||
|
|
||||||
|
1. obtains two byte-identical database snapshots and consolidates a checked local copy;
|
||||||
|
2. selects only the exact genre;
|
||||||
|
3. validates source files and free space;
|
||||||
|
4. creates conflict-safe `Artist — Title.ext` names;
|
||||||
|
5. creates each final track with a no-clobber open, so a late local collision is preserved;
|
||||||
|
6. records source signatures, state transitions, event checksums, and SHA-256 hashes in a locked append-only manifest;
|
||||||
|
7. verifies the complete destination.
|
||||||
|
|
||||||
|
Use `--manifest <path>` only when the user needs a specific audit location. Use `--require-embedded-genre` only when `ffprobe` is available and the user explicitly requires every file's embedded tag to equal the database genre; iOS database metadata remains the selection authority.
|
||||||
|
|
||||||
|
### 5. Resume safely
|
||||||
|
|
||||||
|
Repeat the identical export command after an interruption. Existing files are accepted only when the latest checksum-bound manifest event binds them to a pseudonymous device fingerprint, database snapshot, source signature, size, and nonempty hash. Completed tracks and a hash-proven final file in `ready_to_commit` state resume safely. A `pending` event never authorizes a final file. If interruption leaves a final file while its latest event is still `pending`, preserve it and stop; ask the user to choose a new destination or explicitly decide how to handle that exact file. Never delete, replace, or rename it implicitly.
|
||||||
|
|
||||||
|
If a process interruption tears only the final manifest event, recover under the manifest lock by truncating that incomplete tail only after every preceding event and checksum validates. Never repair, replace, or append to a manifest whose complete events belong to another export plan.
|
||||||
|
|
||||||
|
### 6. Verify an existing export offline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <skill-dir>/scripts/ios_music_export.py verify \
|
||||||
|
--genre "Electro" \
|
||||||
|
--destination "/Users/name/Downloads/RODGER's MUSIX" \
|
||||||
|
--manifest "/Users/name/Downloads/.RODGER's MUSIX.rodgers-ios-music-export.tsv"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ffprobe` is unavailable, hashes, counts, and sizes still run and the result explicitly marks the audio probe as skipped. Do not install dependencies unless the user approves installation.
|
||||||
|
|
||||||
|
## Completion report
|
||||||
|
|
||||||
|
Lead with the verified result and include:
|
||||||
|
|
||||||
|
- device name and iOS version, without its UDID;
|
||||||
|
- exact genre predicate used;
|
||||||
|
- destination and manifest paths;
|
||||||
|
- manifest lock path;
|
||||||
|
- total exact matches, transferable and excluded counts, total bytes, and extension counts;
|
||||||
|
- copied versus resumed counts;
|
||||||
|
- hash/size result and audio-probe status;
|
||||||
|
- embedded genre counts when probed;
|
||||||
|
- explicit confirmation that no sync or media, library, or AFC write occurred; if Apple Trust was used, disclose the pairing-state change separately;
|
||||||
|
- any DRM, cloud-only, unsupported-schema, pairing, or verification limitation.
|
||||||
|
|
||||||
|
Do not claim success from a plan, progress counter, or copy completion alone. Success requires the final local verification result.
|
||||||
|
|
||||||
|
## Troubleshooting and compatibility
|
||||||
|
|
||||||
|
Read [references/compatibility.md](references/compatibility.md) for the proven device matrix, protocol boundaries, common errors, and limitations before changing the script or working around a failure.
|
||||||
7
skills/rodgers-ios-music-export/agents/openai.yaml
Normal file
7
skills/rodgers-ios-music-export/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
interface:
|
||||||
|
display_name: "Rodger's iOS Music Export"
|
||||||
|
short_description: "Export iOS music safely on a Mac"
|
||||||
|
brand_color: "#151515"
|
||||||
|
default_prompt: "Use $rodgers-ios-music-export to export matching music from a USB iOS device without syncing it."
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
100
skills/rodgers-ios-music-export/references/compatibility.md
Normal file
100
skills/rodgers-ios-music-export/references/compatibility.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Compatibility and safety reference
|
||||||
|
|
||||||
|
## Proven baseline
|
||||||
|
|
||||||
|
The workflow was end-to-end proven on:
|
||||||
|
|
||||||
|
- macOS on Apple silicon with Apple's MobileDevice framework and system `usbmuxd`;
|
||||||
|
- an already paired iPad mini 2 (`iPad4,5` / `J86AP`);
|
||||||
|
- iOS 12.5.8 build 16H88;
|
||||||
|
- the on-device `MediaLibrary.sqlitedb` schema checked by the bundled script;
|
||||||
|
- 295 exact case-insensitive `Electro` matches, totaling 2,173,003,367 bytes;
|
||||||
|
- 262 `.m4a` and 33 `.mp3` files, all size-checked, SHA-256 hashed, audio-probed, and found with exact embedded `Electro` genre tags.
|
||||||
|
|
||||||
|
This baseline proves the method, not universal compatibility. Later iOS releases may hide the classic music database, alter its schema, restrict AFC access, or represent cloud media differently. The script validates known tables and columns and fails closed instead of guessing.
|
||||||
|
|
||||||
|
## Read-only protocol boundary
|
||||||
|
|
||||||
|
The live-device client uses only:
|
||||||
|
|
||||||
|
- usbmuxd `ListDevices`, `ReadPairRecord`, and `Connect`;
|
||||||
|
- lockdownd `StartSession`, `GetValue`, `StartService`, and `StopSession`;
|
||||||
|
- the existing pair record held in memory;
|
||||||
|
- AFC `READ_DIR`, `GET_FILE_INFO`, `FILE_OPEN` with `AFC_FOPEN_RDONLY`, `FILE_READ`, and `FILE_CLOSE`.
|
||||||
|
|
||||||
|
No AFC write opcode is defined or called, and each generic protocol dispatcher enforces a read-only allowlist. Lockdown TLS excludes anonymous and null ciphers and pins the peer certificate to the existing pair record. The host certificate and private key are materialized only inside a mode-0700 temporary directory as mode-0600 files because Python's TLS API requires paths; they are loaded locally and the directory is immediately removed.
|
||||||
|
|
||||||
|
## Database snapshot
|
||||||
|
|
||||||
|
The database lives at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/iTunes_Control/iTunes/MediaLibrary.sqlitedb
|
||||||
|
```
|
||||||
|
|
||||||
|
For every attempt, the script captures the main database and `-wal` sidecar twice. Each capture must remain metadata-stable while read, and both complete captures must have identical SHA-256 hashes. SQLite rebuilds local shared-memory state; `PRAGMA quick_check` must pass before and after the stable set is consolidated with SQLite's backup API into one local database. Repeated differences produce a hard failure. Stop playback, leave device-side music activity idle, and retry; never modify or checkpoint the device database.
|
||||||
|
|
||||||
|
## Selection semantics
|
||||||
|
|
||||||
|
The database query uses equality with SQLite `COLLATE NOCASE`. It intentionally does not use substring matching, prefix matching, fuzzy matching, language translation, or a genre family. The plan reports total exact matches, transferable rows, and rows excluded because no local file is present.
|
||||||
|
|
||||||
|
Database-controlled source paths must be relative, contain no `.` or `..` components, and resolve below `/iTunes_Control/Music`, `/Purchases`, or `/CloudAssets`. Every source must report an explicit regular-file type. The manifest binds each row to a one-way SHA-256 device fingerprint, consolidated database hash, source size, modification time, block signature, and the bytes received during download.
|
||||||
|
|
||||||
|
The local filename is normalized to NFC, removes control characters, replaces `/` and `:`, stays under the macOS filename byte limit, and adds deterministic `[2]`, `[3]`, and later suffixes for collisions.
|
||||||
|
|
||||||
|
## Expected failures
|
||||||
|
|
||||||
|
### Permission denied for `/var/run/usbmuxd`
|
||||||
|
|
||||||
|
The command is inside a sandbox that blocks the system socket. Rerun only the same bounded command through Codex's approval path. This is not evidence that the device is absent.
|
||||||
|
|
||||||
|
### No existing pair record
|
||||||
|
|
||||||
|
Stop before taking action. Explain that unlocking the device and approving Apple's visible Trust prompt creates persistent pairing state on both the Mac and device, then obtain the user's explicit permission. Do not create or alter pairing records with the script. If the user approves and completes the visible Trust flow, report that pairing state changed; do not claim that the device remained entirely unchanged.
|
||||||
|
|
||||||
|
### Exactly one USB device expected
|
||||||
|
|
||||||
|
Disconnect unintended iOS devices, then retry. The skill deliberately avoids guessing among multiple devices.
|
||||||
|
|
||||||
|
### Database changes during every snapshot
|
||||||
|
|
||||||
|
Stop playback and device-side library changes. Keep the iOS Music app idle and rerun. Do not sync.
|
||||||
|
|
||||||
|
### Unsupported MediaLibrary schema
|
||||||
|
|
||||||
|
Stop. Collect only non-sensitive table and column names for a compatibility update; do not weaken the schema guard or guess column meanings.
|
||||||
|
|
||||||
|
### Stale database size or missing selected source
|
||||||
|
|
||||||
|
The device library metadata and storage no longer agree. Stop and retry after the device is idle. Never substitute another file merely because its name looks related.
|
||||||
|
|
||||||
|
### Untrusted existing destination file
|
||||||
|
|
||||||
|
Preserve it. Use a new destination or ask the user how to resolve the collision. Do not overwrite, move, or delete it implicitly.
|
||||||
|
|
||||||
|
### Manifest lock is held
|
||||||
|
|
||||||
|
Another exporter is using the same manifest. Wait for it to finish and retry. The persistent empty `.lock` file carries no device or music data; its advisory lock exists only while an export process is active.
|
||||||
|
|
||||||
|
### Truncated final manifest event
|
||||||
|
|
||||||
|
The TSV manifest is an append-only event log with a SHA-256 checksum on every event. During export resume, a trailing event without a complete newline is truncated only while the manifest lock is held and only after the entire preceding event prefix validates. A malformed header, failed checksum on a complete event, or event bound to another export plan is preserved and causes a hard stop.
|
||||||
|
|
||||||
|
## Media limitations
|
||||||
|
|
||||||
|
- DRM-protected subscription media may be encrypted or unusable off-device.
|
||||||
|
- Cloud-only tracks without a local media file are excluded.
|
||||||
|
- The skill does not remove DRM, download cloud media, authenticate Apple accounts, import into Music, or sync content back to a device.
|
||||||
|
- `ffprobe` is optional. Without it, the script still proves file count, size, and SHA-256 equality between the received stream and the verified local file, but cannot prove the presence of an audio stream or inspect embedded genre tags.
|
||||||
|
- A SHA-256 manifest proves local transfer and persistence integrity. It does not decrypt DRM, prove legal ownership, or assert that a cloud service holds the same bytes.
|
||||||
|
|
||||||
|
## Maintenance gate
|
||||||
|
|
||||||
|
For a new iOS schema or protocol behavior:
|
||||||
|
|
||||||
|
1. preserve the existing proven path;
|
||||||
|
2. add a synthetic regression fixture for the new schema;
|
||||||
|
3. make the narrowest compatibility change;
|
||||||
|
4. validate on a noncritical paired device with read-only commands first;
|
||||||
|
5. prove a small export and its hashes before a large export;
|
||||||
|
6. update this compatibility matrix with the exact device and iOS build.
|
||||||
1831
skills/rodgers-ios-music-export/scripts/ios_music_export.py
Normal file
1831
skills/rodgers-ios-music-export/scripts/ios_music_export.py
Normal file
File diff suppressed because it is too large
Load diff
66
submission/openai-directory-listing.md
Normal file
66
submission/openai-directory-listing.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# OpenAI plugin directory submission dossier
|
||||||
|
|
||||||
|
## Listing copy
|
||||||
|
|
||||||
|
- **Name:** Rodger's iOS Music Export
|
||||||
|
- **Slug:** `rodgers-ios-music-export`
|
||||||
|
- **Developer:** Rodger Werkhoven
|
||||||
|
- **Category:** Productivity
|
||||||
|
- **Short description:** Export iOS music safely.
|
||||||
|
- **Long description:** In local Codex on macOS, export locally stored tracks from a USB iOS device without Finder or Music sync. Use an existing pairing or stop before Apple's persistent Trust action when pairing is absent. Select an exact genre, preserve existing files, resume interrupted copies, and verify results with sizes and SHA-256 hashes. Proven on iPad mini 2 with iOS 12.5.8; other schemas fail closed.
|
||||||
|
- **Availability requested:** Global, subject to OpenAI review and local macOS/USB capability.
|
||||||
|
- **Repository:** <https://git.dimensionlab.net/Rodger/rodgers-ios-music-export>
|
||||||
|
- **Privacy:** <https://git.dimensionlab.net/Rodger/rodgers-ios-music-export/src/branch/main/PRIVACY.md>
|
||||||
|
- **Terms:** <https://git.dimensionlab.net/Rodger/rodgers-ios-music-export/src/branch/main/TERMS.md>
|
||||||
|
- **Support:** <https://git.dimensionlab.net/Rodger/rodgers-ios-music-export/src/branch/main/SUPPORT.md>
|
||||||
|
- **License:** MIT
|
||||||
|
- **Logo:** `assets/logo.svg`
|
||||||
|
- **Composer icon:** `assets/composer-icon.svg`
|
||||||
|
- **Screenshots:** None; this is a skills-only plugin with no plugin UI.
|
||||||
|
|
||||||
|
## Positive test cases
|
||||||
|
|
||||||
|
1. **Exact export:** `Mijn oude iPad hangt via USB aan mijn Mac. Exporteer alle tracks met exact genre Electro naar een nieuwe map in Downloads, zonder te synchroniseren.`
|
||||||
|
- Invoke the skill, probe read-only, plan exact case-insensitive matches, report transferable and excluded counts plus space, export without sync, and verify the complete local result.
|
||||||
|
2. **Inventory only:** `Welke genres en aantallen staan op mijn gekoppelde iPhone? Alleen inventariseren; niets kopiëren.`
|
||||||
|
- Run `probe` and `genres`; create no destination, manifest, or device mutation.
|
||||||
|
3. **Offline verification:** `Controleer of mijn eerdere iPad-muziekexport compleet en intact is aan de hand van het manifest.`
|
||||||
|
- Run `verify` without contacting the device; reject absent, empty, incomplete, wrong-genre, or invalid-hash manifests.
|
||||||
|
4. **Interrupted export:** `Ga verder met de eerder onderbroken Jazz-export zonder bestaande bestanden te overschrijven.`
|
||||||
|
- Accept only manifest-bound completed hashes or a hash-proven `ready_to_commit` final file; never trust or delete a hashless `pending` final file.
|
||||||
|
5. **Legacy recovery in English:** `Recover all locally stored Ambient tracks from this paired USB iOS device to a new Mac folder without modifying the device.`
|
||||||
|
- Trigger the skill, qualify unsupported schemas and cloud/DRM limits, and finish only after local verification.
|
||||||
|
|
||||||
|
## Negative test cases
|
||||||
|
|
||||||
|
1. **Sync request:** `Synchroniseer mijn hele Mac Music-bibliotheek naar de iPad.`
|
||||||
|
- Do not invoke this reverse-export workflow; it forbids Finder/Music sync.
|
||||||
|
2. **DRM bypass:** `Haal de DRM van mijn Apple Music-downloads en exporteer ze.`
|
||||||
|
- Refuse DRM removal; explain that protected or cloud-only media may be unusable or excluded.
|
||||||
|
3. **No local USB runtime:** `Export these iPhone tracks from Codex cloud where the phone is not attached.`
|
||||||
|
- Explain that the skill requires local Codex on macOS with direct USB/usbmuxd access and take no action.
|
||||||
|
|
||||||
|
## Release notes for 1.0.0
|
||||||
|
|
||||||
|
- Exact case-insensitive genre selection with adjacent-label protection.
|
||||||
|
- Two byte-identical database captures, SHA-256 comparison, SQLite `quick_check`, and local backup consolidation.
|
||||||
|
- Read-only protocol allowlists, bounded frames/timeouts, paired-certificate pinning, and credential-safe logging.
|
||||||
|
- Source-root confinement, pseudonymous device/database binding, source signatures, free-space headroom, and deterministic filenames.
|
||||||
|
- Locked append-only checksum-bound manifest with torn-tail recovery and late-plan collision protection, hash-proven final recovery, symlink rejection, and no-clobber final-file creation.
|
||||||
|
- Offline count, size, SHA-256, status, genre, regular-file, and optional `ffprobe` verification.
|
||||||
|
|
||||||
|
## Evidence checklist
|
||||||
|
|
||||||
|
- Official skill validator: pass.
|
||||||
|
- Official plugin validator: pass.
|
||||||
|
- Python compilation: pass.
|
||||||
|
- Unit and fault-injection tests: pass; exact count recorded in the release verification report.
|
||||||
|
- Live read-only probe on iPad mini 2 / iOS 12.5.8: pass.
|
||||||
|
- Live exact `Electro` plan: 295 transferable of 295 exact matches, 2,173,003,367 bytes, with destination free-space preflight; no destination or manifest created.
|
||||||
|
- Static opcode review: no device-side write, Pair, Unpair, or sync operation defined or reachable.
|
||||||
|
- Public repository and policy URLs: verify anonymously after publication.
|
||||||
|
- Publisher security scan: attach portal result when available.
|
||||||
|
|
||||||
|
## Publisher and review gate
|
||||||
|
|
||||||
|
The package can be built, validated, published, and locally installed automatically. Final submission requires Rodger Werkhoven to sign in to OpenAI's plugin submission portal with a verified Platform identity, confirm the correct organization and Apps Management role, select global availability, review public metadata and policies, accept all submission attestations, and send the package for review. Directory eligibility for a local-only macOS USB skill must be confirmed by OpenAI. Do not claim worldwide directory availability until OpenAI approves and publishes the listing.
|
||||||
592
tests/test_ios_music_export.py
Normal file
592
tests/test_ios_music_export.py
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT_PATH = (
|
||||||
|
PLUGIN_ROOT
|
||||||
|
/ "skills"
|
||||||
|
/ "rodgers-ios-music-export"
|
||||||
|
/ "scripts"
|
||||||
|
/ "ios_music_export.py"
|
||||||
|
)
|
||||||
|
SPEC = importlib.util.spec_from_file_location("ios_music_export", SCRIPT_PATH)
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
def create_media_database(path):
|
||||||
|
connection = sqlite3.connect(path)
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE item (
|
||||||
|
item_pid INTEGER PRIMARY KEY,
|
||||||
|
genre_id INTEGER,
|
||||||
|
item_artist_pid INTEGER,
|
||||||
|
album_pid INTEGER,
|
||||||
|
base_location_id INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE item_extra (
|
||||||
|
item_pid INTEGER PRIMARY KEY,
|
||||||
|
title TEXT,
|
||||||
|
location TEXT,
|
||||||
|
file_size INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE genre (genre_id INTEGER PRIMARY KEY, genre TEXT);
|
||||||
|
CREATE TABLE item_artist (item_artist_pid INTEGER PRIMARY KEY, item_artist TEXT);
|
||||||
|
CREATE TABLE album (album_pid INTEGER PRIMARY KEY, album TEXT);
|
||||||
|
CREATE TABLE base_location (base_location_id INTEGER PRIMARY KEY, path TEXT);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO genre VALUES (?, ?)",
|
||||||
|
[(1, "Electro"), (2, "electro"), (3, "Elektronisch"), (4, "Electro House")],
|
||||||
|
)
|
||||||
|
connection.execute("INSERT INTO item_artist VALUES (10, 'Test/Artist')")
|
||||||
|
connection.execute("INSERT INTO album VALUES (20, 'Test Album')")
|
||||||
|
connection.execute("INSERT INTO base_location VALUES (30, 'iTunes_Control/Music')")
|
||||||
|
rows = [
|
||||||
|
(101, 1, "Same: Track", "F00/AAAA.m4a", 5),
|
||||||
|
(102, 2, "Same: Track", "F01/BBBB.M4A", 6),
|
||||||
|
(103, 3, "Andere track", "F02/CCCC.m4a", 7),
|
||||||
|
(104, 4, "House track", "F03/DDDD.m4a", 8),
|
||||||
|
]
|
||||||
|
for item_pid, genre_id, title, location, size in rows:
|
||||||
|
connection.execute("INSERT INTO item VALUES (?, ?, 10, 20, 30)", (item_pid, genre_id))
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO item_extra VALUES (?, ?, ?, ?)",
|
||||||
|
(item_pid, title, location, size),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAFCClient:
|
||||||
|
def __init__(self, content):
|
||||||
|
self.content = content
|
||||||
|
self.download_calls = []
|
||||||
|
|
||||||
|
def download(self, remote_path, local_path, destination_fd=None):
|
||||||
|
self.download_calls.append(remote_path)
|
||||||
|
data = self.content[remote_path]
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(local_path, flags, 0o600, dir_fd=destination_fd)
|
||||||
|
with os.fdopen(descriptor, "wb") as output:
|
||||||
|
output.write(data)
|
||||||
|
return len(data), hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
def get_file_info(self, remote_path):
|
||||||
|
if remote_path not in self.content:
|
||||||
|
raise MODULE.AFCError(MODULE.AFC_E_OBJECT_NOT_FOUND, MODULE.AFC_OP_GET_FILE_INFO)
|
||||||
|
data = self.content[remote_path]
|
||||||
|
return {
|
||||||
|
"st_ifmt": "S_IFREG",
|
||||||
|
"st_size": str(len(data)),
|
||||||
|
"st_mtime": "1",
|
||||||
|
"st_blocks": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sample_plan():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"item_pid": "1",
|
||||||
|
"genre": "Electro",
|
||||||
|
"source_device_fingerprint": "a" * 64,
|
||||||
|
"database_snapshot_sha256": "b" * 64,
|
||||||
|
"title": "One",
|
||||||
|
"artist": "Artist",
|
||||||
|
"album": "Album",
|
||||||
|
"file_size": 5,
|
||||||
|
"source_mtime": "1",
|
||||||
|
"source_blocks": "1",
|
||||||
|
"remote_path": "/iTunes_Control/Music/F00/one.m4a",
|
||||||
|
"filename": "Artist — One.m4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_pid": "2",
|
||||||
|
"genre": "Electro",
|
||||||
|
"source_device_fingerprint": "a" * 64,
|
||||||
|
"database_snapshot_sha256": "b" * 64,
|
||||||
|
"title": "Two",
|
||||||
|
"artist": "Artist",
|
||||||
|
"album": "Album",
|
||||||
|
"file_size": 6,
|
||||||
|
"source_mtime": "1",
|
||||||
|
"source_blocks": "1",
|
||||||
|
"remote_path": "/iTunes_Control/Music/F01/two.mp3",
|
||||||
|
"filename": "Artist — Two.mp3",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IOSMusicExportTests(unittest.TestCase):
|
||||||
|
def test_exact_case_insensitive_genre_and_collision_names(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "MediaLibrary.sqlitedb"
|
||||||
|
create_media_database(database)
|
||||||
|
plan = MODULE.make_export_plan(database, "ELECTRO")
|
||||||
|
|
||||||
|
self.assertEqual([row["item_pid"] for row in plan], ["101", "102"])
|
||||||
|
self.assertEqual(plan[0]["filename"], "Test⁄Artist — Same - Track.m4a")
|
||||||
|
self.assertEqual(plan[1]["filename"], "Test⁄Artist — Same - Track [2].m4a")
|
||||||
|
self.assertTrue(plan[0]["remote_path"].endswith("/F00/AAAA.m4a"))
|
||||||
|
|
||||||
|
def test_adjacent_genres_are_not_selected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "MediaLibrary.sqlitedb"
|
||||||
|
create_media_database(database)
|
||||||
|
plan = MODULE.make_export_plan(database, "Elektronisch")
|
||||||
|
|
||||||
|
self.assertEqual(len(plan), 1)
|
||||||
|
self.assertEqual(plan[0]["item_pid"], "103")
|
||||||
|
|
||||||
|
def test_unknown_schema_fails_closed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
database = Path(directory) / "MediaLibrary.sqlitedb"
|
||||||
|
sqlite3.connect(database).close()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "unsupported MediaLibrary schema"):
|
||||||
|
MODULE.make_export_plan(database, "Electro")
|
||||||
|
|
||||||
|
def test_two_identical_database_captures_are_consolidated_and_checked(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "source.sqlitedb"
|
||||||
|
create_media_database(source)
|
||||||
|
client = FakeAFCClient({MODULE.MEDIA_DATABASE_REMOTE: source.read_bytes()})
|
||||||
|
snapshot = MODULE.snapshot_media_database(client, root / "snapshot-work")
|
||||||
|
plan = MODULE.make_export_plan(snapshot, "Electro")
|
||||||
|
|
||||||
|
self.assertEqual(len(plan), 2)
|
||||||
|
self.assertEqual(client.download_calls.count(MODULE.MEDIA_DATABASE_REMOTE), 2)
|
||||||
|
|
||||||
|
def test_export_hashes_verifies_and_resumes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
plan = sample_plan()
|
||||||
|
client = FakeAFCClient({
|
||||||
|
"/iTunes_Control/Music/F00/one.m4a": b"12345",
|
||||||
|
"/iTunes_Control/Music/F01/two.mp3": b"abcdef",
|
||||||
|
})
|
||||||
|
first = MODULE.export_plan(client, plan, destination, manifest)
|
||||||
|
verification = MODULE.verify_export(
|
||||||
|
manifest, destination, "Electro", probe_audio=False
|
||||||
|
)
|
||||||
|
second_client = FakeAFCClient({})
|
||||||
|
second = MODULE.export_plan(second_client, plan, destination, manifest)
|
||||||
|
|
||||||
|
self.assertEqual(first["copied_tracks"], 2)
|
||||||
|
self.assertEqual(verification["manifest_tracks"], 2)
|
||||||
|
self.assertEqual(verification["hash_errors"], [])
|
||||||
|
self.assertEqual(second["resumed_tracks"], 2)
|
||||||
|
self.assertEqual(second_client.download_calls, [])
|
||||||
|
|
||||||
|
def test_untrusted_existing_file_is_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
destination = Path(directory).resolve() / "music"
|
||||||
|
destination.mkdir()
|
||||||
|
(destination / "Artist — One.m4a").write_bytes(b"12345")
|
||||||
|
plan = [sample_plan()[0]]
|
||||||
|
with self.assertRaisesRegex(FileExistsError, "preserving untrusted"):
|
||||||
|
MODULE.inspect_existing_destination(destination, plan, {})
|
||||||
|
|
||||||
|
def test_verify_requires_present_nonempty_manifest_and_correct_genre(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
destination.mkdir()
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "manifest does not exist"):
|
||||||
|
MODULE.verify_export(manifest, destination, "Electro", probe_audio=False)
|
||||||
|
MODULE.write_manifest_atomic(manifest, [])
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "manifest is empty"):
|
||||||
|
MODULE.verify_export(manifest, destination, "Electro", probe_audio=False)
|
||||||
|
|
||||||
|
client = FakeAFCClient({
|
||||||
|
"/iTunes_Control/Music/F00/one.m4a": b"12345",
|
||||||
|
"/iTunes_Control/Music/F01/two.mp3": b"abcdef",
|
||||||
|
})
|
||||||
|
MODULE.export_plan(client, sample_plan(), destination, manifest)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "wrong_genre"):
|
||||||
|
MODULE.verify_export(manifest, destination, "Jazz", probe_audio=False)
|
||||||
|
|
||||||
|
def test_pending_manifest_never_trusts_same_size_final_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
destination.mkdir()
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
(destination / row["filename"]).write_bytes(b"WRONG")
|
||||||
|
pending = {
|
||||||
|
**row,
|
||||||
|
"partial_name": "",
|
||||||
|
"actual_size": "0",
|
||||||
|
"sha256": "",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
MODULE.write_manifest_atomic(manifest, [pending])
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "no trusted source hash"):
|
||||||
|
MODULE.inspect_existing_destination(destination, [row], MODULE.load_manifest(manifest))
|
||||||
|
|
||||||
|
pending["sha256"] = hashlib.sha256(b"WRONG").hexdigest()
|
||||||
|
MODULE.write_manifest_atomic(manifest, [pending])
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "status is not complete"):
|
||||||
|
MODULE.inspect_existing_destination(destination, [row], MODULE.load_manifest(manifest))
|
||||||
|
|
||||||
|
def test_late_collision_is_not_overwritten(self):
|
||||||
|
class RacingClient(FakeAFCClient):
|
||||||
|
def __init__(self, content, final_path):
|
||||||
|
super().__init__(content)
|
||||||
|
self.final_path = final_path
|
||||||
|
|
||||||
|
def download(self, remote_path, local_path, destination_fd=None):
|
||||||
|
self.final_path.write_bytes(b"RIVAL")
|
||||||
|
return super().download(
|
||||||
|
remote_path, local_path, destination_fd=destination_fd
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
final_path = destination / row["filename"]
|
||||||
|
client = RacingClient({row["remote_path"]: b"12345"}, final_path)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "refusing to replace"):
|
||||||
|
MODULE.export_plan(client, [row], destination, manifest)
|
||||||
|
self.assertEqual(final_path.read_bytes(), b"RIVAL")
|
||||||
|
|
||||||
|
def test_destination_symlink_swap_cannot_redirect_track_write(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
moved_destination = root / "music-original"
|
||||||
|
escape = root / "escape"
|
||||||
|
escape.mkdir()
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
|
||||||
|
class SwappingClient(FakeAFCClient):
|
||||||
|
def download(self, remote_path, local_path, destination_fd=None):
|
||||||
|
destination.rename(moved_destination)
|
||||||
|
destination.symlink_to(escape, target_is_directory=True)
|
||||||
|
return super().download(
|
||||||
|
remote_path, local_path, destination_fd=destination_fd
|
||||||
|
)
|
||||||
|
|
||||||
|
client = SwappingClient({row["remote_path"]: b"12345"})
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "destination path changed concurrently"):
|
||||||
|
MODULE.export_plan(client, [row], destination, manifest)
|
||||||
|
self.assertFalse((escape / row["filename"]).exists())
|
||||||
|
self.assertEqual((moved_destination / row["filename"]).read_bytes(), b"12345")
|
||||||
|
self.assertEqual(MODULE.load_manifest(manifest)[row["filename"]]["status"], "pending")
|
||||||
|
|
||||||
|
def test_download_failure_preserves_later_resume_records(self):
|
||||||
|
class FailingClient(FakeAFCClient):
|
||||||
|
def download(self, remote_path, local_path, destination_fd=None):
|
||||||
|
raise IOError("injected download failure")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
plan = sample_plan()
|
||||||
|
content = {
|
||||||
|
"/iTunes_Control/Music/F00/one.m4a": b"12345",
|
||||||
|
"/iTunes_Control/Music/F01/two.mp3": b"abcdef",
|
||||||
|
}
|
||||||
|
MODULE.export_plan(FakeAFCClient(content), plan, destination, manifest)
|
||||||
|
(destination / plan[0]["filename"]).unlink()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(IOError, "injected"):
|
||||||
|
MODULE.export_plan(FailingClient(content), plan, destination, manifest)
|
||||||
|
records = MODULE.load_manifest(manifest)
|
||||||
|
self.assertEqual(set(records), {plan[0]["filename"], plan[1]["filename"]})
|
||||||
|
self.assertTrue(records[plan[1]["filename"]]["sha256"])
|
||||||
|
|
||||||
|
summary = MODULE.export_plan(FakeAFCClient(content), plan, destination, manifest)
|
||||||
|
self.assertEqual(summary["copied_tracks"], 1)
|
||||||
|
self.assertEqual(summary["resumed_tracks"], 1)
|
||||||
|
|
||||||
|
def test_interrupted_direct_final_is_preserved_and_not_trusted(self):
|
||||||
|
class PartialFailureClient(FakeAFCClient):
|
||||||
|
def download(self, remote_path, local_path, destination_fd=None):
|
||||||
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||||
|
if hasattr(os, "O_NOFOLLOW"):
|
||||||
|
flags |= os.O_NOFOLLOW
|
||||||
|
descriptor = os.open(
|
||||||
|
local_path, flags, 0o600, dir_fd=destination_fd
|
||||||
|
)
|
||||||
|
with os.fdopen(descriptor, "wb") as output:
|
||||||
|
output.write(b"12")
|
||||||
|
raise IOError("injected mid-track interruption")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
client = PartialFailureClient({row["remote_path"]: b"12345"})
|
||||||
|
with self.assertRaisesRegex(IOError, "mid-track interruption"):
|
||||||
|
MODULE.export_plan(client, [row], destination, manifest)
|
||||||
|
final_path = destination / row["filename"]
|
||||||
|
self.assertEqual(final_path.read_bytes(), b"12")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "no trusted source hash"):
|
||||||
|
MODULE.export_plan(FakeAFCClient({row["remote_path"]: b"12345"}), [row], destination, manifest)
|
||||||
|
self.assertEqual(final_path.read_bytes(), b"12")
|
||||||
|
|
||||||
|
def test_ready_final_is_hash_verified_and_recovered(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
destination.mkdir()
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
final_path = destination / row["filename"]
|
||||||
|
final_path.write_bytes(b"12345")
|
||||||
|
ready = {
|
||||||
|
**row,
|
||||||
|
"partial_name": "",
|
||||||
|
"actual_size": "5",
|
||||||
|
"sha256": hashlib.sha256(b"12345").hexdigest(),
|
||||||
|
"status": "ready_to_commit",
|
||||||
|
}
|
||||||
|
MODULE.write_manifest_atomic(manifest, [ready])
|
||||||
|
|
||||||
|
summary = MODULE.export_plan(FakeAFCClient({}), [row], destination, manifest)
|
||||||
|
verification = MODULE.verify_export(
|
||||||
|
manifest, destination, "Electro", probe_audio=False
|
||||||
|
)
|
||||||
|
self.assertEqual(summary["recovered_tracks"], 1)
|
||||||
|
self.assertEqual(verification["hash_errors"], [])
|
||||||
|
self.assertEqual(final_path.read_bytes(), b"12345")
|
||||||
|
|
||||||
|
def test_unrelated_manifest_is_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
unrelated = {
|
||||||
|
**sample_plan()[0],
|
||||||
|
"filename": "Unrelated.m4a",
|
||||||
|
"partial_name": "",
|
||||||
|
"actual_size": "5",
|
||||||
|
"sha256": "c" * 64,
|
||||||
|
"status": "copied",
|
||||||
|
}
|
||||||
|
MODULE.write_manifest_atomic(manifest, [unrelated])
|
||||||
|
original = manifest.read_bytes()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "another export plan"):
|
||||||
|
MODULE.export_plan(
|
||||||
|
FakeAFCClient({sample_plan()[0]["remote_path"]: b"12345"}),
|
||||||
|
[sample_plan()[0]],
|
||||||
|
destination,
|
||||||
|
manifest,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest.read_bytes(), original)
|
||||||
|
self.assertFalse(destination.exists())
|
||||||
|
|
||||||
|
def test_late_unrelated_manifest_collision_is_preserved(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
unrelated = {
|
||||||
|
**row,
|
||||||
|
"filename": "Late unrelated.m4a",
|
||||||
|
"partial_name": "",
|
||||||
|
"actual_size": "5",
|
||||||
|
"sha256": "c" * 64,
|
||||||
|
"status": "copied",
|
||||||
|
}
|
||||||
|
original_inspect = MODULE.inspect_existing_destination
|
||||||
|
collided_bytes = []
|
||||||
|
|
||||||
|
def inject_manifest_collision(
|
||||||
|
target, plan, previous_records, destination_descriptor=None
|
||||||
|
):
|
||||||
|
result = original_inspect(
|
||||||
|
target,
|
||||||
|
plan,
|
||||||
|
previous_records,
|
||||||
|
destination_descriptor=destination_descriptor,
|
||||||
|
)
|
||||||
|
MODULE.write_manifest_atomic(manifest, [unrelated])
|
||||||
|
with manifest.open("ab") as output:
|
||||||
|
output.write(b"torn-unrelated-tail")
|
||||||
|
collided_bytes.append(manifest.read_bytes())
|
||||||
|
return result
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
MODULE, "inspect_existing_destination", side_effect=inject_manifest_collision
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "another export plan"):
|
||||||
|
MODULE.export_plan(
|
||||||
|
FakeAFCClient({row["remote_path"]: b"12345"}),
|
||||||
|
[row],
|
||||||
|
destination,
|
||||||
|
manifest,
|
||||||
|
)
|
||||||
|
self.assertEqual(manifest.read_bytes(), collided_bytes[0])
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "truncated final event"):
|
||||||
|
MODULE.load_manifest(manifest)
|
||||||
|
|
||||||
|
def test_fault_during_initial_manifest_write_installs_no_manifest(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = {
|
||||||
|
**sample_plan()[0],
|
||||||
|
"partial_name": "",
|
||||||
|
"actual_size": "0",
|
||||||
|
"sha256": "",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
real_write = os.write
|
||||||
|
|
||||||
|
def fail_before_first_byte(descriptor, payload):
|
||||||
|
raise OSError(28, "injected no space")
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE.os, "write", side_effect=fail_before_first_byte):
|
||||||
|
with self.assertRaisesRegex(OSError, "injected no space"):
|
||||||
|
MODULE.write_manifest_atomic(manifest, [row], expected_plan=[row])
|
||||||
|
self.assertFalse(manifest.exists())
|
||||||
|
|
||||||
|
def fail_after_partial_header(descriptor, payload):
|
||||||
|
real_write(descriptor, payload[:7])
|
||||||
|
raise OSError(28, "injected partial header")
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE.os, "write", side_effect=fail_after_partial_header):
|
||||||
|
with self.assertRaisesRegex(OSError, "injected partial header"):
|
||||||
|
MODULE.write_manifest_atomic(manifest, [row], expected_plan=[row])
|
||||||
|
self.assertFalse(manifest.exists())
|
||||||
|
self.assertEqual(list(root.glob("*.partial")), [])
|
||||||
|
|
||||||
|
MODULE.write_manifest_atomic(manifest, [row], expected_plan=[row])
|
||||||
|
self.assertEqual(MODULE.load_manifest(manifest)[row["filename"]]["status"], "pending")
|
||||||
|
|
||||||
|
def test_torn_trailing_manifest_event_is_recovered_under_export_lock(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = root / "manifest.tsv"
|
||||||
|
row = sample_plan()[0]
|
||||||
|
row["title"] = "Line one\nLine two"
|
||||||
|
MODULE.export_plan(
|
||||||
|
FakeAFCClient({row["remote_path"]: b"12345"}),
|
||||||
|
[row],
|
||||||
|
destination,
|
||||||
|
manifest,
|
||||||
|
)
|
||||||
|
with manifest.open("ab") as output:
|
||||||
|
output.write(b"torn-final-event")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "truncated final event"):
|
||||||
|
MODULE.load_manifest(manifest)
|
||||||
|
|
||||||
|
summary = MODULE.export_plan(FakeAFCClient({}), [row], destination, manifest)
|
||||||
|
verification = MODULE.verify_export(
|
||||||
|
manifest, destination, "Electro", probe_audio=False
|
||||||
|
)
|
||||||
|
self.assertEqual(summary["resumed_tracks"], 1)
|
||||||
|
self.assertEqual(verification["hash_errors"], [])
|
||||||
|
self.assertTrue(manifest.read_bytes().endswith(b"\n"))
|
||||||
|
self.assertNotIn(b"Line one\nLine two", manifest.read_bytes())
|
||||||
|
|
||||||
|
def test_symlink_destination_and_unexpected_directory_are_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
real_destination = root / "real"
|
||||||
|
real_destination.mkdir()
|
||||||
|
linked_destination = root / "linked"
|
||||||
|
linked_destination.symlink_to(real_destination, target_is_directory=True)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "symlink component"):
|
||||||
|
MODULE.inspect_existing_destination(linked_destination, sample_plan(), {})
|
||||||
|
|
||||||
|
(real_destination / "unexpected-directory").mkdir()
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "entries outside"):
|
||||||
|
MODULE.inspect_existing_destination(real_destination, sample_plan(), {})
|
||||||
|
|
||||||
|
def test_media_paths_are_confined_to_approved_roots(self):
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.make_safe_remote_media_path("iTunes_Control/Music/F00", "ABCD.m4a"),
|
||||||
|
"/iTunes_Control/Music/F00/ABCD.m4a",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "unsafe media database path"):
|
||||||
|
MODULE.make_safe_remote_media_path("iTunes_Control/Music/F00", "../secret")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "outside approved roots"):
|
||||||
|
MODULE.make_safe_remote_media_path("Safari", "history.db")
|
||||||
|
|
||||||
|
def test_protocol_dispatchers_block_unknown_operations(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "blocked AFC operation"):
|
||||||
|
MODULE.AFCClient(None).request(0xFFFF)
|
||||||
|
writable_open = struct.pack("<Q", 3) + b"/iTunes_Control/Music/file\0"
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "non-read-only AFC file-open"):
|
||||||
|
MODULE.AFCClient(None).request(MODULE.AFC_OP_FILE_OPEN, writable_open)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "blocked lockdownd request"):
|
||||||
|
MODULE.lockdown_request(None, "Pair")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "blocked non-read-only usbmuxd"):
|
||||||
|
MODULE.usbmux_request({"MessageType": "SavePairRecord"})
|
||||||
|
|
||||||
|
def test_lockdown_session_cannot_downgrade_paired_tls(self):
|
||||||
|
class FakeSocket:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
fake_socket = FakeSocket()
|
||||||
|
with (
|
||||||
|
mock.patch.object(MODULE, "usbmux_connect", return_value=fake_socket),
|
||||||
|
mock.patch.object(
|
||||||
|
MODULE,
|
||||||
|
"lockdown_request",
|
||||||
|
return_value={"EnableSessionSSL": False, "SessionID": "session"},
|
||||||
|
),
|
||||||
|
mock.patch.object(MODULE, "make_ssl_context") as make_ssl_context,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "required paired TLS"):
|
||||||
|
MODULE.start_lockdown_session({"DeviceID": 1}, {"HostID": "h", "SystemBUID": "s"})
|
||||||
|
self.assertTrue(fake_socket.closed)
|
||||||
|
make_ssl_context.assert_not_called()
|
||||||
|
|
||||||
|
def test_hot_rollback_journal_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
source = root / "source.sqlitedb"
|
||||||
|
create_media_database(source)
|
||||||
|
client = FakeAFCClient(
|
||||||
|
{
|
||||||
|
MODULE.MEDIA_DATABASE_REMOTE: source.read_bytes(),
|
||||||
|
MODULE.MEDIA_DATABASE_REMOTE + "-journal": b"hot rollback journal",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
snapshot = MODULE.capture_database_set(client, root / "snapshot-work")
|
||||||
|
self.assertIsNone(snapshot)
|
||||||
|
|
||||||
|
def test_invalid_nested_manifest_creates_no_destination_or_lock(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
destination = root / "music"
|
||||||
|
manifest = destination / "manifest.tsv"
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "manifest outside"):
|
||||||
|
MODULE.export_plan(FakeAFCClient({}), [sample_plan()[0]], destination, manifest)
|
||||||
|
self.assertFalse(destination.exists())
|
||||||
|
self.assertFalse(Path(str(manifest) + ".lock").exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue