feat: 3个实用功能 — 备份锁/Shell补全/并行上传

This commit is contained in:
xiatianxuan committed 2026-06-06 17:35:50 +08:00
1 parent 8a14fda09b
commit 4fbbf7cfd8
32 files changed
+3211 -172

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"paramiko>=4.0.0", "paramiko>=4.0.0",
"py7zr>=1.1.0", "py7zr>=1.1.0",
"tqdm>=4.67.3", "tqdm>=4.67.3",
"watchdog>=6.0.0",
"zstandard>=0.25.0", "zstandard>=0.25.0",
] ]
+260 -27
View File
@@ -100,7 +100,7 @@ class BackupManager:
def save(self, initial: bool = False): def save(self, initial: bool = False):
""" """
将内存数据写入 JSON 文件 将内存数据写入 JSON 文件(原子写入,防止中断导致数据损坏)
""" """
if not initial: if not initial:
logger.debug(t("log.data.write"), self.data_file) logger.debug(t("log.data.write"), self.data_file)
@@ -109,8 +109,23 @@ class BackupManager:
if data_dir: if data_dir:
os.makedirs(data_dir, exist_ok=True) 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) 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: def _get_entry(self, key: str) -> BackupEntry | None:
"""获取指定路径的备份策略条目""" """获取指定路径的备份策略条目"""
@@ -398,7 +413,7 @@ class BackupManager:
max_size: int = 0, max_size: int = 0,
min_size: int = 0, min_size: int = 0,
max_age_seconds: float = 0, max_age_seconds: float = 0,
incremental: bool = False, incremental: str | None = None,
split_size: int = 0, split_size: int = 0,
tag: str = "", tag: str = "",
checksum: bool = False, checksum: bool = False,
@@ -420,7 +435,7 @@ class BackupManager:
:param max_size: 文件大小上限(字节),0 不限制 :param max_size: 文件大小上限(字节),0 不限制
:param min_size: 文件大小下限(字节),0 不限制 :param min_size: 文件大小下限(字节),0 不限制
:param max_age_seconds: 文件年龄上限(秒),0 不限制 :param max_age_seconds: 文件年龄上限(秒),0 不限制
:param incremental: 文件级增量备份(仅压缩变化的文件) :param incremental: 增量备份模式: None/""=全量, "file"=文件级, "block"=块级
:param pre_hooks: 备份前执行的命令列表 :param pre_hooks: 备份前执行的命令列表
:param post_hooks: 备份后执行的命令列表 :param post_hooks: 备份后执行的命令列表
""" """
@@ -430,14 +445,15 @@ class BackupManager:
# 执行前置钩子 # 执行前置钩子
self._run_hooks(pre_hooks or [], "pre") self._run_hooks(pre_hooks or [], "pre")
# 加载文件级元数据(增量备份用) # 加载文件级/块级元数据(增量备份用)
file_meta_all = self.data.get("_file_meta", {}) if incremental else {} file_meta_all = self.data.get("_file_meta", {}) if incremental else {}
chunk_meta_all = self.data.get("_chunk_meta", {}) if incremental == "block" else {}
# 收集需要备份的条目 # 收集需要备份的条目
tasks = [] tasks = []
skip_count = 0 skip_count = 0
for key, raw in list(self.data.items()): 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 continue
if not os.path.exists(key): if not os.path.exists(key):
print(t("warn.source.missing", path=key)) print(t("warn.source.missing", path=key))
@@ -449,6 +465,7 @@ class BackupManager:
continue continue
entry = BackupEntry.from_list(raw) entry = BackupEntry.from_list(raw)
file_meta = file_meta_all.get(key, {}) if incremental else {} 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: if entry.mtime != current_mtime:
tasks.append( tasks.append(
( (
@@ -464,6 +481,8 @@ class BackupManager:
max_age_seconds, max_age_seconds,
file_meta, file_meta,
split_size, split_size,
incremental,
chunk_meta,
) )
) )
else: else:
@@ -486,7 +505,7 @@ class BackupManager:
executor.submit(self._do_backup, *task): task for task in tasks executor.submit(self._do_backup, *task): task for task in tasks
} }
for future in as_completed(futures): for future in as_completed(futures):
key, entry, current_mtime, _, _, _, _, _, _, _, _, _ = futures[ key, entry, current_mtime, _, _, _, _, _, _, _, _, _, incr_mode, _ = futures[
future future
] ]
try: try:
@@ -505,10 +524,12 @@ class BackupManager:
result.get("path", ""), result.get("path", ""),
tag=tag, tag=tag,
) )
if incremental: if incr_mode:
self.data.setdefault("_file_meta", {})[key] = ( self.data.setdefault("_file_meta", {})[key] = (
self._collect_file_meta(key, checksum=checksum) self._collect_file_meta(key, checksum=checksum)
) )
if incr_mode == "block":
self._update_chunk_meta(key)
if keep > 0 or keep_days > 0: if keep > 0 or keep_days > 0:
self._cleanup_old_backups(entry.target, keep, keep_days) self._cleanup_old_backups(entry.target, keep, keep_days)
backup_count += 1 backup_count += 1
@@ -532,6 +553,8 @@ class BackupManager:
age, age,
fmeta, fmeta,
split, split,
incr_mode,
chmeta,
) in tasks: ) in tasks:
result = self._do_backup( result = self._do_backup(
key, key,
@@ -546,6 +569,8 @@ class BackupManager:
age, age,
fmeta, fmeta,
split, split,
incr_mode,
chmeta,
) )
if result and result.get("success"): if result and result.get("success"):
entry.mtime = current_mtime entry.mtime = current_mtime
@@ -558,10 +583,12 @@ class BackupManager:
result.get("path", ""), result.get("path", ""),
tag=tag, tag=tag,
) )
if incremental: if incr_mode:
self.data.setdefault("_file_meta", {})[key] = ( self.data.setdefault("_file_meta", {})[key] = (
self._collect_file_meta(key, checksum=checksum) self._collect_file_meta(key, checksum=checksum)
) )
if incr_mode == "block":
self._update_chunk_meta(key)
if keep > 0 or keep_days > 0: if keep > 0 or keep_days > 0:
self._cleanup_old_backups(entry.target, keep, keep_days) self._cleanup_old_backups(entry.target, keep, keep_days)
backup_count += 1 backup_count += 1
@@ -596,12 +623,27 @@ class BackupManager:
if verify_failures > 0: if verify_failures > 0:
print(t("cmd.verify.partial_fail", count=verify_failures)) print(t("cmd.verify.partial_fail", count=verify_failures))
# SFTP 上传 # 并行上传到 SFTP 和 WebDAV(使用 ThreadPoolExecutor)
if sftp_upload and uploaded_files: sftp_needed = sftp_upload and bool(uploaded_files)
self._upload_to_sftp(uploaded_files, config) webdav_needed = webdav_upload and bool(uploaded_files)
# WebDAV 上传 if sftp_needed and webdav_needed:
if webdav_upload and uploaded_files: 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) self._upload_to_webdav(uploaded_files, config)
# Webhook 通知(支持多个 URL、自定义模板、重试) # Webhook 通知(支持多个 URL、自定义模板、重试)
@@ -634,6 +676,20 @@ class BackupManager:
retries=getattr(config, "webhook_retries", 2), 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") self._run_hooks(post_hooks or [], "post")
@@ -644,6 +700,7 @@ class BackupManager:
:param hook_type: "pre" 或 "post",用于日志标识 :param hook_type: "pre" 或 "post",用于日志标识
""" """
import subprocess import subprocess
import shlex
for cmd in hooks: for cmd in hooks:
if not cmd: if not cmd:
@@ -652,7 +709,11 @@ class BackupManager:
print(t(label, command=cmd)) print(t(label, command=cmd))
try: try:
result = subprocess.run( 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: if result.returncode != 0:
logger.warning( logger.warning(
@@ -679,6 +740,8 @@ class BackupManager:
max_age_seconds: float = 0, max_age_seconds: float = 0,
file_metadata: dict | None = None, file_metadata: dict | None = None,
split_size: int = 0, split_size: int = 0,
incremental: str | None = None,
chunk_meta: dict | None = None,
) -> dict: ) -> dict:
"""执行单个备份策略(线程安全)""" """执行单个备份策略(线程安全)"""
fmt = entry.compression_format or config.compression_format fmt = entry.compression_format or config.compression_format
@@ -765,6 +828,27 @@ class BackupManager:
pass pass
return meta 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 @staticmethod
def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None: def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None:
"""检查目标目录剩余空间,不足时打印警告""" """检查目标目录剩余空间,不足时打印警告"""
@@ -891,7 +975,46 @@ class BackupManager:
ensure_ascii=False, ensure_ascii=False,
).encode("utf-8") ).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): for attempt in range(max_attempts):
try: try:
req = urllib.request.Request( req = urllib.request.Request(
@@ -900,8 +1023,8 @@ class BackupManager:
method="POST", method="POST",
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
urllib.request.urlopen(req, timeout=10) no_redirect_opener.open(req, timeout=10)
logger.debug("Webhook 通知成功: %s", url) logger.debug("Webhook 通知成功: %s", parsed.hostname)
return return
except (urllib.error.URLError, OSError) as e: except (urllib.error.URLError, OSError) as e:
if attempt < max_attempts - 1: if attempt < max_attempts - 1:
@@ -911,11 +1034,11 @@ class BackupManager:
attempt + 1, attempt + 1,
max_attempts, max_attempts,
wait, wait,
url, parsed.hostname,
) )
_time.sleep(wait) _time.sleep(wait)
else: else:
logger.warning("Webhook 通知失败: %s — %s", url, e) logger.warning("Webhook 通知失败: %s — %s", parsed.hostname, e)
@staticmethod @staticmethod
def _send_webhooks( def _send_webhooks(
@@ -946,6 +1069,68 @@ class BackupManager:
# 向后兼容别名 # 向后兼容别名
save_folder = execute_backups 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 @staticmethod
def _upload_to_sftp(file_paths: list[str], config: Config) -> None: def _upload_to_sftp(file_paths: list[str], config: Config) -> None:
"""将备份文件上传到 SFTP 服务器(多文件并行)""" """将备份文件上传到 SFTP 服务器(多文件并行)"""
@@ -1318,7 +1503,7 @@ class BackupManager:
# 收集所有策略的目标目录 # 收集所有策略的目标目录
target_dirs: set[str] = set() target_dirs: set[str] = set()
for key, raw in self.data.items(): for key, raw in self.data.items():
if key in (_HISTORY_KEY, "_file_meta"): if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
continue continue
entry = BackupEntry.from_list(raw) entry = BackupEntry.from_list(raw)
if entry.target: if entry.target:
@@ -1369,7 +1554,7 @@ class BackupManager:
"""导出所有备份策略到 JSON 文件,返回导出的策略数""" """导出所有备份策略到 JSON 文件,返回导出的策略数"""
export_data = {} export_data = {}
for key, raw in self.data.items(): for key, raw in self.data.items():
if key in (_HISTORY_KEY, "_file_meta"): if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
continue continue
export_data[key] = raw export_data[key] = raw
data_dir = os.path.dirname(output_file) data_dir = os.path.dirname(output_file)
@@ -1383,6 +1568,13 @@ class BackupManager:
"""从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None""" """从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None"""
if not os.path.exists(input_file): if not os.path.exists(input_file):
return None 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: try:
with open(input_file, "r", encoding="utf-8") as f: with open(input_file, "r", encoding="utf-8") as f:
import_data = json.load(f) import_data = json.load(f)
@@ -1392,9 +1584,50 @@ class BackupManager:
return None return None
imported = 0 imported = 0
skipped = 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(): for key, raw in import_data.items():
# 安全检查:拒绝内部保留键
if key in _RESERVED_KEYS:
skipped += 1
continue
if not isinstance(raw, list) or len(raw) < 3: if not isinstance(raw, list) or len(raw) < 3:
continue 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: if key in self.data:
skipped += 1 skipped += 1
continue continue
@@ -1410,7 +1643,7 @@ class BackupManager:
# 统计策略数 # 统计策略数
strategies = { 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))) lines.append(t("cmd.status.strategies", count=len(strategies)))
@@ -1481,7 +1714,7 @@ class BackupManager:
return { return {
key: BackupEntry.from_list(raw).target key: BackupEntry.from_list(raw).target
for key, raw in self.data.items() 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 @staticmethod
@@ -1519,7 +1752,7 @@ class BackupManager:
生成对齐的文本表格 生成对齐的文本表格
""" """
non_history_keys = [ 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: if not non_history_keys:
return t("cmd.all.empty") return t("cmd.all.empty")
@@ -1532,7 +1765,7 @@ class BackupManager:
] ]
rows = [] rows = []
for path, raw in self.data.items(): for path, raw in self.data.items():
if path in (_HISTORY_KEY, "_file_meta"): if path in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
continue continue
entry = BackupEntry.from_list(raw) entry = BackupEntry.from_list(raw)
fmt_display = ( fmt_display = (
@@ -1562,7 +1795,7 @@ class BackupManager:
# 策略统计 # 策略统计
strategies = { 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("## 策略概览")
lines.append(f"- 策略总数: {len(strategies)}") lines.append(f"- 策略总数: {len(strategies)}")
+153
View File
@@ -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
View File
@@ -70,8 +70,10 @@ def get_parser() -> argparse.ArgumentParser:
) )
parser.add_argument( parser.add_argument(
"--incremental", "--incremental",
action="store_true", nargs="?",
default=False, const="file",
default=None,
choices=["file", "block"],
help=t("cli.help.incremental"), help=t("cli.help.incremental"),
) )
parser.add_argument( parser.add_argument(
@@ -262,6 +264,18 @@ def get_parser() -> argparse.ArgumentParser:
default=None, default=None,
help=t("cli.help.watch.post_hook"), 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 = subparsers.add_parser("restore", help=t("cli.help.restore"))
restore_parser.add_argument("backup_file", help=t("cli.help.restore.file")) 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")) 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 = 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( verify_parser.add_argument(
"--fast", "--fast",
action="store_true", action="store_true",
default=False, default=False,
help=t("cli.help.verify.fast"), 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( verify_parser.add_argument(
"--password", default="", help=t("cli.help.verify.password") "--password", default="", help=t("cli.help.verify.password")
) )
@@ -484,6 +518,17 @@ def get_parser() -> argparse.ArgumentParser:
clean_parser.add_argument( clean_parser.add_argument(
"--keep-days", type=int, default=0, help=t("cli.help.clean.keep_days") "--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( clean_parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
@@ -618,6 +663,15 @@ def _handle_list(args, config, manager) -> int:
def _handle_save(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( manager.execute_backups(
keep=args.keep, keep=args.keep,
keep_days=args.keep_days, keep_days=args.keep_days,
@@ -639,16 +693,30 @@ def _handle_save(args, config, manager) -> int:
pre_hooks=getattr(args, "pre_hook", None) or [], pre_hooks=getattr(args, "pre_hook", None) or [],
post_hooks=getattr(args, "post_hook", None) or [], post_hooks=getattr(args, "post_hook", None) or [],
) )
finally:
lock.release()
return 0 return 0
def _handle_watch(args, config, manager) -> int: def _handle_watch(args, config, manager) -> int:
from sbackup.lock import BackupLock
import time as _time import time as _time
interval_sec = max(args.interval, 1) * 60 interval_sec = max(args.interval, 1) * 60
print(t("cmd.watch.start", interval=args.interval)) realtime = getattr(args, "realtime", False)
try: debounce = max(getattr(args, "debounce", 30), 1)
while True:
# 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( manager.execute_backups(
keep=args.keep, keep=args.keep,
keep_days=args.keep_days, keep_days=args.keep_days,
@@ -662,9 +730,7 @@ def _handle_watch(args, config, manager) -> int:
follow_symlinks=args.follow_symlinks, follow_symlinks=args.follow_symlinks,
max_size=_parse_size(args.max_size) if args.max_size else 0, 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, min_size=_parse_size(args.min_size) if args.min_size else 0,
max_age_seconds=_parse_duration(args.older_than) max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0,
if args.older_than
else 0,
incremental=args.incremental, incremental=args.incremental,
split_size=_parse_size(args.split) if args.split else 0, split_size=_parse_size(args.split) if args.split else 0,
tag=args.tag or "", tag=args.tag or "",
@@ -672,9 +738,51 @@ def _handle_watch(args, config, manager) -> int:
pre_hooks=getattr(args, "pre_hook", None) or [], pre_hooks=getattr(args, "pre_hook", None) or [],
post_hooks=getattr(args, "post_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) _time.sleep(interval_sec)
except KeyboardInterrupt: except KeyboardInterrupt:
return 0 return 0
finally:
lock.release()
def _handle_restore(args, config, manager) -> int: 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: 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: if args.fast:
# 快速验证:用历史 SHA256 比对,无需解压 # 快速验证:用历史 SHA256 比对,无需解压
sha256 = manager.find_checksum(args.backup_file) sha256 = manager.find_checksum(args.backup_file)
if sha256: if sha256:
from sbackup.compression import verify_backup_fast
result = verify_backup_fast(args.backup_file, sha256) result = verify_backup_fast(args.backup_file, sha256)
if result["success"]: if result["success"]:
print( print(
@@ -731,13 +934,9 @@ def _handle_verify(args, config, manager) -> int:
else: else:
print(t("cmd.verify.no_checksum", path=args.backup_file)) print(t("cmd.verify.no_checksum", path=args.backup_file))
# 回退到完整验证 # 回退到完整验证
from sbackup.compression import verify_backup
result = verify_backup(args.backup_file, args.password) result = verify_backup(args.backup_file, args.password)
return 0 if result["success"] else 1 return 0 if result["success"] else 1
else: else:
from sbackup.compression import verify_backup
result = verify_backup(args.backup_file, args.password) result = verify_backup(args.backup_file, args.password)
return 0 if result["success"] else 1 return 0 if result["success"] else 1
@@ -963,6 +1162,19 @@ def _handle_clean(args, config, manager) -> int:
return 0 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] = { _COMMAND_HANDLERS: dict[str, callable] = {
"version": _handle_version, "version": _handle_version,
"add": _handle_add, "add": _handle_add,
@@ -992,6 +1204,7 @@ _COMMAND_HANDLERS: dict[str, callable] = {
"report": _handle_report, "report": _handle_report,
"search": _handle_search, "search": _handle_search,
"clean": _handle_clean, "clean": _handle_clean,
"completion": _handle_completion,
} }
+387
View File
@@ -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
View File
@@ -134,14 +134,16 @@ def _resolve_name(template: str, folder_name: str) -> str:
def _encrypt_file(file_path: str, password: str) -> str: def _encrypt_file(file_path: str, password: str) -> str:
"""用密码加密文件(PBKDF2 + XOR 流密码),返回加密后的文件路径""" """用密码加密文件(PBKDF2 + XOR 流密码 + HMAC 完整性校验),返回加密后的文件路径"""
import hashlib import hashlib
salt = os.urandom(16) 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" 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) fout.write(salt)
key_stream = bytearray() key_stream = bytearray()
block_index = 0 block_index = 0
@@ -149,29 +151,35 @@ def _encrypt_file(file_path: str, password: str) -> str:
chunk = fin.read(65536) chunk = fin.read(65536)
if not chunk: if not chunk:
break break
# 生成密钥流
while len(key_stream) < len(chunk): while len(key_stream) < len(chunk):
block_index += 1 block_index += 1
key_stream.extend( key_stream.extend(
hashlib.sha256(key + block_index.to_bytes(4, "big")).digest() hashlib.sha256(key + block_index.to_bytes(4, "big")).digest()
) )
# XOR 加密 encrypted = bytes(
encrypted = bytes(a ^ b for a, b in zip(chunk, key_stream[: len(chunk)])) a ^ b for a, b in zip(chunk, key_stream[: len(chunk)])
)
fout.write(encrypted) fout.write(encrypted)
key_stream = key_stream[len(chunk) :] 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) os.remove(file_path)
return encrypted_path return encrypted_path
def _decrypt_file(encrypted_path: str, password: str) -> str: def _decrypt_file(encrypted_path: str, password: str) -> str:
"""解密由 _encrypt_file 加密的文件,返回解密后的文件路径""" """解密由 _encrypt_file 加密的文件,返回解密后的文件路径。密码错误时抛出 ValueError"""
import hashlib import hashlib
decrypted_path = encrypted_path.removesuffix(".enc") decrypted_path = encrypted_path.removesuffix(".enc")
with open(encrypted_path, "rb") as fin: with open(encrypted_path, "rb") as fin:
salt = fin.read(16) 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: with open(decrypted_path, "wb") as fout:
key_stream = bytearray() key_stream = bytearray()
@@ -246,11 +254,12 @@ class BaseCompressor:
negated.append(pattern[1:]) negated.append(pattern[1:])
continue continue
if pattern.startswith("re:"): if pattern.startswith("re:"):
# 正则表达式模式 regex_str = pattern[3:]
# ReDoS 防护:拒绝过长或明显恶意的正则
if len(regex_str) > 256:
continue
try: try:
if re.search(pattern[3:], rel_path) or re.search( if re.search(regex_str, rel_path) or re.search(regex_str, basename):
pattern[3:], basename
):
matched = True matched = True
except re.error: except re.error:
pass pass
@@ -262,10 +271,11 @@ class BaseCompressor:
return False return False
for pattern in negated: for pattern in negated:
if pattern.startswith("re:"): if pattern.startswith("re:"):
regex_str = pattern[3:]
if len(regex_str) > 256:
continue
try: try:
if re.search(pattern[3:], rel_path) or re.search( if re.search(regex_str, rel_path) or re.search(regex_str, basename):
pattern[3:], basename
):
return False return False
except re.error: except re.error:
pass pass
@@ -827,6 +837,85 @@ def _match_select_pattern(name: str, pattern: str) -> bool:
return fnmatch(name, pattern) or fnmatch(basename, pattern) 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( def restore_backup(
backup_path: str, backup_path: str,
target_dir: str, target_dir: str,
@@ -900,23 +989,22 @@ def restore_backup(
with zipfile.ZipFile(backup, "r") as zf: with zipfile.ZipFile(backup, "r") as zf:
all_members = zf.namelist() all_members = zf.namelist()
members = ( 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 if select_pattern
else all_members else [m for m in all_members if _is_safe_member_name(m)]
) )
if not members: if not members:
if not quiet: if not quiet:
print(t("restore.no_match", pattern=select_pattern)) print(t("restore.no_match", pattern=select_pattern))
return {"success": True, "files_count": 0} return {"success": True, "files_count": 0}
with tqdm( skipped = _safe_extract_zip(zf, target, members)
total=len(members), if skipped and not quiet:
desc=t("restore.progress"), print(t("restore.skipped_unsafe", count=len(skipped)))
unit=t("compress.unit"),
disable=quiet,
) as pbar:
for member in members:
zf.extract(member, target)
pbar.update(1)
if not quiet: if not quiet:
print(t("restore.success", path=target, count=len(members))) print(t("restore.success", path=target, count=len(members)))
return {"success": True, "files_count": len(members)} return {"success": True, "files_count": len(members)}
@@ -928,10 +1016,24 @@ def restore_backup(
szf_kwargs["password"] = password szf_kwargs["password"] = password
with py7zr.SevenZipFile(**szf_kwargs) as szf: with py7zr.SevenZipFile(**szf_kwargs) as szf:
all_members = szf.getnames() 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 = ( 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 if select_pattern
else all_members else safe_members
) )
if not members: if not members:
if not quiet: if not quiet:
@@ -975,15 +1077,14 @@ def restore_backup(
tmp.seek(0) tmp.seek(0)
with tarfile.open(fileobj=tmp, mode="r") as tarf: with tarfile.open(fileobj=tmp, mode="r") as tarf:
all_members = tarf.getmembers() all_members = tarf.getmembers()
members = ( if select_pattern:
[ members = [
m m
for m in all_members for m in all_members
if _match_select_pattern(m.name, select_pattern) if _match_select_pattern(m.name, select_pattern)
] ]
if select_pattern else:
else all_members members = list(all_members)
)
if not members: if not members:
if not quiet: if not quiet:
print(t("restore.no_match", pattern=select_pattern)) print(t("restore.no_match", pattern=select_pattern))
@@ -994,12 +1095,14 @@ def restore_backup(
unit=t("compress.unit"), unit=t("compress.unit"),
disable=quiet, disable=quiet,
) as pbar: ) as pbar:
for member in members: skipped = _safe_extract_tar(tarf, target, members)
tarf.extract(member, target, filter="data") pbar.update(len(members))
pbar.update(1) if skipped and not quiet:
print(t("restore.skipped_unsafe", count=skipped))
actual = len(members) - skipped
if not quiet: if not quiet:
print(t("restore.success", path=target, count=len(members))) print(t("restore.success", path=target, count=actual))
return {"success": True, "files_count": len(members)} return {"success": True, "files_count": actual}
elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
mode = "r:gz" mode = "r:gz"
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): 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: with tarfile.open(backup, mode) as tarf:
all_members = tarf.getmembers() all_members = tarf.getmembers()
members = ( if select_pattern:
[ members = [
m m
for m in all_members for m in all_members
if _match_select_pattern(m.name, select_pattern) if _match_select_pattern(m.name, select_pattern)
] ]
if select_pattern else:
else all_members members = list(all_members)
)
if not members: if not members:
if not quiet: if not quiet:
print(t("restore.no_match", pattern=select_pattern)) print(t("restore.no_match", pattern=select_pattern))
@@ -1034,12 +1136,14 @@ def restore_backup(
unit=t("compress.unit"), unit=t("compress.unit"),
disable=quiet, disable=quiet,
) as pbar: ) as pbar:
for member in members: skipped = _safe_extract_tar(tarf, target, members)
tarf.extract(member, target, filter="data") pbar.update(len(members))
pbar.update(1) if skipped and not quiet:
print(t("restore.skipped_unsafe", count=skipped))
actual = len(members) - skipped
if not quiet: if not quiet:
print(t("restore.success", path=target, count=len(members))) print(t("restore.success", path=target, count=actual))
return {"success": True, "files_count": len(members)} return {"success": True, "files_count": actual}
except KeyboardInterrupt: except KeyboardInterrupt:
raise raise
except PermissionError: 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 file_path: 要分割的文件路径
:param chunk_size: 每个分卷的大小(字节) :param chunk_size: 每个分卷的大小(字节)
:param create_manifest: 是否创建清单文件
:return: 分卷文件路径列表 :return: 分卷文件路径列表
""" """
import hashlib
import json
from datetime import datetime
if chunk_size <= 0: if chunk_size <= 0:
return [file_path] return [file_path]
@@ -1352,7 +1463,18 @@ def split_file(file_path: str, chunk_size: int) -> list[str]:
if file_size <= chunk_size: if file_size <= chunk_size:
return [file_path] 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 = [] parts = []
part_checksums = []
part_index = 1 part_index = 1
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
while True: while True:
@@ -1360,31 +1482,175 @@ def split_file(file_path: str, chunk_size: int) -> list[str]:
if not chunk: if not chunk:
break break
part_path = f"{file_path}.{part_index:03d}" 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: with open(part_path, "wb") as pf:
pf.write(chunk) pf.write(chunk)
parts.append(part_path) parts.append(part_path)
part_index += 1 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) os.remove(file_path)
return parts 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 part_paths: 分卷文件路径列表(按顺序)
:param output_path: 输出文件路径 :param output_path: 输出文件路径
:param resume: 是否支持断点续传(从上次中断处继续)
:param progress_callback: 进度回调函数 callback(current_bytes, total_bytes)
:return: 是否成功 :return: 是否成功
""" """
if not part_paths:
return False
# 计算总大小
total_size = 0
for p in part_paths:
try: try:
with open(output_path, "wb") as out_f: total_size += os.path.getsize(p)
for part_path in part_paths: 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: with open(part_path, "rb") as in_f:
if i == start_part_idx and part_offset > 0:
in_f.seek(part_offset)
while True: while True:
chunk = in_f.read(65536) chunk = in_f.read(65536)
if not chunk: if not chunk:
break break
out_f.write(chunk) out_f.write(chunk)
written_bytes += len(chunk)
if progress_callback:
progress_callback(written_bytes, total_size)
return True return True
except OSError: except OSError:
return False 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
View File
@@ -27,7 +27,7 @@ def _load_json_file(config_file: str) -> dict:
def _save_json_file(data: dict, config_file: str) -> None: def _save_json_file(data: dict, config_file: str) -> None:
"""将字典写入 JSON 配置文件,自动创建目录""" """将字典写入 JSON 配置文件,自动创建目录,设置限制性权限"""
data_dir = os.path.dirname(config_file) data_dir = os.path.dirname(config_file)
if data_dir: if data_dir:
try: 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) logger.error(t("log.config.mkdir.error"), data_dir, e)
return return
# 原子写入:先写临时文件再替换
tmp_path = config_file + ".tmp"
try: 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) 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: except OSError as e:
logger.error(t("log.config.write.error"), config_file, 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: def get_default_data_file() -> str:
@@ -101,6 +125,15 @@ class Config:
webdav_password: str = "" webdav_password: str = ""
webdav_remote_path: str = "/" webdav_remote_path: str = "/"
webdav_enabled: bool = False 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: 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: if webhook_url_legacy and not webhook_urls:
webhook_urls = [webhook_url_legacy] webhook_urls = [webhook_url_legacy]
webdav_config = config_data.get("webdav", {}) 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( return Config(
folder_path="", folder_path="",
@@ -158,7 +207,7 @@ def load_config(config_file: str = "config.json") -> Config:
webhook_template=webhook_template, webhook_template=webhook_template,
webhook_retries=webhook_retries, webhook_retries=webhook_retries,
sftp_host=sftp_config.get("host", ""), sftp_host=sftp_config.get("host", ""),
sftp_port=sftp_config.get("port", 22), sftp_port=sftp_port,
sftp_user=sftp_config.get("user", ""), sftp_user=sftp_config.get("user", ""),
sftp_password=sftp_config.get("password", ""), sftp_password=sftp_config.get("password", ""),
sftp_key_file=sftp_config.get("key_file", ""), 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_password=webdav_config.get("password", ""),
webdav_remote_path=webdav_config.get("remote_path", "/"), webdav_remote_path=webdav_config.get("remote_path", "/"),
webdav_enabled=webdav_config.get("enabled", False), 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", "password"),
("sftp", "key_passphrase"), ("sftp", "key_passphrase"),
("webdav", "password"), ("webdav", "password"),
("smtp", "password"),
] ]
@@ -284,28 +342,46 @@ def _derive_key(master_password: str, salt: bytes) -> bytes:
"""从主密码派生加密密钥""" """从主密码派生加密密钥"""
import hashlib 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: def _encrypt_value(value: str, key: bytes) -> str:
"""加密单个字符串值,返回 base64 编码的 salt+密文""" """加密单个字符串值,返回 base64 编码的 salt+密文+HMAC(完整性校验)"""
import base64 import base64
import hashlib
import hmac as _hmac
salt = os.urandom(16) salt = os.urandom(16)
derived = _derive_key(key.hex(), salt) derived = _derive_key(key.hex(), salt)
encrypted = bytes( encrypted = bytes(
a ^ b for a, b in zip(value.encode("utf-8"), derived[: len(value)]) 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: def _decrypt_value(encrypted_value: str, key: bytes) -> str:
"""解密单个字符串值""" """解密单个字符串值(验证 HMAC 完整性)"""
import base64 import base64
import hashlib
import hmac as _hmac
data = base64.b64decode(encrypted_value) 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] 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) derived = _derive_key(key.hex(), salt)
decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)])) decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)]))
return decrypted.decode("utf-8") return decrypted.decode("utf-8")
@@ -319,10 +395,18 @@ def encrypt_config(master_password: str, config_file: str = "config.json") -> bo
if not data: if not data:
return False 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: for field_path in _SENSITIVE_FIELDS:
obj = data 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"): if not data or not data.get("_encrypted"):
return True # 未加密,视为成功 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: try:
for field_path in _SENSITIVE_FIELDS: 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): if not os.path.isfile(gitignore_path):
return [] 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 = [] patterns = []
try: try:
with open(gitignore_path, "r", encoding="utf-8") as f: with open(gitignore_path, "r", encoding="utf-8") as f:
+55 -2
View File
@@ -2,11 +2,38 @@
import getpass import getpass
import logging import logging
import os
import sys
from sbackup.i18n import t from sbackup.i18n import t
logger = logging.getLogger(__name__) 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( def _resolve_sftp_auth(
key_file: str, key_file: str,
key_passphrase: str, key_passphrase: str,
@@ -84,6 +111,18 @@ def handle_sftp(args, config) -> int:
user = args.user or input(t("cli.prompt.sftp.user") + " ") user = args.user or input(t("cli.prompt.sftp.user") + " ")
key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ") 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, key_passphrase, password = _resolve_sftp_auth(
key_file_input, key_file_input,
args.key_passphrase or "", args.key_passphrase or "",
@@ -153,6 +192,12 @@ def handle_webdav(args, config) -> int:
# 交互式配置 # 交互式配置
url = args.url or input(t("cli.prompt.webdav.url") + " ") url = args.url or input(t("cli.prompt.webdav.url") + " ")
user = args.user or input(t("cli.prompt.webdav.user") + " ") 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( password = args.password or getpass.getpass(
t("cli.prompt.webdav.password") + " " t("cli.prompt.webdav.password") + " "
) )
@@ -265,7 +310,11 @@ def _handle_remote_sftp(args, config) -> int:
return 0 return 0
elif args.remote_action == "rm": 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) client.delete_remote_file(remote_path)
print(t("cmd.remote.deleted", path=remote_path)) print(t("cmd.remote.deleted", path=remote_path))
return 0 return 0
@@ -315,7 +364,11 @@ def _handle_remote_webdav(args, config) -> int:
return 0 return 0
elif args.remote_action == "rm": 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) client.delete_remote_file(remote_path)
print(t("cmd.remote.deleted", path=remote_path)) print(t("cmd.remote.deleted", path=remote_path))
return 0 return 0
+35
View File
@@ -34,6 +34,41 @@ def set_locale(lang: str) -> None:
设置当前语言环境并加载对应的翻译文件 设置当前语言环境并加载对应的翻译文件
""" """
global _current_locale, _translations 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 _current_locale = lang
locale_file = os.path.join(_get_locales_dir(), f"{lang}.json") locale_file = os.path.join(_get_locales_dir(), f"{lang}.json")
if os.path.exists(locale_file): if os.path.exists(locale_file):
+265
View 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
+29 -1
View File
@@ -82,6 +82,7 @@
"table.cell.default": "Standard", "table.cell.default": "Standard",
"restore.progress": "Wiederherstellung", "restore.progress": "Wiederherstellung",
"restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)", "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)", "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.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", "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.list": "Backup-Verlauf anzeigen",
"cli.help.verify": "Backup-Datei-Integrität überprüfen", "cli.help.verify": "Backup-Datei-Integrität überprüfen",
"cli.help.verify.file": "Zu überprüfende Backup-Datei", "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": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme)",
"cli.help.info.file": "Pfad zur Backup-Datei", "cli.help.info.file": "Pfad zur Backup-Datei",
"cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)", "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.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.local_not_found": "Lokale Datei nicht gefunden: {path}",
"err.webdav.upload": "Upload zu WebDAV fehlgeschlagen {path}: {error}", "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"
} }
+38 -9
View File
@@ -12,7 +12,9 @@
"warn.json.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.", "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.backup": "Warning: Corrupted data file backed up to {path}.",
"warn.json.renamed": "Warning: Corrupted data file renamed 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.progress": "Compressing",
"compress.unit": "files", "compress.unit": "files",
"err.permission": "Permission denied: Cannot write to '{path}'", "err.permission": "Permission denied: Cannot write to '{path}'",
@@ -25,6 +27,7 @@
"err.argparse.invalid_int": "invalid int value: ", "err.argparse.invalid_int": "invalid int value: ",
"err.argparse.unrecognized_args": "unrecognized arguments: ", "err.argparse.unrecognized_args": "unrecognized arguments: ",
"err.argparse.required": "required", "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.zip.overwrite": "Warning: Overwriting existing backup file {path}",
"warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6", "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.", "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.", "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)", "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.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.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.item": " {source} -> {target} format: {format} files: {count} size: {size} MB",
"cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB",
@@ -72,6 +76,13 @@
"table.cell.default": "Default", "table.cell.default": "Default",
"restore.progress": "Restoring", "restore.progress": "Restoring",
"restore.success": "Restore successful: {path} ({count} files)", "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)", "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.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", "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": "Verify backup file integrity",
"cli.help.verify.file": "Backup file to verify", "cli.help.verify.file": "Backup file to verify",
"cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)", "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)", "cli.help.verify.password": "Decryption password (for encrypted backups)",
"cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}", "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.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.no_checksum": "No historical checksum found, falling back to full verify: {path}",
"cmd.verify.checksum_mismatch": "Checksum mismatch", "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": "Export all backup strategies to a JSON file",
"cli.help.export.file": "Export file path (default: sbackup_export.json)", "cli.help.export.file": "Export file path (default: sbackup_export.json)",
"cli.help.import": "Import backup strategies from a JSON file", "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.nothing": "No backup files to clean up.",
"cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):", "cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):",
"cmd.clean.summary": "Cleaned up {count} backup file(s).", "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.report.saved": "Backup report saved to: {path}",
"cmd.search.results": "Found {count} matching file(s) in {path}:", "cmd.search.results": "Found {count} matching file(s) in {path}:",
"cmd.search.no_results": "No files matching '{pattern}' found 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.local_not_found": "Local file not found: {path}",
"err.webdav.upload": "Upload to WebDAV failed {path}: {error}", "err.webdav.upload": "Upload to WebDAV failed {path}: {error}",
"err.webdav.mkdir": "Failed to create WebDAV remote directory {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)", "err.webdav.invalid_scheme": "Unsupported URL scheme '{scheme}' -- only http and https are allowed.",
"cli.help.remote.action": "Remote action: list / rm", "err.webdav.private_ip": "WebDAV connection to internal/private address blocked for security: {host}",
"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",
"cmd.remote.empty": "Remote directory is empty: {path}", "cmd.remote.empty": "Remote directory is empty: {path}",
"cmd.remote.list_header": "Remote directory: {path} ({count} files)", "cmd.remote.list_header": "Remote directory: {path} ({count} files)",
"cmd.remote.deleted": "Deleted remote file: {path}", "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)", "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.detected": "Split backup detected, {count} parts total",
"restore.split.missing": "Error: split part missing, expected {path}", "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"
} }
+29 -1
View File
@@ -73,6 +73,7 @@
"table.cell.default": "Predet.", "table.cell.default": "Predet.",
"restore.progress": "Restaurando", "restore.progress": "Restaurando",
"restore.success": "Restauración exitosa: {path} ({count} archivos)", "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)", "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.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", "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.list": "Ver historial de copias de seguridad",
"cli.help.verify": "Verificar integridad del archivo de copia 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.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": "Export all backup strategies to a JSON file",
"cli.help.export.file": "Export file path (default: sbackup_export.json)", "cli.help.export.file": "Export file path (default: sbackup_export.json)",
"cli.help.import": "Import backup strategies from a JSON file", "cli.help.import": "Import backup strategies from a JSON file",
@@ -231,5 +251,13 @@
"cmd.diff.modified_header": "Posiblemente modificados ({count}):", "cmd.diff.modified_header": "Posiblemente modificados ({count}):",
"cmd.diff.no_backup": "No se encontraron archivos de copia de seguridad en {path}", "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.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"
} }
+29 -1
View File
@@ -82,6 +82,7 @@
"table.cell.default": "Défaut", "table.cell.default": "Défaut",
"restore.progress": "Restauration en cours", "restore.progress": "Restauration en cours",
"restore.success": "Restauration réussie : {path} ({count} fichiers)", "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)", "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.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", "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.list": "Voir l'historique des sauvegardes",
"cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde", "cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde",
"cli.help.verify.file": "Fichier de sauvegarde à vérifier", "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": "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.file": "Chemin du fichier de sauvegarde",
"cli.help.info.password": "Mot de passe de déchiffrement (pour les sauvegardes 7z chiffrées)", "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.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.local_not_found": "Fichier local introuvable: {path}",
"err.webdav.upload": "Échec du téléchargement vers WebDAV {path}: {error}", "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"
} }
+29 -1
View File
@@ -82,6 +82,7 @@
"table.cell.default": "デフォルト", "table.cell.default": "デフォルト",
"restore.progress": "復元中", "restore.progress": "復元中",
"restore.success": "復元成功: {path} ({count} ファイル)", "restore.success": "復元成功: {path} ({count} ファイル)",
"restore.skipped_unsafe": "安全でないアーカイブメンバー {count} 件をスキップしました(パストラバーサル保護)",
"cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)", "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.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", "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.list": "バックアップ履歴を表示",
"cli.help.verify": "バックアップファイルの整合性を検証", "cli.help.verify": "バックアップファイルの整合性を検証",
"cli.help.verify.file": "検証するバックアップファイル", "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": "バックアップファイルの詳細を表示(形式、サイズ、ファイル数、チェックサム)",
"cli.help.info.file": "バックアップファイルのパス", "cli.help.info.file": "バックアップファイルのパス",
"cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)", "cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)",
@@ -260,5 +280,13 @@
"err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>", "err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>",
"err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}", "err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}",
"err.webdav.upload": "WebDAVへのアップロード失敗 {path}: {error}", "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"
} }
+29 -1
View File
@@ -82,6 +82,7 @@
"table.cell.default": "기본값", "table.cell.default": "기본값",
"restore.progress": "복원 중", "restore.progress": "복원 중",
"restore.success": "복원 성공: {path} ({count} 파일)", "restore.success": "복원 성공: {path} ({count} 파일)",
"restore.skipped_unsafe": "안전하지 않은 아카이브 멤버 {count}개 건너뜀 (경로 순회 방지)",
"cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)", "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.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", "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.list": "백업 기록 보기",
"cli.help.verify": "백업 파일 무결성 검증", "cli.help.verify": "백업 파일 무결성 검증",
"cli.help.verify.file": "검증할 백업 파일", "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": "백업 파일 상세 정보 보기 (형식, 크기, 파일 수, 체크섬)",
"cli.help.info.file": "백업 파일 경로", "cli.help.info.file": "백업 파일 경로",
"cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)", "cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)",
@@ -260,5 +280,13 @@
"err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>", "err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>",
"err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}", "err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}",
"err.webdav.upload": "WebDAV 업로드 실패 {path}: {error}", "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"
} }
+29 -1
View File
@@ -82,6 +82,7 @@
"table.cell.default": "Padrão", "table.cell.default": "Padrão",
"restore.progress": "Restaurando", "restore.progress": "Restaurando",
"restore.success": "Restauração concluída: {path} ({count} arquivos)", "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)", "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.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", "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.list": "Ver histórico de backups",
"cli.help.verify": "Verificar integridade do arquivo de backup", "cli.help.verify": "Verificar integridade do arquivo de backup",
"cli.help.verify.file": "Arquivo de backup para verificar", "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": "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.file": "Caminho do arquivo de backup",
"cli.help.info.password": "Senha de descriptografia (para backups 7z criptografados)", "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.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.local_not_found": "Arquivo local não encontrado: {path}",
"err.webdav.upload": "Upload para WebDAV falhou {path}: {error}", "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"
} }
+29 -1
View File
@@ -82,6 +82,7 @@
"table.cell.default": "По умолч.", "table.cell.default": "По умолч.",
"restore.progress": "Восстановление", "restore.progress": "Восстановление",
"restore.success": "Восстановление выполнено: {path} ({count} файлов)", "restore.success": "Восстановление выполнено: {path} ({count} файлов)",
"restore.skipped_unsafe": "Пропущено {count} небезопасных элементов архива (защита от обхода пути)",
"cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)", "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.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", "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.list": "Просмотреть историю резервного копирования",
"cli.help.verify": "Проверить целостность файла резервной копии", "cli.help.verify": "Проверить целостность файла резервной копии",
"cli.help.verify.file": "Файл резервной копии для проверки", "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": "Просмотреть подробную информацию о файле резервной копии (формат, размер, количество файлов, контрольная сумма)",
"cli.help.info.file": "Путь к файлу резервной копии", "cli.help.info.file": "Путь к файлу резервной копии",
"cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)", "cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)",
@@ -260,5 +280,13 @@
"err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>", "err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>",
"err.webdav.local_not_found": "Локальный файл не найден: {path}", "err.webdav.local_not_found": "Локальный файл не найден: {path}",
"err.webdav.upload": "Ошибка загрузки на WebDAV {path}: {error}", "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"
} }
+38 -9
View File
@@ -12,7 +12,9 @@
"warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。", "warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。",
"warn.json.backup": "警告:已备份损坏的数据文件到 {path}", "warn.json.backup": "警告:已备份损坏的数据文件到 {path}",
"warn.json.renamed": "警告:已将损坏的数据文件重命名为 {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.progress": "正在压缩",
"compress.unit": "文件", "compress.unit": "文件",
"err.permission": "权限不足:无法写入 '{path}'", "err.permission": "权限不足:无法写入 '{path}'",
@@ -25,6 +27,7 @@
"err.argparse.invalid_int": "无效的整数值: ", "err.argparse.invalid_int": "无效的整数值: ",
"err.argparse.unrecognized_args": "未识别的参数: ", "err.argparse.unrecognized_args": "未识别的参数: ",
"err.argparse.required": "必需参数", "err.argparse.required": "必需参数",
"err.lock.conflict": "错误:另一个备份操作正在运行(锁文件已存在)。如果确定没有其他进程在运行,请手动删除锁文件。",
"warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}", "warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}",
"warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6", "warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6",
"cmd.save.uptodate": "所有备份策略均已是最新,无需备份。", "cmd.save.uptodate": "所有备份策略均已是最新,无需备份。",
@@ -42,6 +45,7 @@
"cmd.init.exists": "配置文件已存在: {path},跳过生成。", "cmd.init.exists": "配置文件已存在: {path},跳过生成。",
"warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)", "warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)",
"cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒", "cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒",
"cmd.upload.parallel": "正在并行上传到 SFTP 和 WebDAV...",
"cmd.dry_run.header": "预览模式(不会实际执行备份):", "cmd.dry_run.header": "预览模式(不会实际执行备份):",
"cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB", "cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB",
"cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB", "cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB",
@@ -75,6 +79,13 @@
"table.cell.default": "默认", "table.cell.default": "默认",
"restore.progress": "正在还原", "restore.progress": "正在还原",
"restore.success": "还原成功: {path} ({count} 个文件)", "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 停止)", "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.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", "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": "校验备份文件完整性",
"cli.help.verify.file": "要校验的备份文件路径", "cli.help.verify.file": "要校验的备份文件路径",
"cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)", "cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)",
"cli.help.verify.all": "批量验证所有历史备份",
"cli.help.verify.detail": "显示详细验证信息(大小、格式、校验和等)",
"cli.help.verify.split": "验证分卷文件完整性(需要清单文件)",
"cli.help.verify.password": "解密密码(校验加密备份时使用)", "cli.help.verify.password": "解密密码(校验加密备份时使用)",
"cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}", "cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}",
"cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改", "cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改",
"cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}", "cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}",
"cmd.verify.checksum_mismatch": "校验和不匹配", "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": "导出所有备份策略到 JSON 文件",
"cli.help.export.file": "导出文件路径(默认: sbackup_export.json)", "cli.help.export.file": "导出文件路径(默认: sbackup_export.json)",
"cli.help.import": "从 JSON 文件导入备份策略", "cli.help.import": "从 JSON 文件导入备份策略",
@@ -184,6 +208,8 @@
"cmd.clean.nothing": "没有需要清理的备份文件。", "cmd.clean.nothing": "没有需要清理的备份文件。",
"cmd.clean.dry_run_header": "预览模式(不会实际删除):", "cmd.clean.dry_run_header": "预览模式(不会实际删除):",
"cmd.clean.summary": "共清理 {count} 个备份文件。", "cmd.clean.summary": "共清理 {count} 个备份文件。",
"cli.help.completion": "生成 Shell 自动补全脚本",
"cli.help.completion.shell": "Shell 类型: bash(默认) / zsh / fish / powershell",
"cmd.report.saved": "备份报告已保存到: {path}", "cmd.report.saved": "备份报告已保存到: {path}",
"cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:", "cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:",
"cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件", "cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件",
@@ -327,13 +353,8 @@
"err.webdav.local_not_found": "本地文件不存在: {path}", "err.webdav.local_not_found": "本地文件不存在: {path}",
"err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}", "err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}",
"err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}", "err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}",
"cli.help.remote": "远程备份文件管理(列出/删除远程服务器上的备份文件)", "err.webdav.invalid_scheme": "不支持的 URL 协议 '{scheme}',仅允许 http 和 https。",
"cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)", "err.webdav.private_ip": "已阻止连接到内网地址: {host}",
"cli.help.remote.list": "列出远程服务器上的备份文件",
"cli.help.remote.rm": "删除远程服务器上的备份文件",
"cli.help.remote.rm.filename": "要删除的远程文件名",
"cli.help.remote.sftp": "使用 SFTP 协议",
"cli.help.remote.webdav": "使用 WebDAV 协议",
"cmd.remote.empty": "远程目录为空: {path}", "cmd.remote.empty": "远程目录为空: {path}",
"cmd.remote.list_header": "远程目录: {path} ({count} 个文件)", "cmd.remote.list_header": "远程目录: {path} ({count} 个文件)",
"cmd.remote.deleted": "已删除远程文件: {path}", "cmd.remote.deleted": "已删除远程文件: {path}",
@@ -358,5 +379,13 @@
"cli.help.watch.post_hook": "备份后执行的命令(可多次使用)", "cli.help.watch.post_hook": "备份后执行的命令(可多次使用)",
"restore.split.detected": "检测到分卷备份,共 {count} 个分卷", "restore.split.detected": "检测到分卷备份,共 {count} 个分卷",
"restore.split.missing": "错误:分卷文件缺失,期望 {path}", "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
View File
@@ -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
+160
View File
@@ -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
View File
@@ -44,7 +44,7 @@ class SFTPClient:
self._sftp = None self._sftp = None
def connect(self) -> None: def connect(self) -> None:
"""建立 SFTP 连接(优先使用私钥认证)""" """建立 SFTP 连接(优先使用私钥认证,验证主机密钥)"""
import paramiko import paramiko
# 展开 ~ 为用户目录(跨平台兼容) # 展开 ~ 为用户目录(跨平台兼容)
@@ -52,6 +52,47 @@ class SFTPClient:
try: try:
self._transport = paramiko.Transport((self.host, self.port)) 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): if key_file_expanded and os.path.isfile(key_file_expanded):
# 私钥认证 # 私钥认证
pkey = self._load_private_key(key_file_expanded, self.key_passphrase) pkey = self._load_private_key(key_file_expanded, self.key_passphrase)
@@ -150,6 +191,10 @@ class SFTPClient:
finally: finally:
self._transport = None self._transport = None
# 清除敏感凭据,防止内存泄漏
self.password = ""
self.key_passphrase = ""
def __enter__(self): def __enter__(self):
self.connect() self.connect()
return self return self
@@ -190,7 +235,7 @@ class SFTPClient:
except OSError as e: except OSError as e:
raise SFTPError(t("err.sftp.upload", path=filename, error=str(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: if self._sftp is None:
return return
@@ -203,7 +248,9 @@ class SFTPClient:
# 递归创建父目录 # 递归创建父目录
parent = os.path.dirname(remote_path).replace("\\", "/") parent = os.path.dirname(remote_path).replace("\\", "/")
if parent and parent != remote_path: 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: try:
self._sftp.mkdir(remote_path) self._sftp.mkdir(remote_path)
except OSError as e: except OSError as e:
+50 -1
View File
@@ -4,9 +4,13 @@ WebDAV 远程备份模块:基于 HTTP 的文件上传(支持坚果云、Next
""" """
import os import os
import sys
import socket
import ipaddress
import logging import logging
import urllib.request import urllib.request
import urllib.error import urllib.error
import urllib.parse
from base64 import b64encode from base64 import b64encode
from sbackup.i18n import t from sbackup.i18n import t
@@ -24,6 +28,39 @@ class WebDAVClient:
"""WebDAV 客户端,基于 urllib 实现,零额外依赖""" """WebDAV 客户端,基于 urllib 实现,零额外依赖"""
def __init__(self, url: str, user: str, password: str): 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.url = url.rstrip("/")
self.user = user self.user = user
self.password = password self.password = password
@@ -92,7 +129,14 @@ class WebDAVClient:
content_type="application/xml", content_type="application/xml",
) )
req.add_header("Depth", "0") 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) logger.debug("WebDAV 连接成功: %s", self.url)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code == 401: if e.code == 401:
@@ -167,6 +211,11 @@ class WebDAVClient:
except OSError as e: except OSError as e:
raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(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 = [] files = []
try: try:
root = ET.fromstring(xml_data) root = ET.fromstring(xml_data)
+24 -16
View File
@@ -920,10 +920,12 @@ class TestEditStrategy(unittest.TestCase):
class TestWebhook(unittest.TestCase): class TestWebhook(unittest.TestCase):
"""测试 Webhook 增强功能""" """测试 Webhook 增强功能"""
@patch("urllib.request.urlopen") @patch("urllib.request.OpenerDirector.open")
def test_send_webhook_single(self, mock_urlopen): @patch("socket.getaddrinfo")
def test_send_webhook_single(self, mock_getaddrinfo, mock_open):
"""测试发送单个 webhook""" """测试发送单个 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( BackupManager._send_webhook(
"https://example.com/hook", "https://example.com/hook",
status="success", status="success",
@@ -931,12 +933,14 @@ class TestWebhook(unittest.TestCase):
skipped=1, skipped=1,
elapsed=5.0, elapsed=5.0,
) )
mock_urlopen.assert_called_once() mock_open.assert_called_once()
@patch("urllib.request.urlopen") @patch("urllib.request.OpenerDirector.open")
def test_send_webhook_with_template(self, mock_urlopen): @patch("socket.getaddrinfo")
def test_send_webhook_with_template(self, mock_getaddrinfo, mock_open):
"""测试自定义模板 webhook""" """测试自定义模板 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( BackupManager._send_webhook(
"https://example.com/hook", "https://example.com/hook",
status="success", status="success",
@@ -945,18 +949,20 @@ class TestWebhook(unittest.TestCase):
elapsed=5.0, elapsed=5.0,
template='{"event":"backup","status":"{status}"}', template='{"event":"backup","status":"{status}"}',
) )
req = mock_urlopen.call_args[0][0] req = mock_open.call_args[0][0]
import json import json
payload = json.loads(req.data.decode("utf-8")) payload = json.loads(req.data.decode("utf-8"))
self.assertEqual(payload["status"], "success") self.assertEqual(payload["status"], "success")
@patch("urllib.request.urlopen") @patch("urllib.request.OpenerDirector.open")
def test_send_webhook_retries(self, mock_urlopen): @patch("socket.getaddrinfo")
def test_send_webhook_retries(self, mock_getaddrinfo, mock_open):
"""测试 webhook 重试机制""" """测试 webhook 重试机制"""
import urllib.error 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( BackupManager._send_webhook(
"https://example.com/hook", "https://example.com/hook",
status="success", status="success",
@@ -965,12 +971,14 @@ class TestWebhook(unittest.TestCase):
elapsed=1.0, elapsed=1.0,
retries=3, retries=3,
) )
self.assertEqual(mock_urlopen.call_count, 3) self.assertEqual(mock_open.call_count, 3)
@patch("urllib.request.urlopen") @patch("urllib.request.OpenerDirector.open")
def test_send_webhooks_multiple(self, mock_urlopen): @patch("socket.getaddrinfo")
def test_send_webhooks_multiple(self, mock_getaddrinfo, mock_open):
"""测试多个 webhook URL""" """测试多个 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( BackupManager._send_webhooks(
["https://hook1.example.com", "https://hook2.example.com"], ["https://hook1.example.com", "https://hook2.example.com"],
status="success", status="success",
@@ -978,7 +986,7 @@ class TestWebhook(unittest.TestCase):
skipped=0, skipped=0,
elapsed=1.0, elapsed=1.0,
) )
self.assertEqual(mock_urlopen.call_count, 2) self.assertEqual(mock_open.call_count, 2)
class TestDiffBackup(unittest.TestCase): class TestDiffBackup(unittest.TestCase):
+5 -3
View File
@@ -577,13 +577,12 @@ class TestSplitAndMergeFiles(unittest.TestCase):
self.assertEqual(len(parts), 1) self.assertEqual(len(parts), 1)
def test_merge_empty_list(self): def test_merge_empty_list(self):
"""空列表合并创建空文件""" """空列表合并不创建文件并返回 False"""
from sbackup.compression import merge_files from sbackup.compression import merge_files
output_path = os.path.join(self.test_dir, "empty.bin") output_path = os.path.join(self.test_dir, "empty.bin")
result = merge_files([], output_path) result = merge_files([], output_path)
self.assertTrue(result) self.assertFalse(result)
self.assertEqual(os.path.getsize(output_path), 0)
class TestWebhookPreset(unittest.TestCase): class TestWebhookPreset(unittest.TestCase):
@@ -617,8 +616,11 @@ class TestParseGitignore(unittest.TestCase):
def setUp(self): def setUp(self):
self.test_dir = tempfile.mkdtemp() self.test_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.test_dir)
def tearDown(self): def tearDown(self):
os.chdir(self.original_cwd)
shutil.rmtree(self.test_dir, ignore_errors=True) shutil.rmtree(self.test_dir, ignore_errors=True)
def test_parse_gitignore_basic(self): def test_parse_gitignore_basic(self):
+116
View File
@@ -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)
+133
View File
@@ -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
View File
@@ -763,5 +763,128 @@ class TestInitCommand(unittest.TestCase):
self.assertIn("compression", data) self.assertIn("compression", data)
if __name__ == "__main__": class TestCompletionCommand(unittest.TestCase):
unittest.main() """测试 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()
+251
View File
@@ -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
+35 -10
View File
@@ -3,6 +3,7 @@
""" """
import os import os
import sys
import time import time
import tempfile import tempfile
import shutil import shutil
@@ -228,9 +229,14 @@ class TestPrePostHooks(unittest.TestCase):
time.sleep(0.1) time.sleep(0.1)
(Path(self.source_dir) / "new.txt").write_text("new") (Path(self.source_dir) / "new.txt").write_text("new")
# 使用 echo 创建标记文件(Windows cmd 兼容) # 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
marker = self.hook_marker.replace("\\", "/") hook_cmd = (
hook_cmd = f'echo done > "{marker}"' '"'
+ sys.executable
+ '" -c "open(r\''
+ self.hook_marker
+ "', 'w').write('done')\""
)
self.manager.execute_backups(pre_hooks=[hook_cmd]) self.manager.execute_backups(pre_hooks=[hook_cmd])
self.assertTrue(os.path.exists(self.hook_marker), "前置钩子应被执行") self.assertTrue(os.path.exists(self.hook_marker), "前置钩子应被执行")
@@ -241,8 +247,14 @@ class TestPrePostHooks(unittest.TestCase):
time.sleep(0.1) time.sleep(0.1)
(Path(self.source_dir) / "new.txt").write_text("new") (Path(self.source_dir) / "new.txt").write_text("new")
marker = self.hook_marker.replace("\\", "/") # 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
hook_cmd = f'echo done > "{marker}"' hook_cmd = (
'"'
+ sys.executable
+ '" -c "open(r\''
+ self.hook_marker
+ "', 'w').write('done')\""
)
self.manager.execute_backups(post_hooks=[hook_cmd]) self.manager.execute_backups(post_hooks=[hook_cmd])
self.assertTrue(os.path.exists(self.hook_marker), "后置钩子应被执行") 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") marker1 = os.path.join(self.test_dir, "hook1.txt")
marker2 = os.path.join(self.test_dir, "hook2.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(marker1))
self.assertTrue(os.path.exists(marker2)) self.assertTrue(os.path.exists(marker2))
@@ -297,8 +315,15 @@ class TestRunHooks(unittest.TestCase):
"""有效命令执行成功""" """有效命令执行成功"""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
marker = os.path.join(tmpdir, "marker.txt") marker = os.path.join(tmpdir, "marker.txt")
m = marker.replace("\\", "/") # 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
BackupManager._run_hooks([f'echo ok > "{m}"'], "pre") hook_cmd = (
'"'
+ sys.executable
+ '" -c "open(r\''
+ marker
+ "', 'w').write('ok')\""
)
BackupManager._run_hooks([hook_cmd], "pre")
self.assertTrue(os.path.exists(marker)) self.assertTrue(os.path.exists(marker))
def test_run_hooks_with_empty_list(self): def test_run_hooks_with_empty_list(self):
+6 -2
View File
@@ -403,8 +403,12 @@ class TestSFTPClient(unittest.TestCase):
) )
client.connect() client.connect()
# 验证 expanduser 被调用 # 验证 expanduser 被调用(包括 key_file 和 known_hosts 路径)
mock_expanduser.assert_called_once_with(self.key_file) 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( mock_transport.connect.assert_called_once_with(
username=self.user, pkey=mock_pkey username=self.user, pkey=mock_pkey
) )
Generated
+34
View File
@@ -895,6 +895,7 @@ dependencies = [
{ name = "paramiko" }, { name = "paramiko" },
{ name = "py7zr" }, { name = "py7zr" },
{ name = "tqdm" }, { name = "tqdm" },
{ name = "watchdog" },
{ name = "zstandard" }, { name = "zstandard" },
] ]
@@ -908,6 +909,7 @@ requires-dist = [
{ name = "paramiko", specifier = ">=4.0.0" }, { name = "paramiko", specifier = ">=4.0.0" },
{ name = "py7zr", specifier = ">=1.1.0" }, { name = "py7zr", specifier = ">=1.1.0" },
{ name = "tqdm", specifier = ">=4.67.3" }, { name = "tqdm", specifier = ">=4.67.3" },
{ name = "watchdog", specifier = ">=6.0.0" },
{ name = "zstandard", specifier = ">=0.25.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" }, { 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]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"