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("