Files
7z-encrypt/backup.py
T

347 lines
13 KiB
Python

#!/usr/bin/env python3
"""一键备份: 相册/聊天记录/文档 -> 私有加密服务器
增量备份 (mtime+size 对比本地清单) + 批量 tar + 复用加密上传管线。
手机 ZeroTermux/PRoot 可用: /sdcard/DCIM 等外部存储直接可见。
聊天记录注意: PRoot 无 root 读不到微信数据库 (/data/data), 需先把
微信"备份与迁移/导出"的文件放到手机存储, 再配置 backup.chat 目录。
用法:
sz-backup # 备份全部 (photos+documents+download+chat)
sz-backup photos # 只备份相册
sz-backup list # 已备份清单
sz-backup restore photos # 恢复相册
"""
import argparse
import json
import os
import subprocess
import sys
import tarfile
from datetime import datetime
from pathlib import Path
from typing import Any
from tqdm import tqdm
STATE_DIR = Path.home() / ".local/share/7z-encrypt" / "backup"
BATCH_MB = 500 # 每批 tar 上限
BATCH_FILES = 500 # 每批文件数上限
# 高压缩率类型 (聊天记录/文档) 用 -mx=9, 照片/视频批次 -mx=1 快速
COMPRESS_SUFFIX = {".db", ".sqlite", ".sqlite3", ".sql", ".txt", ".json", ".xml", ".csv", ".log", ".md", ".html"}
def _7z(args: list[str]) -> None:
"""调系统 7z (p7zip-full), 实时透传进度输出 (7z 自带百分比进度条)"""
r = subprocess.run(["7z"] + args)
if r.returncode != 0:
raise RuntimeError(f"7z 失败 (exit {r.returncode})")
def _cfg() -> dict[str, Any]:
"""读配置文件 backup 段 (XDG, 同客户端)"""
env = os.environ.get("SZ_CONFIG")
if env:
p = Path(env).expanduser()
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
p = Path(xdg) / "7z-encrypt" / "config.json"
try:
return dict(json.loads(p.read_text(encoding="utf-8")).get("backup", {}))
except (OSError, json.JSONDecodeError):
return {}
def _manifest_path(name: str) -> Path:
return STATE_DIR / f"{name}.json"
def _load_manifest(name: str) -> dict[str, Any]:
try:
return json.loads(_manifest_path(name).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"files": {}, "batches": {}}
def _save_manifest(name: str, m: dict[str, Any]) -> None:
_manifest_path(name).parent.mkdir(parents=True, exist_ok=True)
_manifest_path(name).write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding="utf-8")
def _server() -> str:
"""服务器地址: 配置 server.url (备份复用客户端配置)"""
env = os.environ.get("SZ_CONFIG")
if env:
p = Path(env).expanduser()
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
p = Path(xdg) / "7z-encrypt" / "config.json"
try:
return str(json.loads(p.read_text(encoding="utf-8")).get("server", {}).get("url", ""))
except (OSError, json.JSONDecodeError):
return ""
def _token() -> str:
"""登录 token: 配置文件 server.token"""
env = os.environ.get("SZ_CONFIG")
if env:
p = Path(env).expanduser()
else:
xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
p = Path(xdg) / "7z-encrypt" / "config.json"
try:
return str(json.loads(p.read_text(encoding="utf-8")).get("server", {}).get("token", ""))
except (OSError, json.JSONDecodeError):
return ""
def _src_dirs(name: str) -> list[Path]:
"""备份源目录: 配置 override 默认"""
defaults = {
"photos": ["~/storage/dcim", "~/storage/pictures", "/sdcard/DCIM", "/sdcard/Pictures"],
"documents": ["/sdcard/Documents"],
"download": ["/sdcard/Download"],
"chat": [],
}
dirs = _cfg().get(name, defaults.get(name, []))
return [Path(d).expanduser() for d in dirs]
def _scan_new(name: str, manifest: dict[str, Any]) -> list[Path]:
"""扫描新增/修改文件 (mtime+size 对比)"""
known = manifest.get("files", {})
new_files: list[Path] = []
for src in _src_dirs(name):
if not src.exists():
continue
for p in src.rglob("*"):
if not p.is_file():
continue
try:
st = p.stat()
except OSError:
continue
key = str(p)
if key in known and known[key][0] == st.st_size and known[key][1] == int(st.st_mtime):
continue
new_files.append(p)
return new_files
def _compress(path: Path) -> bool:
return path.suffix.lower() in COMPRESS_SUFFIX
def _batch(files: list[Path]) -> list[list[Path]]:
"""按大小/数量分批"""
batches: list[list[Path]] = []
cur: list[Path] = []
total = 0
for f in files:
size = f.stat().st_size
if cur and (total + size > BATCH_MB * 1024 * 1024 or len(cur) >= BATCH_FILES):
batches.append(cur)
cur, total = [], 0
cur.append(f)
total += size
if cur:
batches.append(cur)
return batches
def _upload_tar(tar_path: Path, chunk_mb: int) -> str:
"""加密 -> 分卷 -> 上传 (复用 metadata/splitter/crypto/transfer 库, 编译版自包含)"""
from metadata import build_init_json, sha256_of
from splitter import split_stream
from crypto import CryptoEngine, KeyNotFoundError
import transfer as transfer_mod
engine = CryptoEngine()
try:
engine.get_key("default_key")
except KeyNotFoundError:
engine.generate_key("default_key")
chunk_size = chunk_mb * 1024 * 1024
enc_path = tar_path.with_suffix(tar_path.suffix + ".enc")
print(f"[备份] 加密批次 ...")
with open(tar_path, "rb") as src, open(enc_path, "wb") as dst:
pbar = tqdm(total=tar_path.stat().st_size, desc="[加密]", unit="B",
unit_scale=True, leave=False)
enc_params = engine.encrypt_stream(src, dst, "default_key",
progress=lambda n: pbar.update(n))
pbar.close()
print(f"[备份] 分卷 (每卷 {chunk_mb}MB) ...")
with open(enc_path, "rb") as f:
pbar = tqdm(total=enc_path.stat().st_size, desc="[分卷]", unit="B",
unit_scale=True, leave=False)
manifest = split_stream(f, chunk_size, str(enc_path), total_size=enc_path.stat().st_size,
progress=lambda n: pbar.update(n))
pbar.close()
total_sha = sha256_of(enc_path)
init_json = build_init_json(
file_name=engine.encrypt_name(tar_path.name),
file_size=tar_path.stat().st_size,
total_sha256=total_sha,
enc=enc_params,
chunks=manifest,
)
chunk_files = {ch["index"]: Path(ch["filename"]).resolve() for ch in manifest}
client = transfer_mod.TransferClient(_server(), token=_token())
receipt = client.transfer(init_json, chunk_files)
enc_path.unlink(missing_ok=True)
for ch in manifest:
Path(ch["filename"]).unlink(missing_ok=True)
return str(receipt["file_id"])
def backup(name: str, chunk_mb: int = 10) -> int:
manifest = _load_manifest(name)
files = _scan_new(name, manifest)
if not files:
print(f"[备份] {name}: 无新增文件, 已是最新")
return 0
batches = _batch(files)
print(f"[备份] {name}: 新增 {len(files)} 个文件 ({sum(f.stat().st_size for f in files) / 1048576:.1f} MB), {len(batches)} 批")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
for i, batch in enumerate(batches, 1):
tar_path = STATE_DIR / f"{name}_{ts}_{i:02d}.tar"
tar_path.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(tar_path, "w") as tf:
for f in batch:
try:
tf.add(f, arcname=str(f))
except (OSError, tarfile.TarError) as e:
print(f" [跳过] {f}: {e}")
# 7z 压缩 (与上传文件一致): 含文本类 -mx=9, 照片视频 -mx=1
mx = 9 if any(_compress(f) for f in batch) else 1
seven = tar_path.with_suffix(".tar.7z")
print(f"[备份] 7z 压缩 (mx={mx}) ...")
_7z(["a", "-y", "-bd", f"-mx={mx}", str(seven), str(tar_path)])
tar_path.unlink(missing_ok=True)
tar_path = seven
size_mb = tar_path.stat().st_size / 1048576
print(f"[备份] 批次 {i}/{len(batches)}: {tar_path.name} ({size_mb:.1f} MB), 上传中 ...")
try:
fid = _upload_tar(tar_path, chunk_mb)
except RuntimeError as e:
print(f"[错误] {e}")
return 1
files_in_batch = [str(f) for f in batch]
for f in batch:
try:
st = f.stat()
manifest.setdefault("files", {})[str(f)] = [st.st_size, int(st.st_mtime)]
except OSError:
pass
manifest.setdefault("batches", {})[tar_path.name] = {
"server_id": fid, "size": tar_path.stat().st_size, "files": files_in_batch,
}
_save_manifest(name, manifest)
print(f"[备份] 批次 {i} 完成: file_id={fid}")
tar_path.unlink(missing_ok=True)
print(f"[备份] {name} 完成, 共 {len(files)} 个文件")
return 0
def _download_batch(file_id: str, out_dir: Path) -> Path:
"""下载 + 解密备份批次 (复用 transfer/crypto 库), 返回还原的 tar 路径"""
import transfer as transfer_mod
from crypto import CryptoEngine
client = transfer_mod.TransferClient(_server(), token=_token())
enc_path, enc_params = client.download(file_id, out_dir / ".sz-backup.enc")
engine = CryptoEngine()
final = out_dir / str(enc_params.get("file_name", "backup.tar"))
with open(enc_path, "rb") as src, open(final, "wb") as dst:
pbar = tqdm(total=enc_path.stat().st_size, desc="[解密]", unit="B",
unit_scale=True, leave=False)
engine.decrypt_stream(src, dst, enc_params["key_id"],
enc_params.get("context", "7z-encrypt:v1").encode(),
progress=lambda n: pbar.update(n))
pbar.close()
enc_path.unlink(missing_ok=True)
return final
def restore(name: str, out_root: Path | None = None) -> int:
manifest = _load_manifest(name)
batches = manifest.get("batches", {})
if not batches:
print(f"[恢复] {name}: 无备份记录")
return 1
print(f"[恢复] {name}: {len(batches)} 个备份批次")
for tar_name, info in batches.items():
dl = STATE_DIR / f"restore_{tar_name}"
dl.mkdir(parents=True, exist_ok=True)
try:
tar_src = _download_batch(info["server_id"], dl)
except Exception as e:
print(f"[错误] 下载 {tar_name} 失败: {e}")
return 1
# 7z 解压 -> tar -> 解包 (路径校验: 只解回 manifest 记录的文件)
if tar_src.suffix == ".7z":
_7z(["x", "-y", "-bd", f"-o{dl}", str(tar_src)])
tar_src.unlink(missing_ok=True)
tar_src = dl / tar_name
if not tar_src.exists():
print(f"[错误] 未找到 tar: {dl}")
return 1
with tarfile.open(tar_src) as tf:
for member in tf.getmembers():
if member.name not in info.get("files", []):
continue
if out_root is not None:
rel = member.name.lstrip("/")
tf.extract(member, path=out_root / rel, filter="data")
else:
tf.extract(member, path=Path(member.name).parent, filter="data")
print(f"[恢复] 批次完成: {tar_name}")
print(f"[恢复] {name} 完成")
return 0
def list_backups() -> int:
if not STATE_DIR.exists():
print("[备份] 暂无备份记录")
return 0
for mp in sorted(STATE_DIR.glob("*.json")):
name = mp.stem
m = _load_manifest(name)
batches = m.get("batches", {})
total = sum(b["size"] for b in batches.values())
print(f" {name}: {len(m.get('files', {}))} 文件, {len(batches)} 批, {total / 1048576:.1f} MB 已备份")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description="一键备份: 相册/聊天记录/文档 -> 加密服务器")
ap.add_argument("action", nargs="?", default="all", help="备份项: photos/documents/download/chat/all/list/restore")
ap.add_argument("target", nargs="?", help="restore 的备份项")
ap.add_argument("--chunk-size", type=int, default=10, help="分卷大小 MB (默认 10)")
args = ap.parse_args()
if args.action == "list":
return list_backups()
if args.action == "restore":
if not args.target:
print("[错误] 用法: sz-backup restore <photos|documents|download|chat>")
return 1
return restore(args.target)
names = ["photos", "documents", "download", "chat"] if args.action == "all" else [args.action]
if args.action not in ("photos", "documents", "download", "chat", "all"):
print(f"[错误] 未知备份项: {args.action} (photos/documents/download/chat/all)")
return 1
ok = True
for n in names:
if not _src_dirs(n):
print(f"[备份] {n}: 无源目录 (config backup.{n} 为空, 跳过)")
continue
ok = backup(n, args.chunk_size) == 0 and ok
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())