51 lines
2.4 KiB
Python
51 lines
2.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Create a consistent, portable SQLite backup without changing the save API database."""
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
import sqlite3
|
|
import tempfile
|
|
from contextlib import closing
|
|
|
|
ROOT=Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def backup(source,destination):
|
|
source=Path(source).expanduser().resolve(strict=True)
|
|
destination=Path(destination).expanduser().absolute()
|
|
parent=destination.parent.resolve(strict=True)
|
|
destination=parent/destination.name
|
|
if ROOT==source or ROOT in source.parents or ROOT==parent or ROOT in parent.parents:
|
|
raise ValueError('Keep private databases and backups outside the source checkout.')
|
|
if destination.exists() or destination.is_symlink():
|
|
raise FileExistsError('The destination already exists; it was preserved.')
|
|
with tempfile.TemporaryDirectory(prefix='.nxnative-save-backup-',dir=parent) as temporary:
|
|
staged=Path(temporary)/'saves.sqlite3'
|
|
# Read-only URI refuses a missing source. SQLite backup includes committed WAL
|
|
# transactions; copying only saves.sqlite3 could silently lose those writes.
|
|
with closing(sqlite3.connect(source.as_uri()+'?mode=ro',uri=True,timeout=15)) as origin:
|
|
if len(origin.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('revisions','heads','shares')").fetchall())!=3:
|
|
raise ValueError('Not a save API database.')
|
|
with closing(sqlite3.connect(staged)) as target:
|
|
origin.backup(target)
|
|
if target.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:
|
|
raise ValueError('Backup integrity check failed.')
|
|
os.chmod(staged,0o600)
|
|
with staged.open('rb') as stream:os.fsync(stream.fileno())
|
|
# Exclusive publication: never overwrite an existing backup, even in a race.
|
|
os.link(staged,destination)
|
|
return destination
|
|
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--source',type=Path,required=True)
|
|
parser.add_argument('--output',type=Path,required=True,help='new private backup path outside the checkout; parent must exist')
|
|
args=parser.parse_args()
|
|
try:backup(args.source,args.output)
|
|
except (OSError,ValueError,sqlite3.Error) as error:parser.exit(1,f'Backup failed: {error}\n')
|
|
print('Backup verified. Keep it private: it contains save snapshots and sharing records.')
|
|
|
|
|
|
if __name__=='__main__':main()
|