Skip to content

Instantly share code, notes, and snippets.

@generic-github-user
Created March 16, 2026 04:04
Show Gist options
  • Select an option

  • Save generic-github-user/0f0fa0c01228d796c58b0e2cef2d09b9 to your computer and use it in GitHub Desktop.

Select an option

Save generic-github-user/0f0fa0c01228d796c58b0e2cef2d09b9 to your computer and use it in GitHub Desktop.
iOS backup (libimobiledevice encrypted backup) remapping script
#!/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