123 lines
5.8 KiB
Python
123 lines
5.8 KiB
Python
"""Preview and restore user config backups, preserving the current files."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
def stamp():
|
|
return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
|
|
|
|
|
def user_state(home):
|
|
home = home.resolve()
|
|
state = home / '.local/state/Fiw-Gentoo-Dots'
|
|
if not state.resolve().is_relative_to(home):
|
|
raise RuntimeError('Config state directory points outside the target home.')
|
|
backup_root = state / 'backups'
|
|
if backup_root.is_symlink() or not backup_root.resolve().is_relative_to(state.resolve()):
|
|
raise RuntimeError('Config backup directory points outside its managed location.')
|
|
return state
|
|
|
|
|
|
def digest(path):
|
|
if path.is_symlink():
|
|
return 'link:' + os.readlink(path)
|
|
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None
|
|
|
|
|
|
def files(home, backup_id, allowed):
|
|
if not re.fullmatch(r'[0-9]{8}T[0-9]{6}(?:\.[0-9]+)?Z', backup_id):
|
|
raise ValueError('Invalid config backup ID.')
|
|
root = user_state(home) / 'backups' / backup_id
|
|
if not root.is_dir() or root.is_symlink():
|
|
raise RuntimeError('Config backup not found: ' + backup_id)
|
|
if not root.resolve().is_relative_to(user_state(home).resolve()):
|
|
raise RuntimeError('Backup directory points outside config state.')
|
|
result = []
|
|
for directory, dirs, names in os.walk(root, followlinks=False):
|
|
if any((Path(directory) / name).is_symlink() for name in dirs):
|
|
raise RuntimeError('A backup directory is a symlink; preserving all files.')
|
|
for name in sorted(names):
|
|
path = Path(directory) / name
|
|
relative = path.relative_to(root).as_posix()
|
|
if relative not in allowed:
|
|
raise RuntimeError('Unrecognized file in config backup: ' + relative)
|
|
if not path.is_file() and not path.is_symlink():
|
|
raise RuntimeError('Unsupported config backup file: ' + relative)
|
|
destination = home / relative
|
|
if not destination.parent.resolve().is_relative_to(home.resolve()):
|
|
raise RuntimeError('Config parent points outside the target home: ' + relative)
|
|
if destination.is_dir() and not destination.is_symlink():
|
|
raise RuntimeError('Config destination is a directory: ' + relative)
|
|
result.append((relative, path, destination))
|
|
return sorted(result)
|
|
|
|
|
|
def list_backups(home, allowed):
|
|
root = user_state(home) / 'backups'
|
|
if not root.exists():
|
|
return []
|
|
if root.is_symlink():
|
|
raise RuntimeError('Config backup directory is a symlink.')
|
|
choices = []
|
|
for directory in sorted(root.iterdir(), reverse=True):
|
|
if directory.is_dir() and re.fullmatch(r'[0-9]{8}T[0-9]{6}(?:\.[0-9]+)?Z', directory.name):
|
|
entries = files(home, directory.name, allowed)
|
|
if entries:
|
|
choices.append({'id': directory.name, 'count': len(entries)})
|
|
return choices
|
|
|
|
|
|
def plan(home, backup_id, allowed):
|
|
entries = files(home, backup_id, allowed)
|
|
return {'backup': backup_id, 'files': [
|
|
{'path': relative, 'config': allowed[relative]['name'],
|
|
'change': 'unchanged' if digest(source) == digest(destination) else 'restore'}
|
|
for relative, source, destination in entries],
|
|
'note': 'Current files get a new backup. Files created by the original restore are retained. Only user configs are restored.'}
|
|
|
|
|
|
def restore(home, backup_id, allowed, ask, active_plasma, conflict='ask'):
|
|
if os.geteuid() == 0:
|
|
raise RuntimeError('Restore user config backups as the target user, not root.')
|
|
home = home.resolve()
|
|
entries = files(home, backup_id, allowed)
|
|
if any(allowed[relative].get('kde') for relative, _, _ in entries) and active_plasma(home):
|
|
raise RuntimeError('Log out of Plasma before restoring a KDE config backup.')
|
|
print(json.dumps(plan(home, backup_id, allowed), indent=2))
|
|
report = {'backup': backup_id, 'restored': [], 'unchanged': [], 'kept': []}
|
|
if not entries or ask('Restore the previewed user config backup?', ['restore', 'cancel'], 'cancel') != 'restore':
|
|
report['status'] = 'cancelled'
|
|
return report
|
|
state = user_state(home)
|
|
index_path = state / 'managed.json'
|
|
index = json.loads(index_path.read_text()) if index_path.is_file() else {}
|
|
backup_id_new = stamp()
|
|
for relative, source, destination in entries:
|
|
if digest(source) == digest(destination):
|
|
report['unchanged'].append(relative)
|
|
continue
|
|
if destination.exists() or destination.is_symlink():
|
|
if conflict == 'keep' or (conflict == 'ask' and ask('Restore ' + relative + ' after backing up the current file?', ['restore', 'keep'], 'keep') == 'keep'):
|
|
report['kept'].append(relative)
|
|
continue
|
|
current = state / 'backups' / backup_id_new / relative
|
|
current.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(destination, current, follow_symlinks=False)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = destination.with_name('.' + destination.name + '.fiw-restore-' + backup_id_new)
|
|
shutil.copy2(source, temporary, follow_symlinks=False)
|
|
os.replace(temporary, destination)
|
|
# A restored previous preference is a local choice; future updates
|
|
# should propose changes rather than treating it as the current preset.
|
|
index.pop(relative, None)
|
|
report['restored'].append(relative)
|
|
temporary = index_path.with_name('.managed-restore-' + backup_id_new)
|
|
temporary.write_text(json.dumps(index, indent=2) + '\n')
|
|
temporary.replace(index_path)
|
|
report['current_backup'] = backup_id_new if (state / 'backups' / backup_id_new).exists() else None
|
|
return report
|