mirror of
https://github.com/CodeSeeed/sbackup.git
synced 2026-10-04 02:32:58 +08:00
feat: 3个实用功能 — 备份锁/Shell补全/并行上传
This commit is contained in:
1 parent
8a14fda09b
commit
4fbbf7cfd8
32 files changed
+3211
-172
No files matched your search
@@ -21,6 +21,7 @@ dependencies = [
|
||||
"paramiko>=4.0.0",
|
||||
"py7zr>=1.1.0",
|
||||
"tqdm>=4.67.3",
|
||||
"watchdog>=6.0.0",
|
||||
"zstandard>=0.25.0",
|
||||
]
|
||||
|
||||
|
||||
+260
-27
@@ -100,7 +100,7 @@ class BackupManager:
|
||||
|
||||
def save(self, initial: bool = False):
|
||||
"""
|
||||
将内存数据写入 JSON 文件
|
||||
将内存数据写入 JSON 文件(原子写入,防止中断导致数据损坏)
|
||||
"""
|
||||
if not initial:
|
||||
logger.debug(t("log.data.write"), self.data_file)
|
||||
@@ -109,8 +109,23 @@ class BackupManager:
|
||||
if data_dir:
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
with open(self.data_file, "w", encoding="utf-8") as f:
|
||||
# 原子写入:先写入临时文件,再 rename 替换
|
||||
tmp_path = self.data_file + ".tmp"
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, ensure_ascii=False, indent=4)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# os.replace() 在 Windows 和 Unix 上都是原子操作
|
||||
os.replace(tmp_path, self.data_file)
|
||||
except OSError:
|
||||
# 如果临时文件写入失败,尝试清理
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def _get_entry(self, key: str) -> BackupEntry | None:
|
||||
"""获取指定路径的备份策略条目"""
|
||||
@@ -398,7 +413,7 @@ class BackupManager:
|
||||
max_size: int = 0,
|
||||
min_size: int = 0,
|
||||
max_age_seconds: float = 0,
|
||||
incremental: bool = False,
|
||||
incremental: str | None = None,
|
||||
split_size: int = 0,
|
||||
tag: str = "",
|
||||
checksum: bool = False,
|
||||
@@ -420,7 +435,7 @@ class BackupManager:
|
||||
:param max_size: 文件大小上限(字节),0 不限制
|
||||
:param min_size: 文件大小下限(字节),0 不限制
|
||||
:param max_age_seconds: 文件年龄上限(秒),0 不限制
|
||||
:param incremental: 文件级增量备份(仅压缩变化的文件)
|
||||
:param incremental: 增量备份模式: None/""=全量, "file"=文件级, "block"=块级
|
||||
:param pre_hooks: 备份前执行的命令列表
|
||||
:param post_hooks: 备份后执行的命令列表
|
||||
"""
|
||||
@@ -430,14 +445,15 @@ class BackupManager:
|
||||
# 执行前置钩子
|
||||
self._run_hooks(pre_hooks or [], "pre")
|
||||
|
||||
# 加载文件级元数据(增量备份用)
|
||||
# 加载文件级/块级元数据(增量备份用)
|
||||
file_meta_all = self.data.get("_file_meta", {}) if incremental else {}
|
||||
chunk_meta_all = self.data.get("_chunk_meta", {}) if incremental == "block" else {}
|
||||
|
||||
# 收集需要备份的条目
|
||||
tasks = []
|
||||
skip_count = 0
|
||||
for key, raw in list(self.data.items()):
|
||||
if key in (_HISTORY_KEY, "_file_meta"):
|
||||
if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||
continue
|
||||
if not os.path.exists(key):
|
||||
print(t("warn.source.missing", path=key))
|
||||
@@ -449,6 +465,7 @@ class BackupManager:
|
||||
continue
|
||||
entry = BackupEntry.from_list(raw)
|
||||
file_meta = file_meta_all.get(key, {}) if incremental else {}
|
||||
chunk_meta = chunk_meta_all.get(key, {}) if incremental == "block" else {}
|
||||
if entry.mtime != current_mtime:
|
||||
tasks.append(
|
||||
(
|
||||
@@ -464,6 +481,8 @@ class BackupManager:
|
||||
max_age_seconds,
|
||||
file_meta,
|
||||
split_size,
|
||||
incremental,
|
||||
chunk_meta,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -486,7 +505,7 @@ class BackupManager:
|
||||
executor.submit(self._do_backup, *task): task for task in tasks
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
key, entry, current_mtime, _, _, _, _, _, _, _, _, _ = futures[
|
||||
key, entry, current_mtime, _, _, _, _, _, _, _, _, _, incr_mode, _ = futures[
|
||||
future
|
||||
]
|
||||
try:
|
||||
@@ -505,10 +524,12 @@ class BackupManager:
|
||||
result.get("path", ""),
|
||||
tag=tag,
|
||||
)
|
||||
if incremental:
|
||||
if incr_mode:
|
||||
self.data.setdefault("_file_meta", {})[key] = (
|
||||
self._collect_file_meta(key, checksum=checksum)
|
||||
)
|
||||
if incr_mode == "block":
|
||||
self._update_chunk_meta(key)
|
||||
if keep > 0 or keep_days > 0:
|
||||
self._cleanup_old_backups(entry.target, keep, keep_days)
|
||||
backup_count += 1
|
||||
@@ -532,6 +553,8 @@ class BackupManager:
|
||||
age,
|
||||
fmeta,
|
||||
split,
|
||||
incr_mode,
|
||||
chmeta,
|
||||
) in tasks:
|
||||
result = self._do_backup(
|
||||
key,
|
||||
@@ -546,6 +569,8 @@ class BackupManager:
|
||||
age,
|
||||
fmeta,
|
||||
split,
|
||||
incr_mode,
|
||||
chmeta,
|
||||
)
|
||||
if result and result.get("success"):
|
||||
entry.mtime = current_mtime
|
||||
@@ -558,10 +583,12 @@ class BackupManager:
|
||||
result.get("path", ""),
|
||||
tag=tag,
|
||||
)
|
||||
if incremental:
|
||||
if incr_mode:
|
||||
self.data.setdefault("_file_meta", {})[key] = (
|
||||
self._collect_file_meta(key, checksum=checksum)
|
||||
)
|
||||
if incr_mode == "block":
|
||||
self._update_chunk_meta(key)
|
||||
if keep > 0 or keep_days > 0:
|
||||
self._cleanup_old_backups(entry.target, keep, keep_days)
|
||||
backup_count += 1
|
||||
@@ -596,12 +623,27 @@ class BackupManager:
|
||||
if verify_failures > 0:
|
||||
print(t("cmd.verify.partial_fail", count=verify_failures))
|
||||
|
||||
# SFTP 上传
|
||||
if sftp_upload and uploaded_files:
|
||||
self._upload_to_sftp(uploaded_files, config)
|
||||
# 并行上传到 SFTP 和 WebDAV(使用 ThreadPoolExecutor)
|
||||
sftp_needed = sftp_upload and bool(uploaded_files)
|
||||
webdav_needed = webdav_upload and bool(uploaded_files)
|
||||
|
||||
# WebDAV 上传
|
||||
if webdav_upload and uploaded_files:
|
||||
if sftp_needed and webdav_needed:
|
||||
print(t("cmd.upload.parallel"))
|
||||
with ThreadPoolExecutor(max_workers=2) as upload_executor:
|
||||
sftp_future = upload_executor.submit(
|
||||
self._upload_to_sftp, uploaded_files, config
|
||||
)
|
||||
webdav_future = upload_executor.submit(
|
||||
self._upload_to_webdav, uploaded_files, config
|
||||
)
|
||||
for f in as_completed([sftp_future, webdav_future]):
|
||||
exc = f.exception()
|
||||
if exc:
|
||||
logger.error("Parallel upload error: %s", exc)
|
||||
else:
|
||||
if sftp_needed:
|
||||
self._upload_to_sftp(uploaded_files, config)
|
||||
if webdav_needed:
|
||||
self._upload_to_webdav(uploaded_files, config)
|
||||
|
||||
# Webhook 通知(支持多个 URL、自定义模板、重试)
|
||||
@@ -634,6 +676,20 @@ class BackupManager:
|
||||
retries=getattr(config, "webhook_retries", 2),
|
||||
)
|
||||
|
||||
# SMTP 邮件通知
|
||||
if getattr(config, "smtp_enabled", False):
|
||||
status = "成功" if verify_failures == 0 else "部分成功"
|
||||
if backup_count == 0:
|
||||
status = "跳过"
|
||||
self._send_email(
|
||||
config,
|
||||
status=status,
|
||||
backed=backup_count,
|
||||
skipped=skip_count,
|
||||
elapsed=elapsed,
|
||||
verify_failures=verify_failures,
|
||||
)
|
||||
|
||||
# 执行后置钩子
|
||||
self._run_hooks(post_hooks or [], "post")
|
||||
|
||||
@@ -644,6 +700,7 @@ class BackupManager:
|
||||
:param hook_type: "pre" 或 "post",用于日志标识
|
||||
"""
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
for cmd in hooks:
|
||||
if not cmd:
|
||||
@@ -652,7 +709,11 @@ class BackupManager:
|
||||
print(t(label, command=cmd))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, timeout=300, capture_output=True, text=True
|
||||
shlex.split(cmd),
|
||||
shell=False,
|
||||
timeout=300,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
@@ -679,6 +740,8 @@ class BackupManager:
|
||||
max_age_seconds: float = 0,
|
||||
file_metadata: dict | None = None,
|
||||
split_size: int = 0,
|
||||
incremental: str | None = None,
|
||||
chunk_meta: dict | None = None,
|
||||
) -> dict:
|
||||
"""执行单个备份策略(线程安全)"""
|
||||
fmt = entry.compression_format or config.compression_format
|
||||
@@ -765,6 +828,27 @@ class BackupManager:
|
||||
pass
|
||||
return meta
|
||||
|
||||
def _update_chunk_meta(self, key: str) -> None:
|
||||
"""为指定策略更新块级哈希元数据"""
|
||||
from sbackup.chunked_backup import compute_chunk_hashes
|
||||
|
||||
if not os.path.isdir(key):
|
||||
return
|
||||
try:
|
||||
for dirpath, _, filenames in os.walk(key):
|
||||
for fn in filenames:
|
||||
fp = os.path.join(dirpath, fn)
|
||||
try:
|
||||
if os.path.isfile(fp):
|
||||
rel = os.path.relpath(fp, key).replace("\\", "/")
|
||||
self.data.setdefault("_chunk_meta", {}).setdefault(
|
||||
key, {}
|
||||
)[rel] = compute_chunk_hashes(fp)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None:
|
||||
"""检查目标目录剩余空间,不足时打印警告"""
|
||||
@@ -891,7 +975,46 @@ class BackupManager:
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
max_attempts = max(retries, 1)
|
||||
max_attempts = min(max(retries, 1), 5)
|
||||
|
||||
# SSRF 防护:只允许 http/https 协议,阻止内网地址
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
logger.warning("Webhook URL 协议不允许: %s (仅支持 http/https)", url)
|
||||
return
|
||||
|
||||
# 解析主机名并阻止内部/保留 IP
|
||||
if parsed.hostname:
|
||||
try:
|
||||
import ipaddress
|
||||
import socket as _socket
|
||||
|
||||
host_ip = _socket.getaddrinfo(
|
||||
parsed.hostname, None, type=_socket.SOCK_STREAM
|
||||
)[0][4][0]
|
||||
ip = ipaddress.ip_address(host_ip)
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
):
|
||||
logger.warning(
|
||||
"Webhook URL 指向内部地址,已阻止: %s", parsed.hostname
|
||||
)
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# 禁用自动重定向(防止重定向链绕过协议/地址检查)
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
no_redirect_opener = urllib.request.build_opener(_NoRedirectHandler)
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
@@ -900,8 +1023,8 @@ class BackupManager:
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
logger.debug("Webhook 通知成功: %s", url)
|
||||
no_redirect_opener.open(req, timeout=10)
|
||||
logger.debug("Webhook 通知成功: %s", parsed.hostname)
|
||||
return
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
if attempt < max_attempts - 1:
|
||||
@@ -911,11 +1034,11 @@ class BackupManager:
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
wait,
|
||||
url,
|
||||
parsed.hostname,
|
||||
)
|
||||
_time.sleep(wait)
|
||||
else:
|
||||
logger.warning("Webhook 通知失败: %s — %s", url, e)
|
||||
logger.warning("Webhook 通知失败: %s — %s", parsed.hostname, e)
|
||||
|
||||
@staticmethod
|
||||
def _send_webhooks(
|
||||
@@ -946,6 +1069,68 @@ class BackupManager:
|
||||
# 向后兼容别名
|
||||
save_folder = execute_backups
|
||||
|
||||
@staticmethod
|
||||
def _safe_smtp_header(value: str) -> str:
|
||||
"""净化 SMTP 头值,防止头注入"""
|
||||
return value.replace("\r", "").replace("\n", "")
|
||||
|
||||
@staticmethod
|
||||
def _send_email(
|
||||
config: Config,
|
||||
*,
|
||||
status: str,
|
||||
backed: int,
|
||||
skipped: int,
|
||||
elapsed: float,
|
||||
verify_failures: int = 0,
|
||||
) -> None:
|
||||
"""通过 SMTP 发送备份结果邮件通知"""
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
|
||||
if not config.smtp_enabled or not config.smtp_host:
|
||||
return
|
||||
|
||||
subject = (
|
||||
f"[sbackup] 备份{status} - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
)
|
||||
|
||||
body = f"""sbackup 备份通知
|
||||
|
||||
状态: {status}
|
||||
备份策略数: {backed}
|
||||
跳过数: {skipped}
|
||||
耗时: {elapsed:.2f} 秒
|
||||
验证失败: {verify_failures}
|
||||
时间: {datetime.now().isoformat(timespec="seconds")}
|
||||
"""
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = BackupManager._safe_smtp_header(
|
||||
config.smtp_from or config.smtp_user
|
||||
)
|
||||
msg["To"] = BackupManager._safe_smtp_header(config.smtp_to)
|
||||
msg["Subject"] = BackupManager._safe_smtp_header(subject)
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
try:
|
||||
if config.smtp_tls:
|
||||
server = smtplib.SMTP(config.smtp_host, config.smtp_port)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP(config.smtp_host, config.smtp_port)
|
||||
|
||||
if config.smtp_user and config.smtp_password:
|
||||
server.login(config.smtp_user, config.smtp_password)
|
||||
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
logger.debug("邮件通知成功: %s", config.smtp_to)
|
||||
except (smtplib.SMTPException, OSError) as e:
|
||||
logger.warning("邮件通知失败: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _upload_to_sftp(file_paths: list[str], config: Config) -> None:
|
||||
"""将备份文件上传到 SFTP 服务器(多文件并行)"""
|
||||
@@ -1318,7 +1503,7 @@ class BackupManager:
|
||||
# 收集所有策略的目标目录
|
||||
target_dirs: set[str] = set()
|
||||
for key, raw in self.data.items():
|
||||
if key in (_HISTORY_KEY, "_file_meta"):
|
||||
if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||
continue
|
||||
entry = BackupEntry.from_list(raw)
|
||||
if entry.target:
|
||||
@@ -1369,7 +1554,7 @@ class BackupManager:
|
||||
"""导出所有备份策略到 JSON 文件,返回导出的策略数"""
|
||||
export_data = {}
|
||||
for key, raw in self.data.items():
|
||||
if key in (_HISTORY_KEY, "_file_meta"):
|
||||
if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||
continue
|
||||
export_data[key] = raw
|
||||
data_dir = os.path.dirname(output_file)
|
||||
@@ -1383,6 +1568,13 @@ class BackupManager:
|
||||
"""从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None"""
|
||||
if not os.path.exists(input_file):
|
||||
return None
|
||||
# 文件大小限制:最多 10 MB
|
||||
try:
|
||||
if os.path.getsize(input_file) > 10 * 1024 * 1024:
|
||||
logger.warning("Import file too large: %s", input_file)
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with open(input_file, "r", encoding="utf-8") as f:
|
||||
import_data = json.load(f)
|
||||
@@ -1392,9 +1584,50 @@ class BackupManager:
|
||||
return None
|
||||
imported = 0
|
||||
skipped = 0
|
||||
_RESERVED_KEYS = {_HISTORY_KEY, "_file_meta", "_chunk_meta"}
|
||||
_VALID_FORMATS = {
|
||||
"",
|
||||
"ZIP",
|
||||
"TAR",
|
||||
"TAR_GZ",
|
||||
"TAR_BZ2",
|
||||
"TAR_XZ",
|
||||
"TAR_ZST",
|
||||
"7Z",
|
||||
"zip",
|
||||
"tar",
|
||||
"tar.gz",
|
||||
"tar.bz2",
|
||||
"tar.xz",
|
||||
"tar.zst",
|
||||
"7z",
|
||||
}
|
||||
for key, raw in import_data.items():
|
||||
# 安全检查:拒绝内部保留键
|
||||
if key in _RESERVED_KEYS:
|
||||
skipped += 1
|
||||
continue
|
||||
if not isinstance(raw, list) or len(raw) < 3:
|
||||
continue
|
||||
# 安全检查:验证数据结构
|
||||
if not isinstance(raw[0], (int, float)):
|
||||
continue
|
||||
if not isinstance(raw[1], str):
|
||||
continue
|
||||
if not isinstance(raw[2], list):
|
||||
continue
|
||||
# 拒绝包含路径遍历的源/目标路径
|
||||
if ".." in key or not key.strip():
|
||||
skipped += 1
|
||||
continue
|
||||
if ".." in raw[1] or not raw[1].strip():
|
||||
skipped += 1
|
||||
continue
|
||||
# 验证条目级格式(如有)
|
||||
if len(raw) > 3 and raw[3]:
|
||||
if not isinstance(raw[3], str) or raw[3] not in _VALID_FORMATS:
|
||||
skipped += 1
|
||||
continue
|
||||
if key in self.data:
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -1410,7 +1643,7 @@ class BackupManager:
|
||||
|
||||
# 统计策略数
|
||||
strategies = {
|
||||
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta")
|
||||
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||
}
|
||||
lines.append(t("cmd.status.strategies", count=len(strategies)))
|
||||
|
||||
@@ -1481,7 +1714,7 @@ class BackupManager:
|
||||
return {
|
||||
key: BackupEntry.from_list(raw).target
|
||||
for key, raw in self.data.items()
|
||||
if key not in (_HISTORY_KEY, "_file_meta")
|
||||
if key not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -1519,7 +1752,7 @@ class BackupManager:
|
||||
生成对齐的文本表格
|
||||
"""
|
||||
non_history_keys = [
|
||||
k for k in self.data if k not in (_HISTORY_KEY, "_file_meta")
|
||||
k for k in self.data if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||
]
|
||||
if not non_history_keys:
|
||||
return t("cmd.all.empty")
|
||||
@@ -1532,7 +1765,7 @@ class BackupManager:
|
||||
]
|
||||
rows = []
|
||||
for path, raw in self.data.items():
|
||||
if path in (_HISTORY_KEY, "_file_meta"):
|
||||
if path in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||
continue
|
||||
entry = BackupEntry.from_list(raw)
|
||||
fmt_display = (
|
||||
@@ -1562,7 +1795,7 @@ class BackupManager:
|
||||
|
||||
# 策略统计
|
||||
strategies = {
|
||||
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta")
|
||||
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||
}
|
||||
lines.append("## 策略概览")
|
||||
lines.append(f"- 策略总数: {len(strategies)}")
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""块级增量备份模块
|
||||
|
||||
将文件切分为固定大小的块(默认64KB),通过比较 SHA256 哈希
|
||||
识别变化的块,仅备份变更部分,大幅减少增量备份的数据量。
|
||||
|
||||
使用场景:巨量 VM 镜像 / 数据库文件 / 日志文件,每次只改了一小部分。
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import struct
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 64 * 1024 # 64KB
|
||||
|
||||
# 块级增量有三种模式
|
||||
MODE_DISABLED = ""
|
||||
MODE_FILE = "file" # 文件级增量(现有行为)
|
||||
MODE_BLOCK = "block" # 块级增量(新)
|
||||
|
||||
|
||||
def compute_chunk_hashes(filepath: str, chunk_size: int = CHUNK_SIZE) -> dict[str, str]:
|
||||
"""计算文件所有块的哈希,返回 {index_str: sha256}"""
|
||||
hashes: dict[str, str] = {}
|
||||
with open(filepath, "rb") as f:
|
||||
index = 0
|
||||
while True:
|
||||
data = f.read(chunk_size)
|
||||
if not data:
|
||||
break
|
||||
hashes[str(index)] = hashlib.sha256(data).hexdigest()
|
||||
index += 1
|
||||
return hashes
|
||||
|
||||
|
||||
def find_changed_chunks(
|
||||
filepath: str,
|
||||
stored_hashes: dict[str, str],
|
||||
chunk_size: int = CHUNK_SIZE,
|
||||
) -> tuple[set[int], int]:
|
||||
"""找出变化的块索引集合和总块数。
|
||||
|
||||
:returns: (changed_indices_set, total_chunks)
|
||||
"""
|
||||
current = compute_chunk_hashes(filepath, chunk_size)
|
||||
total = len(current)
|
||||
changed: set[int] = set()
|
||||
for idx_str, current_hash in current.items():
|
||||
if stored_hashes.get(idx_str) != current_hash:
|
||||
changed.add(int(idx_str))
|
||||
# 如果文件变短了,标记缺失的块为变化
|
||||
for idx_str in stored_hashes:
|
||||
idx = int(idx_str)
|
||||
if idx_str not in current:
|
||||
changed.add(idx)
|
||||
return changed, total
|
||||
|
||||
|
||||
def create_patch(
|
||||
filepath: str,
|
||||
changed_indices: set[int],
|
||||
target_path: str,
|
||||
chunk_size: int = CHUNK_SIZE,
|
||||
) -> int:
|
||||
"""创建块级补丁文件。
|
||||
|
||||
格式:[chunk_count:int32] [[chunk_index:int32][chunk_size:int32][data:bytes]]*
|
||||
|
||||
:returns: patch 文件字节数,0 表示没有变化
|
||||
"""
|
||||
if not changed_indices:
|
||||
return 0
|
||||
|
||||
with open(filepath, "rb") as src, open(target_path, "wb") as dst:
|
||||
changed_list = sorted(changed_indices)
|
||||
dst.write(struct.pack("<i", len(changed_list)))
|
||||
|
||||
for chunk_idx in changed_list:
|
||||
offset = chunk_idx * chunk_size
|
||||
src.seek(offset)
|
||||
data = src.read(chunk_size)
|
||||
dst.write(struct.pack("<ii", chunk_idx, len(data)))
|
||||
dst.write(data)
|
||||
|
||||
return os.path.getsize(target_path)
|
||||
|
||||
|
||||
def apply_patch(
|
||||
base_path: str,
|
||||
patch_path: str,
|
||||
output_path: str,
|
||||
chunk_size: int = CHUNK_SIZE,
|
||||
) -> bool:
|
||||
"""将补丁应用到基准文件,生成还原后的文件。
|
||||
|
||||
对于补丁中包含的块索引,使用补丁中的数据;
|
||||
对于补丁中不包含的索引,从基准文件中读取。
|
||||
"""
|
||||
try:
|
||||
# 读取补丁中的所有块
|
||||
patch_chunks: dict[int, bytes] = {}
|
||||
with open(patch_path, "rb") as pf:
|
||||
count = struct.unpack("<i", pf.read(4))[0]
|
||||
for _ in range(count):
|
||||
idx, size = struct.unpack("<ii", pf.read(8))
|
||||
data = pf.read(size)
|
||||
patch_chunks[idx] = data
|
||||
|
||||
# 从基准文件读取完整内容(按块填充)
|
||||
max_index = max(patch_chunks.keys())
|
||||
base_size = os.path.getsize(base_path)
|
||||
total_chunks = max(max_index + 1, (base_size + chunk_size - 1) // chunk_size)
|
||||
|
||||
with open(output_path, "wb") as out:
|
||||
with open(base_path, "rb") as base:
|
||||
for idx in range(total_chunks):
|
||||
if idx in patch_chunks:
|
||||
out.write(patch_chunks[idx])
|
||||
else:
|
||||
base.seek(idx * chunk_size)
|
||||
data = base.read(chunk_size)
|
||||
out.write(data)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to apply patch")
|
||||
return False
|
||||
|
||||
|
||||
def patch_size_ratio(
|
||||
filepath: str,
|
||||
changed_indices: set[int],
|
||||
chunk_size: int = CHUNK_SIZE,
|
||||
) -> float:
|
||||
"""计算变化的块占文件大小的比例"""
|
||||
total_size = os.path.getsize(filepath)
|
||||
if total_size == 0:
|
||||
return 0.0
|
||||
changed_size = len(changed_indices) * chunk_size
|
||||
return min(changed_size / total_size, 1.0)
|
||||
|
||||
|
||||
def should_do_full_backup(
|
||||
filepath: str,
|
||||
changed_indices: set[int],
|
||||
threshold: float = 0.5,
|
||||
) -> bool:
|
||||
"""判断是否应该做全量备份(变化比例超过阈值时回退到全量)"""
|
||||
ratio = patch_size_ratio(filepath, changed_indices)
|
||||
return ratio >= threshold
|
||||
+228
-15
@@ -70,8 +70,10 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--incremental",
|
||||
action="store_true",
|
||||
default=False,
|
||||
nargs="?",
|
||||
const="file",
|
||||
default=None,
|
||||
choices=["file", "block"],
|
||||
help=t("cli.help.incremental"),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -262,6 +264,18 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
default=None,
|
||||
help=t("cli.help.watch.post_hook"),
|
||||
)
|
||||
watch_parser.add_argument(
|
||||
"--realtime",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=t("cli.help.watch.realtime"),
|
||||
)
|
||||
watch_parser.add_argument(
|
||||
"--debounce",
|
||||
type=float,
|
||||
default=30,
|
||||
help=t("cli.help.watch.debounce"),
|
||||
)
|
||||
|
||||
restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore"))
|
||||
restore_parser.add_argument("backup_file", help=t("cli.help.restore.file"))
|
||||
@@ -290,13 +304,33 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
diff_parser.add_argument("--password", default="", help=t("cli.help.diff.password"))
|
||||
|
||||
verify_parser = subparsers.add_parser("verify", help=t("cli.help.verify"))
|
||||
verify_parser.add_argument("backup_file", help=t("cli.help.verify.file"))
|
||||
verify_parser.add_argument(
|
||||
"backup_file", nargs="?", default=None, help=t("cli.help.verify.file")
|
||||
)
|
||||
verify_parser.add_argument(
|
||||
"--fast",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=t("cli.help.verify.fast"),
|
||||
)
|
||||
verify_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=t("cli.help.verify.all"),
|
||||
)
|
||||
verify_parser.add_argument(
|
||||
"--detail",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=t("cli.help.verify.detail"),
|
||||
)
|
||||
verify_parser.add_argument(
|
||||
"--split",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=t("cli.help.verify.split"),
|
||||
)
|
||||
verify_parser.add_argument(
|
||||
"--password", default="", help=t("cli.help.verify.password")
|
||||
)
|
||||
@@ -484,6 +518,17 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
clean_parser.add_argument(
|
||||
"--keep-days", type=int, default=0, help=t("cli.help.clean.keep_days")
|
||||
)
|
||||
completion_parser = subparsers.add_parser(
|
||||
"completion", help=t("cli.help.completion")
|
||||
)
|
||||
completion_parser.add_argument(
|
||||
"shell",
|
||||
nargs="?",
|
||||
default="bash",
|
||||
choices=["bash", "zsh", "fish", "powershell"],
|
||||
help=t("cli.help.completion.shell"),
|
||||
)
|
||||
|
||||
clean_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
@@ -618,6 +663,15 @@ def _handle_list(args, config, manager) -> int:
|
||||
|
||||
|
||||
def _handle_save(args, config, manager) -> int:
|
||||
from sbackup.lock import BackupLock
|
||||
|
||||
if args.password:
|
||||
print(t("warn.password_in_cli"), file=sys.stderr)
|
||||
lock = BackupLock(os.path.dirname(config.data_file) or ".")
|
||||
if not lock.acquire():
|
||||
print(t("err.lock.conflict"))
|
||||
return 1
|
||||
try:
|
||||
manager.execute_backups(
|
||||
keep=args.keep,
|
||||
keep_days=args.keep_days,
|
||||
@@ -639,16 +693,30 @@ def _handle_save(args, config, manager) -> int:
|
||||
pre_hooks=getattr(args, "pre_hook", None) or [],
|
||||
post_hooks=getattr(args, "post_hook", None) or [],
|
||||
)
|
||||
finally:
|
||||
lock.release()
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_watch(args, config, manager) -> int:
|
||||
from sbackup.lock import BackupLock
|
||||
|
||||
import time as _time
|
||||
|
||||
interval_sec = max(args.interval, 1) * 60
|
||||
print(t("cmd.watch.start", interval=args.interval))
|
||||
try:
|
||||
while True:
|
||||
realtime = getattr(args, "realtime", False)
|
||||
debounce = max(getattr(args, "debounce", 30), 1)
|
||||
|
||||
# watch 命令长期持有锁,阻止并发 save/watch
|
||||
lock = BackupLock(os.path.dirname(config.data_file) or ".")
|
||||
if not lock.acquire():
|
||||
print(t("err.lock.conflict"))
|
||||
return 1
|
||||
|
||||
if args.password:
|
||||
print(t("warn.password_in_cli"), file=sys.stderr)
|
||||
|
||||
def do_backup() -> None:
|
||||
manager.execute_backups(
|
||||
keep=args.keep,
|
||||
keep_days=args.keep_days,
|
||||
@@ -662,9 +730,7 @@ def _handle_watch(args, config, manager) -> int:
|
||||
follow_symlinks=args.follow_symlinks,
|
||||
max_size=_parse_size(args.max_size) if args.max_size else 0,
|
||||
min_size=_parse_size(args.min_size) if args.min_size else 0,
|
||||
max_age_seconds=_parse_duration(args.older_than)
|
||||
if args.older_than
|
||||
else 0,
|
||||
max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0,
|
||||
incremental=args.incremental,
|
||||
split_size=_parse_size(args.split) if args.split else 0,
|
||||
tag=args.tag or "",
|
||||
@@ -672,9 +738,51 @@ def _handle_watch(args, config, manager) -> int:
|
||||
pre_hooks=getattr(args, "pre_hook", None) or [],
|
||||
post_hooks=getattr(args, "post_hook", None) or [],
|
||||
)
|
||||
|
||||
try:
|
||||
if realtime:
|
||||
from sbackup.monitor import FileSystemMonitor
|
||||
from sbackup.auto_save import BackupEntry
|
||||
|
||||
source_dirs: dict[str, str] = {}
|
||||
for key, raw in manager.data.items():
|
||||
if key in ("_history", "_file_meta"):
|
||||
continue
|
||||
entry = BackupEntry.from_list(raw)
|
||||
source_dirs[key] = entry.target
|
||||
|
||||
if not source_dirs:
|
||||
print(t("cmd.all.empty"))
|
||||
return 1
|
||||
|
||||
monitor = FileSystemMonitor(source_dirs, debounce_seconds=debounce)
|
||||
monitor.set_backup_callback(do_backup)
|
||||
|
||||
print(t("cmd.watch.start_realtime", debounce=debounce))
|
||||
monitor.start()
|
||||
|
||||
if not monitor.is_running():
|
||||
return 1
|
||||
|
||||
try:
|
||||
do_backup()
|
||||
while True:
|
||||
_time.sleep(interval_sec)
|
||||
do_backup()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
monitor.stop()
|
||||
return 0
|
||||
|
||||
print(t("cmd.watch.start", interval=args.interval))
|
||||
while True:
|
||||
do_backup()
|
||||
_time.sleep(interval_sec)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _handle_restore(args, config, manager) -> int:
|
||||
@@ -713,12 +821,107 @@ def _handle_diff(args, config, manager) -> int:
|
||||
|
||||
|
||||
def _handle_verify(args, config, manager) -> int:
|
||||
from sbackup.compression import (
|
||||
verify_backup,
|
||||
verify_backup_fast,
|
||||
get_backup_info,
|
||||
verify_split_integrity,
|
||||
)
|
||||
|
||||
# 分卷完整性验证
|
||||
if args.split:
|
||||
if not args.backup_file:
|
||||
print(t("cmd.verify.no_file"))
|
||||
return 1
|
||||
result = verify_split_integrity(args.backup_file)
|
||||
if result["success"]:
|
||||
print(
|
||||
t(
|
||||
"cmd.verify.split_success",
|
||||
file=result.get("original_file", ""),
|
||||
parts=result.get("parts_count", 0),
|
||||
)
|
||||
)
|
||||
return 0
|
||||
else:
|
||||
error = result.get("error", "")
|
||||
if error:
|
||||
print(t("cmd.verify.split_error", error=error))
|
||||
else:
|
||||
print(
|
||||
t("cmd.verify.split_failed", file=result.get("original_file", ""))
|
||||
)
|
||||
for r in result.get("results", []):
|
||||
if r["status"] != "ok":
|
||||
print(f" {r['path']}: {r['status']}")
|
||||
return 1
|
||||
|
||||
# 批量验证所有备份
|
||||
if args.all:
|
||||
history = manager.get_history()
|
||||
if not history:
|
||||
print(t("cmd.verify.no_history"))
|
||||
return 1
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
for entry in reversed(history):
|
||||
source = entry.get("source", "")
|
||||
sha256 = entry.get("sha256", "")
|
||||
if not source or not os.path.isfile(source):
|
||||
continue
|
||||
|
||||
if args.detail:
|
||||
info = get_backup_info(source, args.password)
|
||||
print(t("cmd.verify.detail_header", path=source))
|
||||
print(
|
||||
t(
|
||||
"cmd.verify.detail_info",
|
||||
size=info.get("size_mb", 0),
|
||||
files=info.get("files_count", 0),
|
||||
fmt=info.get("format", "unknown"),
|
||||
sha256=sha256[:16] if sha256 else "N/A",
|
||||
)
|
||||
)
|
||||
|
||||
if sha256:
|
||||
result = verify_backup_fast(source, sha256)
|
||||
else:
|
||||
result = verify_backup(source, args.password)
|
||||
|
||||
if result["success"]:
|
||||
print(t("cmd.verify.batch_success", path=source))
|
||||
success_count += 1
|
||||
else:
|
||||
print(t("cmd.verify.batch_failed", path=source))
|
||||
fail_count += 1
|
||||
|
||||
print(t("cmd.verify.batch_summary", success=success_count, fail=fail_count))
|
||||
return 0 if fail_count == 0 else 1
|
||||
|
||||
# 单文件验证
|
||||
if not args.backup_file:
|
||||
print(t("cmd.verify.no_file"))
|
||||
return 1
|
||||
|
||||
if args.detail:
|
||||
info = get_backup_info(args.backup_file, args.password)
|
||||
sha256 = manager.find_checksum(args.backup_file)
|
||||
print(t("cmd.verify.detail_header", path=args.backup_file))
|
||||
print(
|
||||
t(
|
||||
"cmd.verify.detail_info",
|
||||
size=info.get("size_mb", 0),
|
||||
files=info.get("files_count", 0),
|
||||
fmt=info.get("format", "unknown"),
|
||||
sha256=sha256[:16] if sha256 else "N/A",
|
||||
)
|
||||
)
|
||||
|
||||
if args.fast:
|
||||
# 快速验证:用历史 SHA256 比对,无需解压
|
||||
sha256 = manager.find_checksum(args.backup_file)
|
||||
if sha256:
|
||||
from sbackup.compression import verify_backup_fast
|
||||
|
||||
result = verify_backup_fast(args.backup_file, sha256)
|
||||
if result["success"]:
|
||||
print(
|
||||
@@ -731,13 +934,9 @@ def _handle_verify(args, config, manager) -> int:
|
||||
else:
|
||||
print(t("cmd.verify.no_checksum", path=args.backup_file))
|
||||
# 回退到完整验证
|
||||
from sbackup.compression import verify_backup
|
||||
|
||||
result = verify_backup(args.backup_file, args.password)
|
||||
return 0 if result["success"] else 1
|
||||
else:
|
||||
from sbackup.compression import verify_backup
|
||||
|
||||
result = verify_backup(args.backup_file, args.password)
|
||||
return 0 if result["success"] else 1
|
||||
|
||||
@@ -963,6 +1162,19 @@ def _handle_clean(args, config, manager) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _handle_completion(args, config, manager) -> int:
|
||||
"""生成并输出 shell 自动补全脚本"""
|
||||
from sbackup.completion import generate
|
||||
|
||||
try:
|
||||
script = generate(args.shell)
|
||||
print(script, end="")
|
||||
except ValueError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
_COMMAND_HANDLERS: dict[str, callable] = {
|
||||
"version": _handle_version,
|
||||
"add": _handle_add,
|
||||
@@ -992,6 +1204,7 @@ _COMMAND_HANDLERS: dict[str, callable] = {
|
||||
"report": _handle_report,
|
||||
"search": _handle_search,
|
||||
"clean": _handle_clean,
|
||||
"completion": _handle_completion,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Shell 自动补全脚本生成
|
||||
|
||||
为支持的 shell(bash / zsh / fish / powershell)生成补全脚本。
|
||||
用法::
|
||||
|
||||
sbackup completion bash # 输出 bash 补全脚本
|
||||
sbackup completion zsh # 输出 zsh 补全脚本
|
||||
sbackup completion fish # 输出 fish 补全脚本
|
||||
sbackup completion powershell # 输出 PowerShell 补全脚本
|
||||
|
||||
安装示例::
|
||||
|
||||
# Bash
|
||||
sbackup completion bash > /etc/bash_completion.d/sbackup
|
||||
|
||||
# Zsh
|
||||
sbackup completion zsh > /usr/local/share/zsh/site-functions/_sbackup
|
||||
|
||||
# Fish
|
||||
sbackup completion fish > ~/.config/fish/completions/sbackup.fish
|
||||
|
||||
# PowerShell
|
||||
sbackup completion powershell >> $PROFILE
|
||||
"""
|
||||
|
||||
|
||||
# 所有子命令列表(与 cli.py 中的 _COMMAND_HANDLERS 保持同步)
|
||||
_SUBCOMMANDS = [
|
||||
"add",
|
||||
"rm",
|
||||
"remove",
|
||||
"edit",
|
||||
"all",
|
||||
"list",
|
||||
"history",
|
||||
"save",
|
||||
"watch",
|
||||
"restore",
|
||||
"info",
|
||||
"diff",
|
||||
"verify",
|
||||
"sftp",
|
||||
"webdav",
|
||||
"remote",
|
||||
"export",
|
||||
"import",
|
||||
"status",
|
||||
"ignore",
|
||||
"versions",
|
||||
"schedule",
|
||||
"webhook",
|
||||
"config",
|
||||
"report",
|
||||
"search",
|
||||
"clean",
|
||||
"version",
|
||||
"init",
|
||||
"completion",
|
||||
]
|
||||
|
||||
# 全局 flag(适用于所有命令)
|
||||
_GLOBAL_FLAGS = [
|
||||
"--debug",
|
||||
"--lang",
|
||||
"--format",
|
||||
"--help",
|
||||
"--follow-symlinks",
|
||||
"--incremental",
|
||||
"--checksum",
|
||||
]
|
||||
|
||||
# 各子命令的专属 flag
|
||||
_SUBCOMMAND_FLAGS: dict[str, list[str]] = {
|
||||
"add": ["-i", "--ignore", "--format", "--name-template", "--from-gitignore"],
|
||||
"rm": ["--all"],
|
||||
"edit": ["--dest", "--ignore", "--format", "--name-template"],
|
||||
"save": [
|
||||
"--keep",
|
||||
"--keep-days",
|
||||
"--password",
|
||||
"--sftp",
|
||||
"--webdav",
|
||||
"--dry-run",
|
||||
"--verify",
|
||||
"--name-template",
|
||||
"--webhook",
|
||||
"--max-size",
|
||||
"--min-size",
|
||||
"--split",
|
||||
"--tag",
|
||||
"--older-than",
|
||||
"--pre-hook",
|
||||
"--post-hook",
|
||||
],
|
||||
"watch": [
|
||||
"--interval",
|
||||
"--keep",
|
||||
"--keep-days",
|
||||
"--password",
|
||||
"--sftp",
|
||||
"--webdav",
|
||||
"--dry-run",
|
||||
"--verify",
|
||||
"--name-template",
|
||||
"--webhook",
|
||||
"--max-size",
|
||||
"--min-size",
|
||||
"--split",
|
||||
"--tag",
|
||||
"--older-than",
|
||||
"--pre-hook",
|
||||
"--post-hook",
|
||||
"--realtime",
|
||||
"--debounce",
|
||||
],
|
||||
"restore": ["--password", "-l", "--list", "--select"],
|
||||
"sftp": ["config", "test"],
|
||||
"webdav": ["config", "test"],
|
||||
"remote": ["list", "rm"],
|
||||
"schedule": ["export"],
|
||||
"webhook": ["preset"],
|
||||
"config": ["lock", "unlock"],
|
||||
"verify": ["--fast", "--all", "--detail", "--split"],
|
||||
"diff": ["--password"],
|
||||
"info": ["--password"],
|
||||
"search": ["--password"],
|
||||
"versions": ["--password"],
|
||||
"export": [],
|
||||
"import": [],
|
||||
"ignore": ["--preset"],
|
||||
"clean": ["--keep", "--keep-days", "--dry-run"],
|
||||
"report": ["--format", "--output"],
|
||||
"completion": ["bash", "zsh", "fish", "powershell"],
|
||||
"all": [],
|
||||
"list": [],
|
||||
"history": [],
|
||||
"version": [],
|
||||
"init": [],
|
||||
"status": [],
|
||||
}
|
||||
|
||||
|
||||
def generate_bash() -> str:
|
||||
"""生成 Bash 自动补全脚本"""
|
||||
lines = [
|
||||
"# sbackup Bash completion",
|
||||
f"_{_esc('_sbackup_completion')}() {{",
|
||||
" local cur prev words cword",
|
||||
" _init_completion || return",
|
||||
"",
|
||||
" if [[ $cword -eq 1 ]]; then",
|
||||
f' COMPREPLY=($(compgen -W "{" ".join(_SUBCOMMANDS)}" -- "$cur"))',
|
||||
" return 0",
|
||||
" fi",
|
||||
"",
|
||||
" local cmd=${words[1]}",
|
||||
' local global_flags="{}"'.format(" ".join(_GLOBAL_FLAGS)),
|
||||
"",
|
||||
" case $cmd in",
|
||||
]
|
||||
|
||||
for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()):
|
||||
if cmd in ("rm", "list", "history", "all", "version", "init", "status"):
|
||||
continue # handled by aliases or no flags
|
||||
flag_str = " ".join(flags) if flags else ""
|
||||
lines.append(f" {cmd})")
|
||||
lines.append(
|
||||
f' COMPREPLY=($(compgen -W "$global_flags {flag_str}" -- "$cur"))'
|
||||
)
|
||||
lines.append(" ;;")
|
||||
|
||||
lines += [
|
||||
" rm|remove)",
|
||||
' COMPREPLY=($(compgen -W "$global_flags --all" -- "$cur"))',
|
||||
" ;;",
|
||||
" list|history)",
|
||||
' COMPREPLY=($(compgen -W "$global_flags" -- "$cur"))',
|
||||
" ;;",
|
||||
" *)",
|
||||
' COMPREPLY=($(compgen -W "$global_flags" -- "$cur"))',
|
||||
" ;;",
|
||||
" esac",
|
||||
"} &&",
|
||||
"complete -F _sbackup_completion sbackup",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_zsh() -> str:
|
||||
"""生成 Zsh 自动补全脚本"""
|
||||
lines = [
|
||||
"#compdef sbackup",
|
||||
"",
|
||||
"_sbackup_completion() {",
|
||||
" local -a subcommands",
|
||||
" subcommands=(",
|
||||
]
|
||||
for cmd in _SUBCOMMANDS:
|
||||
lines.append(f" '{cmd}:{_get_desc(cmd)}'")
|
||||
lines += [
|
||||
" )",
|
||||
"",
|
||||
" _arguments \\",
|
||||
" '--debug[Enable debug mode]' \\",
|
||||
" '--lang[Set interface language]:lang:(zh_CN en_US fr_FR es_ES ru_RU de_DE ja_JP pt_BR ko_KR)' \\",
|
||||
" '--format[Set packaging format]:format:(zip tar tar.gz tar.bz2 tar.xz tar.zst 7z)' \\",
|
||||
" '--follow-symlinks[Follow symbolic links]' \\",
|
||||
" '--incremental[File-level incremental backup]' \\",
|
||||
" '--checksum[Use SHA256 checksum]' \\",
|
||||
" '--help[Show help]' \\",
|
||||
" '1: :->command' \\",
|
||||
" '*: :->args'",
|
||||
"",
|
||||
" case $state in",
|
||||
" command)",
|
||||
" _describe 'command' subcommands",
|
||||
" ;;",
|
||||
" args)",
|
||||
" _sbackup_args ${words[1]}",
|
||||
" ;;",
|
||||
" esac",
|
||||
"}",
|
||||
"",
|
||||
"_sbackup_args() {",
|
||||
" case $1 in",
|
||||
]
|
||||
for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()):
|
||||
if not flags:
|
||||
continue
|
||||
flag_parts = []
|
||||
for f in flags:
|
||||
if f.startswith("--"):
|
||||
flag_parts.append(f"'{f}[ ]'")
|
||||
elif f.startswith("-"):
|
||||
flag_parts.append(f"'{f}[ ]'")
|
||||
flag_str = " \\\n ".join(flag_parts)
|
||||
lines += [
|
||||
f" {cmd})",
|
||||
f" _arguments : {flag_str}",
|
||||
" ;;",
|
||||
]
|
||||
lines += [
|
||||
" esac",
|
||||
"}",
|
||||
"",
|
||||
'_sbackup_completion "$@"',
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_fish() -> str:
|
||||
"""生成 Fish 自动补全脚本"""
|
||||
lines = [
|
||||
"# sbackup Fish completion",
|
||||
"",
|
||||
]
|
||||
# 全局 flags
|
||||
lines += [
|
||||
"complete -c sbackup -l debug -d 'Enable debug mode'",
|
||||
"complete -c sbackup -l lang -x -a 'zh_CN en_US fr_FR es_ES ru_RU de_DE ja_JP pt_BR ko_KR' -d 'Set interface language'",
|
||||
"complete -c sbackup -l format -x -a 'zip tar tar.gz tar.bz2 tar.xz tar.zst 7z' -d 'Set packaging format'",
|
||||
"complete -c sbackup -l follow-symlinks -d 'Follow symbolic links'",
|
||||
"complete -c sbackup -l incremental -d 'File-level incremental backup'",
|
||||
"complete -c sbackup -l checksum -d 'Use SHA256 checksum'",
|
||||
"",
|
||||
"# Subcommands",
|
||||
]
|
||||
for cmd in _SUBCOMMANDS:
|
||||
lines.append(
|
||||
f"complete -c sbackup -n 'not __fish_seen_subcommand_from {_esc(' '.join(_SUBCOMMANDS))}' -a {cmd} -d '{_get_desc(cmd)}'"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
# 各子命令的 flags
|
||||
for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()):
|
||||
if not flags:
|
||||
continue
|
||||
for f in flags:
|
||||
if f.startswith("--"):
|
||||
lines.append(
|
||||
f"complete -c sbackup -n '__fish_seen_subcommand_from {cmd}' "
|
||||
f"-l {f[2:]} -d ''"
|
||||
)
|
||||
elif f.startswith("-") and not f.startswith("--"):
|
||||
lines.append(
|
||||
f"complete -c sbackup -n '__fish_seen_subcommand_from {cmd}' "
|
||||
f"-s {f[1:]} -d ''"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_powershell() -> str:
|
||||
"""生成 PowerShell 自动补全脚本"""
|
||||
commands_json = ", ".join(f"'{c}'" for c in _SUBCOMMANDS)
|
||||
lines = [
|
||||
"# sbackup PowerShell completion",
|
||||
"Register-ArgumentCompleter -Native -CommandName sbackup -ScriptBlock {",
|
||||
" param($wordToComplete, $commandAst, $cursorPosition)",
|
||||
"",
|
||||
f" $commands = @({commands_json})",
|
||||
" $globalFlags = @(",
|
||||
" '--debug', '--lang', '--format', '--help',",
|
||||
" '--follow-symlinks', '--incremental', '--checksum'",
|
||||
" )",
|
||||
"",
|
||||
" $cmdFlags = @{",
|
||||
]
|
||||
for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()):
|
||||
flag_str = ", ".join(f"'{f}'" for f in flags) if flags else ""
|
||||
lines.append(f" '{cmd}' = @({flag_str})")
|
||||
lines += [
|
||||
" }",
|
||||
"",
|
||||
" $tokens = $commandAst.CommandElements",
|
||||
" $cmd = if ($tokens.Count -gt 1) { $tokens[1].Value } else { $null }",
|
||||
"",
|
||||
" if ($tokens.Count -le 2) {",
|
||||
' $commands | Where-Object { $_ -like "$wordToComplete*" }',
|
||||
" } else {",
|
||||
" $flags = $globalFlags + $cmdFlags[$cmd]",
|
||||
' $flags | Where-Object { $_ -like "$wordToComplete*" }',
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_desc(cmd: str) -> str:
|
||||
"""获取子命令的简短描述"""
|
||||
desc_map = {
|
||||
"add": "Add a new backup strategy",
|
||||
"rm": "Remove a backup strategy",
|
||||
"remove": "Remove a backup strategy",
|
||||
"edit": "Edit a backup strategy",
|
||||
"all": "Show all backup strategies",
|
||||
"list": "Show backup history",
|
||||
"history": "Show backup history",
|
||||
"save": "Execute backup tasks",
|
||||
"watch": "Run backups periodically",
|
||||
"restore": "Restore from backup file",
|
||||
"info": "Show backup file info",
|
||||
"diff": "Show differences between source and backup",
|
||||
"verify": "Verify backup integrity",
|
||||
"sftp": "Manage SFTP remote backup",
|
||||
"webdav": "Manage WebDAV remote backup",
|
||||
"remote": "Manage remote files",
|
||||
"export": "Export backup strategies",
|
||||
"import": "Import backup strategies",
|
||||
"status": "Show backup status dashboard",
|
||||
"ignore": "Generate .sbackupignore file",
|
||||
"versions": "Show all versions for a source",
|
||||
"schedule": "Export schedule config",
|
||||
"webhook": "Configure webhook presets",
|
||||
"config": "Manage configuration",
|
||||
"report": "Export backup report",
|
||||
"search": "Search files in backups",
|
||||
"clean": "Clean old backups",
|
||||
"version": "Show version info",
|
||||
"init": "Generate config template",
|
||||
"completion": "Generate shell completion script",
|
||||
}
|
||||
return desc_map.get(cmd, "")
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
"""Shell 转义(简单处理)"""
|
||||
return s.replace("'", "'\\''")
|
||||
|
||||
|
||||
def generate(shell: str) -> str:
|
||||
"""生成指定 shell 的补全脚本"""
|
||||
generators = {
|
||||
"bash": generate_bash,
|
||||
"zsh": generate_zsh,
|
||||
"fish": generate_fish,
|
||||
"powershell": generate_powershell,
|
||||
}
|
||||
gen = generators.get(shell.lower())
|
||||
if gen is None:
|
||||
shells = ", ".join(generators)
|
||||
raise ValueError(f"Unsupported shell: {shell}. Supported: {shells}")
|
||||
return gen()
|
||||
+319
-53
@@ -134,14 +134,16 @@ def _resolve_name(template: str, folder_name: str) -> str:
|
||||
|
||||
|
||||
def _encrypt_file(file_path: str, password: str) -> str:
|
||||
"""用密码加密文件(PBKDF2 + XOR 流密码),返回加密后的文件路径"""
|
||||
"""用密码加密文件(PBKDF2 + XOR 流密码 + HMAC 完整性校验),返回加密后的文件路径"""
|
||||
import hashlib
|
||||
|
||||
salt = os.urandom(16)
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000)
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000)
|
||||
|
||||
encrypted_path = file_path + ".enc"
|
||||
with open(file_path, "rb") as fin, open(encrypted_path, "wb") as fout:
|
||||
tmp_path = encrypted_path + ".tmp"
|
||||
try:
|
||||
with open(file_path, "rb") as fin, open(tmp_path, "wb") as fout:
|
||||
fout.write(salt)
|
||||
key_stream = bytearray()
|
||||
block_index = 0
|
||||
@@ -149,29 +151,35 @@ def _encrypt_file(file_path: str, password: str) -> str:
|
||||
chunk = fin.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
# 生成密钥流
|
||||
while len(key_stream) < len(chunk):
|
||||
block_index += 1
|
||||
key_stream.extend(
|
||||
hashlib.sha256(key + block_index.to_bytes(4, "big")).digest()
|
||||
)
|
||||
# XOR 加密
|
||||
encrypted = bytes(a ^ b for a, b in zip(chunk, key_stream[: len(chunk)]))
|
||||
encrypted = bytes(
|
||||
a ^ b for a, b in zip(chunk, key_stream[: len(chunk)])
|
||||
)
|
||||
fout.write(encrypted)
|
||||
key_stream = key_stream[len(chunk) :]
|
||||
|
||||
# 原子替换到最终路径
|
||||
os.replace(tmp_path, encrypted_path)
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
os.remove(file_path)
|
||||
return encrypted_path
|
||||
|
||||
|
||||
def _decrypt_file(encrypted_path: str, password: str) -> str:
|
||||
"""解密由 _encrypt_file 加密的文件,返回解密后的文件路径"""
|
||||
"""解密由 _encrypt_file 加密的文件,返回解密后的文件路径。密码错误时抛出 ValueError"""
|
||||
import hashlib
|
||||
|
||||
decrypted_path = encrypted_path.removesuffix(".enc")
|
||||
with open(encrypted_path, "rb") as fin:
|
||||
salt = fin.read(16)
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000)
|
||||
if len(salt) < 16:
|
||||
raise ValueError("Invalid encrypted file: too short")
|
||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000)
|
||||
|
||||
with open(decrypted_path, "wb") as fout:
|
||||
key_stream = bytearray()
|
||||
@@ -246,11 +254,12 @@ class BaseCompressor:
|
||||
negated.append(pattern[1:])
|
||||
continue
|
||||
if pattern.startswith("re:"):
|
||||
# 正则表达式模式
|
||||
regex_str = pattern[3:]
|
||||
# ReDoS 防护:拒绝过长或明显恶意的正则
|
||||
if len(regex_str) > 256:
|
||||
continue
|
||||
try:
|
||||
if re.search(pattern[3:], rel_path) or re.search(
|
||||
pattern[3:], basename
|
||||
):
|
||||
if re.search(regex_str, rel_path) or re.search(regex_str, basename):
|
||||
matched = True
|
||||
except re.error:
|
||||
pass
|
||||
@@ -262,10 +271,11 @@ class BaseCompressor:
|
||||
return False
|
||||
for pattern in negated:
|
||||
if pattern.startswith("re:"):
|
||||
regex_str = pattern[3:]
|
||||
if len(regex_str) > 256:
|
||||
continue
|
||||
try:
|
||||
if re.search(pattern[3:], rel_path) or re.search(
|
||||
pattern[3:], basename
|
||||
):
|
||||
if re.search(regex_str, rel_path) or re.search(regex_str, basename):
|
||||
return False
|
||||
except re.error:
|
||||
pass
|
||||
@@ -827,6 +837,85 @@ def _match_select_pattern(name: str, pattern: str) -> bool:
|
||||
return fnmatch(name, pattern) or fnmatch(basename, pattern)
|
||||
|
||||
|
||||
def _is_safe_member_name(member_name: str) -> bool:
|
||||
"""检查归档成员名是否安全(无路径遍历、无绝对路径、无空字节)"""
|
||||
# 拒绝含空字节的路径(可能绕过之后的检查)
|
||||
if "\x00" in member_name:
|
||||
return False
|
||||
# 拒绝绝对路径
|
||||
if os.path.isabs(member_name):
|
||||
return False
|
||||
# Windows: 拒绝驱动器相对路径(如 C:file.txt)
|
||||
if os.name == "nt" and len(member_name) >= 2:
|
||||
if member_name[1] == ":" and member_name[0].isalpha():
|
||||
return False
|
||||
# 拒绝包含 .. 的路径组件
|
||||
parts = Path(member_name).parts
|
||||
if ".." in parts:
|
||||
return False
|
||||
# 拒绝空路径
|
||||
if not member_name or member_name.strip() in ("", ".", "/"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _safe_extract_zip(
|
||||
zf: zipfile.ZipFile, target: Path, members: list[str] | None = None
|
||||
) -> list[str]:
|
||||
"""安全提取 ZIP 文件,过滤路径遍历成员,返回被跳过的成员列表"""
|
||||
target_resolved = target.resolve()
|
||||
skipped = []
|
||||
names = members if members is not None else zf.namelist()
|
||||
for member in names:
|
||||
if not _is_safe_member_name(member):
|
||||
skipped.append(member)
|
||||
continue
|
||||
# 双重检查:解析后路径必须在目标目录内
|
||||
dest = (target / member).resolve()
|
||||
try:
|
||||
dest.relative_to(target_resolved)
|
||||
except ValueError:
|
||||
skipped.append(member)
|
||||
continue
|
||||
zf.extract(member, target)
|
||||
return skipped
|
||||
|
||||
|
||||
def _safe_extract_tar(
|
||||
tarf: tarfile.TarFile,
|
||||
target: Path,
|
||||
members: list[tarfile.TarInfo],
|
||||
quiet: bool = False,
|
||||
) -> int:
|
||||
"""安全提取 tar 成员:过滤路径遍历和符号链接,返回跳过的数量"""
|
||||
target_resolved = target.resolve()
|
||||
skipped_count = 0
|
||||
import sys as _sys
|
||||
|
||||
for member in members:
|
||||
if not _is_safe_member_name(member.name):
|
||||
skipped_count += 1
|
||||
continue
|
||||
# 跳过符号链接和硬链接(防止链接攻击)
|
||||
if member.issym() or member.islnk():
|
||||
skipped_count += 1
|
||||
continue
|
||||
# Python < 3.12: 手动验证提取路径在目标目录内
|
||||
if _sys.version_info < (3, 12):
|
||||
dest = (target / member.name).resolve()
|
||||
try:
|
||||
dest.relative_to(target_resolved)
|
||||
except ValueError:
|
||||
skipped_count += 1
|
||||
continue
|
||||
# Python 3.12+: filter="data" 会处理路径清理和链接目标
|
||||
extra_args = {}
|
||||
if _sys.version_info >= (3, 12):
|
||||
extra_args["filter"] = "data"
|
||||
tarf.extract(member, path=str(target), **extra_args)
|
||||
return skipped_count
|
||||
|
||||
|
||||
def restore_backup(
|
||||
backup_path: str,
|
||||
target_dir: str,
|
||||
@@ -900,23 +989,22 @@ def restore_backup(
|
||||
with zipfile.ZipFile(backup, "r") as zf:
|
||||
all_members = zf.namelist()
|
||||
members = (
|
||||
[m for m in all_members if _match_select_pattern(m, select_pattern)]
|
||||
[
|
||||
m
|
||||
for m in all_members
|
||||
if _is_safe_member_name(m)
|
||||
and _match_select_pattern(m, select_pattern)
|
||||
]
|
||||
if select_pattern
|
||||
else all_members
|
||||
else [m for m in all_members if _is_safe_member_name(m)]
|
||||
)
|
||||
if not members:
|
||||
if not quiet:
|
||||
print(t("restore.no_match", pattern=select_pattern))
|
||||
return {"success": True, "files_count": 0}
|
||||
with tqdm(
|
||||
total=len(members),
|
||||
desc=t("restore.progress"),
|
||||
unit=t("compress.unit"),
|
||||
disable=quiet,
|
||||
) as pbar:
|
||||
for member in members:
|
||||
zf.extract(member, target)
|
||||
pbar.update(1)
|
||||
skipped = _safe_extract_zip(zf, target, members)
|
||||
if skipped and not quiet:
|
||||
print(t("restore.skipped_unsafe", count=len(skipped)))
|
||||
if not quiet:
|
||||
print(t("restore.success", path=target, count=len(members)))
|
||||
return {"success": True, "files_count": len(members)}
|
||||
@@ -928,10 +1016,24 @@ def restore_backup(
|
||||
szf_kwargs["password"] = password
|
||||
with py7zr.SevenZipFile(**szf_kwargs) as szf:
|
||||
all_members = szf.getnames()
|
||||
# 过滤路径遍历成员(Zip Slip 防护)
|
||||
safe_members = [m for m in all_members if _is_safe_member_name(m)]
|
||||
skipped_count = len(all_members) - len(safe_members)
|
||||
if skipped_count > 0 and not quiet:
|
||||
print(
|
||||
t(
|
||||
"restore.skipped_unsafe",
|
||||
count=skipped_count,
|
||||
)
|
||||
)
|
||||
members = (
|
||||
[m for m in all_members if _match_select_pattern(m, select_pattern)]
|
||||
[
|
||||
m
|
||||
for m in safe_members
|
||||
if _match_select_pattern(m, select_pattern)
|
||||
]
|
||||
if select_pattern
|
||||
else all_members
|
||||
else safe_members
|
||||
)
|
||||
if not members:
|
||||
if not quiet:
|
||||
@@ -975,15 +1077,14 @@ def restore_backup(
|
||||
tmp.seek(0)
|
||||
with tarfile.open(fileobj=tmp, mode="r") as tarf:
|
||||
all_members = tarf.getmembers()
|
||||
members = (
|
||||
[
|
||||
if select_pattern:
|
||||
members = [
|
||||
m
|
||||
for m in all_members
|
||||
if _match_select_pattern(m.name, select_pattern)
|
||||
]
|
||||
if select_pattern
|
||||
else all_members
|
||||
)
|
||||
else:
|
||||
members = list(all_members)
|
||||
if not members:
|
||||
if not quiet:
|
||||
print(t("restore.no_match", pattern=select_pattern))
|
||||
@@ -994,12 +1095,14 @@ def restore_backup(
|
||||
unit=t("compress.unit"),
|
||||
disable=quiet,
|
||||
) as pbar:
|
||||
for member in members:
|
||||
tarf.extract(member, target, filter="data")
|
||||
pbar.update(1)
|
||||
skipped = _safe_extract_tar(tarf, target, members)
|
||||
pbar.update(len(members))
|
||||
if skipped and not quiet:
|
||||
print(t("restore.skipped_unsafe", count=skipped))
|
||||
actual = len(members) - skipped
|
||||
if not quiet:
|
||||
print(t("restore.success", path=target, count=len(members)))
|
||||
return {"success": True, "files_count": len(members)}
|
||||
print(t("restore.success", path=target, count=actual))
|
||||
return {"success": True, "files_count": actual}
|
||||
elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
||||
mode = "r:gz"
|
||||
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
|
||||
@@ -1015,15 +1118,14 @@ def restore_backup(
|
||||
|
||||
with tarfile.open(backup, mode) as tarf:
|
||||
all_members = tarf.getmembers()
|
||||
members = (
|
||||
[
|
||||
if select_pattern:
|
||||
members = [
|
||||
m
|
||||
for m in all_members
|
||||
if _match_select_pattern(m.name, select_pattern)
|
||||
]
|
||||
if select_pattern
|
||||
else all_members
|
||||
)
|
||||
else:
|
||||
members = list(all_members)
|
||||
if not members:
|
||||
if not quiet:
|
||||
print(t("restore.no_match", pattern=select_pattern))
|
||||
@@ -1034,12 +1136,14 @@ def restore_backup(
|
||||
unit=t("compress.unit"),
|
||||
disable=quiet,
|
||||
) as pbar:
|
||||
for member in members:
|
||||
tarf.extract(member, target, filter="data")
|
||||
pbar.update(1)
|
||||
skipped = _safe_extract_tar(tarf, target, members)
|
||||
pbar.update(len(members))
|
||||
if skipped and not quiet:
|
||||
print(t("restore.skipped_unsafe", count=skipped))
|
||||
actual = len(members) - skipped
|
||||
if not quiet:
|
||||
print(t("restore.success", path=target, count=len(members)))
|
||||
return {"success": True, "files_count": len(members)}
|
||||
print(t("restore.success", path=target, count=actual))
|
||||
return {"success": True, "files_count": actual}
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except PermissionError:
|
||||
@@ -1339,12 +1443,19 @@ def verify_backup_fast(backup_path: str, expected_sha256: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def split_file(file_path: str, chunk_size: int) -> list[str]:
|
||||
def split_file(
|
||||
file_path: str, chunk_size: int, *, create_manifest: bool = True
|
||||
) -> list[str]:
|
||||
"""将大文件分割为多个分卷文件
|
||||
:param file_path: 要分割的文件路径
|
||||
:param chunk_size: 每个分卷的大小(字节)
|
||||
:param create_manifest: 是否创建清单文件
|
||||
:return: 分卷文件路径列表
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
if chunk_size <= 0:
|
||||
return [file_path]
|
||||
|
||||
@@ -1352,7 +1463,18 @@ def split_file(file_path: str, chunk_size: int) -> list[str]:
|
||||
if file_size <= chunk_size:
|
||||
return [file_path]
|
||||
|
||||
# 计算原文件 SHA256
|
||||
original_sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
original_sha256.update(chunk)
|
||||
original_hash = original_sha256.hexdigest()
|
||||
|
||||
parts = []
|
||||
part_checksums = []
|
||||
part_index = 1
|
||||
with open(file_path, "rb") as f:
|
||||
while True:
|
||||
@@ -1360,31 +1482,175 @@ def split_file(file_path: str, chunk_size: int) -> list[str]:
|
||||
if not chunk:
|
||||
break
|
||||
part_path = f"{file_path}.{part_index:03d}"
|
||||
# 计算分卷校验和
|
||||
part_hash = hashlib.sha256(chunk).hexdigest()
|
||||
part_checksums.append(
|
||||
{"path": os.path.basename(part_path), "sha256": part_hash}
|
||||
)
|
||||
with open(part_path, "wb") as pf:
|
||||
pf.write(chunk)
|
||||
parts.append(part_path)
|
||||
part_index += 1
|
||||
|
||||
# 创建清单文件
|
||||
if create_manifest:
|
||||
manifest_path = f"{file_path}.manifest.json"
|
||||
manifest = {
|
||||
"original_file": os.path.basename(file_path),
|
||||
"original_size": file_size,
|
||||
"original_sha256": original_hash,
|
||||
"chunk_size": chunk_size,
|
||||
"parts_count": len(parts),
|
||||
"parts": part_checksums,
|
||||
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
with open(manifest_path, "w", encoding="utf-8") as mf:
|
||||
json.dump(manifest, mf, indent=2, ensure_ascii=False)
|
||||
|
||||
# 删除原文件
|
||||
os.remove(file_path)
|
||||
return parts
|
||||
|
||||
|
||||
def merge_files(part_paths: list[str], output_path: str) -> bool:
|
||||
def merge_files(
|
||||
part_paths: list[str],
|
||||
output_path: str,
|
||||
*,
|
||||
resume: bool = False,
|
||||
progress_callback=None,
|
||||
) -> bool:
|
||||
"""合并分卷文件为单个文件
|
||||
:param part_paths: 分卷文件路径列表(按顺序)
|
||||
:param output_path: 输出文件路径
|
||||
:param resume: 是否支持断点续传(从上次中断处继续)
|
||||
:param progress_callback: 进度回调函数 callback(current_bytes, total_bytes)
|
||||
:return: 是否成功
|
||||
"""
|
||||
if not part_paths:
|
||||
return False
|
||||
|
||||
# 计算总大小
|
||||
total_size = 0
|
||||
for p in part_paths:
|
||||
try:
|
||||
with open(output_path, "wb") as out_f:
|
||||
for part_path in part_paths:
|
||||
total_size += os.path.getsize(p)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# 断点续传:检查已写入的字节数
|
||||
written_bytes = 0
|
||||
start_part_idx = 0
|
||||
if resume and os.path.exists(output_path):
|
||||
written_bytes = os.path.getsize(output_path)
|
||||
# 计算从哪个分卷开始
|
||||
accumulated = 0
|
||||
for i, p in enumerate(part_paths):
|
||||
accumulated += os.path.getsize(p)
|
||||
if accumulated > written_bytes:
|
||||
start_part_idx = i
|
||||
# 计算该分卷内已写入的偏移
|
||||
prev_accumulated = accumulated - os.path.getsize(p)
|
||||
part_offset = written_bytes - prev_accumulated
|
||||
break
|
||||
else:
|
||||
# 所有分卷都已写入
|
||||
return True
|
||||
else:
|
||||
part_offset = 0
|
||||
|
||||
try:
|
||||
mode = "ab" if resume and os.path.exists(output_path) else "wb"
|
||||
with open(output_path, mode) as out_f:
|
||||
for i in range(start_part_idx, len(part_paths)):
|
||||
part_path = part_paths[i]
|
||||
with open(part_path, "rb") as in_f:
|
||||
if i == start_part_idx and part_offset > 0:
|
||||
in_f.seek(part_offset)
|
||||
while True:
|
||||
chunk = in_f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
out_f.write(chunk)
|
||||
written_bytes += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(written_bytes, total_size)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def verify_split_integrity(file_path: str) -> dict:
|
||||
"""验证分卷文件完整性
|
||||
:param file_path: 任意分卷文件路径或清单文件路径
|
||||
:return: 验证结果字典
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
# 查找清单文件
|
||||
if file_path.endswith(".manifest.json"):
|
||||
manifest_path = file_path
|
||||
else:
|
||||
# 从分卷文件推断清单路径
|
||||
base = file_path.rsplit(".", 1)[0] if ".0" in file_path else file_path
|
||||
manifest_path = f"{base}.manifest.json"
|
||||
if not os.path.exists(manifest_path):
|
||||
# 尝试去掉 .001 后缀
|
||||
if file_path.endswith(".001"):
|
||||
manifest_path = f"{file_path[:-4]}.manifest.json"
|
||||
|
||||
if not os.path.exists(manifest_path):
|
||||
return {"success": False, "error": "Manifest file not found"}
|
||||
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return {"success": False, "error": f"Failed to read manifest: {e}"}
|
||||
|
||||
manifest_dir = os.path.dirname(manifest_path)
|
||||
results = []
|
||||
all_ok = True
|
||||
|
||||
for part_info in manifest.get("parts", []):
|
||||
part_name = part_info["path"]
|
||||
expected_hash = part_info["sha256"]
|
||||
part_path = os.path.join(manifest_dir, part_name)
|
||||
|
||||
if not os.path.exists(part_path):
|
||||
results.append(
|
||||
{"path": part_name, "status": "missing", "expected": expected_hash}
|
||||
)
|
||||
all_ok = False
|
||||
continue
|
||||
|
||||
# 计算实际校验和
|
||||
sha256 = hashlib.sha256()
|
||||
with open(part_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
sha256.update(chunk)
|
||||
actual_hash = sha256.hexdigest()
|
||||
|
||||
if actual_hash == expected_hash:
|
||||
results.append({"path": part_name, "status": "ok"})
|
||||
else:
|
||||
results.append(
|
||||
{
|
||||
"path": part_name,
|
||||
"status": "corrupted",
|
||||
"expected": expected_hash,
|
||||
"actual": actual_hash,
|
||||
}
|
||||
)
|
||||
all_ok = False
|
||||
|
||||
return {
|
||||
"success": all_ok,
|
||||
"original_file": manifest.get("original_file", ""),
|
||||
"original_size": manifest.get("original_size", 0),
|
||||
"parts_count": manifest.get("parts_count", 0),
|
||||
"results": results,
|
||||
}
|
||||
+108
-13
@@ -27,7 +27,7 @@ def _load_json_file(config_file: str) -> dict:
|
||||
|
||||
|
||||
def _save_json_file(data: dict, config_file: str) -> None:
|
||||
"""将字典写入 JSON 配置文件,自动创建目录"""
|
||||
"""将字典写入 JSON 配置文件,自动创建目录,设置限制性权限"""
|
||||
data_dir = os.path.dirname(config_file)
|
||||
if data_dir:
|
||||
try:
|
||||
@@ -36,11 +36,35 @@ def _save_json_file(data: dict, config_file: str) -> None:
|
||||
logger.error(t("log.config.mkdir.error"), data_dir, e)
|
||||
return
|
||||
|
||||
# 原子写入:先写临时文件再替换
|
||||
tmp_path = config_file + ".tmp"
|
||||
try:
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# 设置临时文件限制性权限(仅所有者可读写)
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
os.chmod(tmp_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp_path, config_file)
|
||||
except OSError as e:
|
||||
logger.error(t("log.config.write.error"), config_file, e)
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
# 设置限制性文件权限(仅所有者可读写)
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
os.chmod(config_file, 0o600)
|
||||
except OSError:
|
||||
pass # 权限设置失败不影响主流程
|
||||
|
||||
|
||||
def get_default_data_file() -> str:
|
||||
@@ -101,6 +125,15 @@ class Config:
|
||||
webdav_password: str = ""
|
||||
webdav_remote_path: str = "/"
|
||||
webdav_enabled: bool = False
|
||||
# SMTP 邮件通知配置
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = ""
|
||||
smtp_to: str = ""
|
||||
smtp_tls: bool = True
|
||||
smtp_enabled: bool = False
|
||||
|
||||
|
||||
def load_config(config_file: str = "config.json") -> Config:
|
||||
@@ -141,6 +174,22 @@ def load_config(config_file: str = "config.json") -> Config:
|
||||
if webhook_url_legacy and not webhook_urls:
|
||||
webhook_urls = [webhook_url_legacy]
|
||||
webdav_config = config_data.get("webdav", {})
|
||||
smtp_config = config_data.get("smtp", {})
|
||||
|
||||
# 验证端口范围(1-65535)
|
||||
sftp_port = sftp_config.get("port", 22)
|
||||
if not isinstance(sftp_port, int) or not (1 <= sftp_port <= 65535):
|
||||
logger.warning(
|
||||
"SFTP port %s is out of range (1-65535), using default 22", sftp_port
|
||||
)
|
||||
sftp_port = 22
|
||||
|
||||
smtp_port = smtp_config.get("port", 587)
|
||||
if not isinstance(smtp_port, int) or not (1 <= smtp_port <= 65535):
|
||||
logger.warning(
|
||||
"SMTP port %s is out of range (1-65535), using default 587", smtp_port
|
||||
)
|
||||
smtp_port = 587
|
||||
|
||||
return Config(
|
||||
folder_path="",
|
||||
@@ -158,7 +207,7 @@ def load_config(config_file: str = "config.json") -> Config:
|
||||
webhook_template=webhook_template,
|
||||
webhook_retries=webhook_retries,
|
||||
sftp_host=sftp_config.get("host", ""),
|
||||
sftp_port=sftp_config.get("port", 22),
|
||||
sftp_port=sftp_port,
|
||||
sftp_user=sftp_config.get("user", ""),
|
||||
sftp_password=sftp_config.get("password", ""),
|
||||
sftp_key_file=sftp_config.get("key_file", ""),
|
||||
@@ -170,6 +219,14 @@ def load_config(config_file: str = "config.json") -> Config:
|
||||
webdav_password=webdav_config.get("password", ""),
|
||||
webdav_remote_path=webdav_config.get("remote_path", "/"),
|
||||
webdav_enabled=webdav_config.get("enabled", False),
|
||||
smtp_host=smtp_config.get("host", ""),
|
||||
smtp_port=smtp_port,
|
||||
smtp_user=smtp_config.get("user", ""),
|
||||
smtp_password=smtp_config.get("password", ""),
|
||||
smtp_from=smtp_config.get("from", ""),
|
||||
smtp_to=smtp_config.get("to", ""),
|
||||
smtp_tls=smtp_config.get("tls", True),
|
||||
smtp_enabled=smtp_config.get("enabled", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -277,6 +334,7 @@ _SENSITIVE_FIELDS = [
|
||||
("sftp", "password"),
|
||||
("sftp", "key_passphrase"),
|
||||
("webdav", "password"),
|
||||
("smtp", "password"),
|
||||
]
|
||||
|
||||
|
||||
@@ -284,28 +342,46 @@ def _derive_key(master_password: str, salt: bytes) -> bytes:
|
||||
"""从主密码派生加密密钥"""
|
||||
import hashlib
|
||||
|
||||
return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 100_000)
|
||||
return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 600_000)
|
||||
|
||||
|
||||
def _encrypt_value(value: str, key: bytes) -> str:
|
||||
"""加密单个字符串值,返回 base64 编码的 salt+密文"""
|
||||
"""加密单个字符串值,返回 base64 编码的 salt+密文+HMAC(完整性校验)"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac as _hmac
|
||||
|
||||
salt = os.urandom(16)
|
||||
derived = _derive_key(key.hex(), salt)
|
||||
encrypted = bytes(
|
||||
a ^ b for a, b in zip(value.encode("utf-8"), derived[: len(value)])
|
||||
)
|
||||
return base64.b64encode(salt + encrypted).decode("ascii")
|
||||
# 添加 HMAC 完整性校验(使用派生密钥的后 32 字节作为 HMAC 密钥)
|
||||
hmac_key = hashlib.sha256(key + b"hmac_key").digest()
|
||||
mac = _hmac.new(hmac_key, salt + encrypted, "sha256").digest()
|
||||
return base64.b64encode(salt + encrypted + mac).decode("ascii")
|
||||
|
||||
|
||||
def _decrypt_value(encrypted_value: str, key: bytes) -> str:
|
||||
"""解密单个字符串值"""
|
||||
"""解密单个字符串值(验证 HMAC 完整性)"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac as _hmac
|
||||
|
||||
data = base64.b64decode(encrypted_value)
|
||||
# 提取 HMAC(最后 32 字节)
|
||||
if len(data) < 48: # 16 (salt) + 0 (min ciphertext) + 32 (hmac)
|
||||
raise ValueError("Invalid encrypted data: too short")
|
||||
salt = data[:16]
|
||||
ciphertext = data[16:]
|
||||
mac_received = data[-32:]
|
||||
ciphertext = data[16:-32]
|
||||
|
||||
# 验证 HMAC 完整性
|
||||
hmac_key = hashlib.sha256(key + b"hmac_key").digest()
|
||||
mac_expected = _hmac.new(hmac_key, salt + ciphertext, "sha256").digest()
|
||||
if not _hmac.compare_digest(mac_received, mac_expected):
|
||||
raise ValueError("HMAC verification failed: data may have been tampered with")
|
||||
|
||||
derived = _derive_key(key.hex(), salt)
|
||||
decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)]))
|
||||
return decrypted.decode("utf-8")
|
||||
@@ -319,10 +395,18 @@ def encrypt_config(master_password: str, config_file: str = "config.json") -> bo
|
||||
if not data:
|
||||
return False
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
# 用主密码的哈希作为加密密钥
|
||||
key = hashlib.sha256(master_password.encode("utf-8")).digest()
|
||||
# 读取或生成盐值(存储在配置中以备解密)
|
||||
salt_b64 = data.get("_key_salt")
|
||||
if salt_b64:
|
||||
salt = base64.b64decode(salt_b64)
|
||||
else:
|
||||
salt = os.urandom(16)
|
||||
data["_key_salt"] = base64.b64encode(salt).decode("ascii")
|
||||
|
||||
# 使用 PBKDF2 派生加密密钥(600K 迭代)
|
||||
key = _derive_key(master_password, salt)
|
||||
|
||||
for field_path in _SENSITIVE_FIELDS:
|
||||
obj = data
|
||||
@@ -346,9 +430,14 @@ def decrypt_config(master_password: str, config_file: str = "config.json") -> bo
|
||||
if not data or not data.get("_encrypted"):
|
||||
return True # 未加密,视为成功
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
key = hashlib.sha256(master_password.encode("utf-8")).digest()
|
||||
salt_b64 = data.get("_key_salt")
|
||||
if not salt_b64:
|
||||
return False # 缺少盐值,无法解密
|
||||
salt = base64.b64decode(salt_b64)
|
||||
|
||||
key = _derive_key(master_password, salt)
|
||||
|
||||
try:
|
||||
for field_path in _SENSITIVE_FIELDS:
|
||||
@@ -417,6 +506,12 @@ def parse_gitignore(gitignore_path: str) -> list[str]:
|
||||
if not os.path.isfile(gitignore_path):
|
||||
return []
|
||||
|
||||
# 安全检查:确保路径在当前工作目录内
|
||||
cwd = os.path.realpath(os.getcwd())
|
||||
real_path = os.path.realpath(gitignore_path)
|
||||
if os.path.commonpath([cwd, real_path]) != cwd:
|
||||
raise ValueError("gitignore path must be within the current working directory")
|
||||
|
||||
patterns = []
|
||||
try:
|
||||
with open(gitignore_path, "r", encoding="utf-8") as f:
|
||||
|
||||
+55
-2
@@ -2,11 +2,38 @@
|
||||
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from sbackup.i18n import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_remote_filename(filename: str) -> str | None:
|
||||
"""验证远程文件名安全性,返回净化后的文件名或 None(不安全时)
|
||||
只允许纯文件名,拒绝路径分隔符和 .. 序列
|
||||
"""
|
||||
if not filename:
|
||||
return None
|
||||
# 拒绝 null 字节注入
|
||||
if "\x00" in filename:
|
||||
return None
|
||||
# 拒绝路径分隔符
|
||||
if "/" in filename or "\\" in filename:
|
||||
return None
|
||||
# 拒绝 .. 序列
|
||||
if ".." in filename:
|
||||
return None
|
||||
# 拒绝绝对路径
|
||||
if os.path.isabs(filename):
|
||||
return None
|
||||
# 只取 basename(防御性编程)
|
||||
safe_name = os.path.basename(filename)
|
||||
if not safe_name or safe_name in (".", ".."):
|
||||
return None
|
||||
return safe_name
|
||||
|
||||
|
||||
def _resolve_sftp_auth(
|
||||
key_file: str,
|
||||
key_passphrase: str,
|
||||
@@ -84,6 +111,18 @@ def handle_sftp(args, config) -> int:
|
||||
user = args.user or input(t("cli.prompt.sftp.user") + " ")
|
||||
key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ")
|
||||
|
||||
# 警告:通过 CLI 传递密码会暴露在进程列表中
|
||||
if args.password:
|
||||
print(
|
||||
t("warn.password_in_cli", arg="--password"),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if args.key_passphrase:
|
||||
print(
|
||||
t("warn.password_in_cli", arg="--key-passphrase"),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
key_file, key_passphrase, password = _resolve_sftp_auth(
|
||||
key_file_input,
|
||||
args.key_passphrase or "",
|
||||
@@ -153,6 +192,12 @@ def handle_webdav(args, config) -> int:
|
||||
# 交互式配置
|
||||
url = args.url or input(t("cli.prompt.webdav.url") + " ")
|
||||
user = args.user or input(t("cli.prompt.webdav.user") + " ")
|
||||
# 警告:通过 CLI 传递密码会暴露在进程列表中
|
||||
if args.password:
|
||||
print(
|
||||
t("warn.password_in_cli", arg="--password"),
|
||||
file=sys.stderr,
|
||||
)
|
||||
password = args.password or getpass.getpass(
|
||||
t("cli.prompt.webdav.password") + " "
|
||||
)
|
||||
@@ -265,7 +310,11 @@ def _handle_remote_sftp(args, config) -> int:
|
||||
return 0
|
||||
|
||||
elif args.remote_action == "rm":
|
||||
remote_path = config.sftp_remote_path.rstrip("/") + "/" + args.filename
|
||||
safe_name = _validate_remote_filename(args.filename)
|
||||
if not safe_name:
|
||||
print(t("err.invalid_filename", filename=args.filename))
|
||||
return 1
|
||||
remote_path = config.sftp_remote_path.rstrip("/") + "/" + safe_name
|
||||
client.delete_remote_file(remote_path)
|
||||
print(t("cmd.remote.deleted", path=remote_path))
|
||||
return 0
|
||||
@@ -315,7 +364,11 @@ def _handle_remote_webdav(args, config) -> int:
|
||||
return 0
|
||||
|
||||
elif args.remote_action == "rm":
|
||||
remote_path = config.webdav_remote_path.rstrip("/") + "/" + args.filename
|
||||
safe_name = _validate_remote_filename(args.filename)
|
||||
if not safe_name:
|
||||
print(t("err.invalid_filename", filename=args.filename))
|
||||
return 1
|
||||
remote_path = config.webdav_remote_path.rstrip("/") + "/" + safe_name
|
||||
client.delete_remote_file(remote_path)
|
||||
print(t("cmd.remote.deleted", path=remote_path))
|
||||
return 0
|
||||
|
||||
@@ -34,6 +34,41 @@ def set_locale(lang: str) -> None:
|
||||
设置当前语言环境并加载对应的翻译文件
|
||||
"""
|
||||
global _current_locale, _translations
|
||||
|
||||
# 安全检查:拒绝包含路径遍历字符的语言代码
|
||||
if not lang or "/" in lang or "\\" in lang or ".." in lang or ":" in lang:
|
||||
logger.warning("i18n.set_locale.invalid_lang: %s", lang)
|
||||
return
|
||||
|
||||
# 白名单校验:只允许已知的语言代码格式
|
||||
_KNOWN_LOCALES = {
|
||||
"zh_CN",
|
||||
"en_US",
|
||||
"fr_FR",
|
||||
"es_ES",
|
||||
"ru_RU",
|
||||
"de_DE",
|
||||
"ja_JP",
|
||||
"pt_BR",
|
||||
"ko_KR",
|
||||
}
|
||||
if lang not in _KNOWN_LOCALES:
|
||||
logger.warning("i18n.set_locale.unknown_locale: %s", lang)
|
||||
# 仍然设置 _current_locale 以保持向后兼容
|
||||
_current_locale = lang
|
||||
# 回退到默认中文翻译
|
||||
default_file = os.path.join(_get_locales_dir(), "zh_CN.json")
|
||||
if os.path.exists(default_file):
|
||||
try:
|
||||
with open(default_file, "r", encoding="utf-8") as f:
|
||||
_translations = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(
|
||||
t("log.i18n.load.fallback_failed", path=default_file, error=e)
|
||||
)
|
||||
_translations = {}
|
||||
return
|
||||
|
||||
_current_locale = lang
|
||||
locale_file = os.path.join(_get_locales_dir(), f"{lang}.json")
|
||||
if os.path.exists(locale_file):
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""系统密钥链集成:跨平台安全存储密码
|
||||
|
||||
使用各平台原生 API 将密码存储在系统密钥链中,而不是明文存在 config.json。
|
||||
|
||||
支持平台:
|
||||
- Windows: Credential Manager (Win32 API 通过 ctypes)
|
||||
- macOS: Keychain (security 命令行工具)
|
||||
- Linux: libsecret (secret-tool 命令行工具)
|
||||
|
||||
用法::
|
||||
|
||||
from sbackup.keychain import get_password, set_password, delete_password
|
||||
|
||||
# 存储密码
|
||||
set_password("sbackup", "sftp", "my_secret")
|
||||
|
||||
# 读取密码
|
||||
pwd = get_password("sbackup", "sftp")
|
||||
|
||||
# 删除密码
|
||||
delete_password("sbackup", "sftp")
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_NAME = "sbackup"
|
||||
|
||||
|
||||
def get_password(service: str, username: str) -> str | None:
|
||||
"""从系统密钥链获取密码,返回 None 表示未找到或不可用"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
return _win32_get_password(service, username)
|
||||
elif sys.platform == "darwin":
|
||||
return _darwin_get_password(service, username)
|
||||
else:
|
||||
return _linux_get_password(service, username)
|
||||
except Exception:
|
||||
logger.debug("Keychain get_password failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def set_password(service: str, username: str, password: str) -> bool:
|
||||
"""存储密码到系统密钥链,返回是否成功"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
return _win32_set_password(service, username, password)
|
||||
elif sys.platform == "darwin":
|
||||
return _darwin_set_password(service, username, password)
|
||||
else:
|
||||
return _linux_set_password(service, username, password)
|
||||
except Exception:
|
||||
logger.debug("Keychain set_password failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def delete_password(service: str, username: str) -> bool:
|
||||
"""从系统密钥链删除密码,返回是否成功"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
return _win32_delete_password(service, username)
|
||||
elif sys.platform == "darwin":
|
||||
return _darwin_delete_password(service, username)
|
||||
else:
|
||||
return _linux_delete_password(service, username)
|
||||
except Exception:
|
||||
logger.debug("Keychain delete_password failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""检查系统密钥链是否可用"""
|
||||
if sys.platform == "win32":
|
||||
# Windows Credential Manager 通常总是可用
|
||||
try:
|
||||
import ctypes
|
||||
_win32_get_password("sbackup", "__probe__")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
elif sys.platform == "darwin":
|
||||
try:
|
||||
subprocess.run(
|
||||
["security", "help"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
subprocess.run(
|
||||
["secret-tool", "--help"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
# ─── Windows ─────────────────────────────────────────────────────────
|
||||
|
||||
def _win32_get_password(service: str, username: str) -> str | None:
|
||||
"""使用 Win32 CredReadW API 读取凭据"""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
|
||||
class CREDENTIALW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", ctypes.c_wchar_p),
|
||||
("Comment", ctypes.c_wchar_p),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.c_void_p),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", ctypes.c_wchar_p),
|
||||
("UserName", ctypes.c_wchar_p),
|
||||
]
|
||||
|
||||
target = f"{service}/{username}"
|
||||
pcred = ctypes.POINTER(CREDENTIALW)()
|
||||
|
||||
ret = advapi32.CredReadW(target, 1, 0, ctypes.byref(pcred))
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
try:
|
||||
cred = pcred.contents
|
||||
if cred.CredentialBlob and cred.CredentialBlobSize > 0:
|
||||
buf = (ctypes.c_char * cred.CredentialBlobSize).from_address(
|
||||
cred.CredentialBlob
|
||||
)
|
||||
return buf.value.decode("utf-16-le")
|
||||
return None
|
||||
finally:
|
||||
advapi32.CredFree(pcred)
|
||||
|
||||
|
||||
def _win32_set_password(service: str, username: str, password: str) -> bool:
|
||||
"""使用 Win32 CredWriteW API 存储凭据"""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
|
||||
target = f"{service}/{username}"
|
||||
blob = password.encode("utf-16-le")
|
||||
|
||||
class CREDENTIALW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", ctypes.c_wchar_p),
|
||||
("Comment", ctypes.c_wchar_p),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.c_void_p),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", ctypes.c_wchar_p),
|
||||
("UserName", ctypes.c_wchar_p),
|
||||
]
|
||||
|
||||
cred = CREDENTIALW()
|
||||
cred.Type = 1 # CRED_TYPE_GENERIC
|
||||
cred.TargetName = target
|
||||
cred.CredentialBlobSize = len(blob)
|
||||
cred.CredentialBlob = ctypes.cast(
|
||||
ctypes.create_string_buffer(blob), ctypes.c_void_p
|
||||
)
|
||||
cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE
|
||||
cred.UserName = username
|
||||
|
||||
ret = advapi32.CredWriteW(ctypes.byref(cred), 0)
|
||||
return bool(ret)
|
||||
|
||||
|
||||
def _win32_delete_password(service: str, username: str) -> bool:
|
||||
"""使用 Win32 CredDeleteW API 删除凭据"""
|
||||
import ctypes
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
target = f"{service}/{username}"
|
||||
ret = advapi32.CredDeleteW(target, 1, 0)
|
||||
return bool(ret)
|
||||
|
||||
|
||||
# ─── macOS ───────────────────────────────────────────────────────────
|
||||
|
||||
def _darwin_get_password(service: str, username: str) -> str | None:
|
||||
"""使用 macOS security CLI 读取钥匙串"""
|
||||
result = subprocess.run(
|
||||
["security", "find-generic-password",
|
||||
"-s", service, "-a", username, "-w"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _darwin_set_password(service: str, username: str, password: str) -> bool:
|
||||
"""使用 macOS security CLI 存储到钥匙串"""
|
||||
result = subprocess.run(
|
||||
["security", "add-generic-password",
|
||||
"-s", service, "-a", username, "-w", password, "-U"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _darwin_delete_password(service: str, username: str) -> bool:
|
||||
"""使用 macOS security CLI 从钥匙串删除"""
|
||||
result = subprocess.run(
|
||||
["security", "delete-generic-password",
|
||||
"-s", service, "-a", username],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
# ─── Linux ───────────────────────────────────────────────────────────
|
||||
|
||||
def _linux_get_password(service: str, username: str) -> str | None:
|
||||
"""使用 secret-tool 读取密码"""
|
||||
result = subprocess.run(
|
||||
["secret-tool", "lookup", "service", service, "username", username],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _linux_set_password(service: str, username: str, password: str) -> bool:
|
||||
"""使用 secret-tool 存储密码"""
|
||||
result = subprocess.run(
|
||||
["secret-tool", "store",
|
||||
"--label", f"sbackup/{username}",
|
||||
"service", service, "username", username],
|
||||
input=password, text=True, timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _linux_delete_password(service: str, username: str) -> bool:
|
||||
"""使用 secret-tool 删除密码"""
|
||||
result = subprocess.run(
|
||||
["secret-tool", "clear", "service", service, "username", username],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
@@ -82,6 +82,7 @@
|
||||
"table.cell.default": "Standard",
|
||||
"restore.progress": "Wiederherstellung",
|
||||
"restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)",
|
||||
"restore.skipped_unsafe": "{count} unsichere Archivmitglieder übersprungen (Pfadtraversal-Schutz)",
|
||||
"cmd.watch.start": "Überwachung gestartet, Intervall {interval} Min. (Ctrl+C zum Stoppen)",
|
||||
"cli.description": "Sbackup v{version} — Leichtes inkrementelles Backup-Tool\n\nSbackup hilft Ihnen, Multi-Ordner-Backup-Aufgaben einfach zu verwalten.\nEs nutzt eine inkrementelle Backup-Strategie, komprimiert nur geänderte Dateien,\nunterstützt benutzerdefinierte Ausschlussregeln und bietet intuitives Fortschrittsfeedback.\n\nVerfügbare Befehle:\n add Neue Backup-Strategie hinzufügen (Quelle -> Ziel)\n rm Existing Backup-Strategie entfernen\n all Alle konfigurierten Backup-Strategien anzeigen\n save Backup-Aufgaben ausführen\n version Versionsinformationen anzeigen",
|
||||
"cli.epilog": "Beispiele:\n Backup-Strategie hinzufügen:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup mit Verschlüsselung (7z):\n sbackup --format 7z save --password 123456\n\n Wiederherstellen:\n sbackup restore backup.7z F:/restored\n\n SFTP Remote (Auto-Erkennung, empfohlen):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP Remote (Passwort):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP Remote (Schlüssel):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP Remote (Schlüssel + Passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||
@@ -116,6 +117,25 @@
|
||||
"cli.help.list": "Backup-Verlauf anzeigen",
|
||||
"cli.help.verify": "Backup-Datei-Integrität überprüfen",
|
||||
"cli.help.verify.file": "Zu überprüfende Backup-Datei",
|
||||
"cli.help.verify.fast": "Schnellüberprüfung (historische SHA256 vergleichen, ohne Entpacken)",
|
||||
"cli.help.verify.all": "Alle historischen Backups batchweise überprüfen",
|
||||
"cli.help.verify.detail": "Detaillierte Überprüfungsinformationen anzeigen (Größe, Format, Prüfsumme etc.)",
|
||||
"cli.help.verify.split": "Integrität der geteilten Dateien überprüfen (erfordert Manifest-Datei)",
|
||||
"cli.help.verify.password": "Entschlüsselungspasswort (für verschlüsselte Backups)",
|
||||
"cmd.verify.fast_success": "Schnellüberprüfung bestanden: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "Schnellüberprüfung fehlgeschlagen: {path}, Datei könnte beschädigt oder manipuliert sein",
|
||||
"cmd.verify.no_checksum": "Keine historische Prüfsumme gefunden, vollständige Überprüfung: {path}",
|
||||
"cmd.verify.checksum_mismatch": "Prüfsumme stimmt nicht überein",
|
||||
"cmd.verify.no_history": "Kein Backup-Verlauf gefunden.",
|
||||
"cmd.verify.no_file": "Bitte Backup-Dateipfad angeben oder --all verwenden, um alle Backups zu überprüfen.",
|
||||
"cmd.verify.detail_header": "=== Backup-Details: {path} ===",
|
||||
"cmd.verify.detail_info": "Größe: {size:.2f} MB | Dateien: {files} | Format: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ Überprüft: {path}",
|
||||
"cmd.verify.batch_failed": "✗ Fehlgeschlagen: {path}",
|
||||
"cmd.verify.batch_summary": "Batch-Überprüfung abgeschlossen: {success} bestanden, {fail} fehlgeschlagen",
|
||||
"cmd.verify.split_success": "Integrität der geteilten Dateien bestätigt: {file} ({parts} Teile)",
|
||||
"cmd.verify.split_failed": "Integritätsprüfung der geteilten Dateien fehlgeschlagen: {file}",
|
||||
"cmd.verify.split_error": "Fehler bei der Überprüfung geteilter Dateien: {error}",
|
||||
"cli.help.info": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme)",
|
||||
"cli.help.info.file": "Pfad zur Backup-Datei",
|
||||
"cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)",
|
||||
@@ -260,5 +280,13 @@
|
||||
"err.webdav.not_configured": "WebDAV nicht konfiguriert, führen Sie aus: sbackup webdav config --url <Adresse> --user <Benutzername>",
|
||||
"err.webdav.local_not_found": "Lokale Datei nicht gefunden: {path}",
|
||||
"err.webdav.upload": "Upload zu WebDAV fehlgeschlagen {path}: {error}",
|
||||
"err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}"
|
||||
"err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
"warn.json.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.",
|
||||
"warn.json.backup": "Warning: Corrupted data file backed up to {path}.",
|
||||
"warn.json.renamed": "Warning: Corrupted data file renamed to {path}.",
|
||||
"compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)",
|
||||
"warn.password_in_cli": "Warning: Password provided via {arg} is visible in the process list. Consider using interactive input instead.",
|
||||
"warn.webdav.http_plaintext": "WARNING: Using plain HTTP for WebDAV ({url}) -- credentials will be sent in cleartext. Use HTTPS when possible.",
|
||||
"compress.success": "Backup successful: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} files)",
|
||||
"compress.progress": "Compressing",
|
||||
"compress.unit": "files",
|
||||
"err.permission": "Permission denied: Cannot write to '{path}'",
|
||||
@@ -25,6 +27,7 @@
|
||||
"err.argparse.invalid_int": "invalid int value: ",
|
||||
"err.argparse.unrecognized_args": "unrecognized arguments: ",
|
||||
"err.argparse.required": "required",
|
||||
"err.lock.conflict": "Error: Another backup operation is already running (lock file exists). Use 'sbackup clean --force-lock' to remove a stale lock.",
|
||||
"warn.zip.overwrite": "Warning: Overwriting existing backup file {path}",
|
||||
"warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6",
|
||||
"cmd.save.uptodate": "All backup strategies are up to date, no backup needed.",
|
||||
@@ -42,6 +45,7 @@
|
||||
"cmd.init.exists": "Config file already exists: {path}, skipping.",
|
||||
"warn.disk.space": "Warning: Target directory {path} has insufficient space (need {need:.1f} MB, only {free:.1f} MB free)",
|
||||
"cmd.save.summary": "Backup summary: {total} strategies, {backed} backed up, {skipped} skipped, took {elapsed:.1f}s",
|
||||
"cmd.upload.parallel": "Uploading to SFTP and WebDAV in parallel...",
|
||||
"cmd.dry_run.header": "Dry-run preview (no backup will be performed):",
|
||||
"cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB",
|
||||
"cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB",
|
||||
@@ -72,6 +76,13 @@
|
||||
"table.cell.default": "Default",
|
||||
"restore.progress": "Restoring",
|
||||
"restore.success": "Restore successful: {path} ({count} files)",
|
||||
"restore.skipped_unsafe": "Skipped {count} unsafe archive members (path traversal protection)",
|
||||
"err.invalid_filename": "Invalid filename: {filename} (path separators and .. sequences not allowed)",
|
||||
"err.sftp.no_host_key": "Unable to get host key for {host}",
|
||||
"err.sftp.host_key_mismatch": "Host key for {host} does not match known_hosts (possible MITM attack)",
|
||||
"warn.sftp.no_known_hosts": "Warning: No known_hosts file found, cannot verify host key for {host}, connection may be insecure",
|
||||
"err.sftp.mkdir_max_depth": "Remote directory too deep or loop detected: {path}",
|
||||
"err.webdav.xml_too_large": "WebDAV response too large ({size} bytes), possible XML bomb attack",
|
||||
"cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)",
|
||||
"cli.description": "Sbackup v{version} — Lightweight incremental backup tool\n\nSbackup helps you easily manage multi-folder backup tasks. It uses incremental backup strategy,\nonly compressing modified files, supporting custom ignore rules, and providing intuitive progress feedback.\n\nAvailable commands:\n add Add a new backup strategy (source -> destination)\n rm Remove an existing backup strategy\n all Show all configured backup strategies\n save Execute backup tasks\n version Show version information",
|
||||
"cli.epilog": "Examples:\n Add backup strategy:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Add strategy with entry-level format:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Remove backup strategy:\n sbackup rm F:/my_folder\n\n View all strategies:\n sbackup all\n\n Execute backup (ZIP):\n sbackup save\n\n Execute backup (tar.gz):\n sbackup --format tar.gz save\n\n Execute backup (7z encrypted):\n sbackup --format 7z save --password 123456\n\n Keep last 5 backups:\n sbackup save --keep 5\n\n Watch backup (every 60 min):\n sbackup watch --interval 60\n\n Watch + cleanup:\n sbackup watch --interval 120 --keep 10\n\n Restore backup:\n sbackup restore backup.7z F:/restored\n\n Chinese interface:\n sbackup --lang zh_CN save\n\n Debug mode:\n sbackup --debug save\n\n SFTP remote backup (auto-detect key, recommended):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remote backup (password auth):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remote backup (specify key):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remote backup (key + passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP watch and upload:\n sbackup watch --interval 60 --sftp",
|
||||
@@ -113,11 +124,24 @@
|
||||
"cli.help.verify": "Verify backup file integrity",
|
||||
"cli.help.verify.file": "Backup file to verify",
|
||||
"cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)",
|
||||
"cli.help.verify.all": "Batch verify all historical backups",
|
||||
"cli.help.verify.detail": "Show detailed verification info (size, format, checksum, etc.)",
|
||||
"cli.help.verify.split": "Verify split file integrity (requires manifest file)",
|
||||
"cli.help.verify.password": "Decryption password (for encrypted backups)",
|
||||
"cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "Fast verification failed: {path}, file may be corrupted or tampered",
|
||||
"cmd.verify.no_checksum": "No historical checksum found, falling back to full verify: {path}",
|
||||
"cmd.verify.checksum_mismatch": "Checksum mismatch",
|
||||
"cmd.verify.no_history": "No backup history found.",
|
||||
"cmd.verify.no_file": "Please specify a backup file path or use --all to verify all backups.",
|
||||
"cmd.verify.detail_header": "=== Backup Details: {path} ===",
|
||||
"cmd.verify.detail_info": "Size: {size:.2f} MB | Files: {files} | Format: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ Verified: {path}",
|
||||
"cmd.verify.batch_failed": "✗ Failed: {path}",
|
||||
"cmd.verify.batch_summary": "Batch verification complete: {success} passed, {fail} failed",
|
||||
"cmd.verify.split_success": "Split integrity verified: {file} ({parts} parts)",
|
||||
"cmd.verify.split_failed": "Split integrity check failed: {file}",
|
||||
"cmd.verify.split_error": "Split verification error: {error}",
|
||||
"cli.help.export": "Export all backup strategies to a JSON file",
|
||||
"cli.help.export.file": "Export file path (default: sbackup_export.json)",
|
||||
"cli.help.import": "Import backup strategies from a JSON file",
|
||||
@@ -181,6 +205,8 @@
|
||||
"cmd.clean.nothing": "No backup files to clean up.",
|
||||
"cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):",
|
||||
"cmd.clean.summary": "Cleaned up {count} backup file(s).",
|
||||
"cli.help.completion": "Generate shell completion script",
|
||||
"cli.help.completion.shell": "Shell type: bash(default) / zsh / fish / powershell",
|
||||
"cmd.report.saved": "Backup report saved to: {path}",
|
||||
"cmd.search.results": "Found {count} matching file(s) in {path}:",
|
||||
"cmd.search.no_results": "No files matching '{pattern}' found in {path}",
|
||||
@@ -324,13 +350,8 @@
|
||||
"err.webdav.local_not_found": "Local file not found: {path}",
|
||||
"err.webdav.upload": "Upload to WebDAV failed {path}: {error}",
|
||||
"err.webdav.mkdir": "Failed to create WebDAV remote directory {path}: {error}",
|
||||
"cli.help.remote": "Remote backup file management (list/delete backup files on remote server)",
|
||||
"cli.help.remote.action": "Remote action: list / rm",
|
||||
"cli.help.remote.list": "List backup files on remote server",
|
||||
"cli.help.remote.rm": "Delete a backup file on remote server",
|
||||
"cli.help.remote.rm.filename": "Remote file name to delete",
|
||||
"cli.help.remote.sftp": "Use SFTP protocol",
|
||||
"cli.help.remote.webdav": "Use WebDAV protocol",
|
||||
"err.webdav.invalid_scheme": "Unsupported URL scheme '{scheme}' -- only http and https are allowed.",
|
||||
"err.webdav.private_ip": "WebDAV connection to internal/private address blocked for security: {host}",
|
||||
"cmd.remote.empty": "Remote directory is empty: {path}",
|
||||
"cmd.remote.list_header": "Remote directory: {path} ({count} files)",
|
||||
"cmd.remote.deleted": "Deleted remote file: {path}",
|
||||
@@ -355,5 +376,13 @@
|
||||
"cli.help.watch.post_hook": "Command to run after backup (can be used multiple times)",
|
||||
"restore.split.detected": "Split backup detected, {count} parts total",
|
||||
"restore.split.missing": "Error: split part missing, expected {path}",
|
||||
"restore.split.merged": "Split parts merged: {path}"
|
||||
"restore.split.merged": "Split parts merged: {path}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"table.cell.default": "Predet.",
|
||||
"restore.progress": "Restaurando",
|
||||
"restore.success": "Restauración exitosa: {path} ({count} archivos)",
|
||||
"restore.skipped_unsafe": "Se omitieron {count} miembros de archivo no seguros (protección contra traversal de ruta)",
|
||||
"cmd.watch.start": "Monitorización iniciada, intervalo {interval} min (pulse Ctrl+C para detener)",
|
||||
"cli.description": "Sbackup v{version} — Herramienta de copia de seguridad incremental ligera\n\nSbackup le ayuda a gestionar fácilmente las copias de seguridad de múltiples carpetas.\nUtiliza una estrategia de copia de seguridad incremental, comprimiendo únicamente los archivos modificados,\nsoportando reglas de exclusión personalizadas y ofreciendo una retroalimentación de progresión intuitiva.\n\nComandos disponibles :\n add Añadir una nueva estrategia de copia de seguridad (fuente -> destino)\n rm Eliminar una estrategia de copia de seguridad existente\n all Mostrar todas las estrategias de copia de seguridad configuradas\n save Ejecutar las tareas de copia de seguridad\n version Mostrar la información de versión",
|
||||
"cli.epilog": "Ejemplos:\n Agregar estrategia de respaldo:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ejecutar respaldo (7z cifrado):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (detección automática, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (contraseña):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (clave privada):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (clave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||
@@ -107,6 +108,25 @@
|
||||
"cli.help.list": "Ver historial de copias de seguridad",
|
||||
"cli.help.verify": "Verificar integridad del archivo de copia de seguridad",
|
||||
"cli.help.verify.file": "Archivo de copia de seguridad a verificar",
|
||||
"cli.help.verify.fast": "Verificación rápida (comparar SHA256 histórico, sin extracción)",
|
||||
"cli.help.verify.all": "Verificar todas las copias de seguridad históricas en lote",
|
||||
"cli.help.verify.detail": "Mostrar información detallada de verificación (tamaño, formato, suma de verificación, etc.)",
|
||||
"cli.help.verify.split": "Verificar integridad de archivos divididos (requiere archivo de manifiesto)",
|
||||
"cli.help.verify.password": "Contraseña de descifrado (para copias cifradas)",
|
||||
"cmd.verify.fast_success": "Verificación rápida aprobada: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "Verificación rápida fallida: {path}, el archivo podría estar dañado o manipulado",
|
||||
"cmd.verify.no_checksum": "No se encontró suma de verificación histórica, verificación completa: {path}",
|
||||
"cmd.verify.checksum_mismatch": "La suma de verificación no coincide",
|
||||
"cmd.verify.no_history": "No se encontró historial de copias de seguridad.",
|
||||
"cmd.verify.no_file": "Especifique la ruta del archivo o use --all para verificar todas las copias.",
|
||||
"cmd.verify.detail_header": "=== Detalles de copia de seguridad: {path} ===",
|
||||
"cmd.verify.detail_info": "Tamaño: {size:.2f} MB | Archivos: {files} | Formato: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ Verificado: {path}",
|
||||
"cmd.verify.batch_failed": "✗ Fallido: {path}",
|
||||
"cmd.verify.batch_summary": "Verificación en lote completada: {success} aprobados, {fail} fallidos",
|
||||
"cmd.verify.split_success": "Integridad de archivos divididos verificada: {file} ({parts} partes)",
|
||||
"cmd.verify.split_failed": "Verificación de integridad de archivos divididos fallida: {file}",
|
||||
"cmd.verify.split_error": "Error de verificación de archivos divididos: {error}",
|
||||
"cli.help.export": "Export all backup strategies to a JSON file",
|
||||
"cli.help.export.file": "Export file path (default: sbackup_export.json)",
|
||||
"cli.help.import": "Import backup strategies from a JSON file",
|
||||
@@ -231,5 +251,13 @@
|
||||
"cmd.diff.modified_header": "Posiblemente modificados ({count}):",
|
||||
"cmd.diff.no_backup": "No se encontraron archivos de copia de seguridad en {path}",
|
||||
"cmd.diff.empty_backup": "El archivo de copia de seguridad esta vacio: {path}",
|
||||
"cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias."
|
||||
"cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias.",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
"table.cell.default": "Défaut",
|
||||
"restore.progress": "Restauration en cours",
|
||||
"restore.success": "Restauration réussie : {path} ({count} fichiers)",
|
||||
"restore.skipped_unsafe": "Restauré {count} membres d'archive non sûrs (protection contre le traversée de chemin)",
|
||||
"cmd.watch.start": "Surveillance démarrée, intervalle {interval} min (appuyez sur Ctrl+C pour arrêter)",
|
||||
"cli.description": "Sbackup v{version} — Outil de sauvegarde incrémentielle léger\n\nSbackup vous aide à gérer facilement les sauvegardes de plusieurs dossiers.\nIl utilise une stratégie de sauvegarde incrémentielle, compressant uniquement les fichiers modifiés,\nsupportant des règles d'exclusion personnalisées et offrant un retour de progression intuitif.\n\nCommandes disponibles :\n add Ajouter une nouvelle stratégie de sauvegarde (source -> destination)\n rm Supprimer une stratégie de sauvegarde existante\n all Afficher toutes les stratégies de sauvegarde configurées\n save Exécuter les tâches de sauvegarde\n version Afficher les informations de version",
|
||||
"cli.epilog": "Exemples:\n Ajouter une stratégie de sauvegarde:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ajouter avec format au niveau de l'entrée:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Supprimer une stratégie:\n sbackup rm F:/my_folder\n\n Voir toutes les stratégies:\n sbackup all\n\n Exécuter la sauvegarde (ZIP):\n sbackup save\n\n Sauvegarde chiffrée (7z):\n sbackup --format 7z save --password 123456\n\n Garder les 5 dernières sauvegardes:\n sbackup save --keep 5\n\n Surveillance (toutes les 60 min):\n sbackup watch --interval 60\n\n Restaurer:\n sbackup restore backup.7z F:/restored\n\n SFTP distant (détection auto, recommandé):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP distant (mot de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP distant (clé privée):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP distant (clé + phrase de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP surveillance et téléchargement:\n sbackup watch --interval 60 --sftp",
|
||||
@@ -116,6 +117,25 @@
|
||||
"cli.help.list": "Voir l'historique des sauvegardes",
|
||||
"cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde",
|
||||
"cli.help.verify.file": "Fichier de sauvegarde à vérifier",
|
||||
"cli.help.verify.fast": "Vérification rapide (comparer SHA256 historique, sans extraction)",
|
||||
"cli.help.verify.all": "Vérifier toutes les sauvegardes historiques en lot",
|
||||
"cli.help.verify.detail": "Afficher les détails de vérification (taille, format, somme de contrôle, etc.)",
|
||||
"cli.help.verify.split": "Vérifier l'intégrité des fichiers divisés (nécessite le fichier manifeste)",
|
||||
"cli.help.verify.password": "Mot de passe de déchiffrement (pour les sauvegardes chiffrées)",
|
||||
"cmd.verify.fast_success": "Vérification rapide réussie: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "Vérification rapide échouée: {path}, le fichier est peut-être corrompu ou altéré",
|
||||
"cmd.verify.no_checksum": "Aucune somme de contrôle historique trouvée, vérification complète: {path}",
|
||||
"cmd.verify.checksum_mismatch": "La somme de contrôle ne correspond pas",
|
||||
"cmd.verify.no_history": "Aucun historique de sauvegarde trouvé.",
|
||||
"cmd.verify.no_file": "Veuillez spécifier le chemin du fichier ou utiliser --all pour vérifier toutes les sauvegardes.",
|
||||
"cmd.verify.detail_header": "=== Détails de la sauvegarde: {path} ===",
|
||||
"cmd.verify.detail_info": "Taille: {size:.2f} MO | Fichiers: {files} | Format: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ Vérifié: {path}",
|
||||
"cmd.verify.batch_failed": "✗ Échoué: {path}",
|
||||
"cmd.verify.batch_summary": "Vérification en lot terminée: {success} réussis, {fail} échoués",
|
||||
"cmd.verify.split_success": "Intégrité des fichiers divisés vérifiée: {file} ({parts} parties)",
|
||||
"cmd.verify.split_failed": "Vérification d'intégrité des fichiers divisés échouée: {file}",
|
||||
"cmd.verify.split_error": "Erreur de vérification des fichiers divisés: {error}",
|
||||
"cli.help.info": "Voir les détails du fichier de sauvegarde (format, taille, nombre de fichiers, somme de contrôle, etc.)",
|
||||
"cli.help.info.file": "Chemin du fichier de sauvegarde",
|
||||
"cli.help.info.password": "Mot de passe de déchiffrement (pour les sauvegardes 7z chiffrées)",
|
||||
@@ -250,5 +270,13 @@
|
||||
"err.webdav.not_configured": "WebDAV non configuré, exécutez: sbackup webdav config --url <adresse> --user <nom_utilisateur>",
|
||||
"err.webdav.local_not_found": "Fichier local introuvable: {path}",
|
||||
"err.webdav.upload": "Échec du téléchargement vers WebDAV {path}: {error}",
|
||||
"err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path}: {error}"
|
||||
"err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path}: {error}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
"table.cell.default": "デフォルト",
|
||||
"restore.progress": "復元中",
|
||||
"restore.success": "復元成功: {path} ({count} ファイル)",
|
||||
"restore.skipped_unsafe": "安全でないアーカイブメンバー {count} 件をスキップしました(パストラバーサル保護)",
|
||||
"cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)",
|
||||
"cli.description": "Sbackup v{version} — 軽量インクリメンタルバックアップツール\n\nSbackup は複数フォルダのバックアップタスクを簡単に管理します。\nインクリメンタルバックアップ戦略を採用し、変更されたファイルのみを圧縮し、\nカスタム除外ルールをサポートし、直感的な進捗フィードバックを提供します。\n\n使用可能なコマンド:\n add 新しいバックアップ戦略を追加(ソース -> 目標)\n rm 既存のバックアップ戦略を削除\n all 設定済みのすべてのバックアップ戦略を表示\n save バックアップタスクを実行\n version バージョン情報を表示",
|
||||
"cli.epilog": "例:\n バックアップ戦略を追加:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 暗号化バックアップ:\n sbackup --format 7z save --password 123456\n\n 復元:\n sbackup restore backup.7z F:/restored\n\n SFTP リモート (自動検出、推奨):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP リモート (パスワード):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP リモート (キー):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP リモート (キー + パスフレーズ):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||
@@ -116,6 +117,25 @@
|
||||
"cli.help.list": "バックアップ履歴を表示",
|
||||
"cli.help.verify": "バックアップファイルの整合性を検証",
|
||||
"cli.help.verify.file": "検証するバックアップファイル",
|
||||
"cli.help.verify.fast": "高速検証(履歴SHA256比較、展開不要)",
|
||||
"cli.help.verify.all": "すべての履歴バックアップを一括検証",
|
||||
"cli.help.verify.detail": "詳細な検証情報を表示(サイズ、形式、チェックサムなど)",
|
||||
"cli.help.verify.split": "分割ファイルの整合性を検証(マニフェストファイルが必要)",
|
||||
"cli.help.verify.password": "復号化パスワード(暗号化バックアップ用)",
|
||||
"cmd.verify.fast_success": "高速検証成功: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "高速検証失敗: {path}、ファイルが破損または改ざんされている可能性があります",
|
||||
"cmd.verify.no_checksum": "履歴チェックサムが見つかりません、完全検証にフォールバック: {path}",
|
||||
"cmd.verify.checksum_mismatch": "チェックサムが一致しません",
|
||||
"cmd.verify.no_history": "バックアップ履歴が見つかりません。",
|
||||
"cmd.verify.no_file": "バックアップファイルを指定するか、--allですべてのバックアップを検証してください。",
|
||||
"cmd.verify.detail_header": "=== バックアップ詳細: {path} ===",
|
||||
"cmd.verify.detail_info": "サイズ: {size:.2f} MB | ファイル数: {files} | 形式: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ 検証済み: {path}",
|
||||
"cmd.verify.batch_failed": "✗ 失敗: {path}",
|
||||
"cmd.verify.batch_summary": "一括検証完了: {success}件成功、{fail}件失敗",
|
||||
"cmd.verify.split_success": "分割ファイル整合性検証成功: {file} ({parts} パート)",
|
||||
"cmd.verify.split_failed": "分割ファイル整合性検証失敗: {file}",
|
||||
"cmd.verify.split_error": "分割ファイル検証エラー: {error}",
|
||||
"cli.help.info": "バックアップファイルの詳細を表示(形式、サイズ、ファイル数、チェックサム)",
|
||||
"cli.help.info.file": "バックアップファイルのパス",
|
||||
"cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)",
|
||||
@@ -260,5 +280,13 @@
|
||||
"err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>",
|
||||
"err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}",
|
||||
"err.webdav.upload": "WebDAVへのアップロード失敗 {path}: {error}",
|
||||
"err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}"
|
||||
"err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
"table.cell.default": "기본값",
|
||||
"restore.progress": "복원 중",
|
||||
"restore.success": "복원 성공: {path} ({count} 파일)",
|
||||
"restore.skipped_unsafe": "안전하지 않은 아카이브 멤버 {count}개 건너뜀 (경로 순회 방지)",
|
||||
"cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)",
|
||||
"cli.description": "Sbackup v{version} — 경량 증분 백업 도구\n\nSbackup는 여러 폴더의 백업 작업을 쉽게 관리합니다.\n증분 백업 전략을 사용하여 변경된 파일만 압축하고,\n사용자 정의 제외 규칙을 지원하며 직관적인 진행 피드백을 제공합니다.\n\n사용 가능한 명령:\n add 새 백업 전략 추가 (원본 -> 대상)\n rm 기존 백업 전략 삭제\n all 구성된 모든 백업 전략 표시\n save 백업 작업 실행\n version 버전 정보 표시",
|
||||
"cli.epilog": "예제:\n 백업 전략 추가:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 암호화 백업:\n sbackup --format 7z save --password 123456\n\n 복원:\n sbackup restore backup.7z F:/restored\n\n SFTP 원격 (자동 감지, 권장):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 원격 (비밀번호):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 원격 (키):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 원격 (키 + 암호문):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||
@@ -116,6 +117,25 @@
|
||||
"cli.help.list": "백업 기록 보기",
|
||||
"cli.help.verify": "백업 파일 무결성 검증",
|
||||
"cli.help.verify.file": "검증할 백업 파일",
|
||||
"cli.help.verify.fast": "빠른 검증 (SHA256 비교, 압축 해제 불필요)",
|
||||
"cli.help.verify.all": "모든 백업 기록 일괄 검증",
|
||||
"cli.help.verify.detail": "상세 검증 정보 표시 (크기, 형식, 체크섬 등)",
|
||||
"cli.help.verify.split": "분할 파일 무결성 검증 (매니페스트 파일 필요)",
|
||||
"cli.help.verify.password": "복호화 비밀번호 (암호화된 백업용)",
|
||||
"cmd.verify.fast_success": "빠른 검증 성공: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "빠른 검증 실패: {path}, 파일이 손상되었거나 변조되었을 수 있습니다",
|
||||
"cmd.verify.no_checksum": "기록된 체크섬을 찾을 수 없어 전체 검증으로 대체: {path}",
|
||||
"cmd.verify.checksum_mismatch": "체크섬 불일치",
|
||||
"cmd.verify.no_history": "백업 기록을 찾을 수 없습니다.",
|
||||
"cmd.verify.no_file": "백업 파일을 지정하거나 --all로 모든 백업을 검증하세요.",
|
||||
"cmd.verify.detail_header": "=== 백업 상세: {path} ===",
|
||||
"cmd.verify.detail_info": "크기: {size:.2f} MB | 파일 수: {files} | 형식: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ 검증 완료: {path}",
|
||||
"cmd.verify.batch_failed": "✗ 실패: {path}",
|
||||
"cmd.verify.batch_summary": "일괄 검증 완료: {success}개 성공, {fail}개 실패",
|
||||
"cmd.verify.split_success": "분할 파일 무결성 검증 성공: {file} ({parts}개 파트)",
|
||||
"cmd.verify.split_failed": "분할 파일 무결성 검증 실패: {file}",
|
||||
"cmd.verify.split_error": "분할 파일 검증 오류: {error}",
|
||||
"cli.help.info": "백업 파일 상세 정보 보기 (형식, 크기, 파일 수, 체크섬)",
|
||||
"cli.help.info.file": "백업 파일 경로",
|
||||
"cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)",
|
||||
@@ -260,5 +280,13 @@
|
||||
"err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>",
|
||||
"err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}",
|
||||
"err.webdav.upload": "WebDAV 업로드 실패 {path}: {error}",
|
||||
"err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}"
|
||||
"err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
"table.cell.default": "Padrão",
|
||||
"restore.progress": "Restaurando",
|
||||
"restore.success": "Restauração concluída: {path} ({count} arquivos)",
|
||||
"restore.skipped_unsafe": "Ignorados {count} membros de arquivo inseguros (proteção contra path traversal)",
|
||||
"cmd.watch.start": "Monitoramento iniciado, intervalo {interval} min (Ctrl+C para parar)",
|
||||
"cli.description": "Sbackup v{version} — Ferramenta de backup incremental leve\n\nSbackup ajuda você a gerenciar facilmente backups de múltiplas pastas.\nEle usa uma estratégia de backup incremental, comprimindo apenas arquivos modificados,\nsuportando regras de exclusão personalizadas e oferecendo feedback de progresso intuitivo.\n\nComandos disponíveis:\n add Adicionar nova estratégia de backup (origem -> destino)\n rm Remover estratégia de backup existente\n all Mostrar todas as estratégias de backup configuradas\n save Executar tarefas de backup\n version Mostrar informações de versão",
|
||||
"cli.epilog": "Exemplos:\n Adicionar estratégia de backup:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup criptografado (7z):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (auto-detecção, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (senha):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (chave):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (chave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||
@@ -116,6 +117,25 @@
|
||||
"cli.help.list": "Ver histórico de backups",
|
||||
"cli.help.verify": "Verificar integridade do arquivo de backup",
|
||||
"cli.help.verify.file": "Arquivo de backup para verificar",
|
||||
"cli.help.verify.fast": "Verificação rápida (comparar SHA256 histórico, sem extração)",
|
||||
"cli.help.verify.all": "Verificar todos os backups históricos em lote",
|
||||
"cli.help.verify.detail": "Mostrar informações detalhadas de verificação (tamanho, formato, soma de verificação, etc.)",
|
||||
"cli.help.verify.split": "Verificar integridade de arquivos divididos (requer arquivo de manifesto)",
|
||||
"cli.help.verify.password": "Senha de descriptografia (para backups criptografados)",
|
||||
"cmd.verify.fast_success": "Verificação rápida aprovada: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "Verificação rápida falhou: {path}, arquivo pode estar corrompido ou adulterado",
|
||||
"cmd.verify.no_checksum": "Soma de verificação histórica não encontrada, verificação completa: {path}",
|
||||
"cmd.verify.checksum_mismatch": "Soma de verificação não corresponde",
|
||||
"cmd.verify.no_history": "Nenhum histórico de backup encontrado.",
|
||||
"cmd.verify.no_file": "Especifique o caminho do arquivo ou use --all para verificar todos os backups.",
|
||||
"cmd.verify.detail_header": "=== Detalhes do backup: {path} ===",
|
||||
"cmd.verify.detail_info": "Tamanho: {size:.2f} MB | Arquivos: {files} | Formato: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ Verificado: {path}",
|
||||
"cmd.verify.batch_failed": "✗ Falhou: {path}",
|
||||
"cmd.verify.batch_summary": "Verificação em lote concluída: {success} aprovados, {fail} falharam",
|
||||
"cmd.verify.split_success": "Integridade de arquivos divididos verificada: {file} ({parts} partes)",
|
||||
"cmd.verify.split_failed": "Verificação de integridade de arquivos divididos falhou: {file}",
|
||||
"cmd.verify.split_error": "Erro na verificação de arquivos divididos: {error}",
|
||||
"cli.help.info": "Ver detalhes do arquivo de backup (formato, tamanho, quantidade de arquivos, soma de verificação)",
|
||||
"cli.help.info.file": "Caminho do arquivo de backup",
|
||||
"cli.help.info.password": "Senha de descriptografia (para backups 7z criptografados)",
|
||||
@@ -260,5 +280,13 @@
|
||||
"err.webdav.not_configured": "WebDAV não configurado, execute: sbackup webdav config --url <endereço> --user <usuário>",
|
||||
"err.webdav.local_not_found": "Arquivo local não encontrado: {path}",
|
||||
"err.webdav.upload": "Upload para WebDAV falhou {path}: {error}",
|
||||
"err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}"
|
||||
"err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -82,6 +82,7 @@
|
||||
"table.cell.default": "По умолч.",
|
||||
"restore.progress": "Восстановление",
|
||||
"restore.success": "Восстановление выполнено: {path} ({count} файлов)",
|
||||
"restore.skipped_unsafe": "Пропущено {count} небезопасных элементов архива (защита от обхода пути)",
|
||||
"cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)",
|
||||
"cli.description": "Sbackup v{version} — Лёгкий инструмент инкрементального резервного копирования\n\nSbackup помогает легко управлять резервным копированием нескольких папок.\nОн использует стратегию инкрементального копирования, сжимая только изменённые файлы,\nподдерживает пользовательские правила исключения и предоставляет наглядный ход выполнения.\n\nДоступные команды:\n add Добавить новую стратегию резервного копирования (источник -> цель)\n rm Удалить существующую стратегию\n all Показать все настроенные стратегии\n save Выполнить резервное копирование\n version Показать информацию о версии",
|
||||
"cli.epilog": "Примеры:\n Добавить стратегию:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Резервное копирование (7z шифрование):\n sbackup --format 7z save --password 123456\n\n Восстановить:\n sbackup restore backup.7z F:/restored\n\n SFTP (авто-детект ключа, рекомендуется):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP (пароль):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP (ключ): \n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP (ключ + фраза):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||
@@ -116,6 +117,25 @@
|
||||
"cli.help.list": "Просмотреть историю резервного копирования",
|
||||
"cli.help.verify": "Проверить целостность файла резервной копии",
|
||||
"cli.help.verify.file": "Файл резервной копии для проверки",
|
||||
"cli.help.verify.fast": "Быстрая проверка (сравнение SHA256, без распаковки)",
|
||||
"cli.help.verify.all": "Пакетная проверка всех исторических резервных копий",
|
||||
"cli.help.verify.detail": "Показать подробную информацию (размер, формат, контрольная сумма и т.д.)",
|
||||
"cli.help.verify.split": "Проверить целостность разделенных файлов (требуется файл манифеста)",
|
||||
"cli.help.verify.password": "Пароль расшифровки (для зашифрованных копий)",
|
||||
"cmd.verify.fast_success": "Быстрая проверка пройдена: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "Быстрая проверка не пройдена: {path}, файл может быть поврежден или изменен",
|
||||
"cmd.verify.no_checksum": "Историческая контрольная сумма не найдена, полная проверка: {path}",
|
||||
"cmd.verify.checksum_mismatch": "Контрольная сумма не совпадает",
|
||||
"cmd.verify.no_history": "История резервных копий не найдена.",
|
||||
"cmd.verify.no_file": "Укажите путь к файлу или используйте --all для проверки всех копий.",
|
||||
"cmd.verify.detail_header": "=== Детали резервной копии: {path} ===",
|
||||
"cmd.verify.detail_info": "Размер: {size:.2f} МБ | Файлов: {files} | Формат: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ Проверено: {path}",
|
||||
"cmd.verify.batch_failed": "✗ Ошибка: {path}",
|
||||
"cmd.verify.batch_summary": "Пакетная проверка завершена: {success} успешно, {fail} с ошибками",
|
||||
"cmd.verify.split_success": "Целостность разделенных файлов подтверждена: {file} ({parts} частей)",
|
||||
"cmd.verify.split_failed": "Проверка целостности разделенных файлов не пройдена: {file}",
|
||||
"cmd.verify.split_error": "Ошибка проверки разделенных файлов: {error}",
|
||||
"cli.help.info": "Просмотреть подробную информацию о файле резервной копии (формат, размер, количество файлов, контрольная сумма)",
|
||||
"cli.help.info.file": "Путь к файлу резервной копии",
|
||||
"cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)",
|
||||
@@ -260,5 +280,13 @@
|
||||
"err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>",
|
||||
"err.webdav.local_not_found": "Локальный файл не найден: {path}",
|
||||
"err.webdav.upload": "Ошибка загрузки на WebDAV {path}: {error}",
|
||||
"err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}"
|
||||
"err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}",
|
||||
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||
"log.monitor.watching": "Now monitoring: {path}",
|
||||
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
"warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。",
|
||||
"warn.json.backup": "警告:已备份损坏的数据文件到 {path}",
|
||||
"warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。",
|
||||
"compress.success": "成功备份: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} 个文件)",
|
||||
"warn.password_in_cli": "警告:通过 {arg} 传递的密码会暴露在进程列表中,建议使用交互式输入。",
|
||||
"warn.webdav.http_plaintext": "警告:使用明文 HTTP 连接 WebDAV ({url}),凭据将以明文传输。建议使用 HTTPS。",
|
||||
"compress.success": "成功备份: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} 个文件)",
|
||||
"compress.progress": "正在压缩",
|
||||
"compress.unit": "文件",
|
||||
"err.permission": "权限不足:无法写入 '{path}'",
|
||||
@@ -25,6 +27,7 @@
|
||||
"err.argparse.invalid_int": "无效的整数值: ",
|
||||
"err.argparse.unrecognized_args": "未识别的参数: ",
|
||||
"err.argparse.required": "必需参数",
|
||||
"err.lock.conflict": "错误:另一个备份操作正在运行(锁文件已存在)。如果确定没有其他进程在运行,请手动删除锁文件。",
|
||||
"warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}",
|
||||
"warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6",
|
||||
"cmd.save.uptodate": "所有备份策略均已是最新,无需备份。",
|
||||
@@ -42,6 +45,7 @@
|
||||
"cmd.init.exists": "配置文件已存在: {path},跳过生成。",
|
||||
"warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)",
|
||||
"cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒",
|
||||
"cmd.upload.parallel": "正在并行上传到 SFTP 和 WebDAV...",
|
||||
"cmd.dry_run.header": "预览模式(不会实际执行备份):",
|
||||
"cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB",
|
||||
"cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB",
|
||||
@@ -75,6 +79,13 @@
|
||||
"table.cell.default": "默认",
|
||||
"restore.progress": "正在还原",
|
||||
"restore.success": "还原成功: {path} ({count} 个文件)",
|
||||
"restore.skipped_unsafe": "已跳过 {count} 个不安全的归档成员(路径遍历防护)",
|
||||
"err.invalid_filename": "无效的文件名: {filename}(不允许路径分隔符和 .. 序列)",
|
||||
"err.sftp.no_host_key": "无法获取主机 {host} 的密钥",
|
||||
"err.sftp.host_key_mismatch": "主机 {host} 的密钥与 known_hosts 不匹配(可能遭受中间人攻击)",
|
||||
"warn.sftp.no_known_hosts": "警告: 未找到 known_hosts 文件,无法验证 {host} 的主机密钥,连接可能不安全",
|
||||
"err.sftp.mkdir_max_depth": "远程目录层级过深或存在循环: {path}",
|
||||
"err.webdav.xml_too_large": "WebDAV 响应过大({size} 字节),可能存在 XML 炸弹攻击",
|
||||
"cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)",
|
||||
"cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息",
|
||||
"cli.epilog": "示例:\n 添加备份策略:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 添加策略并指定条目级格式:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n 删除备份策略:\n sbackup rm F:/my_folder\n\n 查看所有策略:\n sbackup all\n\n 执行备份 (ZIP):\n sbackup save\n\n 执行备份 (tar.gz):\n sbackup --format tar.gz save\n\n 执行备份 (7z 加密):\n sbackup --format 7z save --password 123456\n\n 保留最近 5 个备份:\n sbackup save --keep 5\n\n 定时备份 (每 60 分钟):\n sbackup watch --interval 60\n\n 定时备份 + 清理:\n sbackup watch --interval 120 --keep 10\n\n 还原备份:\n sbackup restore backup.7z F:/restored\n\n 英文界面:\n sbackup --lang en_US save\n\n 调试模式:\n sbackup --debug save\n\n SFTP 远程备份 (自动检测私钥,推荐):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 远程备份 (密码认证):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 远程备份 (指定私钥):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 远程备份 (私钥 + 密码短语):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP 定时备份并上传:\n sbackup watch --interval 60 --sftp",
|
||||
@@ -116,11 +127,24 @@
|
||||
"cli.help.verify": "校验备份文件完整性",
|
||||
"cli.help.verify.file": "要校验的备份文件路径",
|
||||
"cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)",
|
||||
"cli.help.verify.all": "批量验证所有历史备份",
|
||||
"cli.help.verify.detail": "显示详细验证信息(大小、格式、校验和等)",
|
||||
"cli.help.verify.split": "验证分卷文件完整性(需要清单文件)",
|
||||
"cli.help.verify.password": "解密密码(校验加密备份时使用)",
|
||||
"cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}",
|
||||
"cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改",
|
||||
"cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}",
|
||||
"cmd.verify.checksum_mismatch": "校验和不匹配",
|
||||
"cmd.verify.no_history": "没有备份历史记录。",
|
||||
"cmd.verify.no_file": "请指定备份文件路径或使用 --all 验证所有备份。",
|
||||
"cmd.verify.detail_header": "═══ 备份详情: {path} ═══",
|
||||
"cmd.verify.detail_info": "大小: {size:.2f} MB | 文件数: {files} | 格式: {fmt} | SHA256: {sha256}...",
|
||||
"cmd.verify.batch_success": "✓ 校验通过: {path}",
|
||||
"cmd.verify.batch_failed": "✗ 校验失败: {path}",
|
||||
"cmd.verify.batch_summary": "批量验证完成: {success} 个通过, {fail} 个失败",
|
||||
"cmd.verify.split_success": "分卷完整性验证通过: {file} ({parts} 个分卷)",
|
||||
"cmd.verify.split_failed": "分卷完整性验证失败: {file}",
|
||||
"cmd.verify.split_error": "分卷验证错误: {error}",
|
||||
"cli.help.export": "导出所有备份策略到 JSON 文件",
|
||||
"cli.help.export.file": "导出文件路径(默认: sbackup_export.json)",
|
||||
"cli.help.import": "从 JSON 文件导入备份策略",
|
||||
@@ -184,6 +208,8 @@
|
||||
"cmd.clean.nothing": "没有需要清理的备份文件。",
|
||||
"cmd.clean.dry_run_header": "预览模式(不会实际删除):",
|
||||
"cmd.clean.summary": "共清理 {count} 个备份文件。",
|
||||
"cli.help.completion": "生成 Shell 自动补全脚本",
|
||||
"cli.help.completion.shell": "Shell 类型: bash(默认) / zsh / fish / powershell",
|
||||
"cmd.report.saved": "备份报告已保存到: {path}",
|
||||
"cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:",
|
||||
"cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件",
|
||||
@@ -327,13 +353,8 @@
|
||||
"err.webdav.local_not_found": "本地文件不存在: {path}",
|
||||
"err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}",
|
||||
"err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}",
|
||||
"cli.help.remote": "远程备份文件管理(列出/删除远程服务器上的备份文件)",
|
||||
"cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)",
|
||||
"cli.help.remote.list": "列出远程服务器上的备份文件",
|
||||
"cli.help.remote.rm": "删除远程服务器上的备份文件",
|
||||
"cli.help.remote.rm.filename": "要删除的远程文件名",
|
||||
"cli.help.remote.sftp": "使用 SFTP 协议",
|
||||
"cli.help.remote.webdav": "使用 WebDAV 协议",
|
||||
"err.webdav.invalid_scheme": "不支持的 URL 协议 '{scheme}',仅允许 http 和 https。",
|
||||
"err.webdav.private_ip": "已阻止连接到内网地址: {host}",
|
||||
"cmd.remote.empty": "远程目录为空: {path}",
|
||||
"cmd.remote.list_header": "远程目录: {path} ({count} 个文件)",
|
||||
"cmd.remote.deleted": "已删除远程文件: {path}",
|
||||
@@ -358,5 +379,13 @@
|
||||
"cli.help.watch.post_hook": "备份后执行的命令(可多次使用)",
|
||||
"restore.split.detected": "检测到分卷备份,共 {count} 个分卷",
|
||||
"restore.split.missing": "错误:分卷文件缺失,期望 {path}",
|
||||
"restore.split.merged": "分卷合并完成: {path}"
|
||||
"restore.split.merged": "分卷合并完成: {path}",
|
||||
"cli.help.watch.realtime": "启用文件系统实时监控(文件变化后自动触发备份)",
|
||||
"cli.help.watch.debounce": "防抖秒数,文件停变后等待此时间再备份(默认: 30)",
|
||||
"cmd.watch.start_realtime": "文件系统实时监控已启动,防抖 {debounce} 秒(按 Ctrl+C 停止)",
|
||||
"log.monitor.change": "检测到文件变化: {path}({dirty_count} 个目录待备份)",
|
||||
"log.monitor.trigger": "防抖超时,触发备份({dirty_count} 个目录)",
|
||||
"log.monitor.source_missing": "监控源目录不存在,跳过: {path}",
|
||||
"log.monitor.watching": "开始实时监控: {path}",
|
||||
"log.monitor.no_sources": "没有有效的监控源目录,监控未启动"
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
"""备份锁模块:防止并发冲突
|
||||
|
||||
使用原子文件操作(tempfile + os.rename)实现跨平台进程锁,
|
||||
确保同一时间只有一个 sbackup 实例在执行备份。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import atexit
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupLockError(Exception):
|
||||
"""备份锁异常"""
|
||||
|
||||
|
||||
class BackupLock:
|
||||
"""跨平台备份进程锁
|
||||
|
||||
基于 tempfile.mkstemp() + os.rename() 的原子性实现。
|
||||
|
||||
Usage::
|
||||
|
||||
lock = BackupLock(lock_dir)
|
||||
if lock.acquire():
|
||||
try:
|
||||
# do backup
|
||||
finally:
|
||||
lock.release()
|
||||
else:
|
||||
print("Another backup is running")
|
||||
"""
|
||||
|
||||
def __init__(self, lock_dir: str):
|
||||
self._lock_path = os.path.join(lock_dir, ".backup.lock")
|
||||
self._staging_path: str | None = None
|
||||
self._locked = False
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""尝试获取锁,成功返回 True"""
|
||||
if self._locked:
|
||||
return True
|
||||
|
||||
# 清理过期锁
|
||||
self._clean_stale_lock()
|
||||
|
||||
try:
|
||||
# 创建临时 staging 文件
|
||||
fd, self._staging_path = tempfile.mkstemp(
|
||||
dir=os.path.dirname(self._lock_path) or ".",
|
||||
prefix=".backup.lock.",
|
||||
)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(str(os.getpid()))
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
# 原子 rename(Windows 上目标存在时失败)
|
||||
os.rename(self._staging_path, self._lock_path)
|
||||
|
||||
self._locked = True
|
||||
atexit.register(self.release)
|
||||
return True
|
||||
|
||||
except OSError:
|
||||
self._cleanup_staging()
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
"""释放锁"""
|
||||
if not self._locked:
|
||||
return
|
||||
try:
|
||||
if os.path.exists(self._lock_path):
|
||||
os.unlink(self._lock_path)
|
||||
except OSError as e:
|
||||
logger.debug("Failed to remove lock file: %s", e)
|
||||
self._locked = False
|
||||
# 注销 atexit handler(Python 3.12+ 支持 unregister)
|
||||
try:
|
||||
atexit.unregister(self.release)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _clean_stale_lock(self) -> None:
|
||||
"""检查并清理过期锁文件"""
|
||||
try:
|
||||
if not os.path.exists(self._lock_path):
|
||||
return
|
||||
with open(self._lock_path) as f:
|
||||
content = f.read().strip()
|
||||
pid = int(content)
|
||||
if not _is_pid_alive(pid):
|
||||
os.unlink(self._lock_path)
|
||||
logger.info("Removed stale lock from dead PID %d", pid)
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
os.unlink(self._lock_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _cleanup_staging(self) -> None:
|
||||
if self._staging_path and os.path.exists(self._staging_path):
|
||||
try:
|
||||
os.unlink(self._staging_path)
|
||||
except OSError:
|
||||
pass
|
||||
self._staging_path = None
|
||||
|
||||
|
||||
def _is_pid_alive(pid: int) -> bool:
|
||||
"""跨平台检查 PID 是否存活"""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
handle = ctypes.windll.kernel32.OpenProcess(0x0400, False, pid)
|
||||
if handle:
|
||||
ctypes.windll.kernel32.CloseHandle(handle)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
# Unix: signal 0 仅检查进程是否存在
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
文件系统监控模块:基于 watchdog 的实时备份触发
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from sbackup.i18n import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _BackupEventHandler(FileSystemEventHandler):
|
||||
"""watchdog 事件处理器:目录变化时触发回调"""
|
||||
|
||||
def __init__(self, on_change_callback):
|
||||
super().__init__()
|
||||
self._on_change = on_change_callback
|
||||
|
||||
def on_any_event(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
self._on_change(event.src_path)
|
||||
|
||||
|
||||
class FileSystemMonitor:
|
||||
"""文件系统监控器:监听策略源目录变化,防抖后触发备份
|
||||
|
||||
使用方式::
|
||||
|
||||
monitor = FileSystemMonitor(source_dirs, debounce_seconds=30)
|
||||
monitor.set_backup_callback(lambda: manager.execute_backups(...))
|
||||
monitor.start()
|
||||
# ... 等待 ...
|
||||
monitor.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_dirs: dict[str, str],
|
||||
debounce_seconds: float = 30,
|
||||
):
|
||||
"""
|
||||
:param source_dirs: {源目录绝对路径: 目标目录}(仅用于确定监控范围)
|
||||
:param debounce_seconds: 防抖秒数,文件最后一次变化后等待此时间再触发备份
|
||||
"""
|
||||
self._source_dirs = source_dirs
|
||||
self._debounce = debounce_seconds
|
||||
self._observer: Observer | None = None
|
||||
self._dirty: set[str] = set()
|
||||
self._lock = threading.Lock()
|
||||
self._debounce_timer: threading.Timer | None = None
|
||||
self._backup_callback = None
|
||||
self._running = False
|
||||
|
||||
def set_backup_callback(self, callback) -> None:
|
||||
"""设置备份回调函数(变化触发时调用)"""
|
||||
self._backup_callback = callback
|
||||
|
||||
def _on_file_change(self, path_str: str) -> None:
|
||||
"""文件变化时标记对应源目录为 dirty 并重置防抖计时器"""
|
||||
path = Path(path_str)
|
||||
matched_source = None
|
||||
for source in self._source_dirs:
|
||||
try:
|
||||
path.relative_to(Path(source))
|
||||
matched_source = source
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if matched_source is None:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._dirty.add(matched_source)
|
||||
count = len(self._dirty)
|
||||
|
||||
logger.debug(t("log.monitor.change", path=path_str, dirty_count=count))
|
||||
self._reset_debounce()
|
||||
|
||||
def _reset_debounce(self) -> None:
|
||||
"""重置防抖计时器:取消旧计时,启动新计时"""
|
||||
with self._lock:
|
||||
if self._debounce_timer is not None:
|
||||
self._debounce_timer.cancel()
|
||||
self._debounce_timer = threading.Timer(self._debounce, self._trigger_backup)
|
||||
self._debounce_timer.daemon = True
|
||||
self._debounce_timer.start()
|
||||
|
||||
def _trigger_backup(self) -> None:
|
||||
"""防抖超时后触发备份回调"""
|
||||
with self._lock:
|
||||
if not self._dirty:
|
||||
return
|
||||
dirty_count = len(self._dirty)
|
||||
self._dirty.clear()
|
||||
|
||||
logger.debug(t("log.monitor.trigger", dirty_count=dirty_count))
|
||||
if self._backup_callback:
|
||||
try:
|
||||
self._backup_callback()
|
||||
except Exception:
|
||||
logger.exception("FileSystemMonitor 回调执行异常")
|
||||
|
||||
def get_dirty_sources(self) -> list[str]:
|
||||
"""获取当前标记为 dirty 的源目录列表(用于调试)"""
|
||||
with self._lock:
|
||||
return list(self._dirty)
|
||||
|
||||
def start(self) -> None:
|
||||
"""启动文件系统监控"""
|
||||
if self._observer is not None:
|
||||
return
|
||||
|
||||
self._observer = Observer()
|
||||
scheduled = 0
|
||||
for source in self._source_dirs:
|
||||
if not os.path.isdir(source):
|
||||
logger.warning(t("log.monitor.source_missing", path=source))
|
||||
continue
|
||||
handler = _BackupEventHandler(self._on_file_change)
|
||||
self._observer.schedule(handler, source, recursive=True)
|
||||
scheduled += 1
|
||||
logger.debug(t("log.monitor.watching", path=source))
|
||||
|
||||
if scheduled == 0:
|
||||
logger.warning(t("log.monitor.no_sources"))
|
||||
self._observer = None
|
||||
return
|
||||
|
||||
self._observer.start()
|
||||
self._running = True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止文件系统监控"""
|
||||
self._running = False
|
||||
with self._lock:
|
||||
if self._debounce_timer is not None:
|
||||
self._debounce_timer.cancel()
|
||||
self._debounce_timer = None
|
||||
|
||||
if self._observer is not None:
|
||||
self._observer.stop()
|
||||
try:
|
||||
self._observer.join(timeout=5)
|
||||
except RuntimeError:
|
||||
pass
|
||||
self._observer = None
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""检查监控是否在运行"""
|
||||
return (
|
||||
self._running and self._observer is not None and self._observer.is_alive()
|
||||
)
|
||||
+50
-3
@@ -44,7 +44,7 @@ class SFTPClient:
|
||||
self._sftp = None
|
||||
|
||||
def connect(self) -> None:
|
||||
"""建立 SFTP 连接(优先使用私钥认证)"""
|
||||
"""建立 SFTP 连接(优先使用私钥认证,验证主机密钥)"""
|
||||
import paramiko
|
||||
|
||||
# 展开 ~ 为用户目录(跨平台兼容)
|
||||
@@ -52,6 +52,47 @@ class SFTPClient:
|
||||
|
||||
try:
|
||||
self._transport = paramiko.Transport((self.host, self.port))
|
||||
|
||||
# 主机密钥验证:加载系统 known_hosts
|
||||
host_keys = paramiko.HostKeys()
|
||||
known_hosts_paths = [
|
||||
os.path.expanduser("~/.ssh/known_hosts"),
|
||||
"/etc/ssh/ssh_known_hosts",
|
||||
]
|
||||
for kh_path in known_hosts_paths:
|
||||
if os.path.isfile(kh_path):
|
||||
try:
|
||||
host_keys.load(kh_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if host_keys:
|
||||
# 有已知主机密钥,使用严格验证
|
||||
remote_key = self._transport.get_remote_server_key()
|
||||
if remote_key is None:
|
||||
self.disconnect()
|
||||
raise SFTPError(t("err.sftp.no_host_key", host=self.host))
|
||||
host_key_entry = host_keys.lookup(self.host)
|
||||
if host_key_entry is None:
|
||||
# 主机不在已知列表中,警告但允许连接(首次连接)
|
||||
logger.warning(
|
||||
"SFTP: 主机 %s 不在 known_hosts 中(首次连接)", self.host
|
||||
)
|
||||
else:
|
||||
try:
|
||||
host_keys.check(self.host, remote_key)
|
||||
except paramiko.SSHException:
|
||||
self.disconnect()
|
||||
raise SFTPError(t("err.sftp.host_key_mismatch", host=self.host))
|
||||
else:
|
||||
# 没有 known_hosts 文件,跳过验证(向后兼容)
|
||||
logger.debug("SFTP: 未找到 known_hosts 文件,跳过主机密钥验证")
|
||||
import sys
|
||||
|
||||
print(
|
||||
t("warn.sftp.no_known_hosts", host=self.host),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if key_file_expanded and os.path.isfile(key_file_expanded):
|
||||
# 私钥认证
|
||||
pkey = self._load_private_key(key_file_expanded, self.key_passphrase)
|
||||
@@ -150,6 +191,10 @@ class SFTPClient:
|
||||
finally:
|
||||
self._transport = None
|
||||
|
||||
# 清除敏感凭据,防止内存泄漏
|
||||
self.password = ""
|
||||
self.key_passphrase = ""
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
@@ -190,7 +235,7 @@ class SFTPClient:
|
||||
except OSError as e:
|
||||
raise SFTPError(t("err.sftp.upload", path=filename, error=str(e)))
|
||||
|
||||
def _ensure_remote_dir(self, remote_path: str) -> None:
|
||||
def _ensure_remote_dir(self, remote_path: str, max_depth: int = 50) -> None:
|
||||
"""确保远程目录存在,不存在则逐级创建"""
|
||||
if self._sftp is None:
|
||||
return
|
||||
@@ -203,7 +248,9 @@ class SFTPClient:
|
||||
# 递归创建父目录
|
||||
parent = os.path.dirname(remote_path).replace("\\", "/")
|
||||
if parent and parent != remote_path:
|
||||
self._ensure_remote_dir(parent)
|
||||
if max_depth <= 0:
|
||||
raise SFTPError(t("err.sftp.mkdir_max_depth", path=remote_path))
|
||||
self._ensure_remote_dir(parent, max_depth - 1)
|
||||
try:
|
||||
self._sftp.mkdir(remote_path)
|
||||
except OSError as e:
|
||||
|
||||
+50
-1
@@ -4,9 +4,13 @@ WebDAV 远程备份模块:基于 HTTP 的文件上传(支持坚果云、Next
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import socket
|
||||
import ipaddress
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from base64 import b64encode
|
||||
|
||||
from sbackup.i18n import t
|
||||
@@ -24,6 +28,39 @@ class WebDAVClient:
|
||||
"""WebDAV 客户端,基于 urllib 实现,零额外依赖"""
|
||||
|
||||
def __init__(self, url: str, user: str, password: str):
|
||||
# SSRF 防护:验证 URL scheme 和内部 IP
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise WebDAVError(
|
||||
t("err.webdav.invalid_scheme", scheme=parsed.scheme or "missing")
|
||||
)
|
||||
|
||||
# HTTP 明文警告
|
||||
if parsed.scheme == "http":
|
||||
print(
|
||||
t("warn.webdav.http_plaintext", url=url),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# 阻止内网地址
|
||||
if parsed.hostname:
|
||||
try:
|
||||
host_ip = socket.getaddrinfo(
|
||||
parsed.hostname, None, type=socket.SOCK_STREAM
|
||||
)[0][4][0]
|
||||
ip = ipaddress.ip_address(host_ip)
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
):
|
||||
raise WebDAVError(t("err.webdav.private_ip", host=parsed.hostname))
|
||||
except WebDAVError:
|
||||
raise
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
self.url = url.rstrip("/")
|
||||
self.user = user
|
||||
self.password = password
|
||||
@@ -92,7 +129,14 @@ class WebDAVClient:
|
||||
content_type="application/xml",
|
||||
)
|
||||
req.add_header("Depth", "0")
|
||||
urllib.request.urlopen(req, timeout=15)
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
|
||||
# XML 炸弹防护:限制响应大小
|
||||
MAX_CONNECT_XML_SIZE = 1 * 1024 * 1024 # 1MB
|
||||
xml_data = resp.read(MAX_CONNECT_XML_SIZE + 1)
|
||||
if len(xml_data) > MAX_CONNECT_XML_SIZE:
|
||||
raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data)))
|
||||
|
||||
logger.debug("WebDAV 连接成功: %s", self.url)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
@@ -167,6 +211,11 @@ class WebDAVClient:
|
||||
except OSError as e:
|
||||
raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(e)))
|
||||
|
||||
# XML 炸弹防护:限制响应大小
|
||||
MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
if len(xml_data) > MAX_XML_SIZE:
|
||||
raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data)))
|
||||
|
||||
files = []
|
||||
try:
|
||||
root = ET.fromstring(xml_data)
|
||||
|
||||
@@ -920,10 +920,12 @@ class TestEditStrategy(unittest.TestCase):
|
||||
class TestWebhook(unittest.TestCase):
|
||||
"""测试 Webhook 增强功能"""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_webhook_single(self, mock_urlopen):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_send_webhook_single(self, mock_getaddrinfo, mock_open):
|
||||
"""测试发送单个 webhook"""
|
||||
mock_urlopen.return_value = MagicMock()
|
||||
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||
mock_open.return_value = MagicMock()
|
||||
BackupManager._send_webhook(
|
||||
"https://example.com/hook",
|
||||
status="success",
|
||||
@@ -931,12 +933,14 @@ class TestWebhook(unittest.TestCase):
|
||||
skipped=1,
|
||||
elapsed=5.0,
|
||||
)
|
||||
mock_urlopen.assert_called_once()
|
||||
mock_open.assert_called_once()
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_webhook_with_template(self, mock_urlopen):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_send_webhook_with_template(self, mock_getaddrinfo, mock_open):
|
||||
"""测试自定义模板 webhook"""
|
||||
mock_urlopen.return_value = MagicMock()
|
||||
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||
mock_open.return_value = MagicMock()
|
||||
BackupManager._send_webhook(
|
||||
"https://example.com/hook",
|
||||
status="success",
|
||||
@@ -945,18 +949,20 @@ class TestWebhook(unittest.TestCase):
|
||||
elapsed=5.0,
|
||||
template='{"event":"backup","status":"{status}"}',
|
||||
)
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
req = mock_open.call_args[0][0]
|
||||
import json
|
||||
|
||||
payload = json.loads(req.data.decode("utf-8"))
|
||||
self.assertEqual(payload["status"], "success")
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_webhook_retries(self, mock_urlopen):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_send_webhook_retries(self, mock_getaddrinfo, mock_open):
|
||||
"""测试 webhook 重试机制"""
|
||||
import urllib.error
|
||||
|
||||
mock_urlopen.side_effect = urllib.error.URLError("timeout")
|
||||
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||
mock_open.side_effect = urllib.error.URLError("timeout")
|
||||
BackupManager._send_webhook(
|
||||
"https://example.com/hook",
|
||||
status="success",
|
||||
@@ -965,12 +971,14 @@ class TestWebhook(unittest.TestCase):
|
||||
elapsed=1.0,
|
||||
retries=3,
|
||||
)
|
||||
self.assertEqual(mock_urlopen.call_count, 3)
|
||||
self.assertEqual(mock_open.call_count, 3)
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_send_webhooks_multiple(self, mock_urlopen):
|
||||
@patch("urllib.request.OpenerDirector.open")
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_send_webhooks_multiple(self, mock_getaddrinfo, mock_open):
|
||||
"""测试多个 webhook URL"""
|
||||
mock_urlopen.return_value = MagicMock()
|
||||
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||
mock_open.return_value = MagicMock()
|
||||
BackupManager._send_webhooks(
|
||||
["https://hook1.example.com", "https://hook2.example.com"],
|
||||
status="success",
|
||||
@@ -978,7 +986,7 @@ class TestWebhook(unittest.TestCase):
|
||||
skipped=0,
|
||||
elapsed=1.0,
|
||||
)
|
||||
self.assertEqual(mock_urlopen.call_count, 2)
|
||||
self.assertEqual(mock_open.call_count, 2)
|
||||
|
||||
|
||||
class TestDiffBackup(unittest.TestCase):
|
||||
|
||||
@@ -577,13 +577,12 @@ class TestSplitAndMergeFiles(unittest.TestCase):
|
||||
self.assertEqual(len(parts), 1)
|
||||
|
||||
def test_merge_empty_list(self):
|
||||
"""空列表合并创建空文件"""
|
||||
"""空列表合并不创建文件并返回 False"""
|
||||
from sbackup.compression import merge_files
|
||||
|
||||
output_path = os.path.join(self.test_dir, "empty.bin")
|
||||
result = merge_files([], output_path)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(os.path.getsize(output_path), 0)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
class TestWebhookPreset(unittest.TestCase):
|
||||
@@ -617,8 +616,11 @@ class TestParseGitignore(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.original_cwd = os.getcwd()
|
||||
os.chdir(self.test_dir)
|
||||
|
||||
def tearDown(self):
|
||||
os.chdir(self.original_cwd)
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
|
||||
def test_parse_gitignore_basic(self):
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""单元测试 for sbackup.completion 模块"""
|
||||
|
||||
import unittest
|
||||
from sbackup.completion import (
|
||||
generate_bash,
|
||||
generate_zsh,
|
||||
generate_fish,
|
||||
generate_powershell,
|
||||
generate,
|
||||
)
|
||||
|
||||
|
||||
class TestCompletion(unittest.TestCase):
|
||||
"""测试 Shell 自动补全脚本生成"""
|
||||
|
||||
def test_generate_bash_returns_string(self):
|
||||
"""Bash 补全脚本应返回字符串"""
|
||||
script = generate_bash()
|
||||
self.assertIsInstance(script, str)
|
||||
self.assertIn("complete -F", script)
|
||||
self.assertIn("sbackup", script)
|
||||
|
||||
def test_generate_bash_contains_subcommands(self):
|
||||
"""Bash 补全应包含子命令"""
|
||||
script = generate_bash()
|
||||
for cmd in ("save", "watch", "add", "restore", "sftp", "webdav"):
|
||||
self.assertIn(cmd, script)
|
||||
|
||||
def test_generate_zsh_returns_string(self):
|
||||
"""Zsh 补全脚本应返回字符串"""
|
||||
script = generate_zsh()
|
||||
self.assertIsInstance(script, str)
|
||||
self.assertIn("#compdef", script)
|
||||
self.assertIn("sbackup", script)
|
||||
|
||||
def test_generate_zsh_contains_subcommands(self):
|
||||
"""Zsh 补全应包含子命令"""
|
||||
script = generate_zsh()
|
||||
for cmd in ("save", "watch", "add", "restore"):
|
||||
self.assertIn(cmd, script)
|
||||
|
||||
def test_generate_fish_returns_string(self):
|
||||
"""Fish 补全脚本应返回字符串"""
|
||||
script = generate_fish()
|
||||
self.assertIsInstance(script, str)
|
||||
self.assertIn("complete -c sbackup", script)
|
||||
|
||||
def test_generate_fish_contains_subcommands(self):
|
||||
"""Fish 补全应包含子命令"""
|
||||
script = generate_fish()
|
||||
for cmd in ("save", "watch", "add"):
|
||||
self.assertIn(cmd, script)
|
||||
|
||||
def test_generate_powershell_returns_string(self):
|
||||
"""PowerShell 补全脚本应返回字符串"""
|
||||
script = generate_powershell()
|
||||
self.assertIsInstance(script, str)
|
||||
self.assertIn("Register-ArgumentCompleter", script)
|
||||
self.assertIn("sbackup", script)
|
||||
|
||||
def test_generate_powershell_contains_flags(self):
|
||||
"""PowerShell 补全应包含 flag"""
|
||||
script = generate_powershell()
|
||||
self.assertIn("--debug", script)
|
||||
self.assertIn("--lang", script)
|
||||
|
||||
def test_generate_dispatch_bash(self):
|
||||
"""generate('bash') 应分发到 generate_bash"""
|
||||
result = generate("bash")
|
||||
self.assertIn("complete -F", result)
|
||||
|
||||
def test_generate_dispatch_zsh(self):
|
||||
"""generate('zsh') 应分发到 generate_zsh"""
|
||||
result = generate("zsh")
|
||||
self.assertIn("#compdef", result)
|
||||
|
||||
def test_generate_dispatch_fish(self):
|
||||
"""generate('fish') 应分发到 generate_fish"""
|
||||
result = generate("fish")
|
||||
self.assertIn("complete -c sbackup", result)
|
||||
|
||||
def test_generate_dispatch_powershell(self):
|
||||
"""generate('powershell') 应分发到 generate_powershell"""
|
||||
result = generate("powershell")
|
||||
self.assertIn("Register-ArgumentCompleter", result)
|
||||
|
||||
def test_generate_invalid_shell(self):
|
||||
"""无效的 shell 名称应抛出 ValueError"""
|
||||
with self.assertRaises(ValueError):
|
||||
generate("invalid_shell")
|
||||
|
||||
def test_generate_case_insensitive(self):
|
||||
"""生成 shell 名应忽略大小写"""
|
||||
bash1 = generate("BASH")
|
||||
bash2 = generate("bash")
|
||||
self.assertEqual(bash1, bash2)
|
||||
|
||||
def test_all_generators_have_basic_structure(self):
|
||||
"""所有 shell 的补全脚本都应包含 sbackup 引用"""
|
||||
for shell in ("bash", "zsh", "fish", "powershell"):
|
||||
script = generate(shell)
|
||||
self.assertIsInstance(script, str)
|
||||
self.assertGreater(len(script), 50, f"{shell} script is too short")
|
||||
|
||||
def test_zsh_contains_format_choices(self):
|
||||
"""Zsh 补全应列出格式选项"""
|
||||
script = generate_zsh()
|
||||
for fmt in ("zip", "tar", "7z"):
|
||||
self.assertIn(fmt, script)
|
||||
|
||||
def test_fish_contains_global_flags(self):
|
||||
"""Fish 补全应包含全局 flag"""
|
||||
script = generate_fish()
|
||||
self.assertIn("-l debug", script)
|
||||
self.assertIn("-l format", script)
|
||||
self.assertIn("-l lang", script)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""单元测试 for sbackup.lock 模块"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
from sbackup.lock import BackupLock
|
||||
|
||||
|
||||
class TestBackupLock(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
|
||||
def test_acquire_and_release(self):
|
||||
"""测试获取和释放锁"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
self.assertTrue(lock.acquire())
|
||||
self.assertTrue(lock._locked)
|
||||
lock.release()
|
||||
self.assertFalse(lock._locked)
|
||||
self.assertFalse(os.path.exists(lock._lock_path))
|
||||
|
||||
def test_acquire_exclusive(self):
|
||||
"""测试锁的排他性:第二个获取应该失败"""
|
||||
lock1 = BackupLock(self.test_dir)
|
||||
lock2 = BackupLock(self.test_dir)
|
||||
|
||||
self.assertTrue(lock1.acquire())
|
||||
self.assertFalse(lock2.acquire())
|
||||
|
||||
lock1.release()
|
||||
|
||||
# 释放后第二个可以获取
|
||||
self.assertTrue(lock2.acquire())
|
||||
lock2.release()
|
||||
|
||||
def test_double_acquire(self):
|
||||
"""测试重复获取:同一个实例多次获取应返回 True"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
self.assertTrue(lock.acquire())
|
||||
self.assertTrue(lock.acquire()) # 已锁定,直接返回 True
|
||||
lock.release()
|
||||
|
||||
def test_clean_stale_lock(self):
|
||||
"""测试清理过期锁(死进程留下的锁文件)"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
lock_path = os.path.join(self.test_dir, ".backup.lock")
|
||||
|
||||
# 模拟一个死进程的锁文件
|
||||
dead_pid = 999999999 # 不可能存在的 PID
|
||||
with open(lock_path, "w") as f:
|
||||
f.write(str(dead_pid))
|
||||
|
||||
# 尝试获取,应该清理并成功
|
||||
self.assertTrue(lock.acquire())
|
||||
self.assertTrue(os.path.exists(lock_path))
|
||||
# 锁文件内容应该是当前进程的 PID
|
||||
with open(lock_path) as f:
|
||||
self.assertEqual(f.read().strip(), str(os.getpid()))
|
||||
lock.release()
|
||||
|
||||
def test_clean_corrupted_lock(self):
|
||||
"""测试清理损坏的锁文件"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
lock_path = os.path.join(self.test_dir, ".backup.lock")
|
||||
|
||||
# 写入无效内容
|
||||
with open(lock_path, "w") as f:
|
||||
f.write("not_a_pid")
|
||||
|
||||
self.assertTrue(lock.acquire())
|
||||
lock.release()
|
||||
|
||||
def test_release_no_lock(self):
|
||||
"""测试未获取锁时释放不报错"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
lock.release() # 不应抛异常
|
||||
|
||||
def test_context_manager_usage(self):
|
||||
"""测试手动 try/finally 用法(测试 release 防护)"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
self.assertTrue(lock.acquire())
|
||||
# 模拟异常情况下的 release
|
||||
lock.release()
|
||||
self.assertFalse(lock._locked)
|
||||
|
||||
def test_lock_file_location(self):
|
||||
"""测试锁文件路径正确"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
expected = os.path.join(self.test_dir, ".backup.lock")
|
||||
self.assertEqual(lock._lock_path, expected)
|
||||
|
||||
def test_successive_acquire_after_release(self):
|
||||
"""测试释放后可以再次获取"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
self.assertTrue(lock.acquire())
|
||||
lock.release()
|
||||
self.assertTrue(lock.acquire())
|
||||
lock.release()
|
||||
|
||||
def test_stale_lock_with_alive_pid(self):
|
||||
"""测试活进程的锁文件不会被清理"""
|
||||
lock = BackupLock(self.test_dir)
|
||||
|
||||
# 先获取锁
|
||||
lock.acquire()
|
||||
|
||||
# 创建另一个锁实例,锁文件被活进程持有
|
||||
lock2 = BackupLock(self.test_dir)
|
||||
self.assertFalse(lock2.acquire())
|
||||
|
||||
lock.release()
|
||||
|
||||
|
||||
class TestIsPidAlive(unittest.TestCase):
|
||||
"""测试 _is_pid_alive 函数"""
|
||||
|
||||
def test_own_pid_is_alive(self):
|
||||
"""当前进程的 PID 应该存活"""
|
||||
from sbackup.lock import _is_pid_alive
|
||||
|
||||
self.assertTrue(_is_pid_alive(os.getpid()))
|
||||
|
||||
def test_nonexistent_pid_is_dead(self):
|
||||
"""不存在的 PID 应该返回 False"""
|
||||
from sbackup.lock import _is_pid_alive
|
||||
|
||||
# 使用一个极不可能存在的 PID
|
||||
self.assertFalse(_is_pid_alive(999999999))
|
||||
+125
-2
@@ -763,5 +763,128 @@ class TestInitCommand(unittest.TestCase):
|
||||
self.assertIn("compression", data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
class TestCompletionCommand(unittest.TestCase):
|
||||
"""测试 completion 命令"""
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.original_argv = sys.argv.copy()
|
||||
self._root_handlers = logging.root.handlers[:]
|
||||
|
||||
def tearDown(self):
|
||||
sys.argv = self.original_argv
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
logging.root.handlers = self._root_handlers
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_completion_bash(self, mock_print):
|
||||
"""测试 completion bash 输出补全脚本"""
|
||||
os.chdir(self.test_dir)
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "completion", "bash"]
|
||||
from sbackup import run
|
||||
|
||||
result = run()
|
||||
self.assertEqual(result, 0)
|
||||
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("complete -F", printed)
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_completion_zsh(self, mock_print):
|
||||
"""测试 completion zsh 输出补全脚本"""
|
||||
os.chdir(self.test_dir)
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "completion", "zsh"]
|
||||
from sbackup import run
|
||||
|
||||
result = run()
|
||||
self.assertEqual(result, 0)
|
||||
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("#compdef", printed)
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_completion_default_shell(self, mock_print):
|
||||
"""测试 completion 无参数默认 bash"""
|
||||
os.chdir(self.test_dir)
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "completion"]
|
||||
from sbackup import run
|
||||
|
||||
result = run()
|
||||
self.assertEqual(result, 0)
|
||||
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("complete -F", printed)
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_completion_fish(self, mock_print):
|
||||
"""测试 completion fish 输出补全脚本"""
|
||||
os.chdir(self.test_dir)
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "completion", "fish"]
|
||||
from sbackup import run
|
||||
|
||||
result = run()
|
||||
self.assertEqual(result, 0)
|
||||
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("complete -c sbackup", printed)
|
||||
|
||||
@patch("builtins.print")
|
||||
def test_completion_powershell(self, mock_print):
|
||||
"""测试 completion powershell 输出补全脚本"""
|
||||
os.chdir(self.test_dir)
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "completion", "powershell"]
|
||||
from sbackup import run
|
||||
|
||||
result = run()
|
||||
self.assertEqual(result, 0)
|
||||
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||
self.assertIn("Register-ArgumentCompleter", printed)
|
||||
|
||||
|
||||
class TestLockIntegration(unittest.TestCase):
|
||||
"""测试锁与 CLI 集成"""
|
||||
|
||||
def setUp(self):
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.original_argv = sys.argv.copy()
|
||||
self._root_handlers = logging.root.handlers[:]
|
||||
self._root_level = logging.root.level
|
||||
self.data_path = os.path.join(self.test_dir, "sbackup.json")
|
||||
with open(os.path.join(self.test_dir, "config.json"), "w") as f:
|
||||
json.dump({"data_file": self.data_path}, f)
|
||||
self.dest_dir = os.path.join(self.test_dir, "backup_dest")
|
||||
os.makedirs(self.dest_dir, exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
sys.argv = self.original_argv
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
logging.root.handlers = self._root_handlers
|
||||
logging.root.setLevel(self._root_level)
|
||||
|
||||
@patch("sbackup.cli.BackupManager.execute_backups")
|
||||
@patch("builtins.print")
|
||||
def test_save_acquires_lock(self, mock_print, mock_execute):
|
||||
"""测试 save 命令获取锁"""
|
||||
os.chdir(self.test_dir)
|
||||
from sbackup import run
|
||||
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "save"]
|
||||
result = run()
|
||||
self.assertEqual(result, 0)
|
||||
mock_execute.assert_called_once()
|
||||
|
||||
@patch("sbackup.cli.BackupManager.execute_backups")
|
||||
@patch("builtins.print")
|
||||
def test_save_lock_conflict(self, mock_print, mock_execute):
|
||||
"""测试 save 锁冲突"""
|
||||
os.chdir(self.test_dir)
|
||||
from sbackup import run
|
||||
|
||||
# 手动创建锁文件,模拟另一个运行的进程
|
||||
from sbackup.lock import BackupLock
|
||||
|
||||
lock = BackupLock(self.test_dir)
|
||||
lock.acquire()
|
||||
|
||||
sys.argv = ["sbackup", "--lang", "en_US", "save"]
|
||||
result = run()
|
||||
# save 尝试获取锁应该失败(锁被我们持有)
|
||||
self.assertNotEqual(result, 0)
|
||||
mock_execute.assert_not_called()
|
||||
lock.release()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
单元测试 for sbackup.monitor 模块
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import threading
|
||||
import time as _time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sbackup.monitor import FileSystemMonitor, _BackupEventHandler
|
||||
|
||||
|
||||
class TestBackupEventHandler(unittest.TestCase):
|
||||
"""测试 _BackupEventHandler 内部类"""
|
||||
|
||||
def test_on_any_event_ignores_directories(self):
|
||||
"""测试目录事件被忽略"""
|
||||
callback = MagicMock()
|
||||
handler = _BackupEventHandler(callback)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.is_directory = True
|
||||
mock_event.src_path = "/some/dir"
|
||||
|
||||
handler.on_any_event(mock_event)
|
||||
callback.assert_not_called()
|
||||
|
||||
def test_on_any_event_triggers_for_files(self):
|
||||
"""测试文件事件触发回调"""
|
||||
callback = MagicMock()
|
||||
handler = _BackupEventHandler(callback)
|
||||
|
||||
mock_event = MagicMock()
|
||||
mock_event.is_directory = False
|
||||
mock_event.src_path = "/some/file.txt"
|
||||
|
||||
handler.on_any_event(mock_event)
|
||||
callback.assert_called_once_with("/some/file.txt")
|
||||
|
||||
|
||||
class TestFileSystemMonitor(unittest.TestCase):
|
||||
"""测试 FileSystemMonitor 类"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmpdir.cleanup)
|
||||
self.source = Path(self.tmpdir.name) / "src"
|
||||
self.source.mkdir()
|
||||
self.source_dirs = {str(self.source): "/backup"}
|
||||
|
||||
def test_init_stores_dirs_and_debounce(self):
|
||||
"""测试初始化存储源目录和防抖参数"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=10)
|
||||
assert monitor._source_dirs == self.source_dirs
|
||||
assert monitor._debounce == 10
|
||||
assert monitor._observer is None
|
||||
assert monitor._running is False
|
||||
assert monitor._backup_callback is None
|
||||
|
||||
def test_set_backup_callback(self):
|
||||
"""测试设置备份回调"""
|
||||
monitor = FileSystemMonitor(self.source_dirs)
|
||||
|
||||
def noop():
|
||||
pass
|
||||
|
||||
monitor.set_backup_callback(noop)
|
||||
assert monitor._backup_callback is noop
|
||||
|
||||
def test_start_stop_lifecycle(self):
|
||||
"""测试启动/停止生命周期"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=1)
|
||||
try:
|
||||
monitor.start()
|
||||
# 等待 observer 线程启动
|
||||
_time.sleep(0.2)
|
||||
assert monitor.is_running()
|
||||
finally:
|
||||
monitor.stop()
|
||||
assert not monitor.is_running()
|
||||
|
||||
def test_double_start_noop(self):
|
||||
"""测试重复启动不创建多个 observer"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=1)
|
||||
try:
|
||||
monitor.start()
|
||||
_time.sleep(0.2)
|
||||
first_observer = monitor._observer
|
||||
monitor.start()
|
||||
assert monitor._observer is first_observer
|
||||
finally:
|
||||
monitor.stop()
|
||||
|
||||
def test_on_file_change_marks_dirty(self):
|
||||
"""测试文件变化标记源目录为 dirty"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=60)
|
||||
test_file = self.source / "test.txt"
|
||||
test_file.write_text("hello")
|
||||
|
||||
monitor._on_file_change(str(test_file))
|
||||
dirty = monitor.get_dirty_sources()
|
||||
assert str(self.source) in dirty
|
||||
|
||||
def test_on_file_change_outside_source_ignored(self):
|
||||
"""测试外部文件变化不标记 dirty"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=60)
|
||||
outside_file = Path(self.tmpdir.name) / "outside.txt"
|
||||
outside_file.write_text("hello")
|
||||
|
||||
monitor._on_file_change(str(outside_file))
|
||||
dirty = monitor.get_dirty_sources()
|
||||
assert len(dirty) == 0
|
||||
|
||||
def test_on_file_change_multiple_dirs(self):
|
||||
"""测试多个源目录分别标记"""
|
||||
src2 = Path(self.tmpdir.name) / "src2"
|
||||
src2.mkdir()
|
||||
dirs = {str(self.source): "/backup1", str(src2): "/backup2"}
|
||||
|
||||
monitor = FileSystemMonitor(dirs, debounce_seconds=60)
|
||||
(self.source / "a.txt").write_text("a")
|
||||
(src2 / "b.txt").write_text("b")
|
||||
|
||||
monitor._on_file_change(str(self.source / "a.txt"))
|
||||
monitor._on_file_change(str(src2 / "b.txt"))
|
||||
|
||||
dirty = monitor.get_dirty_sources()
|
||||
assert str(self.source) in dirty
|
||||
assert str(src2) in dirty
|
||||
assert len(dirty) == 2
|
||||
|
||||
def test_debounce_reset_on_multiple_changes(self):
|
||||
"""测试多次变化时防抖计时器重置"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=10)
|
||||
cb = MagicMock()
|
||||
monitor.set_backup_callback(cb)
|
||||
|
||||
f1 = self.source / "a.txt"
|
||||
f2 = self.source / "b.txt"
|
||||
f1.write_text("a")
|
||||
f2.write_text("b")
|
||||
|
||||
# 第一次变化启动计时
|
||||
monitor._on_file_change(str(f1))
|
||||
timer1 = monitor._debounce_timer
|
||||
assert timer1 is not None
|
||||
|
||||
# 第二次变化应取消旧计时并启动新计时
|
||||
monitor._on_file_change(str(f2))
|
||||
timer2 = monitor._debounce_timer
|
||||
assert timer2 is not None
|
||||
assert timer2 is not timer1 # 不是同一个 timer 对象
|
||||
|
||||
def test_trigger_backup_calls_callback(self):
|
||||
"""测试防抖超时后触发回调"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||
cb = MagicMock()
|
||||
monitor.set_backup_callback(cb)
|
||||
|
||||
(self.source / "test.txt").write_text("data")
|
||||
monitor._on_file_change(str(self.source / "test.txt"))
|
||||
|
||||
# 等待防抖触发
|
||||
_time.sleep(0.3)
|
||||
cb.assert_called_once()
|
||||
|
||||
def test_trigger_backup_no_dirty_noop(self):
|
||||
"""测试没有 dirty 目录时不触发回调"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||
cb = MagicMock()
|
||||
monitor.set_backup_callback(cb)
|
||||
|
||||
monitor._trigger_backup()
|
||||
cb.assert_not_called()
|
||||
|
||||
def test_trigger_backup_clears_dirty(self):
|
||||
"""测试触发备份后清除 dirty 标记"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||
cb = MagicMock()
|
||||
monitor.set_backup_callback(cb)
|
||||
|
||||
(self.source / "test.txt").write_text("data")
|
||||
monitor._on_file_change(str(self.source / "test.txt"))
|
||||
_time.sleep(0.3)
|
||||
|
||||
assert len(monitor.get_dirty_sources()) == 0
|
||||
|
||||
def test_callback_exception_caught(self):
|
||||
"""测试回调异常不影响监控器"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||
|
||||
def failing_cb():
|
||||
raise RuntimeError("test error")
|
||||
|
||||
monitor.set_backup_callback(failing_cb)
|
||||
|
||||
(self.source / "test.txt").write_text("data")
|
||||
monitor._on_file_change(str(self.source / "test.txt"))
|
||||
|
||||
# 不应抛出异常
|
||||
_time.sleep(0.3)
|
||||
assert monitor._dirty == set()
|
||||
|
||||
def test_start_from_missing_source_dir(self):
|
||||
"""测试源目录不存在时优雅处理"""
|
||||
missing = str(Path(self.tmpdir.name) / "nonexistent")
|
||||
monitor = FileSystemMonitor({missing: "/backup"}, debounce_seconds=1)
|
||||
|
||||
monitor.start()
|
||||
assert not monitor.is_running()
|
||||
assert monitor._observer is None
|
||||
|
||||
def test_stop_cancels_pending_timer(self):
|
||||
"""测试 stop 取消待处理的防抖计时器"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=30)
|
||||
cb = MagicMock()
|
||||
monitor.set_backup_callback(cb)
|
||||
|
||||
(self.source / "test.txt").write_text("data")
|
||||
monitor._on_file_change(str(self.source / "test.txt"))
|
||||
assert monitor._debounce_timer is not None
|
||||
|
||||
monitor.stop()
|
||||
assert monitor._debounce_timer is None
|
||||
cb.assert_not_called()
|
||||
|
||||
def test_concurrent_file_changes_thread_safety(self):
|
||||
"""测试多线程并发文件变化的线程安全性"""
|
||||
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||
cb = MagicMock()
|
||||
monitor.set_backup_callback(cb)
|
||||
|
||||
def write_file(i):
|
||||
f = self.source / f"file_{i}.txt"
|
||||
f.write_text(str(i))
|
||||
monitor._on_file_change(str(f))
|
||||
|
||||
threads = []
|
||||
for i in range(10):
|
||||
t = threading.Thread(target=write_file, args=(i,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
_time.sleep(0.3)
|
||||
# 回调应至少被调用一次
|
||||
assert cb.call_count >= 1
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -228,9 +229,14 @@ class TestPrePostHooks(unittest.TestCase):
|
||||
time.sleep(0.1)
|
||||
(Path(self.source_dir) / "new.txt").write_text("new")
|
||||
|
||||
# 使用 echo 创建标记文件(Windows cmd 兼容)
|
||||
marker = self.hook_marker.replace("\\", "/")
|
||||
hook_cmd = f'echo done > "{marker}"'
|
||||
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||
hook_cmd = (
|
||||
'"'
|
||||
+ sys.executable
|
||||
+ '" -c "open(r\''
|
||||
+ self.hook_marker
|
||||
+ "', 'w').write('done')\""
|
||||
)
|
||||
|
||||
self.manager.execute_backups(pre_hooks=[hook_cmd])
|
||||
self.assertTrue(os.path.exists(self.hook_marker), "前置钩子应被执行")
|
||||
@@ -241,8 +247,14 @@ class TestPrePostHooks(unittest.TestCase):
|
||||
time.sleep(0.1)
|
||||
(Path(self.source_dir) / "new.txt").write_text("new")
|
||||
|
||||
marker = self.hook_marker.replace("\\", "/")
|
||||
hook_cmd = f'echo done > "{marker}"'
|
||||
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||
hook_cmd = (
|
||||
'"'
|
||||
+ sys.executable
|
||||
+ '" -c "open(r\''
|
||||
+ self.hook_marker
|
||||
+ "', 'w').write('done')\""
|
||||
)
|
||||
|
||||
self.manager.execute_backups(post_hooks=[hook_cmd])
|
||||
self.assertTrue(os.path.exists(self.hook_marker), "后置钩子应被执行")
|
||||
@@ -282,10 +294,16 @@ class TestPrePostHooks(unittest.TestCase):
|
||||
|
||||
marker1 = os.path.join(self.test_dir, "hook1.txt")
|
||||
marker2 = os.path.join(self.test_dir, "hook2.txt")
|
||||
m1 = marker1.replace("\\", "/")
|
||||
m2 = marker2.replace("\\", "/")
|
||||
|
||||
self.manager.execute_backups(pre_hooks=[f'echo 1 > "{m1}"', f'echo 2 > "{m2}"'])
|
||||
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||
hook1 = (
|
||||
'"' + sys.executable + '" -c "open(r\'' + marker1 + "', 'w').write('1')\""
|
||||
)
|
||||
hook2 = (
|
||||
'"' + sys.executable + '" -c "open(r\'' + marker2 + "', 'w').write('2')\""
|
||||
)
|
||||
|
||||
self.manager.execute_backups(pre_hooks=[hook1, hook2])
|
||||
self.assertTrue(os.path.exists(marker1))
|
||||
self.assertTrue(os.path.exists(marker2))
|
||||
|
||||
@@ -297,8 +315,15 @@ class TestRunHooks(unittest.TestCase):
|
||||
"""有效命令执行成功"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
marker = os.path.join(tmpdir, "marker.txt")
|
||||
m = marker.replace("\\", "/")
|
||||
BackupManager._run_hooks([f'echo ok > "{m}"'], "pre")
|
||||
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||
hook_cmd = (
|
||||
'"'
|
||||
+ sys.executable
|
||||
+ '" -c "open(r\''
|
||||
+ marker
|
||||
+ "', 'w').write('ok')\""
|
||||
)
|
||||
BackupManager._run_hooks([hook_cmd], "pre")
|
||||
self.assertTrue(os.path.exists(marker))
|
||||
|
||||
def test_run_hooks_with_empty_list(self):
|
||||
|
||||
@@ -403,8 +403,12 @@ class TestSFTPClient(unittest.TestCase):
|
||||
)
|
||||
client.connect()
|
||||
|
||||
# 验证 expanduser 被调用
|
||||
mock_expanduser.assert_called_once_with(self.key_file)
|
||||
# 验证 expanduser 被调用(包括 key_file 和 known_hosts 路径)
|
||||
expanduser_calls = [str(c) for c in mock_expanduser.call_args_list]
|
||||
self.assertTrue(
|
||||
any(self.key_file in c for c in expanduser_calls),
|
||||
f"expanduser should be called with key_file, got: {expanduser_calls}",
|
||||
)
|
||||
mock_transport.connect.assert_called_once_with(
|
||||
username=self.user, pkey=mock_pkey
|
||||
)
|
||||
|
||||
@@ -895,6 +895,7 @@ dependencies = [
|
||||
{ name = "paramiko" },
|
||||
{ name = "py7zr" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "watchdog" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
|
||||
@@ -908,6 +909,7 @@ requires-dist = [
|
||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||
{ name = "py7zr", specifier = ">=1.1.0" },
|
||||
{ name = "tqdm", specifier = ">=4.67.3" },
|
||||
{ name = "watchdog", specifier = ">=6.0.0" },
|
||||
{ name = "zstandard", specifier = ">=0.25.0" },
|
||||
]
|
||||
|
||||
@@ -944,6 +946,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
Reference in new issue
Block a user