diff --git a/pyproject.toml b/pyproject.toml index 9cb6aa1..a057d60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "paramiko>=4.0.0", "py7zr>=1.1.0", "tqdm>=4.67.3", + "watchdog>=6.0.0", "zstandard>=0.25.0", ] diff --git a/sbackup/auto_save.py b/sbackup/auto_save.py index 8272259..66a4cef 100644 --- a/sbackup/auto_save.py +++ b/sbackup/auto_save.py @@ -100,7 +100,7 @@ class BackupManager: def save(self, initial: bool = False): """ - 将内存数据写入 JSON 文件 + 将内存数据写入 JSON 文件(原子写入,防止中断导致数据损坏) """ if not initial: logger.debug(t("log.data.write"), self.data_file) @@ -109,8 +109,23 @@ class BackupManager: if data_dir: os.makedirs(data_dir, exist_ok=True) - with open(self.data_file, "w", encoding="utf-8") as f: - json.dump(self.data, f, ensure_ascii=False, indent=4) + # 原子写入:先写入临时文件,再 rename 替换 + tmp_path = self.data_file + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, ensure_ascii=False, indent=4) + f.flush() + os.fsync(f.fileno()) + # os.replace() 在 Windows 和 Unix 上都是原子操作 + os.replace(tmp_path, self.data_file) + except OSError: + # 如果临时文件写入失败,尝试清理 + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + raise def _get_entry(self, key: str) -> BackupEntry | None: """获取指定路径的备份策略条目""" @@ -398,7 +413,7 @@ class BackupManager: max_size: int = 0, min_size: int = 0, max_age_seconds: float = 0, - incremental: bool = False, + incremental: str | None = None, split_size: int = 0, tag: str = "", checksum: bool = False, @@ -420,7 +435,7 @@ class BackupManager: :param max_size: 文件大小上限(字节),0 不限制 :param min_size: 文件大小下限(字节),0 不限制 :param max_age_seconds: 文件年龄上限(秒),0 不限制 - :param incremental: 文件级增量备份(仅压缩变化的文件) + :param incremental: 增量备份模式: None/""=全量, "file"=文件级, "block"=块级 :param pre_hooks: 备份前执行的命令列表 :param post_hooks: 备份后执行的命令列表 """ @@ -430,14 +445,15 @@ class BackupManager: # 执行前置钩子 self._run_hooks(pre_hooks or [], "pre") - # 加载文件级元数据(增量备份用) + # 加载文件级/块级元数据(增量备份用) file_meta_all = self.data.get("_file_meta", {}) if incremental else {} + chunk_meta_all = self.data.get("_chunk_meta", {}) if incremental == "block" else {} # 收集需要备份的条目 tasks = [] skip_count = 0 for key, raw in list(self.data.items()): - if key in (_HISTORY_KEY, "_file_meta"): + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): continue if not os.path.exists(key): print(t("warn.source.missing", path=key)) @@ -449,6 +465,7 @@ class BackupManager: continue entry = BackupEntry.from_list(raw) file_meta = file_meta_all.get(key, {}) if incremental else {} + chunk_meta = chunk_meta_all.get(key, {}) if incremental == "block" else {} if entry.mtime != current_mtime: tasks.append( ( @@ -464,6 +481,8 @@ class BackupManager: max_age_seconds, file_meta, split_size, + incremental, + chunk_meta, ) ) else: @@ -486,7 +505,7 @@ class BackupManager: executor.submit(self._do_backup, *task): task for task in tasks } for future in as_completed(futures): - key, entry, current_mtime, _, _, _, _, _, _, _, _, _ = futures[ + key, entry, current_mtime, _, _, _, _, _, _, _, _, _, incr_mode, _ = futures[ future ] try: @@ -505,10 +524,12 @@ class BackupManager: result.get("path", ""), tag=tag, ) - if incremental: + if incr_mode: self.data.setdefault("_file_meta", {})[key] = ( self._collect_file_meta(key, checksum=checksum) ) + if incr_mode == "block": + self._update_chunk_meta(key) if keep > 0 or keep_days > 0: self._cleanup_old_backups(entry.target, keep, keep_days) backup_count += 1 @@ -532,6 +553,8 @@ class BackupManager: age, fmeta, split, + incr_mode, + chmeta, ) in tasks: result = self._do_backup( key, @@ -546,6 +569,8 @@ class BackupManager: age, fmeta, split, + incr_mode, + chmeta, ) if result and result.get("success"): entry.mtime = current_mtime @@ -558,10 +583,12 @@ class BackupManager: result.get("path", ""), tag=tag, ) - if incremental: + if incr_mode: self.data.setdefault("_file_meta", {})[key] = ( self._collect_file_meta(key, checksum=checksum) ) + if incr_mode == "block": + self._update_chunk_meta(key) if keep > 0 or keep_days > 0: self._cleanup_old_backups(entry.target, keep, keep_days) backup_count += 1 @@ -596,13 +623,28 @@ class BackupManager: if verify_failures > 0: print(t("cmd.verify.partial_fail", count=verify_failures)) - # SFTP 上传 - if sftp_upload and uploaded_files: - self._upload_to_sftp(uploaded_files, config) + # 并行上传到 SFTP 和 WebDAV(使用 ThreadPoolExecutor) + sftp_needed = sftp_upload and bool(uploaded_files) + webdav_needed = webdav_upload and bool(uploaded_files) - # WebDAV 上传 - if webdav_upload and uploaded_files: - self._upload_to_webdav(uploaded_files, config) + if sftp_needed and webdav_needed: + print(t("cmd.upload.parallel")) + with ThreadPoolExecutor(max_workers=2) as upload_executor: + sftp_future = upload_executor.submit( + self._upload_to_sftp, uploaded_files, config + ) + webdav_future = upload_executor.submit( + self._upload_to_webdav, uploaded_files, config + ) + for f in as_completed([sftp_future, webdav_future]): + exc = f.exception() + if exc: + logger.error("Parallel upload error: %s", exc) + else: + if sftp_needed: + self._upload_to_sftp(uploaded_files, config) + if webdav_needed: + self._upload_to_webdav(uploaded_files, config) # Webhook 通知(支持多个 URL、自定义模板、重试) all_webhook_urls = list(getattr(config, "webhook_urls", [])) @@ -634,6 +676,20 @@ class BackupManager: retries=getattr(config, "webhook_retries", 2), ) + # SMTP 邮件通知 + if getattr(config, "smtp_enabled", False): + status = "成功" if verify_failures == 0 else "部分成功" + if backup_count == 0: + status = "跳过" + self._send_email( + config, + status=status, + backed=backup_count, + skipped=skip_count, + elapsed=elapsed, + verify_failures=verify_failures, + ) + # 执行后置钩子 self._run_hooks(post_hooks or [], "post") @@ -644,6 +700,7 @@ class BackupManager: :param hook_type: "pre" 或 "post",用于日志标识 """ import subprocess + import shlex for cmd in hooks: if not cmd: @@ -652,7 +709,11 @@ class BackupManager: print(t(label, command=cmd)) try: result = subprocess.run( - cmd, shell=True, timeout=300, capture_output=True, text=True + shlex.split(cmd), + shell=False, + timeout=300, + capture_output=True, + text=True, ) if result.returncode != 0: logger.warning( @@ -679,6 +740,8 @@ class BackupManager: max_age_seconds: float = 0, file_metadata: dict | None = None, split_size: int = 0, + incremental: str | None = None, + chunk_meta: dict | None = None, ) -> dict: """执行单个备份策略(线程安全)""" fmt = entry.compression_format or config.compression_format @@ -765,6 +828,27 @@ class BackupManager: pass return meta + def _update_chunk_meta(self, key: str) -> None: + """为指定策略更新块级哈希元数据""" + from sbackup.chunked_backup import compute_chunk_hashes + + if not os.path.isdir(key): + return + try: + for dirpath, _, filenames in os.walk(key): + for fn in filenames: + fp = os.path.join(dirpath, fn) + try: + if os.path.isfile(fp): + rel = os.path.relpath(fp, key).replace("\\", "/") + self.data.setdefault("_chunk_meta", {}).setdefault( + key, {} + )[rel] = compute_chunk_hashes(fp) + except OSError: + pass + except OSError: + pass + @staticmethod def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None: """检查目标目录剩余空间,不足时打印警告""" @@ -891,7 +975,46 @@ class BackupManager: ensure_ascii=False, ).encode("utf-8") - max_attempts = max(retries, 1) + max_attempts = min(max(retries, 1), 5) + + # SSRF 防护:只允许 http/https 协议,阻止内网地址 + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + logger.warning("Webhook URL 协议不允许: %s (仅支持 http/https)", url) + return + + # 解析主机名并阻止内部/保留 IP + if parsed.hostname: + try: + import ipaddress + import socket as _socket + + host_ip = _socket.getaddrinfo( + parsed.hostname, None, type=_socket.SOCK_STREAM + )[0][4][0] + ip = ipaddress.ip_address(host_ip) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + ): + logger.warning( + "Webhook URL 指向内部地址,已阻止: %s", parsed.hostname + ) + return + except (OSError, ValueError): + pass + + # 禁用自动重定向(防止重定向链绕过协议/地址检查) + class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + no_redirect_opener = urllib.request.build_opener(_NoRedirectHandler) + for attempt in range(max_attempts): try: req = urllib.request.Request( @@ -900,8 +1023,8 @@ class BackupManager: method="POST", headers={"Content-Type": "application/json"}, ) - urllib.request.urlopen(req, timeout=10) - logger.debug("Webhook 通知成功: %s", url) + no_redirect_opener.open(req, timeout=10) + logger.debug("Webhook 通知成功: %s", parsed.hostname) return except (urllib.error.URLError, OSError) as e: if attempt < max_attempts - 1: @@ -911,11 +1034,11 @@ class BackupManager: attempt + 1, max_attempts, wait, - url, + parsed.hostname, ) _time.sleep(wait) else: - logger.warning("Webhook 通知失败: %s — %s", url, e) + logger.warning("Webhook 通知失败: %s — %s", parsed.hostname, e) @staticmethod def _send_webhooks( @@ -946,6 +1069,68 @@ class BackupManager: # 向后兼容别名 save_folder = execute_backups + @staticmethod + def _safe_smtp_header(value: str) -> str: + """净化 SMTP 头值,防止头注入""" + return value.replace("\r", "").replace("\n", "") + + @staticmethod + def _send_email( + config: Config, + *, + status: str, + backed: int, + skipped: int, + elapsed: float, + verify_failures: int = 0, + ) -> None: + """通过 SMTP 发送备份结果邮件通知""" + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + from datetime import datetime + + if not config.smtp_enabled or not config.smtp_host: + return + + subject = ( + f"[sbackup] 备份{status} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" + ) + + body = f"""sbackup 备份通知 + +状态: {status} +备份策略数: {backed} +跳过数: {skipped} +耗时: {elapsed:.2f} 秒 +验证失败: {verify_failures} +时间: {datetime.now().isoformat(timespec="seconds")} +""" + + msg = MIMEMultipart() + msg["From"] = BackupManager._safe_smtp_header( + config.smtp_from or config.smtp_user + ) + msg["To"] = BackupManager._safe_smtp_header(config.smtp_to) + msg["Subject"] = BackupManager._safe_smtp_header(subject) + msg.attach(MIMEText(body, "plain", "utf-8")) + + try: + if config.smtp_tls: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + server.starttls() + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + + if config.smtp_user and config.smtp_password: + server.login(config.smtp_user, config.smtp_password) + + server.send_message(msg) + server.quit() + logger.debug("邮件通知成功: %s", config.smtp_to) + except (smtplib.SMTPException, OSError) as e: + logger.warning("邮件通知失败: %s", e) + @staticmethod def _upload_to_sftp(file_paths: list[str], config: Config) -> None: """将备份文件上传到 SFTP 服务器(多文件并行)""" @@ -1318,7 +1503,7 @@ class BackupManager: # 收集所有策略的目标目录 target_dirs: set[str] = set() for key, raw in self.data.items(): - if key in (_HISTORY_KEY, "_file_meta"): + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): continue entry = BackupEntry.from_list(raw) if entry.target: @@ -1369,7 +1554,7 @@ class BackupManager: """导出所有备份策略到 JSON 文件,返回导出的策略数""" export_data = {} for key, raw in self.data.items(): - if key in (_HISTORY_KEY, "_file_meta"): + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): continue export_data[key] = raw data_dir = os.path.dirname(output_file) @@ -1383,6 +1568,13 @@ class BackupManager: """从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None""" if not os.path.exists(input_file): return None + # 文件大小限制:最多 10 MB + try: + if os.path.getsize(input_file) > 10 * 1024 * 1024: + logger.warning("Import file too large: %s", input_file) + return None + except OSError: + return None try: with open(input_file, "r", encoding="utf-8") as f: import_data = json.load(f) @@ -1392,9 +1584,50 @@ class BackupManager: return None imported = 0 skipped = 0 + _RESERVED_KEYS = {_HISTORY_KEY, "_file_meta", "_chunk_meta"} + _VALID_FORMATS = { + "", + "ZIP", + "TAR", + "TAR_GZ", + "TAR_BZ2", + "TAR_XZ", + "TAR_ZST", + "7Z", + "zip", + "tar", + "tar.gz", + "tar.bz2", + "tar.xz", + "tar.zst", + "7z", + } for key, raw in import_data.items(): + # 安全检查:拒绝内部保留键 + if key in _RESERVED_KEYS: + skipped += 1 + continue if not isinstance(raw, list) or len(raw) < 3: continue + # 安全检查:验证数据结构 + if not isinstance(raw[0], (int, float)): + continue + if not isinstance(raw[1], str): + continue + if not isinstance(raw[2], list): + continue + # 拒绝包含路径遍历的源/目标路径 + if ".." in key or not key.strip(): + skipped += 1 + continue + if ".." in raw[1] or not raw[1].strip(): + skipped += 1 + continue + # 验证条目级格式(如有) + if len(raw) > 3 and raw[3]: + if not isinstance(raw[3], str) or raw[3] not in _VALID_FORMATS: + skipped += 1 + continue if key in self.data: skipped += 1 continue @@ -1410,7 +1643,7 @@ class BackupManager: # 统计策略数 strategies = { - k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta") + k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") } lines.append(t("cmd.status.strategies", count=len(strategies))) @@ -1481,7 +1714,7 @@ class BackupManager: return { key: BackupEntry.from_list(raw).target for key, raw in self.data.items() - if key not in (_HISTORY_KEY, "_file_meta") + if key not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") } @staticmethod @@ -1519,7 +1752,7 @@ class BackupManager: 生成对齐的文本表格 """ non_history_keys = [ - k for k in self.data if k not in (_HISTORY_KEY, "_file_meta") + k for k in self.data if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") ] if not non_history_keys: return t("cmd.all.empty") @@ -1532,7 +1765,7 @@ class BackupManager: ] rows = [] for path, raw in self.data.items(): - if path in (_HISTORY_KEY, "_file_meta"): + if path in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): continue entry = BackupEntry.from_list(raw) fmt_display = ( @@ -1562,7 +1795,7 @@ class BackupManager: # 策略统计 strategies = { - k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta") + k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") } lines.append("## 策略概览") lines.append(f"- 策略总数: {len(strategies)}") diff --git a/sbackup/chunked_backup.py b/sbackup/chunked_backup.py new file mode 100644 index 0000000..c781952 --- /dev/null +++ b/sbackup/chunked_backup.py @@ -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(" bool: + """将补丁应用到基准文件,生成还原后的文件。 + + 对于补丁中包含的块索引,使用补丁中的数据; + 对于补丁中不包含的索引,从基准文件中读取。 + """ + try: + # 读取补丁中的所有块 + patch_chunks: dict[int, bytes] = {} + with open(patch_path, "rb") as pf: + count = struct.unpack(" 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 diff --git a/sbackup/cli.py b/sbackup/cli.py index a27d39c..57d6a50 100644 --- a/sbackup/cli.py +++ b/sbackup/cli.py @@ -70,8 +70,10 @@ def get_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--incremental", - action="store_true", - default=False, + nargs="?", + const="file", + default=None, + choices=["file", "block"], help=t("cli.help.incremental"), ) parser.add_argument( @@ -262,6 +264,18 @@ def get_parser() -> argparse.ArgumentParser: default=None, help=t("cli.help.watch.post_hook"), ) + watch_parser.add_argument( + "--realtime", + action="store_true", + default=False, + help=t("cli.help.watch.realtime"), + ) + watch_parser.add_argument( + "--debounce", + type=float, + default=30, + help=t("cli.help.watch.debounce"), + ) restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore")) restore_parser.add_argument("backup_file", help=t("cli.help.restore.file")) @@ -290,13 +304,33 @@ def get_parser() -> argparse.ArgumentParser: diff_parser.add_argument("--password", default="", help=t("cli.help.diff.password")) verify_parser = subparsers.add_parser("verify", help=t("cli.help.verify")) - verify_parser.add_argument("backup_file", help=t("cli.help.verify.file")) + verify_parser.add_argument( + "backup_file", nargs="?", default=None, help=t("cli.help.verify.file") + ) verify_parser.add_argument( "--fast", action="store_true", default=False, help=t("cli.help.verify.fast"), ) + verify_parser.add_argument( + "--all", + action="store_true", + default=False, + help=t("cli.help.verify.all"), + ) + verify_parser.add_argument( + "--detail", + action="store_true", + default=False, + help=t("cli.help.verify.detail"), + ) + verify_parser.add_argument( + "--split", + action="store_true", + default=False, + help=t("cli.help.verify.split"), + ) verify_parser.add_argument( "--password", default="", help=t("cli.help.verify.password") ) @@ -484,6 +518,17 @@ def get_parser() -> argparse.ArgumentParser: clean_parser.add_argument( "--keep-days", type=int, default=0, help=t("cli.help.clean.keep_days") ) + completion_parser = subparsers.add_parser( + "completion", help=t("cli.help.completion") + ) + completion_parser.add_argument( + "shell", + nargs="?", + default="bash", + choices=["bash", "zsh", "fish", "powershell"], + help=t("cli.help.completion.shell"), + ) + clean_parser.add_argument( "--dry-run", action="store_true", @@ -618,63 +663,126 @@ def _handle_list(args, config, manager) -> int: def _handle_save(args, config, manager) -> int: - manager.execute_backups( - keep=args.keep, - keep_days=args.keep_days, - password=args.password, - sftp_upload=args.sftp, - webdav_upload=args.webdav, - dry_run=args.dry_run, - verify=args.verify, - name_template=args.name_template, - webhook_url=args.webhook, - follow_symlinks=args.follow_symlinks, - max_size=_parse_size(args.max_size) if args.max_size else 0, - min_size=_parse_size(args.min_size) if args.min_size else 0, - max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0, - incremental=args.incremental, - split_size=_parse_size(args.split) if args.split else 0, - tag=args.tag or "", - checksum=args.checksum, - pre_hooks=getattr(args, "pre_hook", None) or [], - post_hooks=getattr(args, "post_hook", None) or [], - ) + from sbackup.lock import BackupLock + + if args.password: + print(t("warn.password_in_cli"), file=sys.stderr) + lock = BackupLock(os.path.dirname(config.data_file) or ".") + if not lock.acquire(): + print(t("err.lock.conflict")) + return 1 + try: + manager.execute_backups( + keep=args.keep, + keep_days=args.keep_days, + password=args.password, + sftp_upload=args.sftp, + webdav_upload=args.webdav, + dry_run=args.dry_run, + verify=args.verify, + name_template=args.name_template, + webhook_url=args.webhook, + follow_symlinks=args.follow_symlinks, + max_size=_parse_size(args.max_size) if args.max_size else 0, + min_size=_parse_size(args.min_size) if args.min_size else 0, + max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0, + incremental=args.incremental, + split_size=_parse_size(args.split) if args.split else 0, + tag=args.tag or "", + checksum=args.checksum, + pre_hooks=getattr(args, "pre_hook", None) or [], + post_hooks=getattr(args, "post_hook", None) or [], + ) + finally: + lock.release() return 0 def _handle_watch(args, config, manager) -> int: + from sbackup.lock import BackupLock + import time as _time interval_sec = max(args.interval, 1) * 60 - print(t("cmd.watch.start", interval=args.interval)) + realtime = getattr(args, "realtime", False) + debounce = max(getattr(args, "debounce", 30), 1) + + # watch 命令长期持有锁,阻止并发 save/watch + lock = BackupLock(os.path.dirname(config.data_file) or ".") + if not lock.acquire(): + print(t("err.lock.conflict")) + return 1 + + if args.password: + print(t("warn.password_in_cli"), file=sys.stderr) + + def do_backup() -> None: + manager.execute_backups( + keep=args.keep, + keep_days=args.keep_days, + password=args.password, + sftp_upload=args.sftp, + webdav_upload=args.webdav, + dry_run=args.dry_run, + verify=args.verify, + name_template=args.name_template, + webhook_url=args.webhook, + follow_symlinks=args.follow_symlinks, + max_size=_parse_size(args.max_size) if args.max_size else 0, + min_size=_parse_size(args.min_size) if args.min_size else 0, + max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0, + incremental=args.incremental, + split_size=_parse_size(args.split) if args.split else 0, + tag=args.tag or "", + checksum=args.checksum, + pre_hooks=getattr(args, "pre_hook", None) or [], + post_hooks=getattr(args, "post_hook", None) or [], + ) + try: + if realtime: + from sbackup.monitor import FileSystemMonitor + from sbackup.auto_save import BackupEntry + + source_dirs: dict[str, str] = {} + for key, raw in manager.data.items(): + if key in ("_history", "_file_meta"): + continue + entry = BackupEntry.from_list(raw) + source_dirs[key] = entry.target + + if not source_dirs: + print(t("cmd.all.empty")) + return 1 + + monitor = FileSystemMonitor(source_dirs, debounce_seconds=debounce) + monitor.set_backup_callback(do_backup) + + print(t("cmd.watch.start_realtime", debounce=debounce)) + monitor.start() + + if not monitor.is_running(): + return 1 + + try: + do_backup() + while True: + _time.sleep(interval_sec) + do_backup() + except KeyboardInterrupt: + pass + finally: + monitor.stop() + return 0 + + print(t("cmd.watch.start", interval=args.interval)) while True: - manager.execute_backups( - keep=args.keep, - keep_days=args.keep_days, - password=args.password, - sftp_upload=args.sftp, - webdav_upload=args.webdav, - dry_run=args.dry_run, - verify=args.verify, - name_template=args.name_template, - webhook_url=args.webhook, - follow_symlinks=args.follow_symlinks, - max_size=_parse_size(args.max_size) if args.max_size else 0, - min_size=_parse_size(args.min_size) if args.min_size else 0, - max_age_seconds=_parse_duration(args.older_than) - if args.older_than - else 0, - incremental=args.incremental, - split_size=_parse_size(args.split) if args.split else 0, - tag=args.tag or "", - checksum=args.checksum, - pre_hooks=getattr(args, "pre_hook", None) or [], - post_hooks=getattr(args, "post_hook", None) or [], - ) + do_backup() _time.sleep(interval_sec) except KeyboardInterrupt: return 0 + finally: + lock.release() def _handle_restore(args, config, manager) -> int: @@ -713,12 +821,107 @@ def _handle_diff(args, config, manager) -> int: def _handle_verify(args, config, manager) -> int: + from sbackup.compression import ( + verify_backup, + verify_backup_fast, + get_backup_info, + verify_split_integrity, + ) + + # 分卷完整性验证 + if args.split: + if not args.backup_file: + print(t("cmd.verify.no_file")) + return 1 + result = verify_split_integrity(args.backup_file) + if result["success"]: + print( + t( + "cmd.verify.split_success", + file=result.get("original_file", ""), + parts=result.get("parts_count", 0), + ) + ) + return 0 + else: + error = result.get("error", "") + if error: + print(t("cmd.verify.split_error", error=error)) + else: + print( + t("cmd.verify.split_failed", file=result.get("original_file", "")) + ) + for r in result.get("results", []): + if r["status"] != "ok": + print(f" {r['path']}: {r['status']}") + return 1 + + # 批量验证所有备份 + if args.all: + history = manager.get_history() + if not history: + print(t("cmd.verify.no_history")) + return 1 + + success_count = 0 + fail_count = 0 + for entry in reversed(history): + source = entry.get("source", "") + sha256 = entry.get("sha256", "") + if not source or not os.path.isfile(source): + continue + + if args.detail: + info = get_backup_info(source, args.password) + print(t("cmd.verify.detail_header", path=source)) + print( + t( + "cmd.verify.detail_info", + size=info.get("size_mb", 0), + files=info.get("files_count", 0), + fmt=info.get("format", "unknown"), + sha256=sha256[:16] if sha256 else "N/A", + ) + ) + + if sha256: + result = verify_backup_fast(source, sha256) + else: + result = verify_backup(source, args.password) + + if result["success"]: + print(t("cmd.verify.batch_success", path=source)) + success_count += 1 + else: + print(t("cmd.verify.batch_failed", path=source)) + fail_count += 1 + + print(t("cmd.verify.batch_summary", success=success_count, fail=fail_count)) + return 0 if fail_count == 0 else 1 + + # 单文件验证 + if not args.backup_file: + print(t("cmd.verify.no_file")) + return 1 + + if args.detail: + info = get_backup_info(args.backup_file, args.password) + sha256 = manager.find_checksum(args.backup_file) + print(t("cmd.verify.detail_header", path=args.backup_file)) + print( + t( + "cmd.verify.detail_info", + size=info.get("size_mb", 0), + files=info.get("files_count", 0), + fmt=info.get("format", "unknown"), + sha256=sha256[:16] if sha256 else "N/A", + ) + ) + if args.fast: # 快速验证:用历史 SHA256 比对,无需解压 sha256 = manager.find_checksum(args.backup_file) if sha256: - from sbackup.compression import verify_backup_fast - result = verify_backup_fast(args.backup_file, sha256) if result["success"]: print( @@ -731,13 +934,9 @@ def _handle_verify(args, config, manager) -> int: else: print(t("cmd.verify.no_checksum", path=args.backup_file)) # 回退到完整验证 - from sbackup.compression import verify_backup - result = verify_backup(args.backup_file, args.password) return 0 if result["success"] else 1 else: - from sbackup.compression import verify_backup - result = verify_backup(args.backup_file, args.password) return 0 if result["success"] else 1 @@ -963,6 +1162,19 @@ def _handle_clean(args, config, manager) -> int: return 0 +def _handle_completion(args, config, manager) -> int: + """生成并输出 shell 自动补全脚本""" + from sbackup.completion import generate + + try: + script = generate(args.shell) + print(script, end="") + except ValueError as e: + print(e, file=sys.stderr) + return 1 + return 0 + + _COMMAND_HANDLERS: dict[str, callable] = { "version": _handle_version, "add": _handle_add, @@ -992,6 +1204,7 @@ _COMMAND_HANDLERS: dict[str, callable] = { "report": _handle_report, "search": _handle_search, "clean": _handle_clean, + "completion": _handle_completion, } diff --git a/sbackup/completion.py b/sbackup/completion.py new file mode 100644 index 0000000..6491592 --- /dev/null +++ b/sbackup/completion.py @@ -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() diff --git a/sbackup/compression.py b/sbackup/compression.py index dd987ee..214cc4c 100644 --- a/sbackup/compression.py +++ b/sbackup/compression.py @@ -134,44 +134,52 @@ def _resolve_name(template: str, folder_name: str) -> str: def _encrypt_file(file_path: str, password: str) -> str: - """用密码加密文件(PBKDF2 + XOR 流密码),返回加密后的文件路径""" + """用密码加密文件(PBKDF2 + XOR 流密码 + HMAC 完整性校验),返回加密后的文件路径""" import hashlib salt = os.urandom(16) - key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000) + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000) encrypted_path = file_path + ".enc" - with open(file_path, "rb") as fin, open(encrypted_path, "wb") as fout: - fout.write(salt) - key_stream = bytearray() - block_index = 0 - while True: - chunk = fin.read(65536) - if not chunk: - break - # 生成密钥流 - while len(key_stream) < len(chunk): - block_index += 1 - key_stream.extend( - hashlib.sha256(key + block_index.to_bytes(4, "big")).digest() + tmp_path = encrypted_path + ".tmp" + try: + with open(file_path, "rb") as fin, open(tmp_path, "wb") as fout: + fout.write(salt) + key_stream = bytearray() + block_index = 0 + while True: + chunk = fin.read(65536) + if not chunk: + break + while len(key_stream) < len(chunk): + block_index += 1 + key_stream.extend( + hashlib.sha256(key + block_index.to_bytes(4, "big")).digest() + ) + encrypted = bytes( + a ^ b for a, b in zip(chunk, key_stream[: len(chunk)]) ) - # XOR 加密 - encrypted = bytes(a ^ b for a, b in zip(chunk, key_stream[: len(chunk)])) - fout.write(encrypted) - key_stream = key_stream[len(chunk) :] - + fout.write(encrypted) + key_stream = key_stream[len(chunk) :] + # 原子替换到最终路径 + os.replace(tmp_path, encrypted_path) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) os.remove(file_path) return encrypted_path def _decrypt_file(encrypted_path: str, password: str) -> str: - """解密由 _encrypt_file 加密的文件,返回解密后的文件路径""" + """解密由 _encrypt_file 加密的文件,返回解密后的文件路径。密码错误时抛出 ValueError""" import hashlib decrypted_path = encrypted_path.removesuffix(".enc") with open(encrypted_path, "rb") as fin: salt = fin.read(16) - key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000) + if len(salt) < 16: + raise ValueError("Invalid encrypted file: too short") + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000) with open(decrypted_path, "wb") as fout: key_stream = bytearray() @@ -246,11 +254,12 @@ class BaseCompressor: negated.append(pattern[1:]) continue if pattern.startswith("re:"): - # 正则表达式模式 + regex_str = pattern[3:] + # ReDoS 防护:拒绝过长或明显恶意的正则 + if len(regex_str) > 256: + continue try: - if re.search(pattern[3:], rel_path) or re.search( - pattern[3:], basename - ): + if re.search(regex_str, rel_path) or re.search(regex_str, basename): matched = True except re.error: pass @@ -262,10 +271,11 @@ class BaseCompressor: return False for pattern in negated: if pattern.startswith("re:"): + regex_str = pattern[3:] + if len(regex_str) > 256: + continue try: - if re.search(pattern[3:], rel_path) or re.search( - pattern[3:], basename - ): + if re.search(regex_str, rel_path) or re.search(regex_str, basename): return False except re.error: pass @@ -827,6 +837,85 @@ def _match_select_pattern(name: str, pattern: str) -> bool: return fnmatch(name, pattern) or fnmatch(basename, pattern) +def _is_safe_member_name(member_name: str) -> bool: + """检查归档成员名是否安全(无路径遍历、无绝对路径、无空字节)""" + # 拒绝含空字节的路径(可能绕过之后的检查) + if "\x00" in member_name: + return False + # 拒绝绝对路径 + if os.path.isabs(member_name): + return False + # Windows: 拒绝驱动器相对路径(如 C:file.txt) + if os.name == "nt" and len(member_name) >= 2: + if member_name[1] == ":" and member_name[0].isalpha(): + return False + # 拒绝包含 .. 的路径组件 + parts = Path(member_name).parts + if ".." in parts: + return False + # 拒绝空路径 + if not member_name or member_name.strip() in ("", ".", "/"): + return False + return True + + +def _safe_extract_zip( + zf: zipfile.ZipFile, target: Path, members: list[str] | None = None +) -> list[str]: + """安全提取 ZIP 文件,过滤路径遍历成员,返回被跳过的成员列表""" + target_resolved = target.resolve() + skipped = [] + names = members if members is not None else zf.namelist() + for member in names: + if not _is_safe_member_name(member): + skipped.append(member) + continue + # 双重检查:解析后路径必须在目标目录内 + dest = (target / member).resolve() + try: + dest.relative_to(target_resolved) + except ValueError: + skipped.append(member) + continue + zf.extract(member, target) + return skipped + + +def _safe_extract_tar( + tarf: tarfile.TarFile, + target: Path, + members: list[tarfile.TarInfo], + quiet: bool = False, +) -> int: + """安全提取 tar 成员:过滤路径遍历和符号链接,返回跳过的数量""" + target_resolved = target.resolve() + skipped_count = 0 + import sys as _sys + + for member in members: + if not _is_safe_member_name(member.name): + skipped_count += 1 + continue + # 跳过符号链接和硬链接(防止链接攻击) + if member.issym() or member.islnk(): + skipped_count += 1 + continue + # Python < 3.12: 手动验证提取路径在目标目录内 + if _sys.version_info < (3, 12): + dest = (target / member.name).resolve() + try: + dest.relative_to(target_resolved) + except ValueError: + skipped_count += 1 + continue + # Python 3.12+: filter="data" 会处理路径清理和链接目标 + extra_args = {} + if _sys.version_info >= (3, 12): + extra_args["filter"] = "data" + tarf.extract(member, path=str(target), **extra_args) + return skipped_count + + def restore_backup( backup_path: str, target_dir: str, @@ -900,23 +989,22 @@ def restore_backup( with zipfile.ZipFile(backup, "r") as zf: all_members = zf.namelist() members = ( - [m for m in all_members if _match_select_pattern(m, select_pattern)] + [ + m + for m in all_members + if _is_safe_member_name(m) + and _match_select_pattern(m, select_pattern) + ] if select_pattern - else all_members + else [m for m in all_members if _is_safe_member_name(m)] ) if not members: if not quiet: print(t("restore.no_match", pattern=select_pattern)) return {"success": True, "files_count": 0} - with tqdm( - total=len(members), - desc=t("restore.progress"), - unit=t("compress.unit"), - disable=quiet, - ) as pbar: - for member in members: - zf.extract(member, target) - pbar.update(1) + skipped = _safe_extract_zip(zf, target, members) + if skipped and not quiet: + print(t("restore.skipped_unsafe", count=len(skipped))) if not quiet: print(t("restore.success", path=target, count=len(members))) return {"success": True, "files_count": len(members)} @@ -928,10 +1016,24 @@ def restore_backup( szf_kwargs["password"] = password with py7zr.SevenZipFile(**szf_kwargs) as szf: all_members = szf.getnames() + # 过滤路径遍历成员(Zip Slip 防护) + safe_members = [m for m in all_members if _is_safe_member_name(m)] + skipped_count = len(all_members) - len(safe_members) + if skipped_count > 0 and not quiet: + print( + t( + "restore.skipped_unsafe", + count=skipped_count, + ) + ) members = ( - [m for m in all_members if _match_select_pattern(m, select_pattern)] + [ + m + for m in safe_members + if _match_select_pattern(m, select_pattern) + ] if select_pattern - else all_members + else safe_members ) if not members: if not quiet: @@ -975,15 +1077,14 @@ def restore_backup( tmp.seek(0) with tarfile.open(fileobj=tmp, mode="r") as tarf: all_members = tarf.getmembers() - members = ( - [ + if select_pattern: + members = [ m for m in all_members if _match_select_pattern(m.name, select_pattern) ] - if select_pattern - else all_members - ) + else: + members = list(all_members) if not members: if not quiet: print(t("restore.no_match", pattern=select_pattern)) @@ -994,12 +1095,14 @@ def restore_backup( unit=t("compress.unit"), disable=quiet, ) as pbar: - for member in members: - tarf.extract(member, target, filter="data") - pbar.update(1) + skipped = _safe_extract_tar(tarf, target, members) + pbar.update(len(members)) + if skipped and not quiet: + print(t("restore.skipped_unsafe", count=skipped)) + actual = len(members) - skipped if not quiet: - print(t("restore.success", path=target, count=len(members))) - return {"success": True, "files_count": len(members)} + print(t("restore.success", path=target, count=actual)) + return {"success": True, "files_count": actual} elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): mode = "r:gz" elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): @@ -1015,15 +1118,14 @@ def restore_backup( with tarfile.open(backup, mode) as tarf: all_members = tarf.getmembers() - members = ( - [ + if select_pattern: + members = [ m for m in all_members if _match_select_pattern(m.name, select_pattern) ] - if select_pattern - else all_members - ) + else: + members = list(all_members) if not members: if not quiet: print(t("restore.no_match", pattern=select_pattern)) @@ -1034,12 +1136,14 @@ def restore_backup( unit=t("compress.unit"), disable=quiet, ) as pbar: - for member in members: - tarf.extract(member, target, filter="data") - pbar.update(1) + skipped = _safe_extract_tar(tarf, target, members) + pbar.update(len(members)) + if skipped and not quiet: + print(t("restore.skipped_unsafe", count=skipped)) + actual = len(members) - skipped if not quiet: - print(t("restore.success", path=target, count=len(members))) - return {"success": True, "files_count": len(members)} + print(t("restore.success", path=target, count=actual)) + return {"success": True, "files_count": actual} except KeyboardInterrupt: raise except PermissionError: @@ -1339,12 +1443,19 @@ def verify_backup_fast(backup_path: str, expected_sha256: str) -> dict: } -def split_file(file_path: str, chunk_size: int) -> list[str]: +def split_file( + file_path: str, chunk_size: int, *, create_manifest: bool = True +) -> list[str]: """将大文件分割为多个分卷文件 :param file_path: 要分割的文件路径 :param chunk_size: 每个分卷的大小(字节) + :param create_manifest: 是否创建清单文件 :return: 分卷文件路径列表 """ + import hashlib + import json + from datetime import datetime + if chunk_size <= 0: return [file_path] @@ -1352,7 +1463,18 @@ def split_file(file_path: str, chunk_size: int) -> list[str]: if file_size <= chunk_size: return [file_path] + # 计算原文件 SHA256 + original_sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + original_sha256.update(chunk) + original_hash = original_sha256.hexdigest() + parts = [] + part_checksums = [] part_index = 1 with open(file_path, "rb") as f: while True: @@ -1360,31 +1482,175 @@ def split_file(file_path: str, chunk_size: int) -> list[str]: if not chunk: break part_path = f"{file_path}.{part_index:03d}" + # 计算分卷校验和 + part_hash = hashlib.sha256(chunk).hexdigest() + part_checksums.append( + {"path": os.path.basename(part_path), "sha256": part_hash} + ) with open(part_path, "wb") as pf: pf.write(chunk) parts.append(part_path) part_index += 1 + # 创建清单文件 + if create_manifest: + manifest_path = f"{file_path}.manifest.json" + manifest = { + "original_file": os.path.basename(file_path), + "original_size": file_size, + "original_sha256": original_hash, + "chunk_size": chunk_size, + "parts_count": len(parts), + "parts": part_checksums, + "created_at": datetime.now().isoformat(timespec="seconds"), + } + with open(manifest_path, "w", encoding="utf-8") as mf: + json.dump(manifest, mf, indent=2, ensure_ascii=False) + # 删除原文件 os.remove(file_path) return parts -def merge_files(part_paths: list[str], output_path: str) -> bool: +def merge_files( + part_paths: list[str], + output_path: str, + *, + resume: bool = False, + progress_callback=None, +) -> bool: """合并分卷文件为单个文件 :param part_paths: 分卷文件路径列表(按顺序) :param output_path: 输出文件路径 + :param resume: 是否支持断点续传(从上次中断处继续) + :param progress_callback: 进度回调函数 callback(current_bytes, total_bytes) :return: 是否成功 """ + if not part_paths: + return False + + # 计算总大小 + total_size = 0 + for p in part_paths: + try: + total_size += os.path.getsize(p) + except OSError: + return False + + # 断点续传:检查已写入的字节数 + written_bytes = 0 + start_part_idx = 0 + if resume and os.path.exists(output_path): + written_bytes = os.path.getsize(output_path) + # 计算从哪个分卷开始 + accumulated = 0 + for i, p in enumerate(part_paths): + accumulated += os.path.getsize(p) + if accumulated > written_bytes: + start_part_idx = i + # 计算该分卷内已写入的偏移 + prev_accumulated = accumulated - os.path.getsize(p) + part_offset = written_bytes - prev_accumulated + break + else: + # 所有分卷都已写入 + return True + else: + part_offset = 0 + try: - with open(output_path, "wb") as out_f: - for part_path in part_paths: + mode = "ab" if resume and os.path.exists(output_path) else "wb" + with open(output_path, mode) as out_f: + for i in range(start_part_idx, len(part_paths)): + part_path = part_paths[i] with open(part_path, "rb") as in_f: + if i == start_part_idx and part_offset > 0: + in_f.seek(part_offset) while True: chunk = in_f.read(65536) if not chunk: break out_f.write(chunk) + written_bytes += len(chunk) + if progress_callback: + progress_callback(written_bytes, total_size) return True except OSError: return False + + +def verify_split_integrity(file_path: str) -> dict: + """验证分卷文件完整性 + :param file_path: 任意分卷文件路径或清单文件路径 + :return: 验证结果字典 + """ + import hashlib + import json + + # 查找清单文件 + if file_path.endswith(".manifest.json"): + manifest_path = file_path + else: + # 从分卷文件推断清单路径 + base = file_path.rsplit(".", 1)[0] if ".0" in file_path else file_path + manifest_path = f"{base}.manifest.json" + if not os.path.exists(manifest_path): + # 尝试去掉 .001 后缀 + if file_path.endswith(".001"): + manifest_path = f"{file_path[:-4]}.manifest.json" + + if not os.path.exists(manifest_path): + return {"success": False, "error": "Manifest file not found"} + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + except (json.JSONDecodeError, OSError) as e: + return {"success": False, "error": f"Failed to read manifest: {e}"} + + manifest_dir = os.path.dirname(manifest_path) + results = [] + all_ok = True + + for part_info in manifest.get("parts", []): + part_name = part_info["path"] + expected_hash = part_info["sha256"] + part_path = os.path.join(manifest_dir, part_name) + + if not os.path.exists(part_path): + results.append( + {"path": part_name, "status": "missing", "expected": expected_hash} + ) + all_ok = False + continue + + # 计算实际校验和 + sha256 = hashlib.sha256() + with open(part_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha256.update(chunk) + actual_hash = sha256.hexdigest() + + if actual_hash == expected_hash: + results.append({"path": part_name, "status": "ok"}) + else: + results.append( + { + "path": part_name, + "status": "corrupted", + "expected": expected_hash, + "actual": actual_hash, + } + ) + all_ok = False + + return { + "success": all_ok, + "original_file": manifest.get("original_file", ""), + "original_size": manifest.get("original_size", 0), + "parts_count": manifest.get("parts_count", 0), + "results": results, + } diff --git a/sbackup/config.py b/sbackup/config.py index d945af1..4d17330 100644 --- a/sbackup/config.py +++ b/sbackup/config.py @@ -27,7 +27,7 @@ def _load_json_file(config_file: str) -> dict: def _save_json_file(data: dict, config_file: str) -> None: - """将字典写入 JSON 配置文件,自动创建目录""" + """将字典写入 JSON 配置文件,自动创建目录,设置限制性权限""" data_dir = os.path.dirname(config_file) if data_dir: try: @@ -36,11 +36,35 @@ def _save_json_file(data: dict, config_file: str) -> None: logger.error(t("log.config.mkdir.error"), data_dir, e) return + # 原子写入:先写临时文件再替换 + tmp_path = config_file + ".tmp" try: - with open(config_file, "w", encoding="utf-8") as f: + with open(tmp_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=4) + f.flush() + os.fsync(f.fileno()) + # 设置临时文件限制性权限(仅所有者可读写) + if sys.platform != "win32": + try: + os.chmod(tmp_path, 0o600) + except OSError: + pass + os.replace(tmp_path, config_file) except OSError as e: logger.error(t("log.config.write.error"), config_file, e) + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + return + + # 设置限制性文件权限(仅所有者可读写) + if sys.platform != "win32": + try: + os.chmod(config_file, 0o600) + except OSError: + pass # 权限设置失败不影响主流程 def get_default_data_file() -> str: @@ -101,6 +125,15 @@ class Config: webdav_password: str = "" webdav_remote_path: str = "/" webdav_enabled: bool = False + # SMTP 邮件通知配置 + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_to: str = "" + smtp_tls: bool = True + smtp_enabled: bool = False def load_config(config_file: str = "config.json") -> Config: @@ -141,6 +174,22 @@ def load_config(config_file: str = "config.json") -> Config: if webhook_url_legacy and not webhook_urls: webhook_urls = [webhook_url_legacy] webdav_config = config_data.get("webdav", {}) + smtp_config = config_data.get("smtp", {}) + + # 验证端口范围(1-65535) + sftp_port = sftp_config.get("port", 22) + if not isinstance(sftp_port, int) or not (1 <= sftp_port <= 65535): + logger.warning( + "SFTP port %s is out of range (1-65535), using default 22", sftp_port + ) + sftp_port = 22 + + smtp_port = smtp_config.get("port", 587) + if not isinstance(smtp_port, int) or not (1 <= smtp_port <= 65535): + logger.warning( + "SMTP port %s is out of range (1-65535), using default 587", smtp_port + ) + smtp_port = 587 return Config( folder_path="", @@ -158,7 +207,7 @@ def load_config(config_file: str = "config.json") -> Config: webhook_template=webhook_template, webhook_retries=webhook_retries, sftp_host=sftp_config.get("host", ""), - sftp_port=sftp_config.get("port", 22), + sftp_port=sftp_port, sftp_user=sftp_config.get("user", ""), sftp_password=sftp_config.get("password", ""), sftp_key_file=sftp_config.get("key_file", ""), @@ -170,6 +219,14 @@ def load_config(config_file: str = "config.json") -> Config: webdav_password=webdav_config.get("password", ""), webdav_remote_path=webdav_config.get("remote_path", "/"), webdav_enabled=webdav_config.get("enabled", False), + smtp_host=smtp_config.get("host", ""), + smtp_port=smtp_port, + smtp_user=smtp_config.get("user", ""), + smtp_password=smtp_config.get("password", ""), + smtp_from=smtp_config.get("from", ""), + smtp_to=smtp_config.get("to", ""), + smtp_tls=smtp_config.get("tls", True), + smtp_enabled=smtp_config.get("enabled", False), ) @@ -277,6 +334,7 @@ _SENSITIVE_FIELDS = [ ("sftp", "password"), ("sftp", "key_passphrase"), ("webdav", "password"), + ("smtp", "password"), ] @@ -284,28 +342,46 @@ def _derive_key(master_password: str, salt: bytes) -> bytes: """从主密码派生加密密钥""" import hashlib - return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 100_000) + return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 600_000) def _encrypt_value(value: str, key: bytes) -> str: - """加密单个字符串值,返回 base64 编码的 salt+密文""" + """加密单个字符串值,返回 base64 编码的 salt+密文+HMAC(完整性校验)""" import base64 + import hashlib + import hmac as _hmac salt = os.urandom(16) derived = _derive_key(key.hex(), salt) encrypted = bytes( a ^ b for a, b in zip(value.encode("utf-8"), derived[: len(value)]) ) - return base64.b64encode(salt + encrypted).decode("ascii") + # 添加 HMAC 完整性校验(使用派生密钥的后 32 字节作为 HMAC 密钥) + hmac_key = hashlib.sha256(key + b"hmac_key").digest() + mac = _hmac.new(hmac_key, salt + encrypted, "sha256").digest() + return base64.b64encode(salt + encrypted + mac).decode("ascii") def _decrypt_value(encrypted_value: str, key: bytes) -> str: - """解密单个字符串值""" + """解密单个字符串值(验证 HMAC 完整性)""" import base64 + import hashlib + import hmac as _hmac data = base64.b64decode(encrypted_value) + # 提取 HMAC(最后 32 字节) + if len(data) < 48: # 16 (salt) + 0 (min ciphertext) + 32 (hmac) + raise ValueError("Invalid encrypted data: too short") salt = data[:16] - ciphertext = data[16:] + mac_received = data[-32:] + ciphertext = data[16:-32] + + # 验证 HMAC 完整性 + hmac_key = hashlib.sha256(key + b"hmac_key").digest() + mac_expected = _hmac.new(hmac_key, salt + ciphertext, "sha256").digest() + if not _hmac.compare_digest(mac_received, mac_expected): + raise ValueError("HMAC verification failed: data may have been tampered with") + derived = _derive_key(key.hex(), salt) decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)])) return decrypted.decode("utf-8") @@ -319,10 +395,18 @@ def encrypt_config(master_password: str, config_file: str = "config.json") -> bo if not data: return False - import hashlib + import base64 - # 用主密码的哈希作为加密密钥 - key = hashlib.sha256(master_password.encode("utf-8")).digest() + # 读取或生成盐值(存储在配置中以备解密) + salt_b64 = data.get("_key_salt") + if salt_b64: + salt = base64.b64decode(salt_b64) + else: + salt = os.urandom(16) + data["_key_salt"] = base64.b64encode(salt).decode("ascii") + + # 使用 PBKDF2 派生加密密钥(600K 迭代) + key = _derive_key(master_password, salt) for field_path in _SENSITIVE_FIELDS: obj = data @@ -346,9 +430,14 @@ def decrypt_config(master_password: str, config_file: str = "config.json") -> bo if not data or not data.get("_encrypted"): return True # 未加密,视为成功 - import hashlib + import base64 - key = hashlib.sha256(master_password.encode("utf-8")).digest() + salt_b64 = data.get("_key_salt") + if not salt_b64: + return False # 缺少盐值,无法解密 + salt = base64.b64decode(salt_b64) + + key = _derive_key(master_password, salt) try: for field_path in _SENSITIVE_FIELDS: @@ -417,6 +506,12 @@ def parse_gitignore(gitignore_path: str) -> list[str]: if not os.path.isfile(gitignore_path): return [] + # 安全检查:确保路径在当前工作目录内 + cwd = os.path.realpath(os.getcwd()) + real_path = os.path.realpath(gitignore_path) + if os.path.commonpath([cwd, real_path]) != cwd: + raise ValueError("gitignore path must be within the current working directory") + patterns = [] try: with open(gitignore_path, "r", encoding="utf-8") as f: diff --git a/sbackup/handlers.py b/sbackup/handlers.py index e7236e3..a0a1d2b 100644 --- a/sbackup/handlers.py +++ b/sbackup/handlers.py @@ -2,11 +2,38 @@ import getpass import logging +import os +import sys from sbackup.i18n import t logger = logging.getLogger(__name__) +def _validate_remote_filename(filename: str) -> str | None: + """验证远程文件名安全性,返回净化后的文件名或 None(不安全时) + 只允许纯文件名,拒绝路径分隔符和 .. 序列 + """ + if not filename: + return None + # 拒绝 null 字节注入 + if "\x00" in filename: + return None + # 拒绝路径分隔符 + if "/" in filename or "\\" in filename: + return None + # 拒绝 .. 序列 + if ".." in filename: + return None + # 拒绝绝对路径 + if os.path.isabs(filename): + return None + # 只取 basename(防御性编程) + safe_name = os.path.basename(filename) + if not safe_name or safe_name in (".", ".."): + return None + return safe_name + + def _resolve_sftp_auth( key_file: str, key_passphrase: str, @@ -84,6 +111,18 @@ def handle_sftp(args, config) -> int: user = args.user or input(t("cli.prompt.sftp.user") + " ") key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ") + # 警告:通过 CLI 传递密码会暴露在进程列表中 + if args.password: + print( + t("warn.password_in_cli", arg="--password"), + file=sys.stderr, + ) + if args.key_passphrase: + print( + t("warn.password_in_cli", arg="--key-passphrase"), + file=sys.stderr, + ) + key_file, key_passphrase, password = _resolve_sftp_auth( key_file_input, args.key_passphrase or "", @@ -153,6 +192,12 @@ def handle_webdav(args, config) -> int: # 交互式配置 url = args.url or input(t("cli.prompt.webdav.url") + " ") user = args.user or input(t("cli.prompt.webdav.user") + " ") + # 警告:通过 CLI 传递密码会暴露在进程列表中 + if args.password: + print( + t("warn.password_in_cli", arg="--password"), + file=sys.stderr, + ) password = args.password or getpass.getpass( t("cli.prompt.webdav.password") + " " ) @@ -265,7 +310,11 @@ def _handle_remote_sftp(args, config) -> int: return 0 elif args.remote_action == "rm": - remote_path = config.sftp_remote_path.rstrip("/") + "/" + args.filename + safe_name = _validate_remote_filename(args.filename) + if not safe_name: + print(t("err.invalid_filename", filename=args.filename)) + return 1 + remote_path = config.sftp_remote_path.rstrip("/") + "/" + safe_name client.delete_remote_file(remote_path) print(t("cmd.remote.deleted", path=remote_path)) return 0 @@ -315,7 +364,11 @@ def _handle_remote_webdav(args, config) -> int: return 0 elif args.remote_action == "rm": - remote_path = config.webdav_remote_path.rstrip("/") + "/" + args.filename + safe_name = _validate_remote_filename(args.filename) + if not safe_name: + print(t("err.invalid_filename", filename=args.filename)) + return 1 + remote_path = config.webdav_remote_path.rstrip("/") + "/" + safe_name client.delete_remote_file(remote_path) print(t("cmd.remote.deleted", path=remote_path)) return 0 diff --git a/sbackup/i18n.py b/sbackup/i18n.py index 0c90164..f2afdde 100644 --- a/sbackup/i18n.py +++ b/sbackup/i18n.py @@ -34,6 +34,41 @@ def set_locale(lang: str) -> None: 设置当前语言环境并加载对应的翻译文件 """ global _current_locale, _translations + + # 安全检查:拒绝包含路径遍历字符的语言代码 + if not lang or "/" in lang or "\\" in lang or ".." in lang or ":" in lang: + logger.warning("i18n.set_locale.invalid_lang: %s", lang) + return + + # 白名单校验:只允许已知的语言代码格式 + _KNOWN_LOCALES = { + "zh_CN", + "en_US", + "fr_FR", + "es_ES", + "ru_RU", + "de_DE", + "ja_JP", + "pt_BR", + "ko_KR", + } + if lang not in _KNOWN_LOCALES: + logger.warning("i18n.set_locale.unknown_locale: %s", lang) + # 仍然设置 _current_locale 以保持向后兼容 + _current_locale = lang + # 回退到默认中文翻译 + default_file = os.path.join(_get_locales_dir(), "zh_CN.json") + if os.path.exists(default_file): + try: + with open(default_file, "r", encoding="utf-8") as f: + _translations = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning( + t("log.i18n.load.fallback_failed", path=default_file, error=e) + ) + _translations = {} + return + _current_locale = lang locale_file = os.path.join(_get_locales_dir(), f"{lang}.json") if os.path.exists(locale_file): diff --git a/sbackup/keychain.py b/sbackup/keychain.py new file mode 100644 index 0000000..32b304a --- /dev/null +++ b/sbackup/keychain.py @@ -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 diff --git a/sbackup/locales/de_DE.json b/sbackup/locales/de_DE.json index 2702f60..02505fc 100644 --- a/sbackup/locales/de_DE.json +++ b/sbackup/locales/de_DE.json @@ -82,6 +82,7 @@ "table.cell.default": "Standard", "restore.progress": "Wiederherstellung", "restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)", + "restore.skipped_unsafe": "{count} unsichere Archivmitglieder übersprungen (Pfadtraversal-Schutz)", "cmd.watch.start": "Überwachung gestartet, Intervall {interval} Min. (Ctrl+C zum Stoppen)", "cli.description": "Sbackup v{version} — Leichtes inkrementelles Backup-Tool\n\nSbackup hilft Ihnen, Multi-Ordner-Backup-Aufgaben einfach zu verwalten.\nEs nutzt eine inkrementelle Backup-Strategie, komprimiert nur geänderte Dateien,\nunterstützt benutzerdefinierte Ausschlussregeln und bietet intuitives Fortschrittsfeedback.\n\nVerfügbare Befehle:\n add Neue Backup-Strategie hinzufügen (Quelle -> Ziel)\n rm Existing Backup-Strategie entfernen\n all Alle konfigurierten Backup-Strategien anzeigen\n save Backup-Aufgaben ausführen\n version Versionsinformationen anzeigen", "cli.epilog": "Beispiele:\n Backup-Strategie hinzufügen:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup mit Verschlüsselung (7z):\n sbackup --format 7z save --password 123456\n\n Wiederherstellen:\n sbackup restore backup.7z F:/restored\n\n SFTP Remote (Auto-Erkennung, empfohlen):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP Remote (Passwort):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP Remote (Schlüssel):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP Remote (Schlüssel + Passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", @@ -116,6 +117,25 @@ "cli.help.list": "Backup-Verlauf anzeigen", "cli.help.verify": "Backup-Datei-Integrität überprüfen", "cli.help.verify.file": "Zu überprüfende Backup-Datei", + "cli.help.verify.fast": "Schnellüberprüfung (historische SHA256 vergleichen, ohne Entpacken)", + "cli.help.verify.all": "Alle historischen Backups batchweise überprüfen", + "cli.help.verify.detail": "Detaillierte Überprüfungsinformationen anzeigen (Größe, Format, Prüfsumme etc.)", + "cli.help.verify.split": "Integrität der geteilten Dateien überprüfen (erfordert Manifest-Datei)", + "cli.help.verify.password": "Entschlüsselungspasswort (für verschlüsselte Backups)", + "cmd.verify.fast_success": "Schnellüberprüfung bestanden: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Schnellüberprüfung fehlgeschlagen: {path}, Datei könnte beschädigt oder manipuliert sein", + "cmd.verify.no_checksum": "Keine historische Prüfsumme gefunden, vollständige Überprüfung: {path}", + "cmd.verify.checksum_mismatch": "Prüfsumme stimmt nicht überein", + "cmd.verify.no_history": "Kein Backup-Verlauf gefunden.", + "cmd.verify.no_file": "Bitte Backup-Dateipfad angeben oder --all verwenden, um alle Backups zu überprüfen.", + "cmd.verify.detail_header": "=== Backup-Details: {path} ===", + "cmd.verify.detail_info": "Größe: {size:.2f} MB | Dateien: {files} | Format: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Überprüft: {path}", + "cmd.verify.batch_failed": "✗ Fehlgeschlagen: {path}", + "cmd.verify.batch_summary": "Batch-Überprüfung abgeschlossen: {success} bestanden, {fail} fehlgeschlagen", + "cmd.verify.split_success": "Integrität der geteilten Dateien bestätigt: {file} ({parts} Teile)", + "cmd.verify.split_failed": "Integritätsprüfung der geteilten Dateien fehlgeschlagen: {file}", + "cmd.verify.split_error": "Fehler bei der Überprüfung geteilter Dateien: {error}", "cli.help.info": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme)", "cli.help.info.file": "Pfad zur Backup-Datei", "cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)", @@ -260,5 +280,13 @@ "err.webdav.not_configured": "WebDAV nicht konfiguriert, führen Sie aus: sbackup webdav config --url --user ", "err.webdav.local_not_found": "Lokale Datei nicht gefunden: {path}", "err.webdav.upload": "Upload zu WebDAV fehlgeschlagen {path}: {error}", - "err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}" + "err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/en_US.json b/sbackup/locales/en_US.json index 40149ad..af7f79d 100644 --- a/sbackup/locales/en_US.json +++ b/sbackup/locales/en_US.json @@ -1,359 +1,388 @@ { - "cmd.add.success": "Backup strategy added successfully: {source} -> {dest}", - "cmd.rm.success": "Backup strategy removed successfully: {path}", - "cmd.edit.success": "Backup strategy edited successfully: {path}", - "cmd.edit.nothing": "No modification parameters specified.", - "err.folder.invalid": "Error: {path} is not a valid folder or does not exist.", - "err.file.not_found": "Error: Backup file {path} does not exist.", - "err.dest.invalid": "Error: Target folder {path} is not a valid folder or does not exist.", - "info.already.added": "Info: {path} has already been added. Please do not duplicate.", - "warn.no.strategy.found": "Warning: Backup strategy for {path} not found.", - "warn.source.missing": "Warning: Source folder does not exist: {path}", - "warn.json.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.", - "warn.json.backup": "Warning: Corrupted data file backed up to {path}.", - "warn.json.renamed": "Warning: Corrupted data file renamed to {path}.", - "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", - "compress.progress": "Compressing", - "compress.unit": "files", - "err.permission": "Permission denied: Cannot write to '{path}'", - "err.os": "OS Error: {error}", - "err.unknown": "Unknown error: {error}", - "err.unknown.format": "Error: Unrecognized backup file format {path}", - "err.argparse.invalid_choice": "invalid choice: ", - "err.argparse.choose_from": "choose from", - "err.argparse.invalid_float": "invalid float value: ", - "err.argparse.invalid_int": "invalid int value: ", - "err.argparse.unrecognized_args": "unrecognized arguments: ", - "err.argparse.required": "required", - "warn.zip.overwrite": "Warning: Overwriting existing backup file {path}", - "warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6", - "cmd.save.uptodate": "All backup strategies are up to date, no backup needed.", - "cmd.save.completed": "Backup completed, processed {count} strategy(s).", - "cmd.all.empty": "No backup strategies configured.", - "cmd.list.empty": "No backup history yet.", - "cmd.verify.checking": "Verifying backup: {path}", - "cmd.verify.success": "Verification passed: {path} ({count} files)", - "cmd.verify.failed": "Verification failed: {path} — expected {expected} files, got {actual}", - "cmd.rm.all.confirm": "Delete all {count} backup strategies? (y/N): ", - "cmd.rm.all.confirm_words": "y,yes", - "cmd.rm.all.success": "Deleted all {count} backup strategies.", - "cmd.rm.all.empty": "No backup strategies to delete.", - "cmd.init.success": "Config file generated: {path}", - "cmd.init.exists": "Config file already exists: {path}, skipping.", - "warn.disk.space": "Warning: Target directory {path} has insufficient space (need {need:.1f} MB, only {free:.1f} MB free)", - "cmd.save.summary": "Backup summary: {total} strategies, {backed} backed up, {skipped} skipped, took {elapsed:.1f}s", - "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", - "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", - "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", - "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", - "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", - "cmd.status.header": "=== Backup Status Dashboard ===", - "cmd.status.strategies": "Total strategies: {count}", - "cmd.status.history": "History records: {count}", - "cmd.status.total_size": "Total backup size: {size:.2f} MB", - "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", - "cmd.status.strategy.header": "--- Strategy Details ---", - "cmd.status.strategy.item": " {source} -> {target}", - "cmd.status.strategy.last_backup": " Last backup: {time}", - "cmd.status.strategy.never": " Last backup: Never", - "cmd.status.strategy.format": " Format: {format}", - "cmd.status.strategy.size": " Target size: {size:.2f} MB", - "log.parallel.backup": "Parallel backup of %d strategies", - "restore.list.title": "Archive contents: {path}", - "restore.list.empty": "Archive is empty: {path}", - "log.ignore.loaded": "Loaded ignore rules file: %s", - "exit.message": "\nExiting.", - "table.header.source": "Source Folder", - "table.header.dest": "Target Folder", - "table.header.ignore": "Ignore Patterns", - "table.cell.none": "None", - "table.header.format": "Format", - "table.header.ratio": "Ratio", - "table.cell.default": "Default", - "restore.progress": "Restoring", - "restore.success": "Restore successful: {path} ({count} files)", - "cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)", - "cli.description": "Sbackup v{version} — Lightweight incremental backup tool\n\nSbackup helps you easily manage multi-folder backup tasks. It uses incremental backup strategy,\nonly compressing modified files, supporting custom ignore rules, and providing intuitive progress feedback.\n\nAvailable commands:\n add Add a new backup strategy (source -> destination)\n rm Remove an existing backup strategy\n all Show all configured backup strategies\n save Execute backup tasks\n version Show version information", - "cli.epilog": "Examples:\n Add backup strategy:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Add strategy with entry-level format:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Remove backup strategy:\n sbackup rm F:/my_folder\n\n View all strategies:\n sbackup all\n\n Execute backup (ZIP):\n sbackup save\n\n Execute backup (tar.gz):\n sbackup --format tar.gz save\n\n Execute backup (7z encrypted):\n sbackup --format 7z save --password 123456\n\n Keep last 5 backups:\n sbackup save --keep 5\n\n Watch backup (every 60 min):\n sbackup watch --interval 60\n\n Watch + cleanup:\n sbackup watch --interval 120 --keep 10\n\n Restore backup:\n sbackup restore backup.7z F:/restored\n\n Chinese interface:\n sbackup --lang zh_CN save\n\n Debug mode:\n sbackup --debug save\n\n SFTP remote backup (auto-detect key, recommended):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remote backup (password auth):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remote backup (specify key):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remote backup (key + passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP watch and upload:\n sbackup watch --interval 60 --sftp", - "cli.help.debug": "Enable debug mode with detailed logging (can be placed after subcommand)", - "cli.help.help": "Show this help message and exit", - "cli.help.lang": "Set interface language: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Set packaging format: zip(default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(encrypted)", - "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", - "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", - "cli.help.checksum": "Use file content SHA256 checksum for change detection (more precise than mtime, but slower)", - "cli.help.subcommands": "Select a command to execute", - "cli.help.add": "Add a new backup strategy", - "cli.help.add.source": "Source folder path: the directory to be backed up", - "cli.help.add.dest": "Target folder path: where backup files will be stored", - "cli.help.add.ignore": "Files or folders to ignore (comma separated, default: .git,__pycache__)", - "cli.help.add.format": "Entry-level packaging format (only for this strategy, defaults to global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", - "cli.help.add.from_gitignore": "Import ignore rules from .gitignore file", - "cmd.add.gitignore_imported": "Imported {count} ignore rules from {path}", - "cmd.add.gitignore_empty": "No rules imported from {path}", - "cli.help.rm": "Remove a backup strategy", - "cli.help.rm.path": "Source folder path of the strategy to remove", - "cli.help.all": "View all backup strategies", - "cli.help.save": "Execute all backup strategies", - "cli.help.save.keep": "Keep the most recent N backup files, 0 to disable cleanup (default: 0)", - "cli.help.save.keep_days": "Keep backups from the last N days, 0 to disable time-based cleanup (default: 0)", - "cli.help.save.password": "Encryption password (7z format only, can also be set in config.json)", - "cli.help.watch": "Run backups periodically", - "cli.help.watch.interval": "Backup interval in minutes, default 60", - "cli.help.watch.keep": "Keep the most recent N backup files (default: 0)", - "cli.help.watch.keep_days": "Keep backups from the last N days (default: 0)", - "cli.help.watch.password": "Encryption password (7z format only)", - "cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", - "cli.help.restore.file": "Backup file path", - "cli.help.restore.dir": "Target directory for restoration", - "cli.help.restore.password": "Decryption password (for restoring encrypted 7z backups)", - "cli.help.restore.list": "List archive contents without extracting", - "cli.help.list": "View backup history", - "cli.help.verify": "Verify backup file integrity", - "cli.help.verify.file": "Backup file to verify", - "cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)", - "cli.help.verify.password": "Decryption password (for encrypted backups)", - "cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}", - "cmd.verify.fast_failed": "Fast verification failed: {path}, file may be corrupted or tampered", - "cmd.verify.no_checksum": "No historical checksum found, falling back to full verify: {path}", - "cmd.verify.checksum_mismatch": "Checksum mismatch", - "cli.help.export": "Export all backup strategies to a JSON file", - "cli.help.export.file": "Export file path (default: sbackup_export.json)", - "cli.help.import": "Import backup strategies from a JSON file", - "cli.help.import.file": "JSON file to import", - "cli.help.status": "View backup status dashboard", - "cli.help.ignore": "Generate .sbackupignore exclude rules file", - "cli.help.ignore.preset": "Preset template: node / python / go / rust / java / general (default: general)", - "cli.help.ignore.output": "Output file path (default: .sbackupignore)", - "cli.help.ignore.list": "List all available presets", - "cmd.ignore.available_presets": "Available presets:", - "cmd.ignore.overwrite": "File {path} already exists, overwrite? (y/N): ", - "cmd.ignore.cancelled": "Cancelled.", - "cmd.ignore.generated": "Generated {preset} preset exclude rules: {path}", - "cli.help.report": "Generate backup report in Markdown format", - "cli.help.report.output": "Output file path (prints to terminal if not specified)", - "cli.help.search": "Search filenames in backup files", - "cli.help.search.pattern": "Search pattern (supports glob wildcards, e.g. *.txt)", - "cli.help.search.in": "Specify backup file path (searches all strategies if not specified)", - "cli.help.search.password": "Decryption password (for encrypted backups)", - "cli.help.versions": "View backup version history (grouped by source)", - "cli.help.versions.source": "Specify source directory (shows all if not specified)", - "cmd.versions.header": "=== Backup Version History ===", - "cmd.versions.source": "📁 {source}", - "cmd.versions.empty": "No backup version records yet.", - "cmd.versions.total": "Total {count} versions, {sources} source directories", - "cli.help.schedule": "Scheduled backup configuration management", - "cli.help.schedule.action": "Schedule action: export", - "cli.help.schedule.export": "Export scheduled backup configuration file", - "cli.help.schedule.type": "Schedule type: systemd / crontab (default: systemd)", - "cli.help.schedule.interval": "Backup interval in minutes (default: 60)", - "cli.help.schedule.output": "Output file path (prints to terminal if not specified)", - "cmd.schedule.exported": "Schedule config exported to: {path} ({type})", - "cmd.schedule.unknown_type": "Unknown schedule type: {type}", - "cli.help.webhook_cmd": "Webhook notification management", - "cli.help.webhook_cmd.action": "Webhook action: preset / list", - "cli.help.webhook_cmd.preset": "Set IM notification preset template", - "cli.help.webhook_cmd.preset.name": "Preset name: dingtalk / feishu / wechat", - "cli.help.webhook_cmd.list": "List all available IM notification presets", - "cmd.webhook.preset_set": "Set {name} notification template:\n{template}", - "cmd.webhook.preset_unknown": "Unknown preset: {name}", - "cmd.webhook.available_presets": "Available IM notification presets:", - "cli.help.config_cmd": "Config file management (encrypt/decrypt sensitive fields)", - "cli.help.config_cmd.action": "Action: lock / unlock", - "cli.help.config_cmd.lock": "Encrypt sensitive fields in config file with master password", - "cli.help.config_cmd.lock.password": "Master password (interactive input if not specified)", - "cli.help.config_cmd.unlock": "Decrypt sensitive fields in config file with master password", - "cli.help.config_cmd.unlock.password": "Master password (interactive input if not specified)", - "cmd.config.locked": "Config file encrypted (sensitive fields protected)", - "cmd.config.unlocked": "Config file decrypted", - "cmd.config.already_locked": "Config file is already encrypted", - "cmd.config.not_locked": "Config file is not encrypted", - "cmd.config.enter_password": "Enter master password", - "cmd.config.no_password": "Password cannot be empty", - "cmd.config.wrong_password": "Wrong password, decryption failed", - "cmd.config.lock_failed": "Encryption failed", - "cli.help.clean": "Smart cleanup of backup files across all strategies", - "cli.help.clean.keep": "Keep the most recent N backup files per strategy", - "cli.help.clean.keep_days": "Delete backup files older than N days", - "cli.help.clean.dry_run": "Preview files to be deleted without actually deleting", - "cmd.clean.no_criteria": "Please specify cleanup criteria: --keep N or --keep-days N", - "cmd.clean.nothing": "No backup files to clean up.", - "cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):", - "cmd.clean.summary": "Cleaned up {count} backup file(s).", - "cmd.report.saved": "Backup report saved to: {path}", - "cmd.search.results": "Found {count} matching file(s) in {path}:", - "cmd.search.no_results": "No files matching '{pattern}' found in {path}", - "cmd.search.no_results_global": "No files matching '{pattern}' found in any backup", - "cmd.export.success": "Exported {count} backup strategies to {path}", - "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", - "cmd.import.empty": "No valid backup strategies found in: {path}", - "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", - "cli.help.save.verify": "Verify backup integrity after completion", - "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", - "cli.help.save.webhook": "POST backup results to this URL after completion", - "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", - "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", - "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", - "cli.help.save.split": "Split backup file into chunks (e.g. 100MB, 1GB), auto-splits when exceeded", - "cli.help.save.tag": "Add a tag to this backup (e.g. daily, release)", - "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", - "cli.help.watch.verify": "Verify backup integrity after each backup", - "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", - "cli.help.watch.webhook": "POST backup results to this URL after each backup", - "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", - "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", - "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", - "cli.help.watch.split": "Split backup file into chunks (e.g. 100MB, 1GB)", - "cli.help.watch.tag": "Add a tag to this backup", - "cli.help.init": "Generate default config file template", - "cli.help.rm.all": "Delete all backup strategies", - "cli.help.edit": "Edit an existing backup strategy (only modifies specified fields)", - "cli.help.edit.source": "Source folder path (to locate the strategy to edit)", - "cli.help.edit.dest": "Change target folder path", - "cli.help.edit.ignore": "Change ignore patterns (comma separated)", - "cli.help.edit.format": "Change packaging format: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.edit.name_template": "Change filename template (supports {name} {date} {time} variables)", - "cli.help.info": "View backup file details (format, size, file count, checksum, etc.)", - "cli.help.info.file": "Backup file path", - "cli.help.info.password": "Decryption password (for encrypted 7z backups)", - "cmd.info.header": "=== Backup File Info ===", - "cmd.info.path": "File path: {path}", - "cmd.info.format": "Compression format: {format}", - "cmd.info.size": "File size: {size:.2f} MB", - "cmd.info.files": "File count: {count}", - "cmd.info.time": "Modified: {time}", - "cmd.info.sha256": "SHA256: {checksum}", - "cmd.diff.header": "=== Diff Comparison ===\nBackup file: {backup}", - "cmd.diff.added_header": "\nAdded files ({count}):", - "cmd.diff.removed_header": "\nRemoved files ({count}):", - "cmd.diff.modified_header": "\nPossibly modified files ({count}):", - "cmd.diff.no_changes": "\nNo differences, source matches backup.", - "cmd.diff.summary": "\nDiff summary: +{added} added, -{removed} removed, ~{modified} modified", - "cmd.diff.no_backup": "No backup files found in target directory: {path}", - "cmd.diff.empty_backup": "Backup file is empty: {path}", - "cli.help.diff": "Compare source directory with the latest backup", - "cli.help.diff.source": "Source folder path", - "cli.help.diff.file": "Specify backup file path (auto-finds latest if not specified)", - "cli.help.diff.password": "Decryption password (for encrypted backups)", - "cli.help.remote": "Remote backup file management (list/delete backups on SFTP or WebDAV)", - "cli.help.remote.action": "Remote action: list / rm", - "cli.help.remote.list": "List remote backup files", - "cli.help.remote.rm": "Delete a remote backup file", - "cli.help.remote.rm.filename": "Remote filename to delete", - "cli.help.remote.sftp": "Operate on SFTP server", - "cli.help.remote.webdav": "Operate on WebDAV server", - "cli.help.version": "Show version information", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - "log.config.malformed": "Config file %s is malformed, using defaults", - "log.config.reset": "Config file %s is malformed, resetting to empty", - "log.config.mkdir.error": "Cannot create config directory %s: %s", - "log.config.write.error": "Cannot write config file %s: %s", - "log.data.read": "Reading data file: %s", - "log.data.create": "Data file does not exist, creating: %s", - "log.data.load": "Loading existing data file: %s", - "log.data.write": "Writing data file: %s", - "log.cleanup.delete": "Deleted old backup: %s", - "log.i18n.load.failed": "Failed to load locale {path}: {error}", - "log.i18n.load.fallback_failed": "Failed to load fallback locale {path}: {error}", - "err.sftp.auth": "SFTP authentication failed: {host}, check username and password", - "err.sftp.ssh": "SFTP connection error: {error}", - "err.sftp.connect": "Cannot connect to {host}:{port}: {error}", - "err.sftp.not_connected": "SFTP not connected, call connect() first", - "err.sftp.local_not_found": "Local file not found: {path}", - "err.sftp.upload": "Upload failed {path}: {error}", - "err.sftp.mkdir": "Failed to create remote directory {path}: {error}", - "err.sftp.not_configured": "SFTP not configured, run: sbackup sftp config --host
--user ", - "cmd.sftp.uploading": "Uploading: {file}", - "cmd.sftp.progress": "Upload", - "cmd.sftp.success": "Upload successful: {file}", - "cmd.sftp.parallel_upload": "Uploading {count} files in parallel...", - "cmd.sftp.config_saved": "SFTP configuration saved to config.json", - "cmd.sftp.testing": "Testing connection to {host}...", - "cmd.sftp.test_ok": "SFTP connection test successful!", - "cmd.sftp.test_fail": "SFTP connection test failed, check configuration", - "cli.help.save.sftp": "Upload to SFTP server after backup (auto-detects SSH key or uses password)", - "cli.help.watch.sftp": "Upload to SFTP server after each backup", - "cli.help.sftp": "SFTP remote backup management (supports password and SSH key authentication)", - "cli.help.sftp.action": "SFTP action: config / test", - "cli.help.sftp.config": "Configure SFTP connection (supports password and SSH key authentication)", - "cli.help.sftp.test": "Test if SFTP connection is available", - "cli.help.sftp.host": "SFTP server address (e.g., 192.168.1.100 or example.com)", - "cli.help.sftp.port": "SFTP port number (default: 22)", - "cli.help.sftp.user": "SFTP username", - "cli.help.sftp.password": "SFTP password (password auth only)", - "cli.help.sftp.remote_path": "Remote target path (default: /, creates directory if not exists)", - "cli.prompt.sftp.host": "SFTP server address:", - "cli.prompt.sftp.port": "SFTP port (default 22):", - "cli.prompt.sftp.user": "SFTP username:", - "cli.prompt.sftp.password": "SFTP password:", - "cli.prompt.sftp.remote_path": "Remote path (default /):", - "err.sftp.key_load": "Cannot load private key {path}, check file format (RSA/Ed25519/ECDSA supported)", - "cli.help.sftp.key_file": "SSH private key file path (RSA/Ed25519/ECDSA supported, takes priority over password)", - "cli.help.sftp.key_passphrase": "Private key passphrase (for encrypted keys, can be entered interactively)", - "cli.prompt.sftp.key_file": "SSH private key file path (leave empty for password auth):", - "cli.prompt.sftp.key_passphrase": "Private key passphrase (press Enter to skip):", - "cmd.sftp.using_default_key": "Detected default SSH key, using: {path}", - "cmd.sftp.no_default_key": "No default SSH key found, please configure password or specify key path", - "err.sftp.key_needs_passphrase": "Private key {path} requires a passphrase, enter it or use --key-passphrase", - "err.sftp.wrong_passphrase": "Wrong passphrase, please retry (press Enter to skip private key)", - "cli.help.webdav": "WebDAV remote backup management (supports Jianguoyun, NextCloud, Synology, etc.)", - "cli.help.webdav.action": "WebDAV action: config / test", - "cli.help.webdav.config": "Configure WebDAV connection parameters", - "cli.help.webdav.test": "Test if WebDAV connection is available", - "cli.help.webdav.url": "WebDAV server URL (e.g., https://dav.jianguoyun.com/dav/)", - "cli.help.webdav.user": "WebDAV username", - "cli.help.webdav.password": "WebDAV password", - "cli.help.webdav.remote_path": "Remote target path (default: /, creates directory if not exists)", - "cli.help.save.webdav": "Upload to WebDAV server after backup", - "cli.help.watch.webdav": "Upload to WebDAV server after each backup", - "cli.prompt.webdav.url": "WebDAV server URL:", - "cli.prompt.webdav.user": "WebDAV username:", - "cli.prompt.webdav.password": "WebDAV password:", - "cli.prompt.webdav.remote_path": "Remote path (default /):", - "cmd.webdav.config_saved": "WebDAV configuration saved to config.json", - "cmd.webdav.testing": "Testing WebDAV connection to {url}...", - "cmd.webdav.test_ok": "WebDAV connection test successful!", - "cmd.webdav.uploading": "Uploading to WebDAV: {file}", - "cmd.webdav.success": "WebDAV upload successful: {file}", - "cmd.webdav.parallel_upload": "Uploading {count} files to WebDAV in parallel...", - "err.webdav.auth": "WebDAV authentication failed: {host}, check username and password", - "err.webdav.connect": "Cannot connect to WebDAV: {url}, error: {error}", - "err.webdav.ssh": "WebDAV connection error: {error}", - "err.webdav.not_configured": "WebDAV not configured, run: sbackup webdav config --url
--user ", - "err.webdav.local_not_found": "Local file not found: {path}", - "err.webdav.upload": "Upload to WebDAV failed {path}: {error}", - "err.webdav.mkdir": "Failed to create WebDAV remote directory {path}: {error}", - "cli.help.remote": "Remote backup file management (list/delete backup files on remote server)", - "cli.help.remote.action": "Remote action: list / rm", - "cli.help.remote.list": "List backup files on remote server", - "cli.help.remote.rm": "Delete a backup file on remote server", - "cli.help.remote.rm.filename": "Remote file name to delete", - "cli.help.remote.sftp": "Use SFTP protocol", - "cli.help.remote.webdav": "Use WebDAV protocol", - "cmd.remote.empty": "Remote directory is empty: {path}", - "cmd.remote.list_header": "Remote directory: {path} ({count} files)", - "cmd.remote.deleted": "Deleted remote file: {path}", - "cmd.remote.select_protocol": "Please specify protocol: --sftp or --webdav", - "cmd.remote.rm_requires_path": "Please specify the remote filename to delete", - "cli.help.remote.list_path": "Specify remote directory path (uses configured default if not specified)", - "err.sftp.remote_not_found": "Remote file not found: {path}", - "err.sftp.delete": "Failed to delete remote file {path}: {error}", - "err.webdav.not_found": "Remote file not found: {path}", - "err.webdav.delete": "Failed to delete remote file {path}: {error}", - "err.webdav.list": "Failed to list remote files {path}: {error}", - "restore.no_match": "No files matching \"{pattern}\"", - "cli.help.restore.select": "Selective restore: extract only files matching pattern (supports glob wildcards, e.g. \"*.py\" or \"src/main.py\")", - "cli.help.schedule.type.schtasks": "Windows Task Scheduler", - "cmd.schedule.schtasks.generated": "Windows Task Scheduler command generated", - "cmd.hook.pre_running": "Running pre-backup hook: {command}", - "cmd.hook.post_running": "Running post-backup hook: {command}", - "cmd.hook.failed": "Hook execution failed: {command} — {error}", - "cli.help.save.pre_hook": "Command to run before backup (can be used multiple times)", - "cli.help.save.post_hook": "Command to run after backup (can be used multiple times)", - "cli.help.watch.pre_hook": "Command to run before 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.missing": "Error: split part missing, expected {path}", - "restore.split.merged": "Split parts merged: {path}" -} \ No newline at end of file + "cmd.add.success": "Backup strategy added successfully: {source} -> {dest}", + "cmd.rm.success": "Backup strategy removed successfully: {path}", + "cmd.edit.success": "Backup strategy edited successfully: {path}", + "cmd.edit.nothing": "No modification parameters specified.", + "err.folder.invalid": "Error: {path} is not a valid folder or does not exist.", + "err.file.not_found": "Error: Backup file {path} does not exist.", + "err.dest.invalid": "Error: Target folder {path} is not a valid folder or does not exist.", + "info.already.added": "Info: {path} has already been added. Please do not duplicate.", + "warn.no.strategy.found": "Warning: Backup strategy for {path} not found.", + "warn.source.missing": "Warning: Source folder does not exist: {path}", + "warn.json.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.", + "warn.json.backup": "Warning: Corrupted data file backed up to {path}.", + "warn.json.renamed": "Warning: Corrupted data file renamed to {path}.", + "warn.password_in_cli": "Warning: Password provided via {arg} is visible in the process list. Consider using interactive input instead.", + "warn.webdav.http_plaintext": "WARNING: Using plain HTTP for WebDAV ({url}) -- credentials will be sent in cleartext. Use HTTPS when possible.", + "compress.success": "Backup successful: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.progress": "Compressing", + "compress.unit": "files", + "err.permission": "Permission denied: Cannot write to '{path}'", + "err.os": "OS Error: {error}", + "err.unknown": "Unknown error: {error}", + "err.unknown.format": "Error: Unrecognized backup file format {path}", + "err.argparse.invalid_choice": "invalid choice: ", + "err.argparse.choose_from": "choose from", + "err.argparse.invalid_float": "invalid float value: ", + "err.argparse.invalid_int": "invalid int value: ", + "err.argparse.unrecognized_args": "unrecognized arguments: ", + "err.argparse.required": "required", + "err.lock.conflict": "Error: Another backup operation is already running (lock file exists). Use 'sbackup clean --force-lock' to remove a stale lock.", + "warn.zip.overwrite": "Warning: Overwriting existing backup file {path}", + "warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6", + "cmd.save.uptodate": "All backup strategies are up to date, no backup needed.", + "cmd.save.completed": "Backup completed, processed {count} strategy(s).", + "cmd.all.empty": "No backup strategies configured.", + "cmd.list.empty": "No backup history yet.", + "cmd.verify.checking": "Verifying backup: {path}", + "cmd.verify.success": "Verification passed: {path} ({count} files)", + "cmd.verify.failed": "Verification failed: {path} — expected {expected} files, got {actual}", + "cmd.rm.all.confirm": "Delete all {count} backup strategies? (y/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.success": "Deleted all {count} backup strategies.", + "cmd.rm.all.empty": "No backup strategies to delete.", + "cmd.init.success": "Config file generated: {path}", + "cmd.init.exists": "Config file already exists: {path}, skipping.", + "warn.disk.space": "Warning: Target directory {path} has insufficient space (need {need:.1f} MB, only {free:.1f} MB free)", + "cmd.save.summary": "Backup summary: {total} strategies, {backed} backed up, {skipped} skipped, took {elapsed:.1f}s", + "cmd.upload.parallel": "Uploading to SFTP and WebDAV in parallel...", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.history": "History records: {count}", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "log.parallel.backup": "Parallel backup of %d strategies", + "restore.list.title": "Archive contents: {path}", + "restore.list.empty": "Archive is empty: {path}", + "log.ignore.loaded": "Loaded ignore rules file: %s", + "exit.message": "\nExiting.", + "table.header.source": "Source Folder", + "table.header.dest": "Target Folder", + "table.header.ignore": "Ignore Patterns", + "table.cell.none": "None", + "table.header.format": "Format", + "table.header.ratio": "Ratio", + "table.cell.default": "Default", + "restore.progress": "Restoring", + "restore.success": "Restore successful: {path} ({count} files)", + "restore.skipped_unsafe": "Skipped {count} unsafe archive members (path traversal protection)", + "err.invalid_filename": "Invalid filename: {filename} (path separators and .. sequences not allowed)", + "err.sftp.no_host_key": "Unable to get host key for {host}", + "err.sftp.host_key_mismatch": "Host key for {host} does not match known_hosts (possible MITM attack)", + "warn.sftp.no_known_hosts": "Warning: No known_hosts file found, cannot verify host key for {host}, connection may be insecure", + "err.sftp.mkdir_max_depth": "Remote directory too deep or loop detected: {path}", + "err.webdav.xml_too_large": "WebDAV response too large ({size} bytes), possible XML bomb attack", + "cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)", + "cli.description": "Sbackup v{version} — Lightweight incremental backup tool\n\nSbackup helps you easily manage multi-folder backup tasks. It uses incremental backup strategy,\nonly compressing modified files, supporting custom ignore rules, and providing intuitive progress feedback.\n\nAvailable commands:\n add Add a new backup strategy (source -> destination)\n rm Remove an existing backup strategy\n all Show all configured backup strategies\n save Execute backup tasks\n version Show version information", + "cli.epilog": "Examples:\n Add backup strategy:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Add strategy with entry-level format:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Remove backup strategy:\n sbackup rm F:/my_folder\n\n View all strategies:\n sbackup all\n\n Execute backup (ZIP):\n sbackup save\n\n Execute backup (tar.gz):\n sbackup --format tar.gz save\n\n Execute backup (7z encrypted):\n sbackup --format 7z save --password 123456\n\n Keep last 5 backups:\n sbackup save --keep 5\n\n Watch backup (every 60 min):\n sbackup watch --interval 60\n\n Watch + cleanup:\n sbackup watch --interval 120 --keep 10\n\n Restore backup:\n sbackup restore backup.7z F:/restored\n\n Chinese interface:\n sbackup --lang zh_CN save\n\n Debug mode:\n sbackup --debug save\n\n SFTP remote backup (auto-detect key, recommended):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remote backup (password auth):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remote backup (specify key):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remote backup (key + passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP watch and upload:\n sbackup watch --interval 60 --sftp", + "cli.help.debug": "Enable debug mode with detailed logging (can be placed after subcommand)", + "cli.help.help": "Show this help message and exit", + "cli.help.lang": "Set interface language: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.format": "Set packaging format: zip(default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(encrypted)", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.checksum": "Use file content SHA256 checksum for change detection (more precise than mtime, but slower)", + "cli.help.subcommands": "Select a command to execute", + "cli.help.add": "Add a new backup strategy", + "cli.help.add.source": "Source folder path: the directory to be backed up", + "cli.help.add.dest": "Target folder path: where backup files will be stored", + "cli.help.add.ignore": "Files or folders to ignore (comma separated, default: .git,__pycache__)", + "cli.help.add.format": "Entry-level packaging format (only for this strategy, defaults to global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.add.from_gitignore": "Import ignore rules from .gitignore file", + "cmd.add.gitignore_imported": "Imported {count} ignore rules from {path}", + "cmd.add.gitignore_empty": "No rules imported from {path}", + "cli.help.rm": "Remove a backup strategy", + "cli.help.rm.path": "Source folder path of the strategy to remove", + "cli.help.all": "View all backup strategies", + "cli.help.save": "Execute all backup strategies", + "cli.help.save.keep": "Keep the most recent N backup files, 0 to disable cleanup (default: 0)", + "cli.help.save.keep_days": "Keep backups from the last N days, 0 to disable time-based cleanup (default: 0)", + "cli.help.save.password": "Encryption password (7z format only, can also be set in config.json)", + "cli.help.watch": "Run backups periodically", + "cli.help.watch.interval": "Backup interval in minutes, default 60", + "cli.help.watch.keep": "Keep the most recent N backup files (default: 0)", + "cli.help.watch.keep_days": "Keep backups from the last N days (default: 0)", + "cli.help.watch.password": "Encryption password (7z format only)", + "cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", + "cli.help.restore.file": "Backup file path", + "cli.help.restore.dir": "Target directory for restoration", + "cli.help.restore.password": "Decryption password (for restoring encrypted 7z backups)", + "cli.help.restore.list": "List archive contents without extracting", + "cli.help.list": "View backup history", + "cli.help.verify": "Verify backup file integrity", + "cli.help.verify.file": "Backup file to verify", + "cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)", + "cli.help.verify.all": "Batch verify all historical backups", + "cli.help.verify.detail": "Show detailed verification info (size, format, checksum, etc.)", + "cli.help.verify.split": "Verify split file integrity (requires manifest file)", + "cli.help.verify.password": "Decryption password (for encrypted backups)", + "cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Fast verification failed: {path}, file may be corrupted or tampered", + "cmd.verify.no_checksum": "No historical checksum found, falling back to full verify: {path}", + "cmd.verify.checksum_mismatch": "Checksum mismatch", + "cmd.verify.no_history": "No backup history found.", + "cmd.verify.no_file": "Please specify a backup file path or use --all to verify all backups.", + "cmd.verify.detail_header": "=== Backup Details: {path} ===", + "cmd.verify.detail_info": "Size: {size:.2f} MB | Files: {files} | Format: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Verified: {path}", + "cmd.verify.batch_failed": "✗ Failed: {path}", + "cmd.verify.batch_summary": "Batch verification complete: {success} passed, {fail} failed", + "cmd.verify.split_success": "Split integrity verified: {file} ({parts} parts)", + "cmd.verify.split_failed": "Split integrity check failed: {file}", + "cmd.verify.split_error": "Split verification error: {error}", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.status": "View backup status dashboard", + "cli.help.ignore": "Generate .sbackupignore exclude rules file", + "cli.help.ignore.preset": "Preset template: node / python / go / rust / java / general (default: general)", + "cli.help.ignore.output": "Output file path (default: .sbackupignore)", + "cli.help.ignore.list": "List all available presets", + "cmd.ignore.available_presets": "Available presets:", + "cmd.ignore.overwrite": "File {path} already exists, overwrite? (y/N): ", + "cmd.ignore.cancelled": "Cancelled.", + "cmd.ignore.generated": "Generated {preset} preset exclude rules: {path}", + "cli.help.report": "Generate backup report in Markdown format", + "cli.help.report.output": "Output file path (prints to terminal if not specified)", + "cli.help.search": "Search filenames in backup files", + "cli.help.search.pattern": "Search pattern (supports glob wildcards, e.g. *.txt)", + "cli.help.search.in": "Specify backup file path (searches all strategies if not specified)", + "cli.help.search.password": "Decryption password (for encrypted backups)", + "cli.help.versions": "View backup version history (grouped by source)", + "cli.help.versions.source": "Specify source directory (shows all if not specified)", + "cmd.versions.header": "=== Backup Version History ===", + "cmd.versions.source": "📁 {source}", + "cmd.versions.empty": "No backup version records yet.", + "cmd.versions.total": "Total {count} versions, {sources} source directories", + "cli.help.schedule": "Scheduled backup configuration management", + "cli.help.schedule.action": "Schedule action: export", + "cli.help.schedule.export": "Export scheduled backup configuration file", + "cli.help.schedule.type": "Schedule type: systemd / crontab (default: systemd)", + "cli.help.schedule.interval": "Backup interval in minutes (default: 60)", + "cli.help.schedule.output": "Output file path (prints to terminal if not specified)", + "cmd.schedule.exported": "Schedule config exported to: {path} ({type})", + "cmd.schedule.unknown_type": "Unknown schedule type: {type}", + "cli.help.webhook_cmd": "Webhook notification management", + "cli.help.webhook_cmd.action": "Webhook action: preset / list", + "cli.help.webhook_cmd.preset": "Set IM notification preset template", + "cli.help.webhook_cmd.preset.name": "Preset name: dingtalk / feishu / wechat", + "cli.help.webhook_cmd.list": "List all available IM notification presets", + "cmd.webhook.preset_set": "Set {name} notification template:\n{template}", + "cmd.webhook.preset_unknown": "Unknown preset: {name}", + "cmd.webhook.available_presets": "Available IM notification presets:", + "cli.help.config_cmd": "Config file management (encrypt/decrypt sensitive fields)", + "cli.help.config_cmd.action": "Action: lock / unlock", + "cli.help.config_cmd.lock": "Encrypt sensitive fields in config file with master password", + "cli.help.config_cmd.lock.password": "Master password (interactive input if not specified)", + "cli.help.config_cmd.unlock": "Decrypt sensitive fields in config file with master password", + "cli.help.config_cmd.unlock.password": "Master password (interactive input if not specified)", + "cmd.config.locked": "Config file encrypted (sensitive fields protected)", + "cmd.config.unlocked": "Config file decrypted", + "cmd.config.already_locked": "Config file is already encrypted", + "cmd.config.not_locked": "Config file is not encrypted", + "cmd.config.enter_password": "Enter master password", + "cmd.config.no_password": "Password cannot be empty", + "cmd.config.wrong_password": "Wrong password, decryption failed", + "cmd.config.lock_failed": "Encryption failed", + "cli.help.clean": "Smart cleanup of backup files across all strategies", + "cli.help.clean.keep": "Keep the most recent N backup files per strategy", + "cli.help.clean.keep_days": "Delete backup files older than N days", + "cli.help.clean.dry_run": "Preview files to be deleted without actually deleting", + "cmd.clean.no_criteria": "Please specify cleanup criteria: --keep N or --keep-days N", + "cmd.clean.nothing": "No backup files to clean up.", + "cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):", + "cmd.clean.summary": "Cleaned up {count} backup file(s).", + "cli.help.completion": "Generate shell completion script", + "cli.help.completion.shell": "Shell type: bash(default) / zsh / fish / powershell", + "cmd.report.saved": "Backup report saved to: {path}", + "cmd.search.results": "Found {count} matching file(s) in {path}:", + "cmd.search.no_results": "No files matching '{pattern}' found in {path}", + "cmd.search.no_results_global": "No files matching '{pattern}' found in any backup", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.save.split": "Split backup file into chunks (e.g. 100MB, 1GB), auto-splits when exceeded", + "cli.help.save.tag": "Add a tag to this backup (e.g. daily, release)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.split": "Split backup file into chunks (e.g. 100MB, 1GB)", + "cli.help.watch.tag": "Add a tag to this backup", + "cli.help.init": "Generate default config file template", + "cli.help.rm.all": "Delete all backup strategies", + "cli.help.edit": "Edit an existing backup strategy (only modifies specified fields)", + "cli.help.edit.source": "Source folder path (to locate the strategy to edit)", + "cli.help.edit.dest": "Change target folder path", + "cli.help.edit.ignore": "Change ignore patterns (comma separated)", + "cli.help.edit.format": "Change packaging format: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "Change filename template (supports {name} {date} {time} variables)", + "cli.help.info": "View backup file details (format, size, file count, checksum, etc.)", + "cli.help.info.file": "Backup file path", + "cli.help.info.password": "Decryption password (for encrypted 7z backups)", + "cmd.info.header": "=== Backup File Info ===", + "cmd.info.path": "File path: {path}", + "cmd.info.format": "Compression format: {format}", + "cmd.info.size": "File size: {size:.2f} MB", + "cmd.info.files": "File count: {count}", + "cmd.info.time": "Modified: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.diff.header": "=== Diff Comparison ===\nBackup file: {backup}", + "cmd.diff.added_header": "\nAdded files ({count}):", + "cmd.diff.removed_header": "\nRemoved files ({count}):", + "cmd.diff.modified_header": "\nPossibly modified files ({count}):", + "cmd.diff.no_changes": "\nNo differences, source matches backup.", + "cmd.diff.summary": "\nDiff summary: +{added} added, -{removed} removed, ~{modified} modified", + "cmd.diff.no_backup": "No backup files found in target directory: {path}", + "cmd.diff.empty_backup": "Backup file is empty: {path}", + "cli.help.diff": "Compare source directory with the latest backup", + "cli.help.diff.source": "Source folder path", + "cli.help.diff.file": "Specify backup file path (auto-finds latest if not specified)", + "cli.help.diff.password": "Decryption password (for encrypted backups)", + "cli.help.remote": "Remote backup file management (list/delete backups on SFTP or WebDAV)", + "cli.help.remote.action": "Remote action: list / rm", + "cli.help.remote.list": "List remote backup files", + "cli.help.remote.rm": "Delete a remote backup file", + "cli.help.remote.rm.filename": "Remote filename to delete", + "cli.help.remote.sftp": "Operate on SFTP server", + "cli.help.remote.webdav": "Operate on WebDAV server", + "cli.help.version": "Show version information", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "Config file %s is malformed, using defaults", + "log.config.reset": "Config file %s is malformed, resetting to empty", + "log.config.mkdir.error": "Cannot create config directory %s: %s", + "log.config.write.error": "Cannot write config file %s: %s", + "log.data.read": "Reading data file: %s", + "log.data.create": "Data file does not exist, creating: %s", + "log.data.load": "Loading existing data file: %s", + "log.data.write": "Writing data file: %s", + "log.cleanup.delete": "Deleted old backup: %s", + "log.i18n.load.failed": "Failed to load locale {path}: {error}", + "log.i18n.load.fallback_failed": "Failed to load fallback locale {path}: {error}", + "err.sftp.auth": "SFTP authentication failed: {host}, check username and password", + "err.sftp.ssh": "SFTP connection error: {error}", + "err.sftp.connect": "Cannot connect to {host}:{port}: {error}", + "err.sftp.not_connected": "SFTP not connected, call connect() first", + "err.sftp.local_not_found": "Local file not found: {path}", + "err.sftp.upload": "Upload failed {path}: {error}", + "err.sftp.mkdir": "Failed to create remote directory {path}: {error}", + "err.sftp.not_configured": "SFTP not configured, run: sbackup sftp config --host
--user ", + "cmd.sftp.uploading": "Uploading: {file}", + "cmd.sftp.progress": "Upload", + "cmd.sftp.success": "Upload successful: {file}", + "cmd.sftp.parallel_upload": "Uploading {count} files in parallel...", + "cmd.sftp.config_saved": "SFTP configuration saved to config.json", + "cmd.sftp.testing": "Testing connection to {host}...", + "cmd.sftp.test_ok": "SFTP connection test successful!", + "cmd.sftp.test_fail": "SFTP connection test failed, check configuration", + "cli.help.save.sftp": "Upload to SFTP server after backup (auto-detects SSH key or uses password)", + "cli.help.watch.sftp": "Upload to SFTP server after each backup", + "cli.help.sftp": "SFTP remote backup management (supports password and SSH key authentication)", + "cli.help.sftp.action": "SFTP action: config / test", + "cli.help.sftp.config": "Configure SFTP connection (supports password and SSH key authentication)", + "cli.help.sftp.test": "Test if SFTP connection is available", + "cli.help.sftp.host": "SFTP server address (e.g., 192.168.1.100 or example.com)", + "cli.help.sftp.port": "SFTP port number (default: 22)", + "cli.help.sftp.user": "SFTP username", + "cli.help.sftp.password": "SFTP password (password auth only)", + "cli.help.sftp.remote_path": "Remote target path (default: /, creates directory if not exists)", + "cli.prompt.sftp.host": "SFTP server address:", + "cli.prompt.sftp.port": "SFTP port (default 22):", + "cli.prompt.sftp.user": "SFTP username:", + "cli.prompt.sftp.password": "SFTP password:", + "cli.prompt.sftp.remote_path": "Remote path (default /):", + "err.sftp.key_load": "Cannot load private key {path}, check file format (RSA/Ed25519/ECDSA supported)", + "cli.help.sftp.key_file": "SSH private key file path (RSA/Ed25519/ECDSA supported, takes priority over password)", + "cli.help.sftp.key_passphrase": "Private key passphrase (for encrypted keys, can be entered interactively)", + "cli.prompt.sftp.key_file": "SSH private key file path (leave empty for password auth):", + "cli.prompt.sftp.key_passphrase": "Private key passphrase (press Enter to skip):", + "cmd.sftp.using_default_key": "Detected default SSH key, using: {path}", + "cmd.sftp.no_default_key": "No default SSH key found, please configure password or specify key path", + "err.sftp.key_needs_passphrase": "Private key {path} requires a passphrase, enter it or use --key-passphrase", + "err.sftp.wrong_passphrase": "Wrong passphrase, please retry (press Enter to skip private key)", + "cli.help.webdav": "WebDAV remote backup management (supports Jianguoyun, NextCloud, Synology, etc.)", + "cli.help.webdav.action": "WebDAV action: config / test", + "cli.help.webdav.config": "Configure WebDAV connection parameters", + "cli.help.webdav.test": "Test if WebDAV connection is available", + "cli.help.webdav.url": "WebDAV server URL (e.g., https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV username", + "cli.help.webdav.password": "WebDAV password", + "cli.help.webdav.remote_path": "Remote target path (default: /, creates directory if not exists)", + "cli.help.save.webdav": "Upload to WebDAV server after backup", + "cli.help.watch.webdav": "Upload to WebDAV server after each backup", + "cli.prompt.webdav.url": "WebDAV server URL:", + "cli.prompt.webdav.user": "WebDAV username:", + "cli.prompt.webdav.password": "WebDAV password:", + "cli.prompt.webdav.remote_path": "Remote path (default /):", + "cmd.webdav.config_saved": "WebDAV configuration saved to config.json", + "cmd.webdav.testing": "Testing WebDAV connection to {url}...", + "cmd.webdav.test_ok": "WebDAV connection test successful!", + "cmd.webdav.uploading": "Uploading to WebDAV: {file}", + "cmd.webdav.success": "WebDAV upload successful: {file}", + "cmd.webdav.parallel_upload": "Uploading {count} files to WebDAV in parallel...", + "err.webdav.auth": "WebDAV authentication failed: {host}, check username and password", + "err.webdav.connect": "Cannot connect to WebDAV: {url}, error: {error}", + "err.webdav.ssh": "WebDAV connection error: {error}", + "err.webdav.not_configured": "WebDAV not configured, run: sbackup webdav config --url
--user ", + "err.webdav.local_not_found": "Local file not found: {path}", + "err.webdav.upload": "Upload to WebDAV failed {path}: {error}", + "err.webdav.mkdir": "Failed to create WebDAV remote directory {path}: {error}", + "err.webdav.invalid_scheme": "Unsupported URL scheme '{scheme}' -- only http and https are allowed.", + "err.webdav.private_ip": "WebDAV connection to internal/private address blocked for security: {host}", + "cmd.remote.empty": "Remote directory is empty: {path}", + "cmd.remote.list_header": "Remote directory: {path} ({count} files)", + "cmd.remote.deleted": "Deleted remote file: {path}", + "cmd.remote.select_protocol": "Please specify protocol: --sftp or --webdav", + "cmd.remote.rm_requires_path": "Please specify the remote filename to delete", + "cli.help.remote.list_path": "Specify remote directory path (uses configured default if not specified)", + "err.sftp.remote_not_found": "Remote file not found: {path}", + "err.sftp.delete": "Failed to delete remote file {path}: {error}", + "err.webdav.not_found": "Remote file not found: {path}", + "err.webdav.delete": "Failed to delete remote file {path}: {error}", + "err.webdav.list": "Failed to list remote files {path}: {error}", + "restore.no_match": "No files matching \"{pattern}\"", + "cli.help.restore.select": "Selective restore: extract only files matching pattern (supports glob wildcards, e.g. \"*.py\" or \"src/main.py\")", + "cli.help.schedule.type.schtasks": "Windows Task Scheduler", + "cmd.schedule.schtasks.generated": "Windows Task Scheduler command generated", + "cmd.hook.pre_running": "Running pre-backup hook: {command}", + "cmd.hook.post_running": "Running post-backup hook: {command}", + "cmd.hook.failed": "Hook execution failed: {command} — {error}", + "cli.help.save.pre_hook": "Command to run before backup (can be used multiple times)", + "cli.help.save.post_hook": "Command to run after backup (can be used multiple times)", + "cli.help.watch.pre_hook": "Command to run before 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.missing": "Error: split part missing, expected {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" +} diff --git a/sbackup/locales/es_ES.json b/sbackup/locales/es_ES.json index 4300b0b..efcf0f3 100644 --- a/sbackup/locales/es_ES.json +++ b/sbackup/locales/es_ES.json @@ -73,6 +73,7 @@ "table.cell.default": "Predet.", "restore.progress": "Restaurando", "restore.success": "Restauración exitosa: {path} ({count} archivos)", + "restore.skipped_unsafe": "Se omitieron {count} miembros de archivo no seguros (protección contra traversal de ruta)", "cmd.watch.start": "Monitorización iniciada, intervalo {interval} min (pulse Ctrl+C para detener)", "cli.description": "Sbackup v{version} — Herramienta de copia de seguridad incremental ligera\n\nSbackup le ayuda a gestionar fácilmente las copias de seguridad de múltiples carpetas.\nUtiliza una estrategia de copia de seguridad incremental, comprimiendo únicamente los archivos modificados,\nsoportando reglas de exclusión personalizadas y ofreciendo una retroalimentación de progresión intuitiva.\n\nComandos disponibles :\n add Añadir una nueva estrategia de copia de seguridad (fuente -> destino)\n rm Eliminar una estrategia de copia de seguridad existente\n all Mostrar todas las estrategias de copia de seguridad configuradas\n save Ejecutar las tareas de copia de seguridad\n version Mostrar la información de versión", "cli.epilog": "Ejemplos:\n Agregar estrategia de respaldo:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ejecutar respaldo (7z cifrado):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (detección automática, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (contraseña):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (clave privada):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (clave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", @@ -107,6 +108,25 @@ "cli.help.list": "Ver historial de copias de seguridad", "cli.help.verify": "Verificar integridad del archivo de copia de seguridad", "cli.help.verify.file": "Archivo de copia de seguridad a verificar", + "cli.help.verify.fast": "Verificación rápida (comparar SHA256 histórico, sin extracción)", + "cli.help.verify.all": "Verificar todas las copias de seguridad históricas en lote", + "cli.help.verify.detail": "Mostrar información detallada de verificación (tamaño, formato, suma de verificación, etc.)", + "cli.help.verify.split": "Verificar integridad de archivos divididos (requiere archivo de manifiesto)", + "cli.help.verify.password": "Contraseña de descifrado (para copias cifradas)", + "cmd.verify.fast_success": "Verificación rápida aprobada: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Verificación rápida fallida: {path}, el archivo podría estar dañado o manipulado", + "cmd.verify.no_checksum": "No se encontró suma de verificación histórica, verificación completa: {path}", + "cmd.verify.checksum_mismatch": "La suma de verificación no coincide", + "cmd.verify.no_history": "No se encontró historial de copias de seguridad.", + "cmd.verify.no_file": "Especifique la ruta del archivo o use --all para verificar todas las copias.", + "cmd.verify.detail_header": "=== Detalles de copia de seguridad: {path} ===", + "cmd.verify.detail_info": "Tamaño: {size:.2f} MB | Archivos: {files} | Formato: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Verificado: {path}", + "cmd.verify.batch_failed": "✗ Fallido: {path}", + "cmd.verify.batch_summary": "Verificación en lote completada: {success} aprobados, {fail} fallidos", + "cmd.verify.split_success": "Integridad de archivos divididos verificada: {file} ({parts} partes)", + "cmd.verify.split_failed": "Verificación de integridad de archivos divididos fallida: {file}", + "cmd.verify.split_error": "Error de verificación de archivos divididos: {error}", "cli.help.export": "Export all backup strategies to a JSON file", "cli.help.export.file": "Export file path (default: sbackup_export.json)", "cli.help.import": "Import backup strategies from a JSON file", @@ -231,5 +251,13 @@ "cmd.diff.modified_header": "Posiblemente modificados ({count}):", "cmd.diff.no_backup": "No se encontraron archivos de copia de seguridad en {path}", "cmd.diff.empty_backup": "El archivo de copia de seguridad esta vacio: {path}", - "cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias." + "cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias.", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/fr_FR.json b/sbackup/locales/fr_FR.json index bed532f..3e83a9f 100644 --- a/sbackup/locales/fr_FR.json +++ b/sbackup/locales/fr_FR.json @@ -82,6 +82,7 @@ "table.cell.default": "Défaut", "restore.progress": "Restauration en cours", "restore.success": "Restauration réussie : {path} ({count} fichiers)", + "restore.skipped_unsafe": "Restauré {count} membres d'archive non sûrs (protection contre le traversée de chemin)", "cmd.watch.start": "Surveillance démarrée, intervalle {interval} min (appuyez sur Ctrl+C pour arrêter)", "cli.description": "Sbackup v{version} — Outil de sauvegarde incrémentielle léger\n\nSbackup vous aide à gérer facilement les sauvegardes de plusieurs dossiers.\nIl utilise une stratégie de sauvegarde incrémentielle, compressant uniquement les fichiers modifiés,\nsupportant des règles d'exclusion personnalisées et offrant un retour de progression intuitif.\n\nCommandes disponibles :\n add Ajouter une nouvelle stratégie de sauvegarde (source -> destination)\n rm Supprimer une stratégie de sauvegarde existante\n all Afficher toutes les stratégies de sauvegarde configurées\n save Exécuter les tâches de sauvegarde\n version Afficher les informations de version", "cli.epilog": "Exemples:\n Ajouter une stratégie de sauvegarde:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ajouter avec format au niveau de l'entrée:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Supprimer une stratégie:\n sbackup rm F:/my_folder\n\n Voir toutes les stratégies:\n sbackup all\n\n Exécuter la sauvegarde (ZIP):\n sbackup save\n\n Sauvegarde chiffrée (7z):\n sbackup --format 7z save --password 123456\n\n Garder les 5 dernières sauvegardes:\n sbackup save --keep 5\n\n Surveillance (toutes les 60 min):\n sbackup watch --interval 60\n\n Restaurer:\n sbackup restore backup.7z F:/restored\n\n SFTP distant (détection auto, recommandé):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP distant (mot de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP distant (clé privée):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP distant (clé + phrase de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP surveillance et téléchargement:\n sbackup watch --interval 60 --sftp", @@ -116,6 +117,25 @@ "cli.help.list": "Voir l'historique des sauvegardes", "cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde", "cli.help.verify.file": "Fichier de sauvegarde à vérifier", + "cli.help.verify.fast": "Vérification rapide (comparer SHA256 historique, sans extraction)", + "cli.help.verify.all": "Vérifier toutes les sauvegardes historiques en lot", + "cli.help.verify.detail": "Afficher les détails de vérification (taille, format, somme de contrôle, etc.)", + "cli.help.verify.split": "Vérifier l'intégrité des fichiers divisés (nécessite le fichier manifeste)", + "cli.help.verify.password": "Mot de passe de déchiffrement (pour les sauvegardes chiffrées)", + "cmd.verify.fast_success": "Vérification rapide réussie: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Vérification rapide échouée: {path}, le fichier est peut-être corrompu ou altéré", + "cmd.verify.no_checksum": "Aucune somme de contrôle historique trouvée, vérification complète: {path}", + "cmd.verify.checksum_mismatch": "La somme de contrôle ne correspond pas", + "cmd.verify.no_history": "Aucun historique de sauvegarde trouvé.", + "cmd.verify.no_file": "Veuillez spécifier le chemin du fichier ou utiliser --all pour vérifier toutes les sauvegardes.", + "cmd.verify.detail_header": "=== Détails de la sauvegarde: {path} ===", + "cmd.verify.detail_info": "Taille: {size:.2f} MO | Fichiers: {files} | Format: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Vérifié: {path}", + "cmd.verify.batch_failed": "✗ Échoué: {path}", + "cmd.verify.batch_summary": "Vérification en lot terminée: {success} réussis, {fail} échoués", + "cmd.verify.split_success": "Intégrité des fichiers divisés vérifiée: {file} ({parts} parties)", + "cmd.verify.split_failed": "Vérification d'intégrité des fichiers divisés échouée: {file}", + "cmd.verify.split_error": "Erreur de vérification des fichiers divisés: {error}", "cli.help.info": "Voir les détails du fichier de sauvegarde (format, taille, nombre de fichiers, somme de contrôle, etc.)", "cli.help.info.file": "Chemin du fichier de sauvegarde", "cli.help.info.password": "Mot de passe de déchiffrement (pour les sauvegardes 7z chiffrées)", @@ -250,5 +270,13 @@ "err.webdav.not_configured": "WebDAV non configuré, exécutez: sbackup webdav config --url --user ", "err.webdav.local_not_found": "Fichier local introuvable: {path}", "err.webdav.upload": "Échec du téléchargement vers WebDAV {path}: {error}", - "err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path}: {error}" + "err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/ja_JP.json b/sbackup/locales/ja_JP.json index eed4a1a..05fc969 100644 --- a/sbackup/locales/ja_JP.json +++ b/sbackup/locales/ja_JP.json @@ -82,6 +82,7 @@ "table.cell.default": "デフォルト", "restore.progress": "復元中", "restore.success": "復元成功: {path} ({count} ファイル)", + "restore.skipped_unsafe": "安全でないアーカイブメンバー {count} 件をスキップしました(パストラバーサル保護)", "cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)", "cli.description": "Sbackup v{version} — 軽量インクリメンタルバックアップツール\n\nSbackup は複数フォルダのバックアップタスクを簡単に管理します。\nインクリメンタルバックアップ戦略を採用し、変更されたファイルのみを圧縮し、\nカスタム除外ルールをサポートし、直感的な進捗フィードバックを提供します。\n\n使用可能なコマンド:\n add 新しいバックアップ戦略を追加(ソース -> 目標)\n rm 既存のバックアップ戦略を削除\n all 設定済みのすべてのバックアップ戦略を表示\n save バックアップタスクを実行\n version バージョン情報を表示", "cli.epilog": "例:\n バックアップ戦略を追加:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 暗号化バックアップ:\n sbackup --format 7z save --password 123456\n\n 復元:\n sbackup restore backup.7z F:/restored\n\n SFTP リモート (自動検出、推奨):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP リモート (パスワード):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP リモート (キー):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP リモート (キー + パスフレーズ):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", @@ -116,6 +117,25 @@ "cli.help.list": "バックアップ履歴を表示", "cli.help.verify": "バックアップファイルの整合性を検証", "cli.help.verify.file": "検証するバックアップファイル", + "cli.help.verify.fast": "高速検証(履歴SHA256比較、展開不要)", + "cli.help.verify.all": "すべての履歴バックアップを一括検証", + "cli.help.verify.detail": "詳細な検証情報を表示(サイズ、形式、チェックサムなど)", + "cli.help.verify.split": "分割ファイルの整合性を検証(マニフェストファイルが必要)", + "cli.help.verify.password": "復号化パスワード(暗号化バックアップ用)", + "cmd.verify.fast_success": "高速検証成功: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "高速検証失敗: {path}、ファイルが破損または改ざんされている可能性があります", + "cmd.verify.no_checksum": "履歴チェックサムが見つかりません、完全検証にフォールバック: {path}", + "cmd.verify.checksum_mismatch": "チェックサムが一致しません", + "cmd.verify.no_history": "バックアップ履歴が見つかりません。", + "cmd.verify.no_file": "バックアップファイルを指定するか、--allですべてのバックアップを検証してください。", + "cmd.verify.detail_header": "=== バックアップ詳細: {path} ===", + "cmd.verify.detail_info": "サイズ: {size:.2f} MB | ファイル数: {files} | 形式: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ 検証済み: {path}", + "cmd.verify.batch_failed": "✗ 失敗: {path}", + "cmd.verify.batch_summary": "一括検証完了: {success}件成功、{fail}件失敗", + "cmd.verify.split_success": "分割ファイル整合性検証成功: {file} ({parts} パート)", + "cmd.verify.split_failed": "分割ファイル整合性検証失敗: {file}", + "cmd.verify.split_error": "分割ファイル検証エラー: {error}", "cli.help.info": "バックアップファイルの詳細を表示(形式、サイズ、ファイル数、チェックサム)", "cli.help.info.file": "バックアップファイルのパス", "cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)", @@ -260,5 +280,13 @@ "err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>", "err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}", "err.webdav.upload": "WebDAVへのアップロード失敗 {path}: {error}", - "err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}" + "err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/ko_KR.json b/sbackup/locales/ko_KR.json index 9c4b9dc..9ab5094 100644 --- a/sbackup/locales/ko_KR.json +++ b/sbackup/locales/ko_KR.json @@ -82,6 +82,7 @@ "table.cell.default": "기본값", "restore.progress": "복원 중", "restore.success": "복원 성공: {path} ({count} 파일)", + "restore.skipped_unsafe": "안전하지 않은 아카이브 멤버 {count}개 건너뜀 (경로 순회 방지)", "cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)", "cli.description": "Sbackup v{version} — 경량 증분 백업 도구\n\nSbackup는 여러 폴더의 백업 작업을 쉽게 관리합니다.\n증분 백업 전략을 사용하여 변경된 파일만 압축하고,\n사용자 정의 제외 규칙을 지원하며 직관적인 진행 피드백을 제공합니다.\n\n사용 가능한 명령:\n add 새 백업 전략 추가 (원본 -> 대상)\n rm 기존 백업 전략 삭제\n all 구성된 모든 백업 전략 표시\n save 백업 작업 실행\n version 버전 정보 표시", "cli.epilog": "예제:\n 백업 전략 추가:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 암호화 백업:\n sbackup --format 7z save --password 123456\n\n 복원:\n sbackup restore backup.7z F:/restored\n\n SFTP 원격 (자동 감지, 권장):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 원격 (비밀번호):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 원격 (키):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 원격 (키 + 암호문):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", @@ -116,6 +117,25 @@ "cli.help.list": "백업 기록 보기", "cli.help.verify": "백업 파일 무결성 검증", "cli.help.verify.file": "검증할 백업 파일", + "cli.help.verify.fast": "빠른 검증 (SHA256 비교, 압축 해제 불필요)", + "cli.help.verify.all": "모든 백업 기록 일괄 검증", + "cli.help.verify.detail": "상세 검증 정보 표시 (크기, 형식, 체크섬 등)", + "cli.help.verify.split": "분할 파일 무결성 검증 (매니페스트 파일 필요)", + "cli.help.verify.password": "복호화 비밀번호 (암호화된 백업용)", + "cmd.verify.fast_success": "빠른 검증 성공: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "빠른 검증 실패: {path}, 파일이 손상되었거나 변조되었을 수 있습니다", + "cmd.verify.no_checksum": "기록된 체크섬을 찾을 수 없어 전체 검증으로 대체: {path}", + "cmd.verify.checksum_mismatch": "체크섬 불일치", + "cmd.verify.no_history": "백업 기록을 찾을 수 없습니다.", + "cmd.verify.no_file": "백업 파일을 지정하거나 --all로 모든 백업을 검증하세요.", + "cmd.verify.detail_header": "=== 백업 상세: {path} ===", + "cmd.verify.detail_info": "크기: {size:.2f} MB | 파일 수: {files} | 형식: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ 검증 완료: {path}", + "cmd.verify.batch_failed": "✗ 실패: {path}", + "cmd.verify.batch_summary": "일괄 검증 완료: {success}개 성공, {fail}개 실패", + "cmd.verify.split_success": "분할 파일 무결성 검증 성공: {file} ({parts}개 파트)", + "cmd.verify.split_failed": "분할 파일 무결성 검증 실패: {file}", + "cmd.verify.split_error": "분할 파일 검증 오류: {error}", "cli.help.info": "백업 파일 상세 정보 보기 (형식, 크기, 파일 수, 체크섬)", "cli.help.info.file": "백업 파일 경로", "cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)", @@ -260,5 +280,13 @@ "err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>", "err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}", "err.webdav.upload": "WebDAV 업로드 실패 {path}: {error}", - "err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}" + "err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/pt_BR.json b/sbackup/locales/pt_BR.json index 68065dd..295f1c1 100644 --- a/sbackup/locales/pt_BR.json +++ b/sbackup/locales/pt_BR.json @@ -82,6 +82,7 @@ "table.cell.default": "Padrão", "restore.progress": "Restaurando", "restore.success": "Restauração concluída: {path} ({count} arquivos)", + "restore.skipped_unsafe": "Ignorados {count} membros de arquivo inseguros (proteção contra path traversal)", "cmd.watch.start": "Monitoramento iniciado, intervalo {interval} min (Ctrl+C para parar)", "cli.description": "Sbackup v{version} — Ferramenta de backup incremental leve\n\nSbackup ajuda você a gerenciar facilmente backups de múltiplas pastas.\nEle usa uma estratégia de backup incremental, comprimindo apenas arquivos modificados,\nsuportando regras de exclusão personalizadas e oferecendo feedback de progresso intuitivo.\n\nComandos disponíveis:\n add Adicionar nova estratégia de backup (origem -> destino)\n rm Remover estratégia de backup existente\n all Mostrar todas as estratégias de backup configuradas\n save Executar tarefas de backup\n version Mostrar informações de versão", "cli.epilog": "Exemplos:\n Adicionar estratégia de backup:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup criptografado (7z):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (auto-detecção, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (senha):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (chave):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (chave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", @@ -116,6 +117,25 @@ "cli.help.list": "Ver histórico de backups", "cli.help.verify": "Verificar integridade do arquivo de backup", "cli.help.verify.file": "Arquivo de backup para verificar", + "cli.help.verify.fast": "Verificação rápida (comparar SHA256 histórico, sem extração)", + "cli.help.verify.all": "Verificar todos os backups históricos em lote", + "cli.help.verify.detail": "Mostrar informações detalhadas de verificação (tamanho, formato, soma de verificação, etc.)", + "cli.help.verify.split": "Verificar integridade de arquivos divididos (requer arquivo de manifesto)", + "cli.help.verify.password": "Senha de descriptografia (para backups criptografados)", + "cmd.verify.fast_success": "Verificação rápida aprovada: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Verificação rápida falhou: {path}, arquivo pode estar corrompido ou adulterado", + "cmd.verify.no_checksum": "Soma de verificação histórica não encontrada, verificação completa: {path}", + "cmd.verify.checksum_mismatch": "Soma de verificação não corresponde", + "cmd.verify.no_history": "Nenhum histórico de backup encontrado.", + "cmd.verify.no_file": "Especifique o caminho do arquivo ou use --all para verificar todos os backups.", + "cmd.verify.detail_header": "=== Detalhes do backup: {path} ===", + "cmd.verify.detail_info": "Tamanho: {size:.2f} MB | Arquivos: {files} | Formato: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Verificado: {path}", + "cmd.verify.batch_failed": "✗ Falhou: {path}", + "cmd.verify.batch_summary": "Verificação em lote concluída: {success} aprovados, {fail} falharam", + "cmd.verify.split_success": "Integridade de arquivos divididos verificada: {file} ({parts} partes)", + "cmd.verify.split_failed": "Verificação de integridade de arquivos divididos falhou: {file}", + "cmd.verify.split_error": "Erro na verificação de arquivos divididos: {error}", "cli.help.info": "Ver detalhes do arquivo de backup (formato, tamanho, quantidade de arquivos, soma de verificação)", "cli.help.info.file": "Caminho do arquivo de backup", "cli.help.info.password": "Senha de descriptografia (para backups 7z criptografados)", @@ -260,5 +280,13 @@ "err.webdav.not_configured": "WebDAV não configurado, execute: sbackup webdav config --url --user ", "err.webdav.local_not_found": "Arquivo local não encontrado: {path}", "err.webdav.upload": "Upload para WebDAV falhou {path}: {error}", - "err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}" + "err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/ru_RU.json b/sbackup/locales/ru_RU.json index a0688d4..48aee1e 100644 --- a/sbackup/locales/ru_RU.json +++ b/sbackup/locales/ru_RU.json @@ -82,6 +82,7 @@ "table.cell.default": "По умолч.", "restore.progress": "Восстановление", "restore.success": "Восстановление выполнено: {path} ({count} файлов)", + "restore.skipped_unsafe": "Пропущено {count} небезопасных элементов архива (защита от обхода пути)", "cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)", "cli.description": "Sbackup v{version} — Лёгкий инструмент инкрементального резервного копирования\n\nSbackup помогает легко управлять резервным копированием нескольких папок.\nОн использует стратегию инкрементального копирования, сжимая только изменённые файлы,\nподдерживает пользовательские правила исключения и предоставляет наглядный ход выполнения.\n\nДоступные команды:\n add Добавить новую стратегию резервного копирования (источник -> цель)\n rm Удалить существующую стратегию\n all Показать все настроенные стратегии\n save Выполнить резервное копирование\n version Показать информацию о версии", "cli.epilog": "Примеры:\n Добавить стратегию:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Резервное копирование (7z шифрование):\n sbackup --format 7z save --password 123456\n\n Восстановить:\n sbackup restore backup.7z F:/restored\n\n SFTP (авто-детект ключа, рекомендуется):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP (пароль):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP (ключ): \n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP (ключ + фраза):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", @@ -116,6 +117,25 @@ "cli.help.list": "Просмотреть историю резервного копирования", "cli.help.verify": "Проверить целостность файла резервной копии", "cli.help.verify.file": "Файл резервной копии для проверки", + "cli.help.verify.fast": "Быстрая проверка (сравнение SHA256, без распаковки)", + "cli.help.verify.all": "Пакетная проверка всех исторических резервных копий", + "cli.help.verify.detail": "Показать подробную информацию (размер, формат, контрольная сумма и т.д.)", + "cli.help.verify.split": "Проверить целостность разделенных файлов (требуется файл манифеста)", + "cli.help.verify.password": "Пароль расшифровки (для зашифрованных копий)", + "cmd.verify.fast_success": "Быстрая проверка пройдена: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Быстрая проверка не пройдена: {path}, файл может быть поврежден или изменен", + "cmd.verify.no_checksum": "Историческая контрольная сумма не найдена, полная проверка: {path}", + "cmd.verify.checksum_mismatch": "Контрольная сумма не совпадает", + "cmd.verify.no_history": "История резервных копий не найдена.", + "cmd.verify.no_file": "Укажите путь к файлу или используйте --all для проверки всех копий.", + "cmd.verify.detail_header": "=== Детали резервной копии: {path} ===", + "cmd.verify.detail_info": "Размер: {size:.2f} МБ | Файлов: {files} | Формат: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Проверено: {path}", + "cmd.verify.batch_failed": "✗ Ошибка: {path}", + "cmd.verify.batch_summary": "Пакетная проверка завершена: {success} успешно, {fail} с ошибками", + "cmd.verify.split_success": "Целостность разделенных файлов подтверждена: {file} ({parts} частей)", + "cmd.verify.split_failed": "Проверка целостности разделенных файлов не пройдена: {file}", + "cmd.verify.split_error": "Ошибка проверки разделенных файлов: {error}", "cli.help.info": "Просмотреть подробную информацию о файле резервной копии (формат, размер, количество файлов, контрольная сумма)", "cli.help.info.file": "Путь к файлу резервной копии", "cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)", @@ -260,5 +280,13 @@ "err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>", "err.webdav.local_not_found": "Локальный файл не найден: {path}", "err.webdav.upload": "Ошибка загрузки на WebDAV {path}: {error}", - "err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}" + "err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started" } \ No newline at end of file diff --git a/sbackup/locales/zh_CN.json b/sbackup/locales/zh_CN.json index ac35b1d..10adef0 100644 --- a/sbackup/locales/zh_CN.json +++ b/sbackup/locales/zh_CN.json @@ -1,362 +1,391 @@ { - "cmd.add.success": "备份策略添加成功: {source} -> {dest}", - "cmd.rm.success": "备份策略删除成功: {path}", - "cmd.edit.success": "备份策略编辑成功: {path}", - "cmd.edit.nothing": "未指定任何修改参数。", - "err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。", - "err.file.not_found": "错误:备份文件 {path} 不存在。", - "err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。", - "info.already.added": "提示:{path} 已经添加过了,请勿重复添加。", - "warn.no.strategy.found": "警告:未找到 {path} 的备份策略。", - "warn.source.missing": "警告:源文件夹不存在: {path}", - "warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。", - "warn.json.backup": "警告:已备份损坏的数据文件到 {path}", - "warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。", - "compress.success": "成功备份: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} 个文件)", - "compress.progress": "正在压缩", - "compress.unit": "文件", - "err.permission": "权限不足:无法写入 '{path}'", - "err.os": "系统错误:{error}", - "err.unknown": "未知错误:{error}", - "err.unknown.format": "错误:无法识别的备份文件格式 {path}", - "err.argparse.invalid_choice": "无效的选择: ", - "err.argparse.choose_from": "可选值", - "err.argparse.invalid_float": "无效的浮点数值: ", - "err.argparse.invalid_int": "无效的整数值: ", - "err.argparse.unrecognized_args": "未识别的参数: ", - "err.argparse.required": "必需参数", - "warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}", - "warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6", - "cmd.save.uptodate": "所有备份策略均已是最新,无需备份。", - "cmd.save.completed": "备份完成,共处理 {count} 个策略。", - "cmd.all.empty": "没有配置任何备份策略。", - "cmd.list.empty": "暂无备份历史记录。", - "cmd.verify.checking": "正在校验备份文件: {path}", - "cmd.verify.success": "校验通过: {path} ({count} 个文件)", - "cmd.verify.failed": "校验失败: {path} — 预期 {expected} 个文件,实际 {actual} 个", - "cmd.rm.all.confirm": "确定要删除所有 {count} 个备份策略吗?(y/N): ", - "cmd.rm.all.confirm_words": "y,yes,是", - "cmd.rm.all.success": "已删除所有 {count} 个备份策略。", - "cmd.rm.all.empty": "没有可删除的备份策略。", - "cmd.init.success": "配置文件已生成: {path}", - "cmd.init.exists": "配置文件已存在: {path},跳过生成。", - "warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)", - "cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒", - "cmd.dry_run.header": "预览模式(不会实际执行备份):", - "cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB", - "cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB", - "cmd.dry_run.nothing": "没有需要备份的策略(所有策略均已是最新)。", - "cmd.verify.partial_fail": "警告: {count} 个备份文件校验失败!", - "cmd.status.header": "═══ 备份状态仪表盘 ═══", - "cmd.status.strategies": "策略总数: {count}", - "cmd.status.history": "历史记录: {count} 条", - "cmd.status.total_size": "备份总大小: {size:.2f} MB", - "cmd.status.avg_ratio": "平均压缩率: {ratio:.1f}%", - "cmd.status.strategy.header": "─── 策略详情 ───", - "cmd.status.strategy.item": " {source} -> {target}", - "cmd.status.strategy.last_backup": " 上次备份: {time}", - "cmd.status.strategy.never": " 上次备份: 从未备份", - "cmd.status.strategy.format": " 格式: {format}", - "cmd.status.strategy.size": " 目标大小: {size:.2f} MB", - "log.parallel.backup": "并行备份 %d 个策略", - "restore.list.title": "备份内容: {path}", - "restore.list.empty": "备份文件为空: {path}", - "log.ignore.loaded": "已加载忽略规则文件: %s", - "exit.message": "\n已退出。", - "table.header.source": "源文件夹路径", - "table.header.dest": "目标文件夹路径", - "table.header.ignore": "忽略模式", - "table.header.time": "备份时间", - "table.header.size": "大小(MB)", - "table.header.ratio": "压缩率", - "table.header.files": "文件数", - "table.cell.none": "无", - "table.header.format": "格式", - "table.cell.default": "默认", - "restore.progress": "正在还原", - "restore.success": "还原成功: {path} ({count} 个文件)", - "cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)", - "cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息", - "cli.epilog": "示例:\n 添加备份策略:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 添加策略并指定条目级格式:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n 删除备份策略:\n sbackup rm F:/my_folder\n\n 查看所有策略:\n sbackup all\n\n 执行备份 (ZIP):\n sbackup save\n\n 执行备份 (tar.gz):\n sbackup --format tar.gz save\n\n 执行备份 (7z 加密):\n sbackup --format 7z save --password 123456\n\n 保留最近 5 个备份:\n sbackup save --keep 5\n\n 定时备份 (每 60 分钟):\n sbackup watch --interval 60\n\n 定时备份 + 清理:\n sbackup watch --interval 120 --keep 10\n\n 还原备份:\n sbackup restore backup.7z F:/restored\n\n 英文界面:\n sbackup --lang en_US save\n\n 调试模式:\n sbackup --debug save\n\n SFTP 远程备份 (自动检测私钥,推荐):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 远程备份 (密码认证):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 远程备份 (指定私钥):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 远程备份 (私钥 + 密码短语):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP 定时备份并上传:\n sbackup watch --interval 60 --sftp", - "cli.help.debug": "开启调试模式,输出详细的运行日志和状态信息(可放在子命令之后)", - "cli.help.help": "显示此帮助信息并退出", - "cli.help.lang": "设置界面语言: zh_CN(中) / en_US(英) / fr_FR(法) / es_ES(西) / ru_RU(俄) / de_DE(德) / ja_JP(日) / pt_BR(葡) / ko_KR(韩)", - "cli.help.format": "设置打包格式: zip(默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(支持加密)", - "cli.help.follow_symlinks": "跟随符号链接(默认不跟随,避免循环链接)", - "cli.help.incremental": "文件级增量备份(仅压缩实际变化的文件,大幅减少备份时间)", - "cli.help.checksum": "使用文件内容 SHA256 校验和检测变化(比 mtime 更精确,但较慢)", - "cli.help.subcommands": "选择要执行的命令", - "cli.help.add": "添加新的备份策略", - "cli.help.add.source": "源文件夹路径: 需要被备份的目录", - "cli.help.add.dest": "目标文件夹路径: 存放生成的备份文件的目录", - "cli.help.add.ignore": "需要忽略的文件或文件夹名称 (使用逗号分隔,默认: .git,__pycache__)", - "cli.help.add.format": "条目级打包格式(仅作用于该备份策略,不指定则使用全局默认): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.add.name_template": "条目级文件名模板(支持 {name} {date} {time} 变量,不指定则使用全局默认)", - "cli.help.add.from_gitignore": "从 .gitignore 文件导入忽略规则", - "cmd.add.gitignore_imported": "已从 {path} 导入 {count} 条忽略规则", - "cmd.add.gitignore_empty": "未从 {path} 导入任何规则", - "cli.help.rm": "删除备份策略", - "cli.help.rm.path": "需要删除备份策略的源文件夹路径", - "cli.help.all": "查看所有备份策略", - "cli.help.save": "执行所有备份策略", - "cli.help.save.keep": "保留最近 N 个备份文件,0 表示不清理(默认: 0)", - "cli.help.save.keep_days": "保留最近 N 天的备份文件,0 表示不按时间清理(默认: 0)", - "cli.help.save.password": "加密密码(仅 7z 格式支持,也可在 config.json 中配置)", - "cli.help.watch": "定时执行备份", - "cli.help.watch.interval": "备份间隔(分钟),默认 60", - "cli.help.watch.keep": "保留最近 N 个备份文件(默认: 0)", - "cli.help.watch.keep_days": "保留最近 N 天的备份文件(默认: 0)", - "cli.help.watch.password": "加密密码(仅 7z 格式支持)", - "cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", - "cli.help.restore.file": "备份文件路径", - "cli.help.restore.dir": "还原目标目录", - "cli.help.restore.password": "解密密码(还原加密的 7z 备份时使用)", - "cli.help.restore.list": "仅列出备份内容,不解压", - "cli.help.list": "查看备份历史记录", - "cli.help.verify": "校验备份文件完整性", - "cli.help.verify.file": "要校验的备份文件路径", - "cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)", - "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": "校验和不匹配", - "cli.help.export": "导出所有备份策略到 JSON 文件", - "cli.help.export.file": "导出文件路径(默认: sbackup_export.json)", - "cli.help.import": "从 JSON 文件导入备份策略", - "cli.help.import.file": "要导入的 JSON 文件路径", - "cli.help.status": "查看备份状态仪表盘", - "cli.help.ignore": "生成 .sbackupignore 排除规则文件", - "cli.help.ignore.preset": "预设模板: node / python / go / rust / java / general(默认: general)", - "cli.help.ignore.output": "输出文件路径(默认: .sbackupignore)", - "cli.help.ignore.list": "列出所有可用预设", - "cmd.ignore.available_presets": "可用预设:", - "cmd.ignore.overwrite": "文件 {path} 已存在,是否覆盖?(y/N): ", - "cmd.ignore.cancelled": "已取消。", - "cmd.ignore.generated": "已生成 {preset} 预设的排除规则: {path}", - "cli.help.report": "生成 Markdown 格式的备份报告", - "cli.help.report.output": "输出文件路径(不指定则打印到终端)", - "cli.help.search": "在备份文件中搜索文件名", - "cli.help.search.pattern": "搜索模式(支持 glob 通配符,如 *.txt)", - "cli.help.search.in": "指定备份文件路径(不指定则搜索所有策略的最新备份)", - "cli.help.search.password": "解密密码(搜索加密备份时使用)", - "cli.help.versions": "查看备份版本历史(按源目录分组)", - "cli.help.versions.source": "指定源目录路径(不指定则显示全部)", - "cmd.versions.header": "═══ 备份版本历史 ═══", - "cmd.versions.source": "📁 {source}", - "cmd.versions.empty": "暂无备份版本记录。", - "cmd.versions.total": "共 {count} 个版本,{sources} 个源目录", - "cli.help.schedule": "定时调度配置管理", - "cli.help.schedule.action": "调度操作: export(导出配置)", - "cli.help.schedule.export": "导出定时调度配置文件", - "cli.help.schedule.type": "调度类型: systemd / crontab(默认: systemd)", - "cli.help.schedule.interval": "备份间隔(分钟,默认: 60)", - "cli.help.schedule.output": "输出文件路径(不指定则打印到终端)", - "cmd.schedule.exported": "调度配置已导出到: {path} ({type})", - "cmd.schedule.unknown_type": "未知调度类型: {type}", - "cli.help.webhook_cmd": "Webhook 通知管理", - "cli.help.webhook_cmd.action": "Webhook 操作: preset(设置预设) / list(列出预设)", - "cli.help.webhook_cmd.preset": "设置 IM 通知预设模板", - "cli.help.webhook_cmd.preset.name": "预设名称: dingtalk(钉钉) / feishu(飞书) / wechat(企业微信)", - "cli.help.webhook_cmd.list": "列出所有可用的 IM 通知预设", - "cmd.webhook.preset_set": "已设置 {name} 通知模板:\n{template}", - "cmd.webhook.preset_unknown": "未知预设: {name}", - "cmd.webhook.available_presets": "可用 IM 通知预设:", - "cli.help.config_cmd": "配置文件管理(加密/解密敏感字段)", - "cli.help.config_cmd.action": "操作: lock(加密) / unlock(解密)", - "cli.help.config_cmd.lock": "用主密码加密配置文件中的敏感字段", - "cli.help.config_cmd.lock.password": "主密码(不指定则交互式输入)", - "cli.help.config_cmd.unlock": "用主密码解密配置文件中的敏感字段", - "cli.help.config_cmd.unlock.password": "主密码(不指定则交互式输入)", - "cmd.config.locked": "配置文件已加密(敏感字段已保护)", - "cmd.config.unlocked": "配置文件已解密", - "cmd.config.already_locked": "配置文件已经加密过了", - "cmd.config.not_locked": "配置文件未加密", - "cmd.config.enter_password": "请输入主密码", - "cmd.config.no_password": "密码不能为空", - "cmd.config.wrong_password": "密码错误,解密失败", - "cmd.config.lock_failed": "加密失败", - "cli.help.clean": "智能清理所有策略的备份文件", - "cli.help.clean.keep": "每个策略保留最近 N 个备份文件", - "cli.help.clean.keep_days": "删除 N 天前的备份文件", - "cli.help.clean.dry_run": "仅预览将删除的文件,不实际删除", - "cmd.clean.no_criteria": "请指定清理条件: --keep N 或 --keep-days N", - "cmd.clean.nothing": "没有需要清理的备份文件。", - "cmd.clean.dry_run_header": "预览模式(不会实际删除):", - "cmd.clean.summary": "共清理 {count} 个备份文件。", - "cmd.report.saved": "备份报告已保存到: {path}", - "cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:", - "cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件", - "cmd.search.no_results_global": "在所有备份中未找到匹配 '{pattern}' 的文件", - "cmd.export.success": "已导出 {count} 个备份策略到 {path}", - "cmd.import.success": "已导入 {count} 个备份策略,跳过 {skipped} 个已存在的策略", - "cmd.import.empty": "导入文件中没有有效的备份策略: {path}", - "cli.help.save.dry_run": "仅预览将备份的文件,不实际执行压缩", - "cli.help.save.verify": "备份完成后自动校验备份文件完整性", - "cli.help.save.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)", - "cli.help.save.webhook": "备份完成后 POST 结果到此 URL", - "cli.help.save.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)", - "cli.help.save.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)", - "cli.help.save.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)", - "cli.help.save.split": "备份文件分卷大小(如 100MB, 1GB),超过此大小自动分割", - "cli.help.save.tag": "为本次备份添加标签(如 daily、release)", - "cli.help.watch.dry_run": "仅预览将备份的文件,不实际执行压缩", - "cli.help.watch.verify": "每次备份完成后自动校验备份文件完整性", - "cli.help.watch.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)", - "cli.help.watch.webhook": "每次备份完成后 POST 结果到此 URL", - "cli.help.watch.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)", - "cli.help.watch.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)", - "cli.help.watch.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)", - "cli.help.watch.split": "备份文件分卷大小(如 100MB, 1GB)", - "cli.help.watch.tag": "为本次备份添加标签", - "cli.help.init": "生成默认配置文件模板", - "cli.help.rm.all": "删除所有备份策略", - "cli.help.edit": "编辑已有备份策略(仅修改指定的字段)", - "cli.help.edit.source": "源文件夹路径(用于定位要编辑的策略)", - "cli.help.edit.dest": "修改目标文件夹路径", - "cli.help.edit.ignore": "修改忽略模式(逗号分隔)", - "cli.help.edit.format": "修改打包格式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.edit.name_template": "修改文件名模板(支持 {name} {date} {time} 变量)", - "cli.help.info": "查看备份文件详细信息(格式、大小、文件数、校验和等)", - "cli.help.info.file": "备份文件路径", - "cli.help.info.password": "解密密码(加密的 7z 备份使用)", - "cmd.info.header": "═══ 备份文件信息 ═══", - "cmd.info.path": "文件路径: {path}", - "cmd.info.format": "压缩格式: {format}", - "cmd.info.size": "文件大小: {size:.2f} MB", - "cmd.info.files": "文件数量: {count}", - "cmd.info.time": "修改时间: {time}", - "cmd.info.sha256": "SHA256: {checksum}", - "cmd.diff.header": "═══ 差异对比 ═══\n备份文件: {backup}", - "cmd.diff.added_header": "\n新增文件 ({count}):", - "cmd.diff.removed_header": "\n删除文件 ({count}):", - "cmd.diff.modified_header": "\n可能修改的文件 ({count}):", - "cmd.diff.no_changes": "\n无差异,源目录与备份一致。", - "cmd.diff.summary": "\n差异摘要: +{added} 新增, -{removed} 删除, ~{modified} 修改", - "cmd.diff.no_backup": "目标目录中未找到备份文件: {path}", - "cmd.diff.empty_backup": "备份文件为空: {path}", - "cli.help.diff": "对比源目录与最近一次备份的差异", - "cli.help.diff.source": "源文件夹路径", - "cli.help.diff.file": "指定备份文件路径(不指定则自动查找最新备份)", - "cli.help.diff.password": "解密密码(对比加密备份时使用)", - "cli.help.remote": "远程备份文件管理(列出/删除 SFTP 或 WebDAV 上的备份文件)", - "cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)", - "cli.help.remote.list": "列出远程备份文件", - "cli.help.remote.rm": "删除远程备份文件", - "cli.help.remote.rm.filename": "要删除的远程文件名", - "cli.help.remote.sftp": "操作 SFTP 服务器", - "cli.help.remote.webdav": "操作 WebDAV 服务器", - "cli.help.version": "查看版本信息", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - "log.config.malformed": "配置文件 %s 格式错误,使用默认配置", - "log.config.reset": "配置文件 %s 格式错误,重置为空", - "log.config.mkdir.error": "无法创建配置目录 %s: %s", - "log.config.write.error": "无法写入配置文件 %s: %s", - "log.data.read": "读取数据文件: %s", - "log.data.create": "数据文件不存在,创建新文件: %s", - "log.data.load": "加载现有数据文件: %s", - "log.data.write": "写入数据文件: %s", - "log.cleanup.delete": "已删除旧备份: %s", - "log.i18n.load.failed": "无法加载语言包 {path}: {error}", - "log.i18n.load.fallback_failed": "无法加载回退语言包 {path}: {error}", - "err.sftp.auth": "SFTP 认证失败: {host},请检查用户名和密码", - "err.sftp.ssh": "SFTP 连接错误: {error}", - "err.sftp.connect": "无法连接到 {host}:{port}: {error}", - "err.sftp.not_connected": "SFTP 未连接,请先调用 connect()", - "err.sftp.local_not_found": "本地文件不存在: {path}", - "err.sftp.upload": "上传文件失败 {path}: {error}", - "err.sftp.mkdir": "创建远程目录失败 {path}: {error}", - "err.sftp.not_configured": "SFTP 未配置,请先运行: sbackup sftp config --host <地址> --user <用户名>", - "cmd.sftp.uploading": "正在上传: {file}", - "cmd.sftp.progress": "上传进度", - "cmd.sftp.success": "上传成功: {file}", - "cmd.sftp.parallel_upload": "并行上传 {count} 个文件...", - "cmd.sftp.config_saved": "SFTP 配置已保存到 config.json", - "cmd.sftp.testing": "正在测试连接到 {host}...", - "cmd.sftp.test_ok": "SFTP 连接测试成功!", - "cmd.sftp.test_fail": "SFTP 连接测试失败,请检查配置", - "cli.help.save.sftp": "备份完成后上传到 SFTP 服务器(自动检测私钥或使用密码认证)", - "cli.help.watch.sftp": "每次备份后上传到 SFTP 服务器", - "cli.help.sftp": "SFTP 远程备份管理(支持密码认证和 SSH 私钥认证)", - "cli.help.sftp.action": "SFTP 操作: config(配置) / test(测试连接)", - "cli.help.sftp.config": "配置 SFTP 连接参数(支持密码认证和 SSH 私钥认证)", - "cli.help.sftp.test": "测试 SFTP 连接是否可用", - "cli.help.sftp.host": "SFTP 服务器地址(例如: 192.168.1.100 或 example.com)", - "cli.help.sftp.port": "SFTP 端口号(默认: 22)", - "cli.help.sftp.user": "SFTP 用户名", - "cli.help.sftp.password": "SFTP 密码(仅密码认证时使用)", - "cli.help.sftp.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)", - "cli.prompt.sftp.host": "SFTP 服务器地址:", - "cli.prompt.sftp.port": "SFTP 端口 (默认 22):", - "cli.prompt.sftp.user": "SFTP 用户名:", - "cli.prompt.sftp.password": "SFTP 密码:", - "cli.prompt.sftp.remote_path": "远程路径 (默认 /):", - "err.sftp.key_load": "无法加载私钥文件 {path},请确认文件格式正确(支持 RSA/Ed25519/ECDSA)", - "cli.help.sftp.key_file": "SSH 私钥文件路径(支持 RSA/Ed25519/ECDSA,优先于密码认证)", - "cli.help.sftp.key_passphrase": "私钥密码短语(私钥有密码保护时使用,可通过交互式输入)", - "cli.prompt.sftp.key_file": "SSH 私钥文件路径 (留空使用密码认证):", - "cli.prompt.sftp.key_passphrase": "私钥密码短语 (直接回车放弃):", - "cmd.sftp.using_default_key": "检测到默认 SSH 私钥,将使用: {path}", - "cmd.sftp.no_default_key": "未找到默认 SSH 私钥,请配置密码或指定私钥路径", - "err.sftp.key_needs_passphrase": "私钥 {path} 需要密码短语,请输入或使用 --key-passphrase 参数指定", - "err.sftp.wrong_passphrase": "密码短语错误,请重试(直接回车放弃使用私钥)", - "cli.help.webdav": "WebDAV 远程备份管理(支持坚果云、NextCloud、群晖等)", - "cli.help.webdav.action": "WebDAV 操作: config(配置) / test(测试连接)", - "cli.help.webdav.config": "配置 WebDAV 连接参数", - "cli.help.webdav.test": "测试 WebDAV 连接是否可用", - "cli.help.webdav.url": "WebDAV 服务器 URL(例如: https://dav.jianguoyun.com/dav/)", - "cli.help.webdav.user": "WebDAV 用户名", - "cli.help.webdav.password": "WebDAV 密码", - "cli.help.webdav.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)", - "cli.help.save.webdav": "备份完成后上传到 WebDAV 服务器", - "cli.help.watch.webdav": "每次备份后上传到 WebDAV 服务器", - "cli.prompt.webdav.url": "WebDAV 服务器 URL:", - "cli.prompt.webdav.user": "WebDAV 用户名:", - "cli.prompt.webdav.password": "WebDAV 密码:", - "cli.prompt.webdav.remote_path": "远程路径 (默认 /):", - "cmd.webdav.config_saved": "WebDAV 配置已保存到 config.json", - "cmd.webdav.testing": "正在测试 WebDAV 连接到 {url}...", - "cmd.webdav.test_ok": "WebDAV 连接测试成功!", - "cmd.webdav.uploading": "正在上传到 WebDAV: {file}", - "cmd.webdav.success": "WebDAV 上传成功: {file}", - "cmd.webdav.parallel_upload": "并行上传 {count} 个文件到 WebDAV...", - "err.webdav.auth": "WebDAV 认证失败: {host},请检查用户名和密码", - "err.webdav.connect": "无法连接到 WebDAV: {url},错误: {error}", - "err.webdav.ssh": "WebDAV 连接错误: {error}", - "err.webdav.not_configured": "WebDAV 未配置,请先运行: sbackup webdav config --url <地址> --user <用户名>", - "err.webdav.local_not_found": "本地文件不存在: {path}", - "err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}", - "err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}", - "cli.help.remote": "远程备份文件管理(列出/删除远程服务器上的备份文件)", - "cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)", - "cli.help.remote.list": "列出远程服务器上的备份文件", - "cli.help.remote.rm": "删除远程服务器上的备份文件", - "cli.help.remote.rm.filename": "要删除的远程文件名", - "cli.help.remote.sftp": "使用 SFTP 协议", - "cli.help.remote.webdav": "使用 WebDAV 协议", - "cmd.remote.empty": "远程目录为空: {path}", - "cmd.remote.list_header": "远程目录: {path} ({count} 个文件)", - "cmd.remote.deleted": "已删除远程文件: {path}", - "cmd.remote.select_protocol": "请指定协议: --sftp 或 --webdav", - "cmd.remote.rm_requires_path": "请指定要删除的远程文件名", - "cli.help.remote.list_path": "指定远程目录路径(不指定则使用配置的默认路径)", - "err.sftp.remote_not_found": "远程文件不存在: {path}", - "err.sftp.delete": "删除远程文件失败 {path}: {error}", - "err.webdav.not_found": "远程文件不存在: {path}", - "err.webdav.delete": "删除远程文件失败 {path}: {error}", - "err.webdav.list": "列出远程文件失败 {path}: {error}", - "restore.no_match": "没有匹配 \"{pattern}\" 的文件", - "cli.help.restore.select": "选择性还原:仅提取匹配模式的文件(支持 glob 通配符,如 \"*.py\" 或 \"src/main.py\")", - "cli.help.schedule.type.schtasks": "Windows 计划任务", - "cmd.schedule.schtasks.generated": "Windows 计划任务命令已生成", - "cmd.hook.pre_running": "正在执行前置钩子: {command}", - "cmd.hook.post_running": "正在执行后置钩子: {command}", - "cmd.hook.failed": "钩子执行失败: {command} — {error}", - "cli.help.save.pre_hook": "备份前执行的命令(可多次使用)", - "cli.help.save.post_hook": "备份后执行的命令(可多次使用)", - "cli.help.watch.pre_hook": "备份前执行的命令(可多次使用)", - "cli.help.watch.post_hook": "备份后执行的命令(可多次使用)", - "restore.split.detected": "检测到分卷备份,共 {count} 个分卷", - "restore.split.missing": "错误:分卷文件缺失,期望 {path}", - "restore.split.merged": "分卷合并完成: {path}" -} \ No newline at end of file + "cmd.add.success": "备份策略添加成功: {source} -> {dest}", + "cmd.rm.success": "备份策略删除成功: {path}", + "cmd.edit.success": "备份策略编辑成功: {path}", + "cmd.edit.nothing": "未指定任何修改参数。", + "err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。", + "err.file.not_found": "错误:备份文件 {path} 不存在。", + "err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。", + "info.already.added": "提示:{path} 已经添加过了,请勿重复添加。", + "warn.no.strategy.found": "警告:未找到 {path} 的备份策略。", + "warn.source.missing": "警告:源文件夹不存在: {path}", + "warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。", + "warn.json.backup": "警告:已备份损坏的数据文件到 {path}", + "warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。", + "warn.password_in_cli": "警告:通过 {arg} 传递的密码会暴露在进程列表中,建议使用交互式输入。", + "warn.webdav.http_plaintext": "警告:使用明文 HTTP 连接 WebDAV ({url}),凭据将以明文传输。建议使用 HTTPS。", + "compress.success": "成功备份: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} 个文件)", + "compress.progress": "正在压缩", + "compress.unit": "文件", + "err.permission": "权限不足:无法写入 '{path}'", + "err.os": "系统错误:{error}", + "err.unknown": "未知错误:{error}", + "err.unknown.format": "错误:无法识别的备份文件格式 {path}", + "err.argparse.invalid_choice": "无效的选择: ", + "err.argparse.choose_from": "可选值", + "err.argparse.invalid_float": "无效的浮点数值: ", + "err.argparse.invalid_int": "无效的整数值: ", + "err.argparse.unrecognized_args": "未识别的参数: ", + "err.argparse.required": "必需参数", + "err.lock.conflict": "错误:另一个备份操作正在运行(锁文件已存在)。如果确定没有其他进程在运行,请手动删除锁文件。", + "warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}", + "warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6", + "cmd.save.uptodate": "所有备份策略均已是最新,无需备份。", + "cmd.save.completed": "备份完成,共处理 {count} 个策略。", + "cmd.all.empty": "没有配置任何备份策略。", + "cmd.list.empty": "暂无备份历史记录。", + "cmd.verify.checking": "正在校验备份文件: {path}", + "cmd.verify.success": "校验通过: {path} ({count} 个文件)", + "cmd.verify.failed": "校验失败: {path} — 预期 {expected} 个文件,实际 {actual} 个", + "cmd.rm.all.confirm": "确定要删除所有 {count} 个备份策略吗?(y/N): ", + "cmd.rm.all.confirm_words": "y,yes,是", + "cmd.rm.all.success": "已删除所有 {count} 个备份策略。", + "cmd.rm.all.empty": "没有可删除的备份策略。", + "cmd.init.success": "配置文件已生成: {path}", + "cmd.init.exists": "配置文件已存在: {path},跳过生成。", + "warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)", + "cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒", + "cmd.upload.parallel": "正在并行上传到 SFTP 和 WebDAV...", + "cmd.dry_run.header": "预览模式(不会实际执行备份):", + "cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB", + "cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB", + "cmd.dry_run.nothing": "没有需要备份的策略(所有策略均已是最新)。", + "cmd.verify.partial_fail": "警告: {count} 个备份文件校验失败!", + "cmd.status.header": "═══ 备份状态仪表盘 ═══", + "cmd.status.strategies": "策略总数: {count}", + "cmd.status.history": "历史记录: {count} 条", + "cmd.status.total_size": "备份总大小: {size:.2f} MB", + "cmd.status.avg_ratio": "平均压缩率: {ratio:.1f}%", + "cmd.status.strategy.header": "─── 策略详情 ───", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " 上次备份: {time}", + "cmd.status.strategy.never": " 上次备份: 从未备份", + "cmd.status.strategy.format": " 格式: {format}", + "cmd.status.strategy.size": " 目标大小: {size:.2f} MB", + "log.parallel.backup": "并行备份 %d 个策略", + "restore.list.title": "备份内容: {path}", + "restore.list.empty": "备份文件为空: {path}", + "log.ignore.loaded": "已加载忽略规则文件: %s", + "exit.message": "\n已退出。", + "table.header.source": "源文件夹路径", + "table.header.dest": "目标文件夹路径", + "table.header.ignore": "忽略模式", + "table.header.time": "备份时间", + "table.header.size": "大小(MB)", + "table.header.ratio": "压缩率", + "table.header.files": "文件数", + "table.cell.none": "无", + "table.header.format": "格式", + "table.cell.default": "默认", + "restore.progress": "正在还原", + "restore.success": "还原成功: {path} ({count} 个文件)", + "restore.skipped_unsafe": "已跳过 {count} 个不安全的归档成员(路径遍历防护)", + "err.invalid_filename": "无效的文件名: {filename}(不允许路径分隔符和 .. 序列)", + "err.sftp.no_host_key": "无法获取主机 {host} 的密钥", + "err.sftp.host_key_mismatch": "主机 {host} 的密钥与 known_hosts 不匹配(可能遭受中间人攻击)", + "warn.sftp.no_known_hosts": "警告: 未找到 known_hosts 文件,无法验证 {host} 的主机密钥,连接可能不安全", + "err.sftp.mkdir_max_depth": "远程目录层级过深或存在循环: {path}", + "err.webdav.xml_too_large": "WebDAV 响应过大({size} 字节),可能存在 XML 炸弹攻击", + "cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)", + "cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息", + "cli.epilog": "示例:\n 添加备份策略:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 添加策略并指定条目级格式:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n 删除备份策略:\n sbackup rm F:/my_folder\n\n 查看所有策略:\n sbackup all\n\n 执行备份 (ZIP):\n sbackup save\n\n 执行备份 (tar.gz):\n sbackup --format tar.gz save\n\n 执行备份 (7z 加密):\n sbackup --format 7z save --password 123456\n\n 保留最近 5 个备份:\n sbackup save --keep 5\n\n 定时备份 (每 60 分钟):\n sbackup watch --interval 60\n\n 定时备份 + 清理:\n sbackup watch --interval 120 --keep 10\n\n 还原备份:\n sbackup restore backup.7z F:/restored\n\n 英文界面:\n sbackup --lang en_US save\n\n 调试模式:\n sbackup --debug save\n\n SFTP 远程备份 (自动检测私钥,推荐):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 远程备份 (密码认证):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 远程备份 (指定私钥):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 远程备份 (私钥 + 密码短语):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP 定时备份并上传:\n sbackup watch --interval 60 --sftp", + "cli.help.debug": "开启调试模式,输出详细的运行日志和状态信息(可放在子命令之后)", + "cli.help.help": "显示此帮助信息并退出", + "cli.help.lang": "设置界面语言: zh_CN(中) / en_US(英) / fr_FR(法) / es_ES(西) / ru_RU(俄) / de_DE(德) / ja_JP(日) / pt_BR(葡) / ko_KR(韩)", + "cli.help.format": "设置打包格式: zip(默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(支持加密)", + "cli.help.follow_symlinks": "跟随符号链接(默认不跟随,避免循环链接)", + "cli.help.incremental": "文件级增量备份(仅压缩实际变化的文件,大幅减少备份时间)", + "cli.help.checksum": "使用文件内容 SHA256 校验和检测变化(比 mtime 更精确,但较慢)", + "cli.help.subcommands": "选择要执行的命令", + "cli.help.add": "添加新的备份策略", + "cli.help.add.source": "源文件夹路径: 需要被备份的目录", + "cli.help.add.dest": "目标文件夹路径: 存放生成的备份文件的目录", + "cli.help.add.ignore": "需要忽略的文件或文件夹名称 (使用逗号分隔,默认: .git,__pycache__)", + "cli.help.add.format": "条目级打包格式(仅作用于该备份策略,不指定则使用全局默认): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "条目级文件名模板(支持 {name} {date} {time} 变量,不指定则使用全局默认)", + "cli.help.add.from_gitignore": "从 .gitignore 文件导入忽略规则", + "cmd.add.gitignore_imported": "已从 {path} 导入 {count} 条忽略规则", + "cmd.add.gitignore_empty": "未从 {path} 导入任何规则", + "cli.help.rm": "删除备份策略", + "cli.help.rm.path": "需要删除备份策略的源文件夹路径", + "cli.help.all": "查看所有备份策略", + "cli.help.save": "执行所有备份策略", + "cli.help.save.keep": "保留最近 N 个备份文件,0 表示不清理(默认: 0)", + "cli.help.save.keep_days": "保留最近 N 天的备份文件,0 表示不按时间清理(默认: 0)", + "cli.help.save.password": "加密密码(仅 7z 格式支持,也可在 config.json 中配置)", + "cli.help.watch": "定时执行备份", + "cli.help.watch.interval": "备份间隔(分钟),默认 60", + "cli.help.watch.keep": "保留最近 N 个备份文件(默认: 0)", + "cli.help.watch.keep_days": "保留最近 N 天的备份文件(默认: 0)", + "cli.help.watch.password": "加密密码(仅 7z 格式支持)", + "cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", + "cli.help.restore.file": "备份文件路径", + "cli.help.restore.dir": "还原目标目录", + "cli.help.restore.password": "解密密码(还原加密的 7z 备份时使用)", + "cli.help.restore.list": "仅列出备份内容,不解压", + "cli.help.list": "查看备份历史记录", + "cli.help.verify": "校验备份文件完整性", + "cli.help.verify.file": "要校验的备份文件路径", + "cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)", + "cli.help.verify.all": "批量验证所有历史备份", + "cli.help.verify.detail": "显示详细验证信息(大小、格式、校验和等)", + "cli.help.verify.split": "验证分卷文件完整性(需要清单文件)", + "cli.help.verify.password": "解密密码(校验加密备份时使用)", + "cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改", + "cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}", + "cmd.verify.checksum_mismatch": "校验和不匹配", + "cmd.verify.no_history": "没有备份历史记录。", + "cmd.verify.no_file": "请指定备份文件路径或使用 --all 验证所有备份。", + "cmd.verify.detail_header": "═══ 备份详情: {path} ═══", + "cmd.verify.detail_info": "大小: {size:.2f} MB | 文件数: {files} | 格式: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ 校验通过: {path}", + "cmd.verify.batch_failed": "✗ 校验失败: {path}", + "cmd.verify.batch_summary": "批量验证完成: {success} 个通过, {fail} 个失败", + "cmd.verify.split_success": "分卷完整性验证通过: {file} ({parts} 个分卷)", + "cmd.verify.split_failed": "分卷完整性验证失败: {file}", + "cmd.verify.split_error": "分卷验证错误: {error}", + "cli.help.export": "导出所有备份策略到 JSON 文件", + "cli.help.export.file": "导出文件路径(默认: sbackup_export.json)", + "cli.help.import": "从 JSON 文件导入备份策略", + "cli.help.import.file": "要导入的 JSON 文件路径", + "cli.help.status": "查看备份状态仪表盘", + "cli.help.ignore": "生成 .sbackupignore 排除规则文件", + "cli.help.ignore.preset": "预设模板: node / python / go / rust / java / general(默认: general)", + "cli.help.ignore.output": "输出文件路径(默认: .sbackupignore)", + "cli.help.ignore.list": "列出所有可用预设", + "cmd.ignore.available_presets": "可用预设:", + "cmd.ignore.overwrite": "文件 {path} 已存在,是否覆盖?(y/N): ", + "cmd.ignore.cancelled": "已取消。", + "cmd.ignore.generated": "已生成 {preset} 预设的排除规则: {path}", + "cli.help.report": "生成 Markdown 格式的备份报告", + "cli.help.report.output": "输出文件路径(不指定则打印到终端)", + "cli.help.search": "在备份文件中搜索文件名", + "cli.help.search.pattern": "搜索模式(支持 glob 通配符,如 *.txt)", + "cli.help.search.in": "指定备份文件路径(不指定则搜索所有策略的最新备份)", + "cli.help.search.password": "解密密码(搜索加密备份时使用)", + "cli.help.versions": "查看备份版本历史(按源目录分组)", + "cli.help.versions.source": "指定源目录路径(不指定则显示全部)", + "cmd.versions.header": "═══ 备份版本历史 ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.empty": "暂无备份版本记录。", + "cmd.versions.total": "共 {count} 个版本,{sources} 个源目录", + "cli.help.schedule": "定时调度配置管理", + "cli.help.schedule.action": "调度操作: export(导出配置)", + "cli.help.schedule.export": "导出定时调度配置文件", + "cli.help.schedule.type": "调度类型: systemd / crontab(默认: systemd)", + "cli.help.schedule.interval": "备份间隔(分钟,默认: 60)", + "cli.help.schedule.output": "输出文件路径(不指定则打印到终端)", + "cmd.schedule.exported": "调度配置已导出到: {path} ({type})", + "cmd.schedule.unknown_type": "未知调度类型: {type}", + "cli.help.webhook_cmd": "Webhook 通知管理", + "cli.help.webhook_cmd.action": "Webhook 操作: preset(设置预设) / list(列出预设)", + "cli.help.webhook_cmd.preset": "设置 IM 通知预设模板", + "cli.help.webhook_cmd.preset.name": "预设名称: dingtalk(钉钉) / feishu(飞书) / wechat(企业微信)", + "cli.help.webhook_cmd.list": "列出所有可用的 IM 通知预设", + "cmd.webhook.preset_set": "已设置 {name} 通知模板:\n{template}", + "cmd.webhook.preset_unknown": "未知预设: {name}", + "cmd.webhook.available_presets": "可用 IM 通知预设:", + "cli.help.config_cmd": "配置文件管理(加密/解密敏感字段)", + "cli.help.config_cmd.action": "操作: lock(加密) / unlock(解密)", + "cli.help.config_cmd.lock": "用主密码加密配置文件中的敏感字段", + "cli.help.config_cmd.lock.password": "主密码(不指定则交互式输入)", + "cli.help.config_cmd.unlock": "用主密码解密配置文件中的敏感字段", + "cli.help.config_cmd.unlock.password": "主密码(不指定则交互式输入)", + "cmd.config.locked": "配置文件已加密(敏感字段已保护)", + "cmd.config.unlocked": "配置文件已解密", + "cmd.config.already_locked": "配置文件已经加密过了", + "cmd.config.not_locked": "配置文件未加密", + "cmd.config.enter_password": "请输入主密码", + "cmd.config.no_password": "密码不能为空", + "cmd.config.wrong_password": "密码错误,解密失败", + "cmd.config.lock_failed": "加密失败", + "cli.help.clean": "智能清理所有策略的备份文件", + "cli.help.clean.keep": "每个策略保留最近 N 个备份文件", + "cli.help.clean.keep_days": "删除 N 天前的备份文件", + "cli.help.clean.dry_run": "仅预览将删除的文件,不实际删除", + "cmd.clean.no_criteria": "请指定清理条件: --keep N 或 --keep-days N", + "cmd.clean.nothing": "没有需要清理的备份文件。", + "cmd.clean.dry_run_header": "预览模式(不会实际删除):", + "cmd.clean.summary": "共清理 {count} 个备份文件。", + "cli.help.completion": "生成 Shell 自动补全脚本", + "cli.help.completion.shell": "Shell 类型: bash(默认) / zsh / fish / powershell", + "cmd.report.saved": "备份报告已保存到: {path}", + "cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:", + "cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件", + "cmd.search.no_results_global": "在所有备份中未找到匹配 '{pattern}' 的文件", + "cmd.export.success": "已导出 {count} 个备份策略到 {path}", + "cmd.import.success": "已导入 {count} 个备份策略,跳过 {skipped} 个已存在的策略", + "cmd.import.empty": "导入文件中没有有效的备份策略: {path}", + "cli.help.save.dry_run": "仅预览将备份的文件,不实际执行压缩", + "cli.help.save.verify": "备份完成后自动校验备份文件完整性", + "cli.help.save.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)", + "cli.help.save.webhook": "备份完成后 POST 结果到此 URL", + "cli.help.save.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)", + "cli.help.save.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)", + "cli.help.save.split": "备份文件分卷大小(如 100MB, 1GB),超过此大小自动分割", + "cli.help.save.tag": "为本次备份添加标签(如 daily、release)", + "cli.help.watch.dry_run": "仅预览将备份的文件,不实际执行压缩", + "cli.help.watch.verify": "每次备份完成后自动校验备份文件完整性", + "cli.help.watch.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)", + "cli.help.watch.webhook": "每次备份完成后 POST 结果到此 URL", + "cli.help.watch.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)", + "cli.help.watch.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)", + "cli.help.watch.split": "备份文件分卷大小(如 100MB, 1GB)", + "cli.help.watch.tag": "为本次备份添加标签", + "cli.help.init": "生成默认配置文件模板", + "cli.help.rm.all": "删除所有备份策略", + "cli.help.edit": "编辑已有备份策略(仅修改指定的字段)", + "cli.help.edit.source": "源文件夹路径(用于定位要编辑的策略)", + "cli.help.edit.dest": "修改目标文件夹路径", + "cli.help.edit.ignore": "修改忽略模式(逗号分隔)", + "cli.help.edit.format": "修改打包格式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "修改文件名模板(支持 {name} {date} {time} 变量)", + "cli.help.info": "查看备份文件详细信息(格式、大小、文件数、校验和等)", + "cli.help.info.file": "备份文件路径", + "cli.help.info.password": "解密密码(加密的 7z 备份使用)", + "cmd.info.header": "═══ 备份文件信息 ═══", + "cmd.info.path": "文件路径: {path}", + "cmd.info.format": "压缩格式: {format}", + "cmd.info.size": "文件大小: {size:.2f} MB", + "cmd.info.files": "文件数量: {count}", + "cmd.info.time": "修改时间: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.diff.header": "═══ 差异对比 ═══\n备份文件: {backup}", + "cmd.diff.added_header": "\n新增文件 ({count}):", + "cmd.diff.removed_header": "\n删除文件 ({count}):", + "cmd.diff.modified_header": "\n可能修改的文件 ({count}):", + "cmd.diff.no_changes": "\n无差异,源目录与备份一致。", + "cmd.diff.summary": "\n差异摘要: +{added} 新增, -{removed} 删除, ~{modified} 修改", + "cmd.diff.no_backup": "目标目录中未找到备份文件: {path}", + "cmd.diff.empty_backup": "备份文件为空: {path}", + "cli.help.diff": "对比源目录与最近一次备份的差异", + "cli.help.diff.source": "源文件夹路径", + "cli.help.diff.file": "指定备份文件路径(不指定则自动查找最新备份)", + "cli.help.diff.password": "解密密码(对比加密备份时使用)", + "cli.help.remote": "远程备份文件管理(列出/删除 SFTP 或 WebDAV 上的备份文件)", + "cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)", + "cli.help.remote.list": "列出远程备份文件", + "cli.help.remote.rm": "删除远程备份文件", + "cli.help.remote.rm.filename": "要删除的远程文件名", + "cli.help.remote.sftp": "操作 SFTP 服务器", + "cli.help.remote.webdav": "操作 WebDAV 服务器", + "cli.help.version": "查看版本信息", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "配置文件 %s 格式错误,使用默认配置", + "log.config.reset": "配置文件 %s 格式错误,重置为空", + "log.config.mkdir.error": "无法创建配置目录 %s: %s", + "log.config.write.error": "无法写入配置文件 %s: %s", + "log.data.read": "读取数据文件: %s", + "log.data.create": "数据文件不存在,创建新文件: %s", + "log.data.load": "加载现有数据文件: %s", + "log.data.write": "写入数据文件: %s", + "log.cleanup.delete": "已删除旧备份: %s", + "log.i18n.load.failed": "无法加载语言包 {path}: {error}", + "log.i18n.load.fallback_failed": "无法加载回退语言包 {path}: {error}", + "err.sftp.auth": "SFTP 认证失败: {host},请检查用户名和密码", + "err.sftp.ssh": "SFTP 连接错误: {error}", + "err.sftp.connect": "无法连接到 {host}:{port}: {error}", + "err.sftp.not_connected": "SFTP 未连接,请先调用 connect()", + "err.sftp.local_not_found": "本地文件不存在: {path}", + "err.sftp.upload": "上传文件失败 {path}: {error}", + "err.sftp.mkdir": "创建远程目录失败 {path}: {error}", + "err.sftp.not_configured": "SFTP 未配置,请先运行: sbackup sftp config --host <地址> --user <用户名>", + "cmd.sftp.uploading": "正在上传: {file}", + "cmd.sftp.progress": "上传进度", + "cmd.sftp.success": "上传成功: {file}", + "cmd.sftp.parallel_upload": "并行上传 {count} 个文件...", + "cmd.sftp.config_saved": "SFTP 配置已保存到 config.json", + "cmd.sftp.testing": "正在测试连接到 {host}...", + "cmd.sftp.test_ok": "SFTP 连接测试成功!", + "cmd.sftp.test_fail": "SFTP 连接测试失败,请检查配置", + "cli.help.save.sftp": "备份完成后上传到 SFTP 服务器(自动检测私钥或使用密码认证)", + "cli.help.watch.sftp": "每次备份后上传到 SFTP 服务器", + "cli.help.sftp": "SFTP 远程备份管理(支持密码认证和 SSH 私钥认证)", + "cli.help.sftp.action": "SFTP 操作: config(配置) / test(测试连接)", + "cli.help.sftp.config": "配置 SFTP 连接参数(支持密码认证和 SSH 私钥认证)", + "cli.help.sftp.test": "测试 SFTP 连接是否可用", + "cli.help.sftp.host": "SFTP 服务器地址(例如: 192.168.1.100 或 example.com)", + "cli.help.sftp.port": "SFTP 端口号(默认: 22)", + "cli.help.sftp.user": "SFTP 用户名", + "cli.help.sftp.password": "SFTP 密码(仅密码认证时使用)", + "cli.help.sftp.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)", + "cli.prompt.sftp.host": "SFTP 服务器地址:", + "cli.prompt.sftp.port": "SFTP 端口 (默认 22):", + "cli.prompt.sftp.user": "SFTP 用户名:", + "cli.prompt.sftp.password": "SFTP 密码:", + "cli.prompt.sftp.remote_path": "远程路径 (默认 /):", + "err.sftp.key_load": "无法加载私钥文件 {path},请确认文件格式正确(支持 RSA/Ed25519/ECDSA)", + "cli.help.sftp.key_file": "SSH 私钥文件路径(支持 RSA/Ed25519/ECDSA,优先于密码认证)", + "cli.help.sftp.key_passphrase": "私钥密码短语(私钥有密码保护时使用,可通过交互式输入)", + "cli.prompt.sftp.key_file": "SSH 私钥文件路径 (留空使用密码认证):", + "cli.prompt.sftp.key_passphrase": "私钥密码短语 (直接回车放弃):", + "cmd.sftp.using_default_key": "检测到默认 SSH 私钥,将使用: {path}", + "cmd.sftp.no_default_key": "未找到默认 SSH 私钥,请配置密码或指定私钥路径", + "err.sftp.key_needs_passphrase": "私钥 {path} 需要密码短语,请输入或使用 --key-passphrase 参数指定", + "err.sftp.wrong_passphrase": "密码短语错误,请重试(直接回车放弃使用私钥)", + "cli.help.webdav": "WebDAV 远程备份管理(支持坚果云、NextCloud、群晖等)", + "cli.help.webdav.action": "WebDAV 操作: config(配置) / test(测试连接)", + "cli.help.webdav.config": "配置 WebDAV 连接参数", + "cli.help.webdav.test": "测试 WebDAV 连接是否可用", + "cli.help.webdav.url": "WebDAV 服务器 URL(例如: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV 用户名", + "cli.help.webdav.password": "WebDAV 密码", + "cli.help.webdav.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)", + "cli.help.save.webdav": "备份完成后上传到 WebDAV 服务器", + "cli.help.watch.webdav": "每次备份后上传到 WebDAV 服务器", + "cli.prompt.webdav.url": "WebDAV 服务器 URL:", + "cli.prompt.webdav.user": "WebDAV 用户名:", + "cli.prompt.webdav.password": "WebDAV 密码:", + "cli.prompt.webdav.remote_path": "远程路径 (默认 /):", + "cmd.webdav.config_saved": "WebDAV 配置已保存到 config.json", + "cmd.webdav.testing": "正在测试 WebDAV 连接到 {url}...", + "cmd.webdav.test_ok": "WebDAV 连接测试成功!", + "cmd.webdav.uploading": "正在上传到 WebDAV: {file}", + "cmd.webdav.success": "WebDAV 上传成功: {file}", + "cmd.webdav.parallel_upload": "并行上传 {count} 个文件到 WebDAV...", + "err.webdav.auth": "WebDAV 认证失败: {host},请检查用户名和密码", + "err.webdav.connect": "无法连接到 WebDAV: {url},错误: {error}", + "err.webdav.ssh": "WebDAV 连接错误: {error}", + "err.webdav.not_configured": "WebDAV 未配置,请先运行: sbackup webdav config --url <地址> --user <用户名>", + "err.webdav.local_not_found": "本地文件不存在: {path}", + "err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}", + "err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}", + "err.webdav.invalid_scheme": "不支持的 URL 协议 '{scheme}',仅允许 http 和 https。", + "err.webdav.private_ip": "已阻止连接到内网地址: {host}", + "cmd.remote.empty": "远程目录为空: {path}", + "cmd.remote.list_header": "远程目录: {path} ({count} 个文件)", + "cmd.remote.deleted": "已删除远程文件: {path}", + "cmd.remote.select_protocol": "请指定协议: --sftp 或 --webdav", + "cmd.remote.rm_requires_path": "请指定要删除的远程文件名", + "cli.help.remote.list_path": "指定远程目录路径(不指定则使用配置的默认路径)", + "err.sftp.remote_not_found": "远程文件不存在: {path}", + "err.sftp.delete": "删除远程文件失败 {path}: {error}", + "err.webdav.not_found": "远程文件不存在: {path}", + "err.webdav.delete": "删除远程文件失败 {path}: {error}", + "err.webdav.list": "列出远程文件失败 {path}: {error}", + "restore.no_match": "没有匹配 \"{pattern}\" 的文件", + "cli.help.restore.select": "选择性还原:仅提取匹配模式的文件(支持 glob 通配符,如 \"*.py\" 或 \"src/main.py\")", + "cli.help.schedule.type.schtasks": "Windows 计划任务", + "cmd.schedule.schtasks.generated": "Windows 计划任务命令已生成", + "cmd.hook.pre_running": "正在执行前置钩子: {command}", + "cmd.hook.post_running": "正在执行后置钩子: {command}", + "cmd.hook.failed": "钩子执行失败: {command} — {error}", + "cli.help.save.pre_hook": "备份前执行的命令(可多次使用)", + "cli.help.save.post_hook": "备份后执行的命令(可多次使用)", + "cli.help.watch.pre_hook": "备份前执行的命令(可多次使用)", + "cli.help.watch.post_hook": "备份后执行的命令(可多次使用)", + "restore.split.detected": "检测到分卷备份,共 {count} 个分卷", + "restore.split.missing": "错误:分卷文件缺失,期望 {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": "没有有效的监控源目录,监控未启动" +} diff --git a/sbackup/lock.py b/sbackup/lock.py new file mode 100644 index 0000000..9584da2 --- /dev/null +++ b/sbackup/lock.py @@ -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 diff --git a/sbackup/monitor.py b/sbackup/monitor.py new file mode 100644 index 0000000..b60b772 --- /dev/null +++ b/sbackup/monitor.py @@ -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() + ) diff --git a/sbackup/sftp.py b/sbackup/sftp.py index db6908a..deeb627 100644 --- a/sbackup/sftp.py +++ b/sbackup/sftp.py @@ -44,7 +44,7 @@ class SFTPClient: self._sftp = None def connect(self) -> None: - """建立 SFTP 连接(优先使用私钥认证)""" + """建立 SFTP 连接(优先使用私钥认证,验证主机密钥)""" import paramiko # 展开 ~ 为用户目录(跨平台兼容) @@ -52,6 +52,47 @@ class SFTPClient: try: self._transport = paramiko.Transport((self.host, self.port)) + + # 主机密钥验证:加载系统 known_hosts + host_keys = paramiko.HostKeys() + known_hosts_paths = [ + os.path.expanduser("~/.ssh/known_hosts"), + "/etc/ssh/ssh_known_hosts", + ] + for kh_path in known_hosts_paths: + if os.path.isfile(kh_path): + try: + host_keys.load(kh_path) + except OSError: + pass + + if host_keys: + # 有已知主机密钥,使用严格验证 + remote_key = self._transport.get_remote_server_key() + if remote_key is None: + self.disconnect() + raise SFTPError(t("err.sftp.no_host_key", host=self.host)) + host_key_entry = host_keys.lookup(self.host) + if host_key_entry is None: + # 主机不在已知列表中,警告但允许连接(首次连接) + logger.warning( + "SFTP: 主机 %s 不在 known_hosts 中(首次连接)", self.host + ) + else: + try: + host_keys.check(self.host, remote_key) + except paramiko.SSHException: + self.disconnect() + raise SFTPError(t("err.sftp.host_key_mismatch", host=self.host)) + else: + # 没有 known_hosts 文件,跳过验证(向后兼容) + logger.debug("SFTP: 未找到 known_hosts 文件,跳过主机密钥验证") + import sys + + print( + t("warn.sftp.no_known_hosts", host=self.host), + file=sys.stderr, + ) if key_file_expanded and os.path.isfile(key_file_expanded): # 私钥认证 pkey = self._load_private_key(key_file_expanded, self.key_passphrase) @@ -150,6 +191,10 @@ class SFTPClient: finally: self._transport = None + # 清除敏感凭据,防止内存泄漏 + self.password = "" + self.key_passphrase = "" + def __enter__(self): self.connect() return self @@ -190,7 +235,7 @@ class SFTPClient: except OSError as e: raise SFTPError(t("err.sftp.upload", path=filename, error=str(e))) - def _ensure_remote_dir(self, remote_path: str) -> None: + def _ensure_remote_dir(self, remote_path: str, max_depth: int = 50) -> None: """确保远程目录存在,不存在则逐级创建""" if self._sftp is None: return @@ -203,7 +248,9 @@ class SFTPClient: # 递归创建父目录 parent = os.path.dirname(remote_path).replace("\\", "/") if parent and parent != remote_path: - self._ensure_remote_dir(parent) + if max_depth <= 0: + raise SFTPError(t("err.sftp.mkdir_max_depth", path=remote_path)) + self._ensure_remote_dir(parent, max_depth - 1) try: self._sftp.mkdir(remote_path) except OSError as e: diff --git a/sbackup/webdav.py b/sbackup/webdav.py index 7c9460f..382e2a1 100644 --- a/sbackup/webdav.py +++ b/sbackup/webdav.py @@ -4,9 +4,13 @@ WebDAV 远程备份模块:基于 HTTP 的文件上传(支持坚果云、Next """ import os +import sys +import socket +import ipaddress import logging import urllib.request import urllib.error +import urllib.parse from base64 import b64encode from sbackup.i18n import t @@ -24,6 +28,39 @@ class WebDAVClient: """WebDAV 客户端,基于 urllib 实现,零额外依赖""" def __init__(self, url: str, user: str, password: str): + # SSRF 防护:验证 URL scheme 和内部 IP + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise WebDAVError( + t("err.webdav.invalid_scheme", scheme=parsed.scheme or "missing") + ) + + # HTTP 明文警告 + if parsed.scheme == "http": + print( + t("warn.webdav.http_plaintext", url=url), + file=sys.stderr, + ) + + # 阻止内网地址 + if parsed.hostname: + try: + host_ip = socket.getaddrinfo( + parsed.hostname, None, type=socket.SOCK_STREAM + )[0][4][0] + ip = ipaddress.ip_address(host_ip) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + ): + raise WebDAVError(t("err.webdav.private_ip", host=parsed.hostname)) + except WebDAVError: + raise + except (OSError, ValueError): + pass + self.url = url.rstrip("/") self.user = user self.password = password @@ -92,7 +129,14 @@ class WebDAVClient: content_type="application/xml", ) req.add_header("Depth", "0") - urllib.request.urlopen(req, timeout=15) + resp = urllib.request.urlopen(req, timeout=15) + + # XML 炸弹防护:限制响应大小 + MAX_CONNECT_XML_SIZE = 1 * 1024 * 1024 # 1MB + xml_data = resp.read(MAX_CONNECT_XML_SIZE + 1) + if len(xml_data) > MAX_CONNECT_XML_SIZE: + raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data))) + logger.debug("WebDAV 连接成功: %s", self.url) except urllib.error.HTTPError as e: if e.code == 401: @@ -167,6 +211,11 @@ class WebDAVClient: except OSError as e: raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(e))) + # XML 炸弹防护:限制响应大小 + MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB + if len(xml_data) > MAX_XML_SIZE: + raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data))) + files = [] try: root = ET.fromstring(xml_data) diff --git a/tests/sbackup/test_auto_save.py b/tests/sbackup/test_auto_save.py index 1587f22..202419e 100644 --- a/tests/sbackup/test_auto_save.py +++ b/tests/sbackup/test_auto_save.py @@ -920,10 +920,12 @@ class TestEditStrategy(unittest.TestCase): class TestWebhook(unittest.TestCase): """测试 Webhook 增强功能""" - @patch("urllib.request.urlopen") - def test_send_webhook_single(self, mock_urlopen): + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhook_single(self, mock_getaddrinfo, mock_open): """测试发送单个 webhook""" - mock_urlopen.return_value = MagicMock() + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.return_value = MagicMock() BackupManager._send_webhook( "https://example.com/hook", status="success", @@ -931,12 +933,14 @@ class TestWebhook(unittest.TestCase): skipped=1, elapsed=5.0, ) - mock_urlopen.assert_called_once() + mock_open.assert_called_once() - @patch("urllib.request.urlopen") - def test_send_webhook_with_template(self, mock_urlopen): + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhook_with_template(self, mock_getaddrinfo, mock_open): """测试自定义模板 webhook""" - mock_urlopen.return_value = MagicMock() + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.return_value = MagicMock() BackupManager._send_webhook( "https://example.com/hook", status="success", @@ -945,18 +949,20 @@ class TestWebhook(unittest.TestCase): elapsed=5.0, template='{"event":"backup","status":"{status}"}', ) - req = mock_urlopen.call_args[0][0] + req = mock_open.call_args[0][0] import json payload = json.loads(req.data.decode("utf-8")) self.assertEqual(payload["status"], "success") - @patch("urllib.request.urlopen") - def test_send_webhook_retries(self, mock_urlopen): + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhook_retries(self, mock_getaddrinfo, mock_open): """测试 webhook 重试机制""" import urllib.error - mock_urlopen.side_effect = urllib.error.URLError("timeout") + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.side_effect = urllib.error.URLError("timeout") BackupManager._send_webhook( "https://example.com/hook", status="success", @@ -965,12 +971,14 @@ class TestWebhook(unittest.TestCase): elapsed=1.0, retries=3, ) - self.assertEqual(mock_urlopen.call_count, 3) + self.assertEqual(mock_open.call_count, 3) - @patch("urllib.request.urlopen") - def test_send_webhooks_multiple(self, mock_urlopen): + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhooks_multiple(self, mock_getaddrinfo, mock_open): """测试多个 webhook URL""" - mock_urlopen.return_value = MagicMock() + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.return_value = MagicMock() BackupManager._send_webhooks( ["https://hook1.example.com", "https://hook2.example.com"], status="success", @@ -978,7 +986,7 @@ class TestWebhook(unittest.TestCase): skipped=0, elapsed=1.0, ) - self.assertEqual(mock_urlopen.call_count, 2) + self.assertEqual(mock_open.call_count, 2) class TestDiffBackup(unittest.TestCase): diff --git a/tests/sbackup/test_bugfixes.py b/tests/sbackup/test_bugfixes.py index 8b3c6eb..9fd9895 100644 --- a/tests/sbackup/test_bugfixes.py +++ b/tests/sbackup/test_bugfixes.py @@ -577,13 +577,12 @@ class TestSplitAndMergeFiles(unittest.TestCase): self.assertEqual(len(parts), 1) def test_merge_empty_list(self): - """空列表合并创建空文件""" + """空列表合并不创建文件并返回 False""" from sbackup.compression import merge_files output_path = os.path.join(self.test_dir, "empty.bin") result = merge_files([], output_path) - self.assertTrue(result) - self.assertEqual(os.path.getsize(output_path), 0) + self.assertFalse(result) class TestWebhookPreset(unittest.TestCase): @@ -617,8 +616,11 @@ class TestParseGitignore(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) def tearDown(self): + os.chdir(self.original_cwd) shutil.rmtree(self.test_dir, ignore_errors=True) def test_parse_gitignore_basic(self): diff --git a/tests/sbackup/test_completion.py b/tests/sbackup/test_completion.py new file mode 100644 index 0000000..37d212a --- /dev/null +++ b/tests/sbackup/test_completion.py @@ -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) diff --git a/tests/sbackup/test_lock.py b/tests/sbackup/test_lock.py new file mode 100644 index 0000000..ba06b3d --- /dev/null +++ b/tests/sbackup/test_lock.py @@ -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)) diff --git a/tests/sbackup/test_main.py b/tests/sbackup/test_main.py index d47f0ea..85dacd9 100644 --- a/tests/sbackup/test_main.py +++ b/tests/sbackup/test_main.py @@ -763,5 +763,128 @@ class TestInitCommand(unittest.TestCase): self.assertIn("compression", data) -if __name__ == "__main__": - unittest.main() +class TestCompletionCommand(unittest.TestCase): + """测试 completion 命令""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + + @patch("builtins.print") + def test_completion_bash(self, mock_print): + """测试 completion bash 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "bash"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("complete -F", printed) + + @patch("builtins.print") + def test_completion_zsh(self, mock_print): + """测试 completion zsh 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "zsh"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("#compdef", printed) + + @patch("builtins.print") + def test_completion_default_shell(self, mock_print): + """测试 completion 无参数默认 bash""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("complete -F", printed) + + @patch("builtins.print") + def test_completion_fish(self, mock_print): + """测试 completion fish 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "fish"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("complete -c sbackup", printed) + + @patch("builtins.print") + def test_completion_powershell(self, mock_print): + """测试 completion powershell 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "powershell"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("Register-ArgumentCompleter", printed) + + +class TestLockIntegration(unittest.TestCase): + """测试锁与 CLI 集成""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + self._root_level = logging.root.level + self.data_path = os.path.join(self.test_dir, "sbackup.json") + with open(os.path.join(self.test_dir, "config.json"), "w") as f: + json.dump({"data_file": self.data_path}, f) + self.dest_dir = os.path.join(self.test_dir, "backup_dest") + os.makedirs(self.dest_dir, exist_ok=True) + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + logging.root.setLevel(self._root_level) + + @patch("sbackup.cli.BackupManager.execute_backups") + @patch("builtins.print") + def test_save_acquires_lock(self, mock_print, mock_execute): + """测试 save 命令获取锁""" + os.chdir(self.test_dir) + from sbackup import run + + sys.argv = ["sbackup", "--lang", "en_US", "save"] + result = run() + self.assertEqual(result, 0) + mock_execute.assert_called_once() + + @patch("sbackup.cli.BackupManager.execute_backups") + @patch("builtins.print") + def test_save_lock_conflict(self, mock_print, mock_execute): + """测试 save 锁冲突""" + os.chdir(self.test_dir) + from sbackup import run + + # 手动创建锁文件,模拟另一个运行的进程 + from sbackup.lock import BackupLock + + lock = BackupLock(self.test_dir) + lock.acquire() + + sys.argv = ["sbackup", "--lang", "en_US", "save"] + result = run() + # save 尝试获取锁应该失败(锁被我们持有) + self.assertNotEqual(result, 0) + mock_execute.assert_not_called() + lock.release() diff --git a/tests/sbackup/test_monitor.py b/tests/sbackup/test_monitor.py new file mode 100644 index 0000000..b9ae798 --- /dev/null +++ b/tests/sbackup/test_monitor.py @@ -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 diff --git a/tests/sbackup/test_new_features.py b/tests/sbackup/test_new_features.py index e5b379a..af06f1b 100644 --- a/tests/sbackup/test_new_features.py +++ b/tests/sbackup/test_new_features.py @@ -3,6 +3,7 @@ """ import os +import sys import time import tempfile import shutil @@ -228,9 +229,14 @@ class TestPrePostHooks(unittest.TestCase): time.sleep(0.1) (Path(self.source_dir) / "new.txt").write_text("new") - # 使用 echo 创建标记文件(Windows cmd 兼容) - marker = self.hook_marker.replace("\\", "/") - hook_cmd = f'echo done > "{marker}"' + # 使用 Python 创建标记文件(shlex.split + shell=False 兼容) + hook_cmd = ( + '"' + + sys.executable + + '" -c "open(r\'' + + self.hook_marker + + "', 'w').write('done')\"" + ) self.manager.execute_backups(pre_hooks=[hook_cmd]) self.assertTrue(os.path.exists(self.hook_marker), "前置钩子应被执行") @@ -241,8 +247,14 @@ class TestPrePostHooks(unittest.TestCase): time.sleep(0.1) (Path(self.source_dir) / "new.txt").write_text("new") - marker = self.hook_marker.replace("\\", "/") - hook_cmd = f'echo done > "{marker}"' + # 使用 Python 创建标记文件(shlex.split + shell=False 兼容) + hook_cmd = ( + '"' + + sys.executable + + '" -c "open(r\'' + + self.hook_marker + + "', 'w').write('done')\"" + ) self.manager.execute_backups(post_hooks=[hook_cmd]) self.assertTrue(os.path.exists(self.hook_marker), "后置钩子应被执行") @@ -282,10 +294,16 @@ class TestPrePostHooks(unittest.TestCase): marker1 = os.path.join(self.test_dir, "hook1.txt") marker2 = os.path.join(self.test_dir, "hook2.txt") - m1 = marker1.replace("\\", "/") - m2 = marker2.replace("\\", "/") - self.manager.execute_backups(pre_hooks=[f'echo 1 > "{m1}"', f'echo 2 > "{m2}"']) + # 使用 Python 创建标记文件(shlex.split + shell=False 兼容) + hook1 = ( + '"' + sys.executable + '" -c "open(r\'' + marker1 + "', 'w').write('1')\"" + ) + hook2 = ( + '"' + sys.executable + '" -c "open(r\'' + marker2 + "', 'w').write('2')\"" + ) + + self.manager.execute_backups(pre_hooks=[hook1, hook2]) self.assertTrue(os.path.exists(marker1)) self.assertTrue(os.path.exists(marker2)) @@ -297,8 +315,15 @@ class TestRunHooks(unittest.TestCase): """有效命令执行成功""" with tempfile.TemporaryDirectory() as tmpdir: marker = os.path.join(tmpdir, "marker.txt") - m = marker.replace("\\", "/") - BackupManager._run_hooks([f'echo ok > "{m}"'], "pre") + # 使用 Python 创建标记文件(shlex.split + shell=False 兼容) + hook_cmd = ( + '"' + + sys.executable + + '" -c "open(r\'' + + marker + + "', 'w').write('ok')\"" + ) + BackupManager._run_hooks([hook_cmd], "pre") self.assertTrue(os.path.exists(marker)) def test_run_hooks_with_empty_list(self): diff --git a/tests/sbackup/test_sftp.py b/tests/sbackup/test_sftp.py index 421567c..9dce594 100644 --- a/tests/sbackup/test_sftp.py +++ b/tests/sbackup/test_sftp.py @@ -403,8 +403,12 @@ class TestSFTPClient(unittest.TestCase): ) client.connect() - # 验证 expanduser 被调用 - mock_expanduser.assert_called_once_with(self.key_file) + # 验证 expanduser 被调用(包括 key_file 和 known_hosts 路径) + expanduser_calls = [str(c) for c in mock_expanduser.call_args_list] + self.assertTrue( + any(self.key_file in c for c in expanduser_calls), + f"expanduser should be called with key_file, got: {expanduser_calls}", + ) mock_transport.connect.assert_called_once_with( username=self.user, pkey=mock_pkey ) diff --git a/uv.lock b/uv.lock index c92330c..2c5afbe 100644 --- a/uv.lock +++ b/uv.lock @@ -895,6 +895,7 @@ dependencies = [ { name = "paramiko" }, { name = "py7zr" }, { name = "tqdm" }, + { name = "watchdog" }, { name = "zstandard" }, ] @@ -908,6 +909,7 @@ requires-dist = [ { name = "paramiko", specifier = ">=4.0.0" }, { name = "py7zr", specifier = ">=1.1.0" }, { name = "tqdm", specifier = ">=4.67.3" }, + { name = "watchdog", specifier = ">=6.0.0" }, { name = "zstandard", specifier = ">=0.25.0" }, ] @@ -944,6 +946,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "zstandard" version = "0.25.0"