Created
March 16, 2026 04:04
-
-
Save generic-github-user/0f0fa0c01228d796c58b0e2cef2d09b9 to your computer and use it in GitHub Desktop.
iOS backup (libimobiledevice encrypted backup) remapping script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Remap mvt-decrypted iOS backup files to human-readable directory structure. | |
| Usage: | |
| python remap_backup.py <mvt_decrypted_dir> <output_dir> | |
| The mvt_decrypted_dir should contain the decrypted Manifest.db and the | |
| flat file structure that mvt-ios produces. | |
| """ | |
| import argparse | |
| import os | |
| import sqlite3 | |
| import sys | |
| from pathlib import Path | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| import shutil | |
| import hashlib | |
| def get_file_id(domain: str, relative_path: str) -> str: | |
| """Compute the fileID hash that iOS uses for backup filenames.""" | |
| return hashlib.sha1(f"{domain}-{relative_path}".encode()).hexdigest() | |
| def find_decrypted_file(mvt_dir: Path, file_id: str) -> Path | None: | |
| """ | |
| Find a decrypted file in the mvt output directory. | |
| mvt-ios places files either flat or in 2-char prefix subdirs. | |
| """ | |
| # Try flat structure first | |
| flat_path = mvt_dir / file_id | |
| if flat_path.exists(): | |
| return flat_path | |
| # Try 2-char prefix subdirectory (like original backup structure) | |
| prefixed_path = mvt_dir / file_id[:2] / file_id | |
| if prefixed_path.exists(): | |
| return prefixed_path | |
| return None | |
| def copy_file(src: Path, dst: Path, use_hardlinks: bool = True) -> bool: | |
| """Copy or hardlink a file, creating parent directories as needed.""" | |
| try: | |
| dst.parent.mkdir(parents=True, exist_ok=True) | |
| if use_hardlinks: | |
| try: | |
| os.link(src, dst) | |
| return True | |
| except OSError: | |
| # Fall back to copy if hardlink fails (cross-device, etc.) | |
| pass | |
| shutil.copy2(src, dst) | |
| return True | |
| except Exception as e: | |
| print(f" Error copying {src} -> {dst}: {e}", file=sys.stderr) | |
| return False | |
| def remap_backup(mvt_dir: Path, output_dir: Path, use_hardlinks: bool = True, | |
| max_workers: int = 8) -> tuple[int, int, int]: | |
| """ | |
| Remap decrypted backup files to human-readable structure. | |
| Returns: (success_count, skipped_count, error_count) | |
| """ | |
| manifest_path = mvt_dir / "Manifest.db" | |
| if not manifest_path.exists(): | |
| # Sometimes mvt puts it in a subdirectory | |
| for candidate in mvt_dir.rglob("Manifest.db"): | |
| manifest_path = candidate | |
| break | |
| if not manifest_path.exists(): | |
| print(f"Error: Manifest.db not found in {mvt_dir}", file=sys.stderr) | |
| sys.exit(1) | |
| print(f"Using manifest: {manifest_path}") | |
| conn = sqlite3.connect(manifest_path) | |
| conn.row_factory = sqlite3.Row | |
| cursor = conn.cursor() | |
| # Get all files from the manifest | |
| cursor.execute(""" | |
| SELECT fileID, domain, relativePath, flags | |
| FROM Files | |
| WHERE flags != 2 | |
| """) # flags=2 means directory | |
| rows = cursor.fetchall() | |
| total = len(rows) | |
| print(f"Found {total} files in manifest") | |
| success = 0 | |
| skipped = 0 | |
| errors = 0 | |
| def process_row(row): | |
| file_id = row["fileID"] | |
| domain = row["domain"] or "Unknown" | |
| relative_path = row["relativePath"] or "" | |
| src = find_decrypted_file(mvt_dir, file_id) | |
| if src is None: | |
| return ("skipped", file_id, domain, relative_path) | |
| # Sanitize path components | |
| safe_domain = domain.replace("/", "_").replace("\\", "_") | |
| dst = output_dir / safe_domain / relative_path | |
| if copy_file(src, dst, use_hardlinks): | |
| return ("success", file_id, domain, relative_path) | |
| else: | |
| return ("error", file_id, domain, relative_path) | |
| with ThreadPoolExecutor(max_workers=max_workers) as executor: | |
| futures = {executor.submit(process_row, row): row for row in rows} | |
| for i, future in enumerate(as_completed(futures), 1): | |
| status, file_id, domain, rel_path = future.result() | |
| if status == "success": | |
| success += 1 | |
| elif status == "skipped": | |
| skipped += 1 | |
| else: | |
| errors += 1 | |
| if i % 1000 == 0 or i == total: | |
| print(f" Progress: {i}/{total} ({success} copied, {skipped} skipped, {errors} errors)") | |
| conn.close() | |
| return success, skipped, errors | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Remap mvt-decrypted iOS backup to human-readable directory structure" | |
| ) | |
| parser.add_argument("mvt_dir", type=Path, help="Path to mvt-decrypted backup directory") | |
| parser.add_argument("output_dir", type=Path, help="Output directory for remapped files") | |
| parser.add_argument("--copy", action="store_true", | |
| help="Force copy instead of hardlinks (slower, uses more space)") | |
| parser.add_argument("--workers", type=int, default=8, | |
| help="Number of parallel workers (default: 8)") | |
| args = parser.parse_args() | |
| if not args.mvt_dir.exists(): | |
| print(f"Error: {args.mvt_dir} does not exist", file=sys.stderr) | |
| sys.exit(1) | |
| args.output_dir.mkdir(parents=True, exist_ok=True) | |
| print(f"Remapping {args.mvt_dir} -> {args.output_dir}") | |
| print(f"Using {'copies' if args.copy else 'hardlinks (with copy fallback)'}") | |
| success, skipped, errors = remap_backup( | |
| args.mvt_dir, | |
| args.output_dir, | |
| use_hardlinks=not args.copy, | |
| max_workers=args.workers | |
| ) | |
| print() | |
| print(f"Done!") | |
| print(f" Copied: {success}") | |
| print(f" Skipped: {skipped} (file not found in decrypted output)") | |
| print(f" Errors: {errors}") | |
| if skipped > 0: | |
| print() | |
| print("Note: Skipped files are normal - mvt may not decrypt all file types,") | |
| print("or some entries in Manifest.db may reference files that weren't backed up.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment