mirror of
https://github.com/CodeSeeed/sbackup.git
synced 2026-10-04 02:32:58 +08:00
feat: 3个实用功能 — 备份锁/Shell补全/并行上传
This commit is contained in:
1 parent
8a14fda09b
commit
4fbbf7cfd8
32 files changed
+3964
-925
No files matched your search
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
"paramiko>=4.0.0",
|
"paramiko>=4.0.0",
|
||||||
"py7zr>=1.1.0",
|
"py7zr>=1.1.0",
|
||||||
"tqdm>=4.67.3",
|
"tqdm>=4.67.3",
|
||||||
|
"watchdog>=6.0.0",
|
||||||
"zstandard>=0.25.0",
|
"zstandard>=0.25.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+262
-29
@@ -100,7 +100,7 @@ class BackupManager:
|
|||||||
|
|
||||||
def save(self, initial: bool = False):
|
def save(self, initial: bool = False):
|
||||||
"""
|
"""
|
||||||
将内存数据写入 JSON 文件
|
将内存数据写入 JSON 文件(原子写入,防止中断导致数据损坏)
|
||||||
"""
|
"""
|
||||||
if not initial:
|
if not initial:
|
||||||
logger.debug(t("log.data.write"), self.data_file)
|
logger.debug(t("log.data.write"), self.data_file)
|
||||||
@@ -109,8 +109,23 @@ class BackupManager:
|
|||||||
if data_dir:
|
if data_dir:
|
||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
|
||||||
with open(self.data_file, "w", encoding="utf-8") as f:
|
# 原子写入:先写入临时文件,再 rename 替换
|
||||||
json.dump(self.data, f, ensure_ascii=False, indent=4)
|
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:
|
def _get_entry(self, key: str) -> BackupEntry | None:
|
||||||
"""获取指定路径的备份策略条目"""
|
"""获取指定路径的备份策略条目"""
|
||||||
@@ -398,7 +413,7 @@ class BackupManager:
|
|||||||
max_size: int = 0,
|
max_size: int = 0,
|
||||||
min_size: int = 0,
|
min_size: int = 0,
|
||||||
max_age_seconds: float = 0,
|
max_age_seconds: float = 0,
|
||||||
incremental: bool = False,
|
incremental: str | None = None,
|
||||||
split_size: int = 0,
|
split_size: int = 0,
|
||||||
tag: str = "",
|
tag: str = "",
|
||||||
checksum: bool = False,
|
checksum: bool = False,
|
||||||
@@ -420,7 +435,7 @@ class BackupManager:
|
|||||||
:param max_size: 文件大小上限(字节),0 不限制
|
:param max_size: 文件大小上限(字节),0 不限制
|
||||||
:param min_size: 文件大小下限(字节),0 不限制
|
:param min_size: 文件大小下限(字节),0 不限制
|
||||||
:param max_age_seconds: 文件年龄上限(秒),0 不限制
|
:param max_age_seconds: 文件年龄上限(秒),0 不限制
|
||||||
:param incremental: 文件级增量备份(仅压缩变化的文件)
|
:param incremental: 增量备份模式: None/""=全量, "file"=文件级, "block"=块级
|
||||||
:param pre_hooks: 备份前执行的命令列表
|
:param pre_hooks: 备份前执行的命令列表
|
||||||
:param post_hooks: 备份后执行的命令列表
|
:param post_hooks: 备份后执行的命令列表
|
||||||
"""
|
"""
|
||||||
@@ -430,14 +445,15 @@ class BackupManager:
|
|||||||
# 执行前置钩子
|
# 执行前置钩子
|
||||||
self._run_hooks(pre_hooks or [], "pre")
|
self._run_hooks(pre_hooks or [], "pre")
|
||||||
|
|
||||||
# 加载文件级元数据(增量备份用)
|
# 加载文件级/块级元数据(增量备份用)
|
||||||
file_meta_all = self.data.get("_file_meta", {}) if incremental else {}
|
file_meta_all = self.data.get("_file_meta", {}) if incremental else {}
|
||||||
|
chunk_meta_all = self.data.get("_chunk_meta", {}) if incremental == "block" else {}
|
||||||
|
|
||||||
# 收集需要备份的条目
|
# 收集需要备份的条目
|
||||||
tasks = []
|
tasks = []
|
||||||
skip_count = 0
|
skip_count = 0
|
||||||
for key, raw in list(self.data.items()):
|
for key, raw in list(self.data.items()):
|
||||||
if key in (_HISTORY_KEY, "_file_meta"):
|
if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||||
continue
|
continue
|
||||||
if not os.path.exists(key):
|
if not os.path.exists(key):
|
||||||
print(t("warn.source.missing", path=key))
|
print(t("warn.source.missing", path=key))
|
||||||
@@ -449,6 +465,7 @@ class BackupManager:
|
|||||||
continue
|
continue
|
||||||
entry = BackupEntry.from_list(raw)
|
entry = BackupEntry.from_list(raw)
|
||||||
file_meta = file_meta_all.get(key, {}) if incremental else {}
|
file_meta = file_meta_all.get(key, {}) if incremental else {}
|
||||||
|
chunk_meta = chunk_meta_all.get(key, {}) if incremental == "block" else {}
|
||||||
if entry.mtime != current_mtime:
|
if entry.mtime != current_mtime:
|
||||||
tasks.append(
|
tasks.append(
|
||||||
(
|
(
|
||||||
@@ -464,6 +481,8 @@ class BackupManager:
|
|||||||
max_age_seconds,
|
max_age_seconds,
|
||||||
file_meta,
|
file_meta,
|
||||||
split_size,
|
split_size,
|
||||||
|
incremental,
|
||||||
|
chunk_meta,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -486,7 +505,7 @@ class BackupManager:
|
|||||||
executor.submit(self._do_backup, *task): task for task in tasks
|
executor.submit(self._do_backup, *task): task for task in tasks
|
||||||
}
|
}
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
key, entry, current_mtime, _, _, _, _, _, _, _, _, _ = futures[
|
key, entry, current_mtime, _, _, _, _, _, _, _, _, _, incr_mode, _ = futures[
|
||||||
future
|
future
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
@@ -505,10 +524,12 @@ class BackupManager:
|
|||||||
result.get("path", ""),
|
result.get("path", ""),
|
||||||
tag=tag,
|
tag=tag,
|
||||||
)
|
)
|
||||||
if incremental:
|
if incr_mode:
|
||||||
self.data.setdefault("_file_meta", {})[key] = (
|
self.data.setdefault("_file_meta", {})[key] = (
|
||||||
self._collect_file_meta(key, checksum=checksum)
|
self._collect_file_meta(key, checksum=checksum)
|
||||||
)
|
)
|
||||||
|
if incr_mode == "block":
|
||||||
|
self._update_chunk_meta(key)
|
||||||
if keep > 0 or keep_days > 0:
|
if keep > 0 or keep_days > 0:
|
||||||
self._cleanup_old_backups(entry.target, keep, keep_days)
|
self._cleanup_old_backups(entry.target, keep, keep_days)
|
||||||
backup_count += 1
|
backup_count += 1
|
||||||
@@ -532,6 +553,8 @@ class BackupManager:
|
|||||||
age,
|
age,
|
||||||
fmeta,
|
fmeta,
|
||||||
split,
|
split,
|
||||||
|
incr_mode,
|
||||||
|
chmeta,
|
||||||
) in tasks:
|
) in tasks:
|
||||||
result = self._do_backup(
|
result = self._do_backup(
|
||||||
key,
|
key,
|
||||||
@@ -546,6 +569,8 @@ class BackupManager:
|
|||||||
age,
|
age,
|
||||||
fmeta,
|
fmeta,
|
||||||
split,
|
split,
|
||||||
|
incr_mode,
|
||||||
|
chmeta,
|
||||||
)
|
)
|
||||||
if result and result.get("success"):
|
if result and result.get("success"):
|
||||||
entry.mtime = current_mtime
|
entry.mtime = current_mtime
|
||||||
@@ -558,10 +583,12 @@ class BackupManager:
|
|||||||
result.get("path", ""),
|
result.get("path", ""),
|
||||||
tag=tag,
|
tag=tag,
|
||||||
)
|
)
|
||||||
if incremental:
|
if incr_mode:
|
||||||
self.data.setdefault("_file_meta", {})[key] = (
|
self.data.setdefault("_file_meta", {})[key] = (
|
||||||
self._collect_file_meta(key, checksum=checksum)
|
self._collect_file_meta(key, checksum=checksum)
|
||||||
)
|
)
|
||||||
|
if incr_mode == "block":
|
||||||
|
self._update_chunk_meta(key)
|
||||||
if keep > 0 or keep_days > 0:
|
if keep > 0 or keep_days > 0:
|
||||||
self._cleanup_old_backups(entry.target, keep, keep_days)
|
self._cleanup_old_backups(entry.target, keep, keep_days)
|
||||||
backup_count += 1
|
backup_count += 1
|
||||||
@@ -596,13 +623,28 @@ class BackupManager:
|
|||||||
if verify_failures > 0:
|
if verify_failures > 0:
|
||||||
print(t("cmd.verify.partial_fail", count=verify_failures))
|
print(t("cmd.verify.partial_fail", count=verify_failures))
|
||||||
|
|
||||||
# SFTP 上传
|
# 并行上传到 SFTP 和 WebDAV(使用 ThreadPoolExecutor)
|
||||||
if sftp_upload and uploaded_files:
|
sftp_needed = sftp_upload and bool(uploaded_files)
|
||||||
self._upload_to_sftp(uploaded_files, config)
|
webdav_needed = webdav_upload and bool(uploaded_files)
|
||||||
|
|
||||||
# WebDAV 上传
|
if sftp_needed and webdav_needed:
|
||||||
if webdav_upload and uploaded_files:
|
print(t("cmd.upload.parallel"))
|
||||||
self._upload_to_webdav(uploaded_files, config)
|
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、自定义模板、重试)
|
# Webhook 通知(支持多个 URL、自定义模板、重试)
|
||||||
all_webhook_urls = list(getattr(config, "webhook_urls", []))
|
all_webhook_urls = list(getattr(config, "webhook_urls", []))
|
||||||
@@ -634,6 +676,20 @@ class BackupManager:
|
|||||||
retries=getattr(config, "webhook_retries", 2),
|
retries=getattr(config, "webhook_retries", 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SMTP 邮件通知
|
||||||
|
if getattr(config, "smtp_enabled", False):
|
||||||
|
status = "成功" if verify_failures == 0 else "部分成功"
|
||||||
|
if backup_count == 0:
|
||||||
|
status = "跳过"
|
||||||
|
self._send_email(
|
||||||
|
config,
|
||||||
|
status=status,
|
||||||
|
backed=backup_count,
|
||||||
|
skipped=skip_count,
|
||||||
|
elapsed=elapsed,
|
||||||
|
verify_failures=verify_failures,
|
||||||
|
)
|
||||||
|
|
||||||
# 执行后置钩子
|
# 执行后置钩子
|
||||||
self._run_hooks(post_hooks or [], "post")
|
self._run_hooks(post_hooks or [], "post")
|
||||||
|
|
||||||
@@ -644,6 +700,7 @@ class BackupManager:
|
|||||||
:param hook_type: "pre" 或 "post",用于日志标识
|
:param hook_type: "pre" 或 "post",用于日志标识
|
||||||
"""
|
"""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import shlex
|
||||||
|
|
||||||
for cmd in hooks:
|
for cmd in hooks:
|
||||||
if not cmd:
|
if not cmd:
|
||||||
@@ -652,7 +709,11 @@ class BackupManager:
|
|||||||
print(t(label, command=cmd))
|
print(t(label, command=cmd))
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd, shell=True, timeout=300, capture_output=True, text=True
|
shlex.split(cmd),
|
||||||
|
shell=False,
|
||||||
|
timeout=300,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -679,6 +740,8 @@ class BackupManager:
|
|||||||
max_age_seconds: float = 0,
|
max_age_seconds: float = 0,
|
||||||
file_metadata: dict | None = None,
|
file_metadata: dict | None = None,
|
||||||
split_size: int = 0,
|
split_size: int = 0,
|
||||||
|
incremental: str | None = None,
|
||||||
|
chunk_meta: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""执行单个备份策略(线程安全)"""
|
"""执行单个备份策略(线程安全)"""
|
||||||
fmt = entry.compression_format or config.compression_format
|
fmt = entry.compression_format or config.compression_format
|
||||||
@@ -765,6 +828,27 @@ class BackupManager:
|
|||||||
pass
|
pass
|
||||||
return meta
|
return meta
|
||||||
|
|
||||||
|
def _update_chunk_meta(self, key: str) -> None:
|
||||||
|
"""为指定策略更新块级哈希元数据"""
|
||||||
|
from sbackup.chunked_backup import compute_chunk_hashes
|
||||||
|
|
||||||
|
if not os.path.isdir(key):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for dirpath, _, filenames in os.walk(key):
|
||||||
|
for fn in filenames:
|
||||||
|
fp = os.path.join(dirpath, fn)
|
||||||
|
try:
|
||||||
|
if os.path.isfile(fp):
|
||||||
|
rel = os.path.relpath(fp, key).replace("\\", "/")
|
||||||
|
self.data.setdefault("_chunk_meta", {}).setdefault(
|
||||||
|
key, {}
|
||||||
|
)[rel] = compute_chunk_hashes(fp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None:
|
def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None:
|
||||||
"""检查目标目录剩余空间,不足时打印警告"""
|
"""检查目标目录剩余空间,不足时打印警告"""
|
||||||
@@ -891,7 +975,46 @@ class BackupManager:
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
|
|
||||||
max_attempts = max(retries, 1)
|
max_attempts = min(max(retries, 1), 5)
|
||||||
|
|
||||||
|
# SSRF 防护:只允许 http/https 协议,阻止内网地址
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
logger.warning("Webhook URL 协议不允许: %s (仅支持 http/https)", url)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 解析主机名并阻止内部/保留 IP
|
||||||
|
if parsed.hostname:
|
||||||
|
try:
|
||||||
|
import ipaddress
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
host_ip = _socket.getaddrinfo(
|
||||||
|
parsed.hostname, None, type=_socket.SOCK_STREAM
|
||||||
|
)[0][4][0]
|
||||||
|
ip = ipaddress.ip_address(host_ip)
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Webhook URL 指向内部地址,已阻止: %s", parsed.hostname
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 禁用自动重定向(防止重定向链绕过协议/地址检查)
|
||||||
|
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
return None
|
||||||
|
|
||||||
|
no_redirect_opener = urllib.request.build_opener(_NoRedirectHandler)
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -900,8 +1023,8 @@ class BackupManager:
|
|||||||
method="POST",
|
method="POST",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
urllib.request.urlopen(req, timeout=10)
|
no_redirect_opener.open(req, timeout=10)
|
||||||
logger.debug("Webhook 通知成功: %s", url)
|
logger.debug("Webhook 通知成功: %s", parsed.hostname)
|
||||||
return
|
return
|
||||||
except (urllib.error.URLError, OSError) as e:
|
except (urllib.error.URLError, OSError) as e:
|
||||||
if attempt < max_attempts - 1:
|
if attempt < max_attempts - 1:
|
||||||
@@ -911,11 +1034,11 @@ class BackupManager:
|
|||||||
attempt + 1,
|
attempt + 1,
|
||||||
max_attempts,
|
max_attempts,
|
||||||
wait,
|
wait,
|
||||||
url,
|
parsed.hostname,
|
||||||
)
|
)
|
||||||
_time.sleep(wait)
|
_time.sleep(wait)
|
||||||
else:
|
else:
|
||||||
logger.warning("Webhook 通知失败: %s — %s", url, e)
|
logger.warning("Webhook 通知失败: %s — %s", parsed.hostname, e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send_webhooks(
|
def _send_webhooks(
|
||||||
@@ -946,6 +1069,68 @@ class BackupManager:
|
|||||||
# 向后兼容别名
|
# 向后兼容别名
|
||||||
save_folder = execute_backups
|
save_folder = execute_backups
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_smtp_header(value: str) -> str:
|
||||||
|
"""净化 SMTP 头值,防止头注入"""
|
||||||
|
return value.replace("\r", "").replace("\n", "")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_email(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
backed: int,
|
||||||
|
skipped: int,
|
||||||
|
elapsed: float,
|
||||||
|
verify_failures: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""通过 SMTP 发送备份结果邮件通知"""
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
if not config.smtp_enabled or not config.smtp_host:
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = (
|
||||||
|
f"[sbackup] 备份{status} - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = f"""sbackup 备份通知
|
||||||
|
|
||||||
|
状态: {status}
|
||||||
|
备份策略数: {backed}
|
||||||
|
跳过数: {skipped}
|
||||||
|
耗时: {elapsed:.2f} 秒
|
||||||
|
验证失败: {verify_failures}
|
||||||
|
时间: {datetime.now().isoformat(timespec="seconds")}
|
||||||
|
"""
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["From"] = BackupManager._safe_smtp_header(
|
||||||
|
config.smtp_from or config.smtp_user
|
||||||
|
)
|
||||||
|
msg["To"] = BackupManager._safe_smtp_header(config.smtp_to)
|
||||||
|
msg["Subject"] = BackupManager._safe_smtp_header(subject)
|
||||||
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if config.smtp_tls:
|
||||||
|
server = smtplib.SMTP(config.smtp_host, config.smtp_port)
|
||||||
|
server.starttls()
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(config.smtp_host, config.smtp_port)
|
||||||
|
|
||||||
|
if config.smtp_user and config.smtp_password:
|
||||||
|
server.login(config.smtp_user, config.smtp_password)
|
||||||
|
|
||||||
|
server.send_message(msg)
|
||||||
|
server.quit()
|
||||||
|
logger.debug("邮件通知成功: %s", config.smtp_to)
|
||||||
|
except (smtplib.SMTPException, OSError) as e:
|
||||||
|
logger.warning("邮件通知失败: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _upload_to_sftp(file_paths: list[str], config: Config) -> None:
|
def _upload_to_sftp(file_paths: list[str], config: Config) -> None:
|
||||||
"""将备份文件上传到 SFTP 服务器(多文件并行)"""
|
"""将备份文件上传到 SFTP 服务器(多文件并行)"""
|
||||||
@@ -1318,7 +1503,7 @@ class BackupManager:
|
|||||||
# 收集所有策略的目标目录
|
# 收集所有策略的目标目录
|
||||||
target_dirs: set[str] = set()
|
target_dirs: set[str] = set()
|
||||||
for key, raw in self.data.items():
|
for key, raw in self.data.items():
|
||||||
if key in (_HISTORY_KEY, "_file_meta"):
|
if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||||
continue
|
continue
|
||||||
entry = BackupEntry.from_list(raw)
|
entry = BackupEntry.from_list(raw)
|
||||||
if entry.target:
|
if entry.target:
|
||||||
@@ -1369,7 +1554,7 @@ class BackupManager:
|
|||||||
"""导出所有备份策略到 JSON 文件,返回导出的策略数"""
|
"""导出所有备份策略到 JSON 文件,返回导出的策略数"""
|
||||||
export_data = {}
|
export_data = {}
|
||||||
for key, raw in self.data.items():
|
for key, raw in self.data.items():
|
||||||
if key in (_HISTORY_KEY, "_file_meta"):
|
if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||||
continue
|
continue
|
||||||
export_data[key] = raw
|
export_data[key] = raw
|
||||||
data_dir = os.path.dirname(output_file)
|
data_dir = os.path.dirname(output_file)
|
||||||
@@ -1383,6 +1568,13 @@ class BackupManager:
|
|||||||
"""从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None"""
|
"""从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None"""
|
||||||
if not os.path.exists(input_file):
|
if not os.path.exists(input_file):
|
||||||
return None
|
return None
|
||||||
|
# 文件大小限制:最多 10 MB
|
||||||
|
try:
|
||||||
|
if os.path.getsize(input_file) > 10 * 1024 * 1024:
|
||||||
|
logger.warning("Import file too large: %s", input_file)
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
with open(input_file, "r", encoding="utf-8") as f:
|
with open(input_file, "r", encoding="utf-8") as f:
|
||||||
import_data = json.load(f)
|
import_data = json.load(f)
|
||||||
@@ -1392,9 +1584,50 @@ class BackupManager:
|
|||||||
return None
|
return None
|
||||||
imported = 0
|
imported = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
_RESERVED_KEYS = {_HISTORY_KEY, "_file_meta", "_chunk_meta"}
|
||||||
|
_VALID_FORMATS = {
|
||||||
|
"",
|
||||||
|
"ZIP",
|
||||||
|
"TAR",
|
||||||
|
"TAR_GZ",
|
||||||
|
"TAR_BZ2",
|
||||||
|
"TAR_XZ",
|
||||||
|
"TAR_ZST",
|
||||||
|
"7Z",
|
||||||
|
"zip",
|
||||||
|
"tar",
|
||||||
|
"tar.gz",
|
||||||
|
"tar.bz2",
|
||||||
|
"tar.xz",
|
||||||
|
"tar.zst",
|
||||||
|
"7z",
|
||||||
|
}
|
||||||
for key, raw in import_data.items():
|
for key, raw in import_data.items():
|
||||||
|
# 安全检查:拒绝内部保留键
|
||||||
|
if key in _RESERVED_KEYS:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
if not isinstance(raw, list) or len(raw) < 3:
|
if not isinstance(raw, list) or len(raw) < 3:
|
||||||
continue
|
continue
|
||||||
|
# 安全检查:验证数据结构
|
||||||
|
if not isinstance(raw[0], (int, float)):
|
||||||
|
continue
|
||||||
|
if not isinstance(raw[1], str):
|
||||||
|
continue
|
||||||
|
if not isinstance(raw[2], list):
|
||||||
|
continue
|
||||||
|
# 拒绝包含路径遍历的源/目标路径
|
||||||
|
if ".." in key or not key.strip():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if ".." in raw[1] or not raw[1].strip():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
# 验证条目级格式(如有)
|
||||||
|
if len(raw) > 3 and raw[3]:
|
||||||
|
if not isinstance(raw[3], str) or raw[3] not in _VALID_FORMATS:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
if key in self.data:
|
if key in self.data:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
@@ -1410,7 +1643,7 @@ class BackupManager:
|
|||||||
|
|
||||||
# 统计策略数
|
# 统计策略数
|
||||||
strategies = {
|
strategies = {
|
||||||
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta")
|
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||||
}
|
}
|
||||||
lines.append(t("cmd.status.strategies", count=len(strategies)))
|
lines.append(t("cmd.status.strategies", count=len(strategies)))
|
||||||
|
|
||||||
@@ -1481,7 +1714,7 @@ class BackupManager:
|
|||||||
return {
|
return {
|
||||||
key: BackupEntry.from_list(raw).target
|
key: BackupEntry.from_list(raw).target
|
||||||
for key, raw in self.data.items()
|
for key, raw in self.data.items()
|
||||||
if key not in (_HISTORY_KEY, "_file_meta")
|
if key not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1519,7 +1752,7 @@ class BackupManager:
|
|||||||
生成对齐的文本表格
|
生成对齐的文本表格
|
||||||
"""
|
"""
|
||||||
non_history_keys = [
|
non_history_keys = [
|
||||||
k for k in self.data if k not in (_HISTORY_KEY, "_file_meta")
|
k for k in self.data if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||||
]
|
]
|
||||||
if not non_history_keys:
|
if not non_history_keys:
|
||||||
return t("cmd.all.empty")
|
return t("cmd.all.empty")
|
||||||
@@ -1532,7 +1765,7 @@ class BackupManager:
|
|||||||
]
|
]
|
||||||
rows = []
|
rows = []
|
||||||
for path, raw in self.data.items():
|
for path, raw in self.data.items():
|
||||||
if path in (_HISTORY_KEY, "_file_meta"):
|
if path in (_HISTORY_KEY, "_file_meta", "_chunk_meta"):
|
||||||
continue
|
continue
|
||||||
entry = BackupEntry.from_list(raw)
|
entry = BackupEntry.from_list(raw)
|
||||||
fmt_display = (
|
fmt_display = (
|
||||||
@@ -1562,7 +1795,7 @@ class BackupManager:
|
|||||||
|
|
||||||
# 策略统计
|
# 策略统计
|
||||||
strategies = {
|
strategies = {
|
||||||
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta")
|
k: v for k, v in self.data.items() if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta")
|
||||||
}
|
}
|
||||||
lines.append("## 策略概览")
|
lines.append("## 策略概览")
|
||||||
lines.append(f"- 策略总数: {len(strategies)}")
|
lines.append(f"- 策略总数: {len(strategies)}")
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""块级增量备份模块
|
||||||
|
|
||||||
|
将文件切分为固定大小的块(默认64KB),通过比较 SHA256 哈希
|
||||||
|
识别变化的块,仅备份变更部分,大幅减少增量备份的数据量。
|
||||||
|
|
||||||
|
使用场景:巨量 VM 镜像 / 数据库文件 / 日志文件,每次只改了一小部分。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import struct
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHUNK_SIZE = 64 * 1024 # 64KB
|
||||||
|
|
||||||
|
# 块级增量有三种模式
|
||||||
|
MODE_DISABLED = ""
|
||||||
|
MODE_FILE = "file" # 文件级增量(现有行为)
|
||||||
|
MODE_BLOCK = "block" # 块级增量(新)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_chunk_hashes(filepath: str, chunk_size: int = CHUNK_SIZE) -> dict[str, str]:
|
||||||
|
"""计算文件所有块的哈希,返回 {index_str: sha256}"""
|
||||||
|
hashes: dict[str, str] = {}
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
index = 0
|
||||||
|
while True:
|
||||||
|
data = f.read(chunk_size)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
hashes[str(index)] = hashlib.sha256(data).hexdigest()
|
||||||
|
index += 1
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def find_changed_chunks(
|
||||||
|
filepath: str,
|
||||||
|
stored_hashes: dict[str, str],
|
||||||
|
chunk_size: int = CHUNK_SIZE,
|
||||||
|
) -> tuple[set[int], int]:
|
||||||
|
"""找出变化的块索引集合和总块数。
|
||||||
|
|
||||||
|
:returns: (changed_indices_set, total_chunks)
|
||||||
|
"""
|
||||||
|
current = compute_chunk_hashes(filepath, chunk_size)
|
||||||
|
total = len(current)
|
||||||
|
changed: set[int] = set()
|
||||||
|
for idx_str, current_hash in current.items():
|
||||||
|
if stored_hashes.get(idx_str) != current_hash:
|
||||||
|
changed.add(int(idx_str))
|
||||||
|
# 如果文件变短了,标记缺失的块为变化
|
||||||
|
for idx_str in stored_hashes:
|
||||||
|
idx = int(idx_str)
|
||||||
|
if idx_str not in current:
|
||||||
|
changed.add(idx)
|
||||||
|
return changed, total
|
||||||
|
|
||||||
|
|
||||||
|
def create_patch(
|
||||||
|
filepath: str,
|
||||||
|
changed_indices: set[int],
|
||||||
|
target_path: str,
|
||||||
|
chunk_size: int = CHUNK_SIZE,
|
||||||
|
) -> int:
|
||||||
|
"""创建块级补丁文件。
|
||||||
|
|
||||||
|
格式:[chunk_count:int32] [[chunk_index:int32][chunk_size:int32][data:bytes]]*
|
||||||
|
|
||||||
|
:returns: patch 文件字节数,0 表示没有变化
|
||||||
|
"""
|
||||||
|
if not changed_indices:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with open(filepath, "rb") as src, open(target_path, "wb") as dst:
|
||||||
|
changed_list = sorted(changed_indices)
|
||||||
|
dst.write(struct.pack("<i", len(changed_list)))
|
||||||
|
|
||||||
|
for chunk_idx in changed_list:
|
||||||
|
offset = chunk_idx * chunk_size
|
||||||
|
src.seek(offset)
|
||||||
|
data = src.read(chunk_size)
|
||||||
|
dst.write(struct.pack("<ii", chunk_idx, len(data)))
|
||||||
|
dst.write(data)
|
||||||
|
|
||||||
|
return os.path.getsize(target_path)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_patch(
|
||||||
|
base_path: str,
|
||||||
|
patch_path: str,
|
||||||
|
output_path: str,
|
||||||
|
chunk_size: int = CHUNK_SIZE,
|
||||||
|
) -> bool:
|
||||||
|
"""将补丁应用到基准文件,生成还原后的文件。
|
||||||
|
|
||||||
|
对于补丁中包含的块索引,使用补丁中的数据;
|
||||||
|
对于补丁中不包含的索引,从基准文件中读取。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 读取补丁中的所有块
|
||||||
|
patch_chunks: dict[int, bytes] = {}
|
||||||
|
with open(patch_path, "rb") as pf:
|
||||||
|
count = struct.unpack("<i", pf.read(4))[0]
|
||||||
|
for _ in range(count):
|
||||||
|
idx, size = struct.unpack("<ii", pf.read(8))
|
||||||
|
data = pf.read(size)
|
||||||
|
patch_chunks[idx] = data
|
||||||
|
|
||||||
|
# 从基准文件读取完整内容(按块填充)
|
||||||
|
max_index = max(patch_chunks.keys())
|
||||||
|
base_size = os.path.getsize(base_path)
|
||||||
|
total_chunks = max(max_index + 1, (base_size + chunk_size - 1) // chunk_size)
|
||||||
|
|
||||||
|
with open(output_path, "wb") as out:
|
||||||
|
with open(base_path, "rb") as base:
|
||||||
|
for idx in range(total_chunks):
|
||||||
|
if idx in patch_chunks:
|
||||||
|
out.write(patch_chunks[idx])
|
||||||
|
else:
|
||||||
|
base.seek(idx * chunk_size)
|
||||||
|
data = base.read(chunk_size)
|
||||||
|
out.write(data)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to apply patch")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def patch_size_ratio(
|
||||||
|
filepath: str,
|
||||||
|
changed_indices: set[int],
|
||||||
|
chunk_size: int = CHUNK_SIZE,
|
||||||
|
) -> float:
|
||||||
|
"""计算变化的块占文件大小的比例"""
|
||||||
|
total_size = os.path.getsize(filepath)
|
||||||
|
if total_size == 0:
|
||||||
|
return 0.0
|
||||||
|
changed_size = len(changed_indices) * chunk_size
|
||||||
|
return min(changed_size / total_size, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def should_do_full_backup(
|
||||||
|
filepath: str,
|
||||||
|
changed_indices: set[int],
|
||||||
|
threshold: float = 0.5,
|
||||||
|
) -> bool:
|
||||||
|
"""判断是否应该做全量备份(变化比例超过阈值时回退到全量)"""
|
||||||
|
ratio = patch_size_ratio(filepath, changed_indices)
|
||||||
|
return ratio >= threshold
|
||||||
+267
-54
@@ -70,8 +70,10 @@ def get_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--incremental",
|
"--incremental",
|
||||||
action="store_true",
|
nargs="?",
|
||||||
default=False,
|
const="file",
|
||||||
|
default=None,
|
||||||
|
choices=["file", "block"],
|
||||||
help=t("cli.help.incremental"),
|
help=t("cli.help.incremental"),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -262,6 +264,18 @@ def get_parser() -> argparse.ArgumentParser:
|
|||||||
default=None,
|
default=None,
|
||||||
help=t("cli.help.watch.post_hook"),
|
help=t("cli.help.watch.post_hook"),
|
||||||
)
|
)
|
||||||
|
watch_parser.add_argument(
|
||||||
|
"--realtime",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help=t("cli.help.watch.realtime"),
|
||||||
|
)
|
||||||
|
watch_parser.add_argument(
|
||||||
|
"--debounce",
|
||||||
|
type=float,
|
||||||
|
default=30,
|
||||||
|
help=t("cli.help.watch.debounce"),
|
||||||
|
)
|
||||||
|
|
||||||
restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore"))
|
restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore"))
|
||||||
restore_parser.add_argument("backup_file", help=t("cli.help.restore.file"))
|
restore_parser.add_argument("backup_file", help=t("cli.help.restore.file"))
|
||||||
@@ -290,13 +304,33 @@ def get_parser() -> argparse.ArgumentParser:
|
|||||||
diff_parser.add_argument("--password", default="", help=t("cli.help.diff.password"))
|
diff_parser.add_argument("--password", default="", help=t("cli.help.diff.password"))
|
||||||
|
|
||||||
verify_parser = subparsers.add_parser("verify", help=t("cli.help.verify"))
|
verify_parser = subparsers.add_parser("verify", help=t("cli.help.verify"))
|
||||||
verify_parser.add_argument("backup_file", help=t("cli.help.verify.file"))
|
verify_parser.add_argument(
|
||||||
|
"backup_file", nargs="?", default=None, help=t("cli.help.verify.file")
|
||||||
|
)
|
||||||
verify_parser.add_argument(
|
verify_parser.add_argument(
|
||||||
"--fast",
|
"--fast",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help=t("cli.help.verify.fast"),
|
help=t("cli.help.verify.fast"),
|
||||||
)
|
)
|
||||||
|
verify_parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help=t("cli.help.verify.all"),
|
||||||
|
)
|
||||||
|
verify_parser.add_argument(
|
||||||
|
"--detail",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help=t("cli.help.verify.detail"),
|
||||||
|
)
|
||||||
|
verify_parser.add_argument(
|
||||||
|
"--split",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help=t("cli.help.verify.split"),
|
||||||
|
)
|
||||||
verify_parser.add_argument(
|
verify_parser.add_argument(
|
||||||
"--password", default="", help=t("cli.help.verify.password")
|
"--password", default="", help=t("cli.help.verify.password")
|
||||||
)
|
)
|
||||||
@@ -484,6 +518,17 @@ def get_parser() -> argparse.ArgumentParser:
|
|||||||
clean_parser.add_argument(
|
clean_parser.add_argument(
|
||||||
"--keep-days", type=int, default=0, help=t("cli.help.clean.keep_days")
|
"--keep-days", type=int, default=0, help=t("cli.help.clean.keep_days")
|
||||||
)
|
)
|
||||||
|
completion_parser = subparsers.add_parser(
|
||||||
|
"completion", help=t("cli.help.completion")
|
||||||
|
)
|
||||||
|
completion_parser.add_argument(
|
||||||
|
"shell",
|
||||||
|
nargs="?",
|
||||||
|
default="bash",
|
||||||
|
choices=["bash", "zsh", "fish", "powershell"],
|
||||||
|
help=t("cli.help.completion.shell"),
|
||||||
|
)
|
||||||
|
|
||||||
clean_parser.add_argument(
|
clean_parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -618,63 +663,126 @@ def _handle_list(args, config, manager) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _handle_save(args, config, manager) -> int:
|
def _handle_save(args, config, manager) -> int:
|
||||||
manager.execute_backups(
|
from sbackup.lock import BackupLock
|
||||||
keep=args.keep,
|
|
||||||
keep_days=args.keep_days,
|
if args.password:
|
||||||
password=args.password,
|
print(t("warn.password_in_cli"), file=sys.stderr)
|
||||||
sftp_upload=args.sftp,
|
lock = BackupLock(os.path.dirname(config.data_file) or ".")
|
||||||
webdav_upload=args.webdav,
|
if not lock.acquire():
|
||||||
dry_run=args.dry_run,
|
print(t("err.lock.conflict"))
|
||||||
verify=args.verify,
|
return 1
|
||||||
name_template=args.name_template,
|
try:
|
||||||
webhook_url=args.webhook,
|
manager.execute_backups(
|
||||||
follow_symlinks=args.follow_symlinks,
|
keep=args.keep,
|
||||||
max_size=_parse_size(args.max_size) if args.max_size else 0,
|
keep_days=args.keep_days,
|
||||||
min_size=_parse_size(args.min_size) if args.min_size else 0,
|
password=args.password,
|
||||||
max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0,
|
sftp_upload=args.sftp,
|
||||||
incremental=args.incremental,
|
webdav_upload=args.webdav,
|
||||||
split_size=_parse_size(args.split) if args.split else 0,
|
dry_run=args.dry_run,
|
||||||
tag=args.tag or "",
|
verify=args.verify,
|
||||||
checksum=args.checksum,
|
name_template=args.name_template,
|
||||||
pre_hooks=getattr(args, "pre_hook", None) or [],
|
webhook_url=args.webhook,
|
||||||
post_hooks=getattr(args, "post_hook", None) or [],
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _handle_watch(args, config, manager) -> int:
|
def _handle_watch(args, config, manager) -> int:
|
||||||
|
from sbackup.lock import BackupLock
|
||||||
|
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
interval_sec = max(args.interval, 1) * 60
|
interval_sec = max(args.interval, 1) * 60
|
||||||
print(t("cmd.watch.start", interval=args.interval))
|
realtime = getattr(args, "realtime", False)
|
||||||
|
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:
|
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:
|
while True:
|
||||||
manager.execute_backups(
|
do_backup()
|
||||||
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 [],
|
|
||||||
)
|
|
||||||
_time.sleep(interval_sec)
|
_time.sleep(interval_sec)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
return 0
|
return 0
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
def _handle_restore(args, config, manager) -> int:
|
def _handle_restore(args, config, manager) -> int:
|
||||||
@@ -713,12 +821,107 @@ def _handle_diff(args, config, manager) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _handle_verify(args, config, manager) -> int:
|
def _handle_verify(args, config, manager) -> int:
|
||||||
|
from sbackup.compression import (
|
||||||
|
verify_backup,
|
||||||
|
verify_backup_fast,
|
||||||
|
get_backup_info,
|
||||||
|
verify_split_integrity,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 分卷完整性验证
|
||||||
|
if args.split:
|
||||||
|
if not args.backup_file:
|
||||||
|
print(t("cmd.verify.no_file"))
|
||||||
|
return 1
|
||||||
|
result = verify_split_integrity(args.backup_file)
|
||||||
|
if result["success"]:
|
||||||
|
print(
|
||||||
|
t(
|
||||||
|
"cmd.verify.split_success",
|
||||||
|
file=result.get("original_file", ""),
|
||||||
|
parts=result.get("parts_count", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
error = result.get("error", "")
|
||||||
|
if error:
|
||||||
|
print(t("cmd.verify.split_error", error=error))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
t("cmd.verify.split_failed", file=result.get("original_file", ""))
|
||||||
|
)
|
||||||
|
for r in result.get("results", []):
|
||||||
|
if r["status"] != "ok":
|
||||||
|
print(f" {r['path']}: {r['status']}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 批量验证所有备份
|
||||||
|
if args.all:
|
||||||
|
history = manager.get_history()
|
||||||
|
if not history:
|
||||||
|
print(t("cmd.verify.no_history"))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
for entry in reversed(history):
|
||||||
|
source = entry.get("source", "")
|
||||||
|
sha256 = entry.get("sha256", "")
|
||||||
|
if not source or not os.path.isfile(source):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if args.detail:
|
||||||
|
info = get_backup_info(source, args.password)
|
||||||
|
print(t("cmd.verify.detail_header", path=source))
|
||||||
|
print(
|
||||||
|
t(
|
||||||
|
"cmd.verify.detail_info",
|
||||||
|
size=info.get("size_mb", 0),
|
||||||
|
files=info.get("files_count", 0),
|
||||||
|
fmt=info.get("format", "unknown"),
|
||||||
|
sha256=sha256[:16] if sha256 else "N/A",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if sha256:
|
||||||
|
result = verify_backup_fast(source, sha256)
|
||||||
|
else:
|
||||||
|
result = verify_backup(source, args.password)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
print(t("cmd.verify.batch_success", path=source))
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print(t("cmd.verify.batch_failed", path=source))
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
print(t("cmd.verify.batch_summary", success=success_count, fail=fail_count))
|
||||||
|
return 0 if fail_count == 0 else 1
|
||||||
|
|
||||||
|
# 单文件验证
|
||||||
|
if not args.backup_file:
|
||||||
|
print(t("cmd.verify.no_file"))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.detail:
|
||||||
|
info = get_backup_info(args.backup_file, args.password)
|
||||||
|
sha256 = manager.find_checksum(args.backup_file)
|
||||||
|
print(t("cmd.verify.detail_header", path=args.backup_file))
|
||||||
|
print(
|
||||||
|
t(
|
||||||
|
"cmd.verify.detail_info",
|
||||||
|
size=info.get("size_mb", 0),
|
||||||
|
files=info.get("files_count", 0),
|
||||||
|
fmt=info.get("format", "unknown"),
|
||||||
|
sha256=sha256[:16] if sha256 else "N/A",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if args.fast:
|
if args.fast:
|
||||||
# 快速验证:用历史 SHA256 比对,无需解压
|
# 快速验证:用历史 SHA256 比对,无需解压
|
||||||
sha256 = manager.find_checksum(args.backup_file)
|
sha256 = manager.find_checksum(args.backup_file)
|
||||||
if sha256:
|
if sha256:
|
||||||
from sbackup.compression import verify_backup_fast
|
|
||||||
|
|
||||||
result = verify_backup_fast(args.backup_file, sha256)
|
result = verify_backup_fast(args.backup_file, sha256)
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
print(
|
print(
|
||||||
@@ -731,13 +934,9 @@ def _handle_verify(args, config, manager) -> int:
|
|||||||
else:
|
else:
|
||||||
print(t("cmd.verify.no_checksum", path=args.backup_file))
|
print(t("cmd.verify.no_checksum", path=args.backup_file))
|
||||||
# 回退到完整验证
|
# 回退到完整验证
|
||||||
from sbackup.compression import verify_backup
|
|
||||||
|
|
||||||
result = verify_backup(args.backup_file, args.password)
|
result = verify_backup(args.backup_file, args.password)
|
||||||
return 0 if result["success"] else 1
|
return 0 if result["success"] else 1
|
||||||
else:
|
else:
|
||||||
from sbackup.compression import verify_backup
|
|
||||||
|
|
||||||
result = verify_backup(args.backup_file, args.password)
|
result = verify_backup(args.backup_file, args.password)
|
||||||
return 0 if result["success"] else 1
|
return 0 if result["success"] else 1
|
||||||
|
|
||||||
@@ -963,6 +1162,19 @@ def _handle_clean(args, config, manager) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_completion(args, config, manager) -> int:
|
||||||
|
"""生成并输出 shell 自动补全脚本"""
|
||||||
|
from sbackup.completion import generate
|
||||||
|
|
||||||
|
try:
|
||||||
|
script = generate(args.shell)
|
||||||
|
print(script, end="")
|
||||||
|
except ValueError as e:
|
||||||
|
print(e, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
_COMMAND_HANDLERS: dict[str, callable] = {
|
_COMMAND_HANDLERS: dict[str, callable] = {
|
||||||
"version": _handle_version,
|
"version": _handle_version,
|
||||||
"add": _handle_add,
|
"add": _handle_add,
|
||||||
@@ -992,6 +1204,7 @@ _COMMAND_HANDLERS: dict[str, callable] = {
|
|||||||
"report": _handle_report,
|
"report": _handle_report,
|
||||||
"search": _handle_search,
|
"search": _handle_search,
|
||||||
"clean": _handle_clean,
|
"clean": _handle_clean,
|
||||||
|
"completion": _handle_completion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+332
-66
@@ -134,44 +134,52 @@ def _resolve_name(template: str, folder_name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _encrypt_file(file_path: str, password: str) -> str:
|
def _encrypt_file(file_path: str, password: str) -> str:
|
||||||
"""用密码加密文件(PBKDF2 + XOR 流密码),返回加密后的文件路径"""
|
"""用密码加密文件(PBKDF2 + XOR 流密码 + HMAC 完整性校验),返回加密后的文件路径"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
salt = os.urandom(16)
|
salt = os.urandom(16)
|
||||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000)
|
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000)
|
||||||
|
|
||||||
encrypted_path = file_path + ".enc"
|
encrypted_path = file_path + ".enc"
|
||||||
with open(file_path, "rb") as fin, open(encrypted_path, "wb") as fout:
|
tmp_path = encrypted_path + ".tmp"
|
||||||
fout.write(salt)
|
try:
|
||||||
key_stream = bytearray()
|
with open(file_path, "rb") as fin, open(tmp_path, "wb") as fout:
|
||||||
block_index = 0
|
fout.write(salt)
|
||||||
while True:
|
key_stream = bytearray()
|
||||||
chunk = fin.read(65536)
|
block_index = 0
|
||||||
if not chunk:
|
while True:
|
||||||
break
|
chunk = fin.read(65536)
|
||||||
# 生成密钥流
|
if not chunk:
|
||||||
while len(key_stream) < len(chunk):
|
break
|
||||||
block_index += 1
|
while len(key_stream) < len(chunk):
|
||||||
key_stream.extend(
|
block_index += 1
|
||||||
hashlib.sha256(key + block_index.to_bytes(4, "big")).digest()
|
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 加密
|
fout.write(encrypted)
|
||||||
encrypted = bytes(a ^ b for a, b in zip(chunk, key_stream[: len(chunk)]))
|
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)
|
os.remove(file_path)
|
||||||
return encrypted_path
|
return encrypted_path
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_file(encrypted_path: str, password: str) -> str:
|
def _decrypt_file(encrypted_path: str, password: str) -> str:
|
||||||
"""解密由 _encrypt_file 加密的文件,返回解密后的文件路径"""
|
"""解密由 _encrypt_file 加密的文件,返回解密后的文件路径。密码错误时抛出 ValueError"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
decrypted_path = encrypted_path.removesuffix(".enc")
|
decrypted_path = encrypted_path.removesuffix(".enc")
|
||||||
with open(encrypted_path, "rb") as fin:
|
with open(encrypted_path, "rb") as fin:
|
||||||
salt = fin.read(16)
|
salt = fin.read(16)
|
||||||
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100_000)
|
if len(salt) < 16:
|
||||||
|
raise ValueError("Invalid encrypted file: too short")
|
||||||
|
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000)
|
||||||
|
|
||||||
with open(decrypted_path, "wb") as fout:
|
with open(decrypted_path, "wb") as fout:
|
||||||
key_stream = bytearray()
|
key_stream = bytearray()
|
||||||
@@ -246,11 +254,12 @@ class BaseCompressor:
|
|||||||
negated.append(pattern[1:])
|
negated.append(pattern[1:])
|
||||||
continue
|
continue
|
||||||
if pattern.startswith("re:"):
|
if pattern.startswith("re:"):
|
||||||
# 正则表达式模式
|
regex_str = pattern[3:]
|
||||||
|
# ReDoS 防护:拒绝过长或明显恶意的正则
|
||||||
|
if len(regex_str) > 256:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if re.search(pattern[3:], rel_path) or re.search(
|
if re.search(regex_str, rel_path) or re.search(regex_str, basename):
|
||||||
pattern[3:], basename
|
|
||||||
):
|
|
||||||
matched = True
|
matched = True
|
||||||
except re.error:
|
except re.error:
|
||||||
pass
|
pass
|
||||||
@@ -262,10 +271,11 @@ class BaseCompressor:
|
|||||||
return False
|
return False
|
||||||
for pattern in negated:
|
for pattern in negated:
|
||||||
if pattern.startswith("re:"):
|
if pattern.startswith("re:"):
|
||||||
|
regex_str = pattern[3:]
|
||||||
|
if len(regex_str) > 256:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if re.search(pattern[3:], rel_path) or re.search(
|
if re.search(regex_str, rel_path) or re.search(regex_str, basename):
|
||||||
pattern[3:], basename
|
|
||||||
):
|
|
||||||
return False
|
return False
|
||||||
except re.error:
|
except re.error:
|
||||||
pass
|
pass
|
||||||
@@ -827,6 +837,85 @@ def _match_select_pattern(name: str, pattern: str) -> bool:
|
|||||||
return fnmatch(name, pattern) or fnmatch(basename, pattern)
|
return fnmatch(name, pattern) or fnmatch(basename, pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_member_name(member_name: str) -> bool:
|
||||||
|
"""检查归档成员名是否安全(无路径遍历、无绝对路径、无空字节)"""
|
||||||
|
# 拒绝含空字节的路径(可能绕过之后的检查)
|
||||||
|
if "\x00" in member_name:
|
||||||
|
return False
|
||||||
|
# 拒绝绝对路径
|
||||||
|
if os.path.isabs(member_name):
|
||||||
|
return False
|
||||||
|
# Windows: 拒绝驱动器相对路径(如 C:file.txt)
|
||||||
|
if os.name == "nt" and len(member_name) >= 2:
|
||||||
|
if member_name[1] == ":" and member_name[0].isalpha():
|
||||||
|
return False
|
||||||
|
# 拒绝包含 .. 的路径组件
|
||||||
|
parts = Path(member_name).parts
|
||||||
|
if ".." in parts:
|
||||||
|
return False
|
||||||
|
# 拒绝空路径
|
||||||
|
if not member_name or member_name.strip() in ("", ".", "/"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract_zip(
|
||||||
|
zf: zipfile.ZipFile, target: Path, members: list[str] | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""安全提取 ZIP 文件,过滤路径遍历成员,返回被跳过的成员列表"""
|
||||||
|
target_resolved = target.resolve()
|
||||||
|
skipped = []
|
||||||
|
names = members if members is not None else zf.namelist()
|
||||||
|
for member in names:
|
||||||
|
if not _is_safe_member_name(member):
|
||||||
|
skipped.append(member)
|
||||||
|
continue
|
||||||
|
# 双重检查:解析后路径必须在目标目录内
|
||||||
|
dest = (target / member).resolve()
|
||||||
|
try:
|
||||||
|
dest.relative_to(target_resolved)
|
||||||
|
except ValueError:
|
||||||
|
skipped.append(member)
|
||||||
|
continue
|
||||||
|
zf.extract(member, target)
|
||||||
|
return skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_extract_tar(
|
||||||
|
tarf: tarfile.TarFile,
|
||||||
|
target: Path,
|
||||||
|
members: list[tarfile.TarInfo],
|
||||||
|
quiet: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""安全提取 tar 成员:过滤路径遍历和符号链接,返回跳过的数量"""
|
||||||
|
target_resolved = target.resolve()
|
||||||
|
skipped_count = 0
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
for member in members:
|
||||||
|
if not _is_safe_member_name(member.name):
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
# 跳过符号链接和硬链接(防止链接攻击)
|
||||||
|
if member.issym() or member.islnk():
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
# Python < 3.12: 手动验证提取路径在目标目录内
|
||||||
|
if _sys.version_info < (3, 12):
|
||||||
|
dest = (target / member.name).resolve()
|
||||||
|
try:
|
||||||
|
dest.relative_to(target_resolved)
|
||||||
|
except ValueError:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
# Python 3.12+: filter="data" 会处理路径清理和链接目标
|
||||||
|
extra_args = {}
|
||||||
|
if _sys.version_info >= (3, 12):
|
||||||
|
extra_args["filter"] = "data"
|
||||||
|
tarf.extract(member, path=str(target), **extra_args)
|
||||||
|
return skipped_count
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(
|
def restore_backup(
|
||||||
backup_path: str,
|
backup_path: str,
|
||||||
target_dir: str,
|
target_dir: str,
|
||||||
@@ -900,23 +989,22 @@ def restore_backup(
|
|||||||
with zipfile.ZipFile(backup, "r") as zf:
|
with zipfile.ZipFile(backup, "r") as zf:
|
||||||
all_members = zf.namelist()
|
all_members = zf.namelist()
|
||||||
members = (
|
members = (
|
||||||
[m for m in all_members if _match_select_pattern(m, select_pattern)]
|
[
|
||||||
|
m
|
||||||
|
for m in all_members
|
||||||
|
if _is_safe_member_name(m)
|
||||||
|
and _match_select_pattern(m, select_pattern)
|
||||||
|
]
|
||||||
if select_pattern
|
if select_pattern
|
||||||
else all_members
|
else [m for m in all_members if _is_safe_member_name(m)]
|
||||||
)
|
)
|
||||||
if not members:
|
if not members:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(t("restore.no_match", pattern=select_pattern))
|
print(t("restore.no_match", pattern=select_pattern))
|
||||||
return {"success": True, "files_count": 0}
|
return {"success": True, "files_count": 0}
|
||||||
with tqdm(
|
skipped = _safe_extract_zip(zf, target, members)
|
||||||
total=len(members),
|
if skipped and not quiet:
|
||||||
desc=t("restore.progress"),
|
print(t("restore.skipped_unsafe", count=len(skipped)))
|
||||||
unit=t("compress.unit"),
|
|
||||||
disable=quiet,
|
|
||||||
) as pbar:
|
|
||||||
for member in members:
|
|
||||||
zf.extract(member, target)
|
|
||||||
pbar.update(1)
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(t("restore.success", path=target, count=len(members)))
|
print(t("restore.success", path=target, count=len(members)))
|
||||||
return {"success": True, "files_count": len(members)}
|
return {"success": True, "files_count": len(members)}
|
||||||
@@ -928,10 +1016,24 @@ def restore_backup(
|
|||||||
szf_kwargs["password"] = password
|
szf_kwargs["password"] = password
|
||||||
with py7zr.SevenZipFile(**szf_kwargs) as szf:
|
with py7zr.SevenZipFile(**szf_kwargs) as szf:
|
||||||
all_members = szf.getnames()
|
all_members = szf.getnames()
|
||||||
|
# 过滤路径遍历成员(Zip Slip 防护)
|
||||||
|
safe_members = [m for m in all_members if _is_safe_member_name(m)]
|
||||||
|
skipped_count = len(all_members) - len(safe_members)
|
||||||
|
if skipped_count > 0 and not quiet:
|
||||||
|
print(
|
||||||
|
t(
|
||||||
|
"restore.skipped_unsafe",
|
||||||
|
count=skipped_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
members = (
|
members = (
|
||||||
[m for m in all_members if _match_select_pattern(m, select_pattern)]
|
[
|
||||||
|
m
|
||||||
|
for m in safe_members
|
||||||
|
if _match_select_pattern(m, select_pattern)
|
||||||
|
]
|
||||||
if select_pattern
|
if select_pattern
|
||||||
else all_members
|
else safe_members
|
||||||
)
|
)
|
||||||
if not members:
|
if not members:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
@@ -975,15 +1077,14 @@ def restore_backup(
|
|||||||
tmp.seek(0)
|
tmp.seek(0)
|
||||||
with tarfile.open(fileobj=tmp, mode="r") as tarf:
|
with tarfile.open(fileobj=tmp, mode="r") as tarf:
|
||||||
all_members = tarf.getmembers()
|
all_members = tarf.getmembers()
|
||||||
members = (
|
if select_pattern:
|
||||||
[
|
members = [
|
||||||
m
|
m
|
||||||
for m in all_members
|
for m in all_members
|
||||||
if _match_select_pattern(m.name, select_pattern)
|
if _match_select_pattern(m.name, select_pattern)
|
||||||
]
|
]
|
||||||
if select_pattern
|
else:
|
||||||
else all_members
|
members = list(all_members)
|
||||||
)
|
|
||||||
if not members:
|
if not members:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(t("restore.no_match", pattern=select_pattern))
|
print(t("restore.no_match", pattern=select_pattern))
|
||||||
@@ -994,12 +1095,14 @@ def restore_backup(
|
|||||||
unit=t("compress.unit"),
|
unit=t("compress.unit"),
|
||||||
disable=quiet,
|
disable=quiet,
|
||||||
) as pbar:
|
) as pbar:
|
||||||
for member in members:
|
skipped = _safe_extract_tar(tarf, target, members)
|
||||||
tarf.extract(member, target, filter="data")
|
pbar.update(len(members))
|
||||||
pbar.update(1)
|
if skipped and not quiet:
|
||||||
|
print(t("restore.skipped_unsafe", count=skipped))
|
||||||
|
actual = len(members) - skipped
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(t("restore.success", path=target, count=len(members)))
|
print(t("restore.success", path=target, count=actual))
|
||||||
return {"success": True, "files_count": len(members)}
|
return {"success": True, "files_count": actual}
|
||||||
elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"):
|
||||||
mode = "r:gz"
|
mode = "r:gz"
|
||||||
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
|
elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"):
|
||||||
@@ -1015,15 +1118,14 @@ def restore_backup(
|
|||||||
|
|
||||||
with tarfile.open(backup, mode) as tarf:
|
with tarfile.open(backup, mode) as tarf:
|
||||||
all_members = tarf.getmembers()
|
all_members = tarf.getmembers()
|
||||||
members = (
|
if select_pattern:
|
||||||
[
|
members = [
|
||||||
m
|
m
|
||||||
for m in all_members
|
for m in all_members
|
||||||
if _match_select_pattern(m.name, select_pattern)
|
if _match_select_pattern(m.name, select_pattern)
|
||||||
]
|
]
|
||||||
if select_pattern
|
else:
|
||||||
else all_members
|
members = list(all_members)
|
||||||
)
|
|
||||||
if not members:
|
if not members:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(t("restore.no_match", pattern=select_pattern))
|
print(t("restore.no_match", pattern=select_pattern))
|
||||||
@@ -1034,12 +1136,14 @@ def restore_backup(
|
|||||||
unit=t("compress.unit"),
|
unit=t("compress.unit"),
|
||||||
disable=quiet,
|
disable=quiet,
|
||||||
) as pbar:
|
) as pbar:
|
||||||
for member in members:
|
skipped = _safe_extract_tar(tarf, target, members)
|
||||||
tarf.extract(member, target, filter="data")
|
pbar.update(len(members))
|
||||||
pbar.update(1)
|
if skipped and not quiet:
|
||||||
|
print(t("restore.skipped_unsafe", count=skipped))
|
||||||
|
actual = len(members) - skipped
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(t("restore.success", path=target, count=len(members)))
|
print(t("restore.success", path=target, count=actual))
|
||||||
return {"success": True, "files_count": len(members)}
|
return {"success": True, "files_count": actual}
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
raise
|
raise
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -1339,12 +1443,19 @@ def verify_backup_fast(backup_path: str, expected_sha256: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def split_file(file_path: str, chunk_size: int) -> list[str]:
|
def split_file(
|
||||||
|
file_path: str, chunk_size: int, *, create_manifest: bool = True
|
||||||
|
) -> list[str]:
|
||||||
"""将大文件分割为多个分卷文件
|
"""将大文件分割为多个分卷文件
|
||||||
:param file_path: 要分割的文件路径
|
:param file_path: 要分割的文件路径
|
||||||
:param chunk_size: 每个分卷的大小(字节)
|
:param chunk_size: 每个分卷的大小(字节)
|
||||||
|
:param create_manifest: 是否创建清单文件
|
||||||
:return: 分卷文件路径列表
|
:return: 分卷文件路径列表
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
if chunk_size <= 0:
|
if chunk_size <= 0:
|
||||||
return [file_path]
|
return [file_path]
|
||||||
|
|
||||||
@@ -1352,7 +1463,18 @@ def split_file(file_path: str, chunk_size: int) -> list[str]:
|
|||||||
if file_size <= chunk_size:
|
if file_size <= chunk_size:
|
||||||
return [file_path]
|
return [file_path]
|
||||||
|
|
||||||
|
# 计算原文件 SHA256
|
||||||
|
original_sha256 = hashlib.sha256()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
original_sha256.update(chunk)
|
||||||
|
original_hash = original_sha256.hexdigest()
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
|
part_checksums = []
|
||||||
part_index = 1
|
part_index = 1
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
@@ -1360,31 +1482,175 @@ def split_file(file_path: str, chunk_size: int) -> list[str]:
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
part_path = f"{file_path}.{part_index:03d}"
|
part_path = f"{file_path}.{part_index:03d}"
|
||||||
|
# 计算分卷校验和
|
||||||
|
part_hash = hashlib.sha256(chunk).hexdigest()
|
||||||
|
part_checksums.append(
|
||||||
|
{"path": os.path.basename(part_path), "sha256": part_hash}
|
||||||
|
)
|
||||||
with open(part_path, "wb") as pf:
|
with open(part_path, "wb") as pf:
|
||||||
pf.write(chunk)
|
pf.write(chunk)
|
||||||
parts.append(part_path)
|
parts.append(part_path)
|
||||||
part_index += 1
|
part_index += 1
|
||||||
|
|
||||||
|
# 创建清单文件
|
||||||
|
if create_manifest:
|
||||||
|
manifest_path = f"{file_path}.manifest.json"
|
||||||
|
manifest = {
|
||||||
|
"original_file": os.path.basename(file_path),
|
||||||
|
"original_size": file_size,
|
||||||
|
"original_sha256": original_hash,
|
||||||
|
"chunk_size": chunk_size,
|
||||||
|
"parts_count": len(parts),
|
||||||
|
"parts": part_checksums,
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
with open(manifest_path, "w", encoding="utf-8") as mf:
|
||||||
|
json.dump(manifest, mf, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
# 删除原文件
|
# 删除原文件
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
|
|
||||||
def merge_files(part_paths: list[str], output_path: str) -> bool:
|
def merge_files(
|
||||||
|
part_paths: list[str],
|
||||||
|
output_path: str,
|
||||||
|
*,
|
||||||
|
resume: bool = False,
|
||||||
|
progress_callback=None,
|
||||||
|
) -> bool:
|
||||||
"""合并分卷文件为单个文件
|
"""合并分卷文件为单个文件
|
||||||
:param part_paths: 分卷文件路径列表(按顺序)
|
:param part_paths: 分卷文件路径列表(按顺序)
|
||||||
:param output_path: 输出文件路径
|
:param output_path: 输出文件路径
|
||||||
|
:param resume: 是否支持断点续传(从上次中断处继续)
|
||||||
|
:param progress_callback: 进度回调函数 callback(current_bytes, total_bytes)
|
||||||
:return: 是否成功
|
:return: 是否成功
|
||||||
"""
|
"""
|
||||||
|
if not part_paths:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 计算总大小
|
||||||
|
total_size = 0
|
||||||
|
for p in part_paths:
|
||||||
|
try:
|
||||||
|
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:
|
try:
|
||||||
with open(output_path, "wb") as out_f:
|
mode = "ab" if resume and os.path.exists(output_path) else "wb"
|
||||||
for part_path in part_paths:
|
with open(output_path, mode) as out_f:
|
||||||
|
for i in range(start_part_idx, len(part_paths)):
|
||||||
|
part_path = part_paths[i]
|
||||||
with open(part_path, "rb") as in_f:
|
with open(part_path, "rb") as in_f:
|
||||||
|
if i == start_part_idx and part_offset > 0:
|
||||||
|
in_f.seek(part_offset)
|
||||||
while True:
|
while True:
|
||||||
chunk = in_f.read(65536)
|
chunk = in_f.read(65536)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
out_f.write(chunk)
|
out_f.write(chunk)
|
||||||
|
written_bytes += len(chunk)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(written_bytes, total_size)
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_split_integrity(file_path: str) -> dict:
|
||||||
|
"""验证分卷文件完整性
|
||||||
|
:param file_path: 任意分卷文件路径或清单文件路径
|
||||||
|
:return: 验证结果字典
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 查找清单文件
|
||||||
|
if file_path.endswith(".manifest.json"):
|
||||||
|
manifest_path = file_path
|
||||||
|
else:
|
||||||
|
# 从分卷文件推断清单路径
|
||||||
|
base = file_path.rsplit(".", 1)[0] if ".0" in file_path else file_path
|
||||||
|
manifest_path = f"{base}.manifest.json"
|
||||||
|
if not os.path.exists(manifest_path):
|
||||||
|
# 尝试去掉 .001 后缀
|
||||||
|
if file_path.endswith(".001"):
|
||||||
|
manifest_path = f"{file_path[:-4]}.manifest.json"
|
||||||
|
|
||||||
|
if not os.path.exists(manifest_path):
|
||||||
|
return {"success": False, "error": "Manifest file not found"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
return {"success": False, "error": f"Failed to read manifest: {e}"}
|
||||||
|
|
||||||
|
manifest_dir = os.path.dirname(manifest_path)
|
||||||
|
results = []
|
||||||
|
all_ok = True
|
||||||
|
|
||||||
|
for part_info in manifest.get("parts", []):
|
||||||
|
part_name = part_info["path"]
|
||||||
|
expected_hash = part_info["sha256"]
|
||||||
|
part_path = os.path.join(manifest_dir, part_name)
|
||||||
|
|
||||||
|
if not os.path.exists(part_path):
|
||||||
|
results.append(
|
||||||
|
{"path": part_name, "status": "missing", "expected": expected_hash}
|
||||||
|
)
|
||||||
|
all_ok = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算实际校验和
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
with open(part_path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
sha256.update(chunk)
|
||||||
|
actual_hash = sha256.hexdigest()
|
||||||
|
|
||||||
|
if actual_hash == expected_hash:
|
||||||
|
results.append({"path": part_name, "status": "ok"})
|
||||||
|
else:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"path": part_name,
|
||||||
|
"status": "corrupted",
|
||||||
|
"expected": expected_hash,
|
||||||
|
"actual": actual_hash,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": all_ok,
|
||||||
|
"original_file": manifest.get("original_file", ""),
|
||||||
|
"original_size": manifest.get("original_size", 0),
|
||||||
|
"parts_count": manifest.get("parts_count", 0),
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
+108
-13
@@ -27,7 +27,7 @@ def _load_json_file(config_file: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _save_json_file(data: dict, config_file: str) -> None:
|
def _save_json_file(data: dict, config_file: str) -> None:
|
||||||
"""将字典写入 JSON 配置文件,自动创建目录"""
|
"""将字典写入 JSON 配置文件,自动创建目录,设置限制性权限"""
|
||||||
data_dir = os.path.dirname(config_file)
|
data_dir = os.path.dirname(config_file)
|
||||||
if data_dir:
|
if data_dir:
|
||||||
try:
|
try:
|
||||||
@@ -36,11 +36,35 @@ def _save_json_file(data: dict, config_file: str) -> None:
|
|||||||
logger.error(t("log.config.mkdir.error"), data_dir, e)
|
logger.error(t("log.config.mkdir.error"), data_dir, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 原子写入:先写临时文件再替换
|
||||||
|
tmp_path = config_file + ".tmp"
|
||||||
try:
|
try:
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
# 设置临时文件限制性权限(仅所有者可读写)
|
||||||
|
if sys.platform != "win32":
|
||||||
|
try:
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(tmp_path, config_file)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(t("log.config.write.error"), config_file, e)
|
logger.error(t("log.config.write.error"), config_file, e)
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# 设置限制性文件权限(仅所有者可读写)
|
||||||
|
if sys.platform != "win32":
|
||||||
|
try:
|
||||||
|
os.chmod(config_file, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass # 权限设置失败不影响主流程
|
||||||
|
|
||||||
|
|
||||||
def get_default_data_file() -> str:
|
def get_default_data_file() -> str:
|
||||||
@@ -101,6 +125,15 @@ class Config:
|
|||||||
webdav_password: str = ""
|
webdav_password: str = ""
|
||||||
webdav_remote_path: str = "/"
|
webdav_remote_path: str = "/"
|
||||||
webdav_enabled: bool = False
|
webdav_enabled: bool = False
|
||||||
|
# SMTP 邮件通知配置
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_user: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from: str = ""
|
||||||
|
smtp_to: str = ""
|
||||||
|
smtp_tls: bool = True
|
||||||
|
smtp_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_file: str = "config.json") -> Config:
|
def load_config(config_file: str = "config.json") -> Config:
|
||||||
@@ -141,6 +174,22 @@ def load_config(config_file: str = "config.json") -> Config:
|
|||||||
if webhook_url_legacy and not webhook_urls:
|
if webhook_url_legacy and not webhook_urls:
|
||||||
webhook_urls = [webhook_url_legacy]
|
webhook_urls = [webhook_url_legacy]
|
||||||
webdav_config = config_data.get("webdav", {})
|
webdav_config = config_data.get("webdav", {})
|
||||||
|
smtp_config = config_data.get("smtp", {})
|
||||||
|
|
||||||
|
# 验证端口范围(1-65535)
|
||||||
|
sftp_port = sftp_config.get("port", 22)
|
||||||
|
if not isinstance(sftp_port, int) or not (1 <= sftp_port <= 65535):
|
||||||
|
logger.warning(
|
||||||
|
"SFTP port %s is out of range (1-65535), using default 22", sftp_port
|
||||||
|
)
|
||||||
|
sftp_port = 22
|
||||||
|
|
||||||
|
smtp_port = smtp_config.get("port", 587)
|
||||||
|
if not isinstance(smtp_port, int) or not (1 <= smtp_port <= 65535):
|
||||||
|
logger.warning(
|
||||||
|
"SMTP port %s is out of range (1-65535), using default 587", smtp_port
|
||||||
|
)
|
||||||
|
smtp_port = 587
|
||||||
|
|
||||||
return Config(
|
return Config(
|
||||||
folder_path="",
|
folder_path="",
|
||||||
@@ -158,7 +207,7 @@ def load_config(config_file: str = "config.json") -> Config:
|
|||||||
webhook_template=webhook_template,
|
webhook_template=webhook_template,
|
||||||
webhook_retries=webhook_retries,
|
webhook_retries=webhook_retries,
|
||||||
sftp_host=sftp_config.get("host", ""),
|
sftp_host=sftp_config.get("host", ""),
|
||||||
sftp_port=sftp_config.get("port", 22),
|
sftp_port=sftp_port,
|
||||||
sftp_user=sftp_config.get("user", ""),
|
sftp_user=sftp_config.get("user", ""),
|
||||||
sftp_password=sftp_config.get("password", ""),
|
sftp_password=sftp_config.get("password", ""),
|
||||||
sftp_key_file=sftp_config.get("key_file", ""),
|
sftp_key_file=sftp_config.get("key_file", ""),
|
||||||
@@ -170,6 +219,14 @@ def load_config(config_file: str = "config.json") -> Config:
|
|||||||
webdav_password=webdav_config.get("password", ""),
|
webdav_password=webdav_config.get("password", ""),
|
||||||
webdav_remote_path=webdav_config.get("remote_path", "/"),
|
webdav_remote_path=webdav_config.get("remote_path", "/"),
|
||||||
webdav_enabled=webdav_config.get("enabled", False),
|
webdav_enabled=webdav_config.get("enabled", False),
|
||||||
|
smtp_host=smtp_config.get("host", ""),
|
||||||
|
smtp_port=smtp_port,
|
||||||
|
smtp_user=smtp_config.get("user", ""),
|
||||||
|
smtp_password=smtp_config.get("password", ""),
|
||||||
|
smtp_from=smtp_config.get("from", ""),
|
||||||
|
smtp_to=smtp_config.get("to", ""),
|
||||||
|
smtp_tls=smtp_config.get("tls", True),
|
||||||
|
smtp_enabled=smtp_config.get("enabled", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -277,6 +334,7 @@ _SENSITIVE_FIELDS = [
|
|||||||
("sftp", "password"),
|
("sftp", "password"),
|
||||||
("sftp", "key_passphrase"),
|
("sftp", "key_passphrase"),
|
||||||
("webdav", "password"),
|
("webdav", "password"),
|
||||||
|
("smtp", "password"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -284,28 +342,46 @@ def _derive_key(master_password: str, salt: bytes) -> bytes:
|
|||||||
"""从主密码派生加密密钥"""
|
"""从主密码派生加密密钥"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 100_000)
|
return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 600_000)
|
||||||
|
|
||||||
|
|
||||||
def _encrypt_value(value: str, key: bytes) -> str:
|
def _encrypt_value(value: str, key: bytes) -> str:
|
||||||
"""加密单个字符串值,返回 base64 编码的 salt+密文"""
|
"""加密单个字符串值,返回 base64 编码的 salt+密文+HMAC(完整性校验)"""
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac as _hmac
|
||||||
|
|
||||||
salt = os.urandom(16)
|
salt = os.urandom(16)
|
||||||
derived = _derive_key(key.hex(), salt)
|
derived = _derive_key(key.hex(), salt)
|
||||||
encrypted = bytes(
|
encrypted = bytes(
|
||||||
a ^ b for a, b in zip(value.encode("utf-8"), derived[: len(value)])
|
a ^ b for a, b in zip(value.encode("utf-8"), derived[: len(value)])
|
||||||
)
|
)
|
||||||
return base64.b64encode(salt + encrypted).decode("ascii")
|
# 添加 HMAC 完整性校验(使用派生密钥的后 32 字节作为 HMAC 密钥)
|
||||||
|
hmac_key = hashlib.sha256(key + b"hmac_key").digest()
|
||||||
|
mac = _hmac.new(hmac_key, salt + encrypted, "sha256").digest()
|
||||||
|
return base64.b64encode(salt + encrypted + mac).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_value(encrypted_value: str, key: bytes) -> str:
|
def _decrypt_value(encrypted_value: str, key: bytes) -> str:
|
||||||
"""解密单个字符串值"""
|
"""解密单个字符串值(验证 HMAC 完整性)"""
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac as _hmac
|
||||||
|
|
||||||
data = base64.b64decode(encrypted_value)
|
data = base64.b64decode(encrypted_value)
|
||||||
|
# 提取 HMAC(最后 32 字节)
|
||||||
|
if len(data) < 48: # 16 (salt) + 0 (min ciphertext) + 32 (hmac)
|
||||||
|
raise ValueError("Invalid encrypted data: too short")
|
||||||
salt = data[:16]
|
salt = data[:16]
|
||||||
ciphertext = data[16:]
|
mac_received = data[-32:]
|
||||||
|
ciphertext = data[16:-32]
|
||||||
|
|
||||||
|
# 验证 HMAC 完整性
|
||||||
|
hmac_key = hashlib.sha256(key + b"hmac_key").digest()
|
||||||
|
mac_expected = _hmac.new(hmac_key, salt + ciphertext, "sha256").digest()
|
||||||
|
if not _hmac.compare_digest(mac_received, mac_expected):
|
||||||
|
raise ValueError("HMAC verification failed: data may have been tampered with")
|
||||||
|
|
||||||
derived = _derive_key(key.hex(), salt)
|
derived = _derive_key(key.hex(), salt)
|
||||||
decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)]))
|
decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)]))
|
||||||
return decrypted.decode("utf-8")
|
return decrypted.decode("utf-8")
|
||||||
@@ -319,10 +395,18 @@ def encrypt_config(master_password: str, config_file: str = "config.json") -> bo
|
|||||||
if not data:
|
if not data:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
import hashlib
|
import base64
|
||||||
|
|
||||||
# 用主密码的哈希作为加密密钥
|
# 读取或生成盐值(存储在配置中以备解密)
|
||||||
key = hashlib.sha256(master_password.encode("utf-8")).digest()
|
salt_b64 = data.get("_key_salt")
|
||||||
|
if salt_b64:
|
||||||
|
salt = base64.b64decode(salt_b64)
|
||||||
|
else:
|
||||||
|
salt = os.urandom(16)
|
||||||
|
data["_key_salt"] = base64.b64encode(salt).decode("ascii")
|
||||||
|
|
||||||
|
# 使用 PBKDF2 派生加密密钥(600K 迭代)
|
||||||
|
key = _derive_key(master_password, salt)
|
||||||
|
|
||||||
for field_path in _SENSITIVE_FIELDS:
|
for field_path in _SENSITIVE_FIELDS:
|
||||||
obj = data
|
obj = data
|
||||||
@@ -346,9 +430,14 @@ def decrypt_config(master_password: str, config_file: str = "config.json") -> bo
|
|||||||
if not data or not data.get("_encrypted"):
|
if not data or not data.get("_encrypted"):
|
||||||
return True # 未加密,视为成功
|
return True # 未加密,视为成功
|
||||||
|
|
||||||
import hashlib
|
import base64
|
||||||
|
|
||||||
key = hashlib.sha256(master_password.encode("utf-8")).digest()
|
salt_b64 = data.get("_key_salt")
|
||||||
|
if not salt_b64:
|
||||||
|
return False # 缺少盐值,无法解密
|
||||||
|
salt = base64.b64decode(salt_b64)
|
||||||
|
|
||||||
|
key = _derive_key(master_password, salt)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for field_path in _SENSITIVE_FIELDS:
|
for field_path in _SENSITIVE_FIELDS:
|
||||||
@@ -417,6 +506,12 @@ def parse_gitignore(gitignore_path: str) -> list[str]:
|
|||||||
if not os.path.isfile(gitignore_path):
|
if not os.path.isfile(gitignore_path):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 安全检查:确保路径在当前工作目录内
|
||||||
|
cwd = os.path.realpath(os.getcwd())
|
||||||
|
real_path = os.path.realpath(gitignore_path)
|
||||||
|
if os.path.commonpath([cwd, real_path]) != cwd:
|
||||||
|
raise ValueError("gitignore path must be within the current working directory")
|
||||||
|
|
||||||
patterns = []
|
patterns = []
|
||||||
try:
|
try:
|
||||||
with open(gitignore_path, "r", encoding="utf-8") as f:
|
with open(gitignore_path, "r", encoding="utf-8") as f:
|
||||||
|
|||||||
+55
-2
@@ -2,11 +2,38 @@
|
|||||||
|
|
||||||
import getpass
|
import getpass
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from sbackup.i18n import t
|
from sbackup.i18n import t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_remote_filename(filename: str) -> str | None:
|
||||||
|
"""验证远程文件名安全性,返回净化后的文件名或 None(不安全时)
|
||||||
|
只允许纯文件名,拒绝路径分隔符和 .. 序列
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
# 拒绝 null 字节注入
|
||||||
|
if "\x00" in filename:
|
||||||
|
return None
|
||||||
|
# 拒绝路径分隔符
|
||||||
|
if "/" in filename or "\\" in filename:
|
||||||
|
return None
|
||||||
|
# 拒绝 .. 序列
|
||||||
|
if ".." in filename:
|
||||||
|
return None
|
||||||
|
# 拒绝绝对路径
|
||||||
|
if os.path.isabs(filename):
|
||||||
|
return None
|
||||||
|
# 只取 basename(防御性编程)
|
||||||
|
safe_name = os.path.basename(filename)
|
||||||
|
if not safe_name or safe_name in (".", ".."):
|
||||||
|
return None
|
||||||
|
return safe_name
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sftp_auth(
|
def _resolve_sftp_auth(
|
||||||
key_file: str,
|
key_file: str,
|
||||||
key_passphrase: str,
|
key_passphrase: str,
|
||||||
@@ -84,6 +111,18 @@ def handle_sftp(args, config) -> int:
|
|||||||
user = args.user or input(t("cli.prompt.sftp.user") + " ")
|
user = args.user or input(t("cli.prompt.sftp.user") + " ")
|
||||||
key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ")
|
key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ")
|
||||||
|
|
||||||
|
# 警告:通过 CLI 传递密码会暴露在进程列表中
|
||||||
|
if args.password:
|
||||||
|
print(
|
||||||
|
t("warn.password_in_cli", arg="--password"),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if args.key_passphrase:
|
||||||
|
print(
|
||||||
|
t("warn.password_in_cli", arg="--key-passphrase"),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
key_file, key_passphrase, password = _resolve_sftp_auth(
|
key_file, key_passphrase, password = _resolve_sftp_auth(
|
||||||
key_file_input,
|
key_file_input,
|
||||||
args.key_passphrase or "",
|
args.key_passphrase or "",
|
||||||
@@ -153,6 +192,12 @@ def handle_webdav(args, config) -> int:
|
|||||||
# 交互式配置
|
# 交互式配置
|
||||||
url = args.url or input(t("cli.prompt.webdav.url") + " ")
|
url = args.url or input(t("cli.prompt.webdav.url") + " ")
|
||||||
user = args.user or input(t("cli.prompt.webdav.user") + " ")
|
user = args.user or input(t("cli.prompt.webdav.user") + " ")
|
||||||
|
# 警告:通过 CLI 传递密码会暴露在进程列表中
|
||||||
|
if args.password:
|
||||||
|
print(
|
||||||
|
t("warn.password_in_cli", arg="--password"),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
password = args.password or getpass.getpass(
|
password = args.password or getpass.getpass(
|
||||||
t("cli.prompt.webdav.password") + " "
|
t("cli.prompt.webdav.password") + " "
|
||||||
)
|
)
|
||||||
@@ -265,7 +310,11 @@ def _handle_remote_sftp(args, config) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
elif args.remote_action == "rm":
|
elif args.remote_action == "rm":
|
||||||
remote_path = config.sftp_remote_path.rstrip("/") + "/" + args.filename
|
safe_name = _validate_remote_filename(args.filename)
|
||||||
|
if not safe_name:
|
||||||
|
print(t("err.invalid_filename", filename=args.filename))
|
||||||
|
return 1
|
||||||
|
remote_path = config.sftp_remote_path.rstrip("/") + "/" + safe_name
|
||||||
client.delete_remote_file(remote_path)
|
client.delete_remote_file(remote_path)
|
||||||
print(t("cmd.remote.deleted", path=remote_path))
|
print(t("cmd.remote.deleted", path=remote_path))
|
||||||
return 0
|
return 0
|
||||||
@@ -315,7 +364,11 @@ def _handle_remote_webdav(args, config) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
elif args.remote_action == "rm":
|
elif args.remote_action == "rm":
|
||||||
remote_path = config.webdav_remote_path.rstrip("/") + "/" + args.filename
|
safe_name = _validate_remote_filename(args.filename)
|
||||||
|
if not safe_name:
|
||||||
|
print(t("err.invalid_filename", filename=args.filename))
|
||||||
|
return 1
|
||||||
|
remote_path = config.webdav_remote_path.rstrip("/") + "/" + safe_name
|
||||||
client.delete_remote_file(remote_path)
|
client.delete_remote_file(remote_path)
|
||||||
print(t("cmd.remote.deleted", path=remote_path))
|
print(t("cmd.remote.deleted", path=remote_path))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -34,6 +34,41 @@ def set_locale(lang: str) -> None:
|
|||||||
设置当前语言环境并加载对应的翻译文件
|
设置当前语言环境并加载对应的翻译文件
|
||||||
"""
|
"""
|
||||||
global _current_locale, _translations
|
global _current_locale, _translations
|
||||||
|
|
||||||
|
# 安全检查:拒绝包含路径遍历字符的语言代码
|
||||||
|
if not lang or "/" in lang or "\\" in lang or ".." in lang or ":" in lang:
|
||||||
|
logger.warning("i18n.set_locale.invalid_lang: %s", lang)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 白名单校验:只允许已知的语言代码格式
|
||||||
|
_KNOWN_LOCALES = {
|
||||||
|
"zh_CN",
|
||||||
|
"en_US",
|
||||||
|
"fr_FR",
|
||||||
|
"es_ES",
|
||||||
|
"ru_RU",
|
||||||
|
"de_DE",
|
||||||
|
"ja_JP",
|
||||||
|
"pt_BR",
|
||||||
|
"ko_KR",
|
||||||
|
}
|
||||||
|
if lang not in _KNOWN_LOCALES:
|
||||||
|
logger.warning("i18n.set_locale.unknown_locale: %s", lang)
|
||||||
|
# 仍然设置 _current_locale 以保持向后兼容
|
||||||
|
_current_locale = lang
|
||||||
|
# 回退到默认中文翻译
|
||||||
|
default_file = os.path.join(_get_locales_dir(), "zh_CN.json")
|
||||||
|
if os.path.exists(default_file):
|
||||||
|
try:
|
||||||
|
with open(default_file, "r", encoding="utf-8") as f:
|
||||||
|
_translations = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.warning(
|
||||||
|
t("log.i18n.load.fallback_failed", path=default_file, error=e)
|
||||||
|
)
|
||||||
|
_translations = {}
|
||||||
|
return
|
||||||
|
|
||||||
_current_locale = lang
|
_current_locale = lang
|
||||||
locale_file = os.path.join(_get_locales_dir(), f"{lang}.json")
|
locale_file = os.path.join(_get_locales_dir(), f"{lang}.json")
|
||||||
if os.path.exists(locale_file):
|
if os.path.exists(locale_file):
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""系统密钥链集成:跨平台安全存储密码
|
||||||
|
|
||||||
|
使用各平台原生 API 将密码存储在系统密钥链中,而不是明文存在 config.json。
|
||||||
|
|
||||||
|
支持平台:
|
||||||
|
- Windows: Credential Manager (Win32 API 通过 ctypes)
|
||||||
|
- macOS: Keychain (security 命令行工具)
|
||||||
|
- Linux: libsecret (secret-tool 命令行工具)
|
||||||
|
|
||||||
|
用法::
|
||||||
|
|
||||||
|
from sbackup.keychain import get_password, set_password, delete_password
|
||||||
|
|
||||||
|
# 存储密码
|
||||||
|
set_password("sbackup", "sftp", "my_secret")
|
||||||
|
|
||||||
|
# 读取密码
|
||||||
|
pwd = get_password("sbackup", "sftp")
|
||||||
|
|
||||||
|
# 删除密码
|
||||||
|
delete_password("sbackup", "sftp")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SERVICE_NAME = "sbackup"
|
||||||
|
|
||||||
|
|
||||||
|
def get_password(service: str, username: str) -> str | None:
|
||||||
|
"""从系统密钥链获取密码,返回 None 表示未找到或不可用"""
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return _win32_get_password(service, username)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
return _darwin_get_password(service, username)
|
||||||
|
else:
|
||||||
|
return _linux_get_password(service, username)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Keychain get_password failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(service: str, username: str, password: str) -> bool:
|
||||||
|
"""存储密码到系统密钥链,返回是否成功"""
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return _win32_set_password(service, username, password)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
return _darwin_set_password(service, username, password)
|
||||||
|
else:
|
||||||
|
return _linux_set_password(service, username, password)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Keychain set_password failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_password(service: str, username: str) -> bool:
|
||||||
|
"""从系统密钥链删除密码,返回是否成功"""
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return _win32_delete_password(service, username)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
return _darwin_delete_password(service, username)
|
||||||
|
else:
|
||||||
|
return _linux_delete_password(service, username)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Keychain delete_password failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_available() -> bool:
|
||||||
|
"""检查系统密钥链是否可用"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Windows Credential Manager 通常总是可用
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
_win32_get_password("sbackup", "__probe__")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["security", "help"],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["secret-tool", "--help"],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Windows ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _win32_get_password(service: str, username: str) -> str | None:
|
||||||
|
"""使用 Win32 CredReadW API 读取凭据"""
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
advapi32 = ctypes.windll.advapi32
|
||||||
|
|
||||||
|
class CREDENTIALW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("Flags", wintypes.DWORD),
|
||||||
|
("Type", wintypes.DWORD),
|
||||||
|
("TargetName", ctypes.c_wchar_p),
|
||||||
|
("Comment", ctypes.c_wchar_p),
|
||||||
|
("LastWritten", wintypes.FILETIME),
|
||||||
|
("CredentialBlobSize", wintypes.DWORD),
|
||||||
|
("CredentialBlob", ctypes.c_void_p),
|
||||||
|
("Persist", wintypes.DWORD),
|
||||||
|
("AttributeCount", wintypes.DWORD),
|
||||||
|
("Attributes", ctypes.c_void_p),
|
||||||
|
("TargetAlias", ctypes.c_wchar_p),
|
||||||
|
("UserName", ctypes.c_wchar_p),
|
||||||
|
]
|
||||||
|
|
||||||
|
target = f"{service}/{username}"
|
||||||
|
pcred = ctypes.POINTER(CREDENTIALW)()
|
||||||
|
|
||||||
|
ret = advapi32.CredReadW(target, 1, 0, ctypes.byref(pcred))
|
||||||
|
if not ret:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cred = pcred.contents
|
||||||
|
if cred.CredentialBlob and cred.CredentialBlobSize > 0:
|
||||||
|
buf = (ctypes.c_char * cred.CredentialBlobSize).from_address(
|
||||||
|
cred.CredentialBlob
|
||||||
|
)
|
||||||
|
return buf.value.decode("utf-16-le")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
advapi32.CredFree(pcred)
|
||||||
|
|
||||||
|
|
||||||
|
def _win32_set_password(service: str, username: str, password: str) -> bool:
|
||||||
|
"""使用 Win32 CredWriteW API 存储凭据"""
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
advapi32 = ctypes.windll.advapi32
|
||||||
|
|
||||||
|
target = f"{service}/{username}"
|
||||||
|
blob = password.encode("utf-16-le")
|
||||||
|
|
||||||
|
class CREDENTIALW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("Flags", wintypes.DWORD),
|
||||||
|
("Type", wintypes.DWORD),
|
||||||
|
("TargetName", ctypes.c_wchar_p),
|
||||||
|
("Comment", ctypes.c_wchar_p),
|
||||||
|
("LastWritten", wintypes.FILETIME),
|
||||||
|
("CredentialBlobSize", wintypes.DWORD),
|
||||||
|
("CredentialBlob", ctypes.c_void_p),
|
||||||
|
("Persist", wintypes.DWORD),
|
||||||
|
("AttributeCount", wintypes.DWORD),
|
||||||
|
("Attributes", ctypes.c_void_p),
|
||||||
|
("TargetAlias", ctypes.c_wchar_p),
|
||||||
|
("UserName", ctypes.c_wchar_p),
|
||||||
|
]
|
||||||
|
|
||||||
|
cred = CREDENTIALW()
|
||||||
|
cred.Type = 1 # CRED_TYPE_GENERIC
|
||||||
|
cred.TargetName = target
|
||||||
|
cred.CredentialBlobSize = len(blob)
|
||||||
|
cred.CredentialBlob = ctypes.cast(
|
||||||
|
ctypes.create_string_buffer(blob), ctypes.c_void_p
|
||||||
|
)
|
||||||
|
cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE
|
||||||
|
cred.UserName = username
|
||||||
|
|
||||||
|
ret = advapi32.CredWriteW(ctypes.byref(cred), 0)
|
||||||
|
return bool(ret)
|
||||||
|
|
||||||
|
|
||||||
|
def _win32_delete_password(service: str, username: str) -> bool:
|
||||||
|
"""使用 Win32 CredDeleteW API 删除凭据"""
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
advapi32 = ctypes.windll.advapi32
|
||||||
|
target = f"{service}/{username}"
|
||||||
|
ret = advapi32.CredDeleteW(target, 1, 0)
|
||||||
|
return bool(ret)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── macOS ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _darwin_get_password(service: str, username: str) -> str | None:
|
||||||
|
"""使用 macOS security CLI 读取钥匙串"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "find-generic-password",
|
||||||
|
"-s", service, "-a", username, "-w"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _darwin_set_password(service: str, username: str, password: str) -> bool:
|
||||||
|
"""使用 macOS security CLI 存储到钥匙串"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "add-generic-password",
|
||||||
|
"-s", service, "-a", username, "-w", password, "-U"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _darwin_delete_password(service: str, username: str) -> bool:
|
||||||
|
"""使用 macOS security CLI 从钥匙串删除"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["security", "delete-generic-password",
|
||||||
|
"-s", service, "-a", username],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Linux ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _linux_get_password(service: str, username: str) -> str | None:
|
||||||
|
"""使用 secret-tool 读取密码"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["secret-tool", "lookup", "service", service, "username", username],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_set_password(service: str, username: str, password: str) -> bool:
|
||||||
|
"""使用 secret-tool 存储密码"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["secret-tool", "store",
|
||||||
|
"--label", f"sbackup/{username}",
|
||||||
|
"service", service, "username", username],
|
||||||
|
input=password, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_delete_password(service: str, username: str) -> bool:
|
||||||
|
"""使用 secret-tool 删除密码"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["secret-tool", "clear", "service", service, "username", username],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"table.cell.default": "Standard",
|
"table.cell.default": "Standard",
|
||||||
"restore.progress": "Wiederherstellung",
|
"restore.progress": "Wiederherstellung",
|
||||||
"restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)",
|
"restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)",
|
||||||
|
"restore.skipped_unsafe": "{count} unsichere Archivmitglieder übersprungen (Pfadtraversal-Schutz)",
|
||||||
"cmd.watch.start": "Überwachung gestartet, Intervall {interval} Min. (Ctrl+C zum Stoppen)",
|
"cmd.watch.start": "Überwachung gestartet, Intervall {interval} Min. (Ctrl+C zum Stoppen)",
|
||||||
"cli.description": "Sbackup v{version} — Leichtes inkrementelles Backup-Tool\n\nSbackup hilft Ihnen, Multi-Ordner-Backup-Aufgaben einfach zu verwalten.\nEs nutzt eine inkrementelle Backup-Strategie, komprimiert nur geänderte Dateien,\nunterstützt benutzerdefinierte Ausschlussregeln und bietet intuitives Fortschrittsfeedback.\n\nVerfügbare Befehle:\n add Neue Backup-Strategie hinzufügen (Quelle -> Ziel)\n rm Existing Backup-Strategie entfernen\n all Alle konfigurierten Backup-Strategien anzeigen\n save Backup-Aufgaben ausführen\n version Versionsinformationen anzeigen",
|
"cli.description": "Sbackup v{version} — Leichtes inkrementelles Backup-Tool\n\nSbackup hilft Ihnen, Multi-Ordner-Backup-Aufgaben einfach zu verwalten.\nEs nutzt eine inkrementelle Backup-Strategie, komprimiert nur geänderte Dateien,\nunterstützt benutzerdefinierte Ausschlussregeln und bietet intuitives Fortschrittsfeedback.\n\nVerfügbare Befehle:\n add Neue Backup-Strategie hinzufügen (Quelle -> Ziel)\n rm Existing Backup-Strategie entfernen\n all Alle konfigurierten Backup-Strategien anzeigen\n save Backup-Aufgaben ausführen\n version Versionsinformationen anzeigen",
|
||||||
"cli.epilog": "Beispiele:\n Backup-Strategie hinzufügen:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup mit Verschlüsselung (7z):\n sbackup --format 7z save --password 123456\n\n Wiederherstellen:\n sbackup restore backup.7z F:/restored\n\n SFTP Remote (Auto-Erkennung, empfohlen):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP Remote (Passwort):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP Remote (Schlüssel):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP Remote (Schlüssel + Passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
"cli.epilog": "Beispiele:\n Backup-Strategie hinzufügen:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup mit Verschlüsselung (7z):\n sbackup --format 7z save --password 123456\n\n Wiederherstellen:\n sbackup restore backup.7z F:/restored\n\n SFTP Remote (Auto-Erkennung, empfohlen):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP Remote (Passwort):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP Remote (Schlüssel):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP Remote (Schlüssel + Passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||||
@@ -116,6 +117,25 @@
|
|||||||
"cli.help.list": "Backup-Verlauf anzeigen",
|
"cli.help.list": "Backup-Verlauf anzeigen",
|
||||||
"cli.help.verify": "Backup-Datei-Integrität überprüfen",
|
"cli.help.verify": "Backup-Datei-Integrität überprüfen",
|
||||||
"cli.help.verify.file": "Zu überprüfende Backup-Datei",
|
"cli.help.verify.file": "Zu überprüfende Backup-Datei",
|
||||||
|
"cli.help.verify.fast": "Schnellüberprüfung (historische SHA256 vergleichen, ohne Entpacken)",
|
||||||
|
"cli.help.verify.all": "Alle historischen Backups batchweise überprüfen",
|
||||||
|
"cli.help.verify.detail": "Detaillierte Überprüfungsinformationen anzeigen (Größe, Format, Prüfsumme etc.)",
|
||||||
|
"cli.help.verify.split": "Integrität der geteilten Dateien überprüfen (erfordert Manifest-Datei)",
|
||||||
|
"cli.help.verify.password": "Entschlüsselungspasswort (für verschlüsselte Backups)",
|
||||||
|
"cmd.verify.fast_success": "Schnellüberprüfung bestanden: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "Schnellüberprüfung fehlgeschlagen: {path}, Datei könnte beschädigt oder manipuliert sein",
|
||||||
|
"cmd.verify.no_checksum": "Keine historische Prüfsumme gefunden, vollständige Überprüfung: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "Prüfsumme stimmt nicht überein",
|
||||||
|
"cmd.verify.no_history": "Kein Backup-Verlauf gefunden.",
|
||||||
|
"cmd.verify.no_file": "Bitte Backup-Dateipfad angeben oder --all verwenden, um alle Backups zu überprüfen.",
|
||||||
|
"cmd.verify.detail_header": "=== Backup-Details: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "Größe: {size:.2f} MB | Dateien: {files} | Format: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ Überprüft: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ Fehlgeschlagen: {path}",
|
||||||
|
"cmd.verify.batch_summary": "Batch-Überprüfung abgeschlossen: {success} bestanden, {fail} fehlgeschlagen",
|
||||||
|
"cmd.verify.split_success": "Integrität der geteilten Dateien bestätigt: {file} ({parts} Teile)",
|
||||||
|
"cmd.verify.split_failed": "Integritätsprüfung der geteilten Dateien fehlgeschlagen: {file}",
|
||||||
|
"cmd.verify.split_error": "Fehler bei der Überprüfung geteilter Dateien: {error}",
|
||||||
"cli.help.info": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme)",
|
"cli.help.info": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme)",
|
||||||
"cli.help.info.file": "Pfad zur Backup-Datei",
|
"cli.help.info.file": "Pfad zur Backup-Datei",
|
||||||
"cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)",
|
"cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)",
|
||||||
@@ -260,5 +280,13 @@
|
|||||||
"err.webdav.not_configured": "WebDAV nicht konfiguriert, führen Sie aus: sbackup webdav config --url <Adresse> --user <Benutzername>",
|
"err.webdav.not_configured": "WebDAV nicht konfiguriert, führen Sie aus: sbackup webdav config --url <Adresse> --user <Benutzername>",
|
||||||
"err.webdav.local_not_found": "Lokale Datei nicht gefunden: {path}",
|
"err.webdav.local_not_found": "Lokale Datei nicht gefunden: {path}",
|
||||||
"err.webdav.upload": "Upload zu WebDAV fehlgeschlagen {path}: {error}",
|
"err.webdav.upload": "Upload zu WebDAV fehlgeschlagen {path}: {error}",
|
||||||
"err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}"
|
"err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
+386
-357
@@ -1,359 +1,388 @@
|
|||||||
{
|
{
|
||||||
"cmd.add.success": "Backup strategy added successfully: {source} -> {dest}",
|
"cmd.add.success": "Backup strategy added successfully: {source} -> {dest}",
|
||||||
"cmd.rm.success": "Backup strategy removed successfully: {path}",
|
"cmd.rm.success": "Backup strategy removed successfully: {path}",
|
||||||
"cmd.edit.success": "Backup strategy edited successfully: {path}",
|
"cmd.edit.success": "Backup strategy edited successfully: {path}",
|
||||||
"cmd.edit.nothing": "No modification parameters specified.",
|
"cmd.edit.nothing": "No modification parameters specified.",
|
||||||
"err.folder.invalid": "Error: {path} is not a valid folder or does not exist.",
|
"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.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.",
|
"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.",
|
"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.no.strategy.found": "Warning: Backup strategy for {path} not found.",
|
||||||
"warn.source.missing": "Warning: Source folder does not exist: {path}",
|
"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.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.",
|
||||||
"warn.json.backup": "Warning: Corrupted data file backed up to {path}.",
|
"warn.json.backup": "Warning: Corrupted data file backed up to {path}.",
|
||||||
"warn.json.renamed": "Warning: Corrupted data file renamed to {path}.",
|
"warn.json.renamed": "Warning: Corrupted data file renamed to {path}.",
|
||||||
"compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)",
|
"warn.password_in_cli": "Warning: Password provided via {arg} is visible in the process list. Consider using interactive input instead.",
|
||||||
"compress.progress": "Compressing",
|
"warn.webdav.http_plaintext": "WARNING: Using plain HTTP for WebDAV ({url}) -- credentials will be sent in cleartext. Use HTTPS when possible.",
|
||||||
"compress.unit": "files",
|
"compress.success": "Backup successful: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} files)",
|
||||||
"err.permission": "Permission denied: Cannot write to '{path}'",
|
"compress.progress": "Compressing",
|
||||||
"err.os": "OS Error: {error}",
|
"compress.unit": "files",
|
||||||
"err.unknown": "Unknown error: {error}",
|
"err.permission": "Permission denied: Cannot write to '{path}'",
|
||||||
"err.unknown.format": "Error: Unrecognized backup file format {path}",
|
"err.os": "OS Error: {error}",
|
||||||
"err.argparse.invalid_choice": "invalid choice: ",
|
"err.unknown": "Unknown error: {error}",
|
||||||
"err.argparse.choose_from": "choose from",
|
"err.unknown.format": "Error: Unrecognized backup file format {path}",
|
||||||
"err.argparse.invalid_float": "invalid float value: ",
|
"err.argparse.invalid_choice": "invalid choice: ",
|
||||||
"err.argparse.invalid_int": "invalid int value: ",
|
"err.argparse.choose_from": "choose from",
|
||||||
"err.argparse.unrecognized_args": "unrecognized arguments: ",
|
"err.argparse.invalid_float": "invalid float value: ",
|
||||||
"err.argparse.required": "required",
|
"err.argparse.invalid_int": "invalid int value: ",
|
||||||
"warn.zip.overwrite": "Warning: Overwriting existing backup file {path}",
|
"err.argparse.unrecognized_args": "unrecognized arguments: ",
|
||||||
"warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6",
|
"err.argparse.required": "required",
|
||||||
"cmd.save.uptodate": "All backup strategies are up to date, no backup needed.",
|
"err.lock.conflict": "Error: Another backup operation is already running (lock file exists). Use 'sbackup clean --force-lock' to remove a stale lock.",
|
||||||
"cmd.save.completed": "Backup completed, processed {count} strategy(s).",
|
"warn.zip.overwrite": "Warning: Overwriting existing backup file {path}",
|
||||||
"cmd.all.empty": "No backup strategies configured.",
|
"warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6",
|
||||||
"cmd.list.empty": "No backup history yet.",
|
"cmd.save.uptodate": "All backup strategies are up to date, no backup needed.",
|
||||||
"cmd.verify.checking": "Verifying backup: {path}",
|
"cmd.save.completed": "Backup completed, processed {count} strategy(s).",
|
||||||
"cmd.verify.success": "Verification passed: {path} ({count} files)",
|
"cmd.all.empty": "No backup strategies configured.",
|
||||||
"cmd.verify.failed": "Verification failed: {path} — expected {expected} files, got {actual}",
|
"cmd.list.empty": "No backup history yet.",
|
||||||
"cmd.rm.all.confirm": "Delete all {count} backup strategies? (y/N): ",
|
"cmd.verify.checking": "Verifying backup: {path}",
|
||||||
"cmd.rm.all.confirm_words": "y,yes",
|
"cmd.verify.success": "Verification passed: {path} ({count} files)",
|
||||||
"cmd.rm.all.success": "Deleted all {count} backup strategies.",
|
"cmd.verify.failed": "Verification failed: {path} — expected {expected} files, got {actual}",
|
||||||
"cmd.rm.all.empty": "No backup strategies to delete.",
|
"cmd.rm.all.confirm": "Delete all {count} backup strategies? (y/N): ",
|
||||||
"cmd.init.success": "Config file generated: {path}",
|
"cmd.rm.all.confirm_words": "y,yes",
|
||||||
"cmd.init.exists": "Config file already exists: {path}, skipping.",
|
"cmd.rm.all.success": "Deleted all {count} backup strategies.",
|
||||||
"warn.disk.space": "Warning: Target directory {path} has insufficient space (need {need:.1f} MB, only {free:.1f} MB free)",
|
"cmd.rm.all.empty": "No backup strategies to delete.",
|
||||||
"cmd.save.summary": "Backup summary: {total} strategies, {backed} backed up, {skipped} skipped, took {elapsed:.1f}s",
|
"cmd.init.success": "Config file generated: {path}",
|
||||||
"cmd.dry_run.header": "Dry-run preview (no backup will be performed):",
|
"cmd.init.exists": "Config file already exists: {path}, skipping.",
|
||||||
"cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB",
|
"warn.disk.space": "Warning: Target directory {path} has insufficient space (need {need:.1f} MB, only {free:.1f} MB free)",
|
||||||
"cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB",
|
"cmd.save.summary": "Backup summary: {total} strategies, {backed} backed up, {skipped} skipped, took {elapsed:.1f}s",
|
||||||
"cmd.dry_run.nothing": "No strategies need backup (all are up to date).",
|
"cmd.upload.parallel": "Uploading to SFTP and WebDAV in parallel...",
|
||||||
"cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!",
|
"cmd.dry_run.header": "Dry-run preview (no backup will be performed):",
|
||||||
"cmd.status.header": "=== Backup Status Dashboard ===",
|
"cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB",
|
||||||
"cmd.status.strategies": "Total strategies: {count}",
|
"cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB",
|
||||||
"cmd.status.history": "History records: {count}",
|
"cmd.dry_run.nothing": "No strategies need backup (all are up to date).",
|
||||||
"cmd.status.total_size": "Total backup size: {size:.2f} MB",
|
"cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!",
|
||||||
"cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%",
|
"cmd.status.header": "=== Backup Status Dashboard ===",
|
||||||
"cmd.status.strategy.header": "--- Strategy Details ---",
|
"cmd.status.strategies": "Total strategies: {count}",
|
||||||
"cmd.status.strategy.item": " {source} -> {target}",
|
"cmd.status.history": "History records: {count}",
|
||||||
"cmd.status.strategy.last_backup": " Last backup: {time}",
|
"cmd.status.total_size": "Total backup size: {size:.2f} MB",
|
||||||
"cmd.status.strategy.never": " Last backup: Never",
|
"cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%",
|
||||||
"cmd.status.strategy.format": " Format: {format}",
|
"cmd.status.strategy.header": "--- Strategy Details ---",
|
||||||
"cmd.status.strategy.size": " Target size: {size:.2f} MB",
|
"cmd.status.strategy.item": " {source} -> {target}",
|
||||||
"log.parallel.backup": "Parallel backup of %d strategies",
|
"cmd.status.strategy.last_backup": " Last backup: {time}",
|
||||||
"restore.list.title": "Archive contents: {path}",
|
"cmd.status.strategy.never": " Last backup: Never",
|
||||||
"restore.list.empty": "Archive is empty: {path}",
|
"cmd.status.strategy.format": " Format: {format}",
|
||||||
"log.ignore.loaded": "Loaded ignore rules file: %s",
|
"cmd.status.strategy.size": " Target size: {size:.2f} MB",
|
||||||
"exit.message": "\nExiting.",
|
"log.parallel.backup": "Parallel backup of %d strategies",
|
||||||
"table.header.source": "Source Folder",
|
"restore.list.title": "Archive contents: {path}",
|
||||||
"table.header.dest": "Target Folder",
|
"restore.list.empty": "Archive is empty: {path}",
|
||||||
"table.header.ignore": "Ignore Patterns",
|
"log.ignore.loaded": "Loaded ignore rules file: %s",
|
||||||
"table.cell.none": "None",
|
"exit.message": "\nExiting.",
|
||||||
"table.header.format": "Format",
|
"table.header.source": "Source Folder",
|
||||||
"table.header.ratio": "Ratio",
|
"table.header.dest": "Target Folder",
|
||||||
"table.cell.default": "Default",
|
"table.header.ignore": "Ignore Patterns",
|
||||||
"restore.progress": "Restoring",
|
"table.cell.none": "None",
|
||||||
"restore.success": "Restore successful: {path} ({count} files)",
|
"table.header.format": "Format",
|
||||||
"cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)",
|
"table.header.ratio": "Ratio",
|
||||||
"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",
|
"table.cell.default": "Default",
|
||||||
"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",
|
"restore.progress": "Restoring",
|
||||||
"cli.help.debug": "Enable debug mode with detailed logging (can be placed after subcommand)",
|
"restore.success": "Restore successful: {path} ({count} files)",
|
||||||
"cli.help.help": "Show this help message and exit",
|
"restore.skipped_unsafe": "Skipped {count} unsafe archive members (path traversal protection)",
|
||||||
"cli.help.lang": "Set interface language: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR",
|
"err.invalid_filename": "Invalid filename: {filename} (path separators and .. sequences not allowed)",
|
||||||
"cli.help.format": "Set packaging format: zip(default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(encrypted)",
|
"err.sftp.no_host_key": "Unable to get host key for {host}",
|
||||||
"cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)",
|
"err.sftp.host_key_mismatch": "Host key for {host} does not match known_hosts (possible MITM attack)",
|
||||||
"cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)",
|
"warn.sftp.no_known_hosts": "Warning: No known_hosts file found, cannot verify host key for {host}, connection may be insecure",
|
||||||
"cli.help.checksum": "Use file content SHA256 checksum for change detection (more precise than mtime, but slower)",
|
"err.sftp.mkdir_max_depth": "Remote directory too deep or loop detected: {path}",
|
||||||
"cli.help.subcommands": "Select a command to execute",
|
"err.webdav.xml_too_large": "WebDAV response too large ({size} bytes), possible XML bomb attack",
|
||||||
"cli.help.add": "Add a new backup strategy",
|
"cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)",
|
||||||
"cli.help.add.source": "Source folder path: the directory to be backed up",
|
"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.help.add.dest": "Target folder path: where backup files will be stored",
|
"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.add.ignore": "Files or folders to ignore (comma separated, default: .git,__pycache__)",
|
"cli.help.debug": "Enable debug mode with detailed logging (can be placed after subcommand)",
|
||||||
"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.help": "Show this help message and exit",
|
||||||
"cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)",
|
"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.add.from_gitignore": "Import ignore rules from .gitignore file",
|
"cli.help.format": "Set packaging format: zip(default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(encrypted)",
|
||||||
"cmd.add.gitignore_imported": "Imported {count} ignore rules from {path}",
|
"cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)",
|
||||||
"cmd.add.gitignore_empty": "No rules imported from {path}",
|
"cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)",
|
||||||
"cli.help.rm": "Remove a backup strategy",
|
"cli.help.checksum": "Use file content SHA256 checksum for change detection (more precise than mtime, but slower)",
|
||||||
"cli.help.rm.path": "Source folder path of the strategy to remove",
|
"cli.help.subcommands": "Select a command to execute",
|
||||||
"cli.help.all": "View all backup strategies",
|
"cli.help.add": "Add a new backup strategy",
|
||||||
"cli.help.save": "Execute all backup strategies",
|
"cli.help.add.source": "Source folder path: the directory to be backed up",
|
||||||
"cli.help.save.keep": "Keep the most recent N backup files, 0 to disable cleanup (default: 0)",
|
"cli.help.add.dest": "Target folder path: where backup files will be stored",
|
||||||
"cli.help.save.keep_days": "Keep backups from the last N days, 0 to disable time-based cleanup (default: 0)",
|
"cli.help.add.ignore": "Files or folders to ignore (comma separated, default: .git,__pycache__)",
|
||||||
"cli.help.save.password": "Encryption password (7z format only, can also be set in config.json)",
|
"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.watch": "Run backups periodically",
|
"cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)",
|
||||||
"cli.help.watch.interval": "Backup interval in minutes, default 60",
|
"cli.help.add.from_gitignore": "Import ignore rules from .gitignore file",
|
||||||
"cli.help.watch.keep": "Keep the most recent N backup files (default: 0)",
|
"cmd.add.gitignore_imported": "Imported {count} ignore rules from {path}",
|
||||||
"cli.help.watch.keep_days": "Keep backups from the last N days (default: 0)",
|
"cmd.add.gitignore_empty": "No rules imported from {path}",
|
||||||
"cli.help.watch.password": "Encryption password (7z format only)",
|
"cli.help.rm": "Remove a backup strategy",
|
||||||
"cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)",
|
"cli.help.rm.path": "Source folder path of the strategy to remove",
|
||||||
"cli.help.restore.file": "Backup file path",
|
"cli.help.all": "View all backup strategies",
|
||||||
"cli.help.restore.dir": "Target directory for restoration",
|
"cli.help.save": "Execute all backup strategies",
|
||||||
"cli.help.restore.password": "Decryption password (for restoring encrypted 7z backups)",
|
"cli.help.save.keep": "Keep the most recent N backup files, 0 to disable cleanup (default: 0)",
|
||||||
"cli.help.restore.list": "List archive contents without extracting",
|
"cli.help.save.keep_days": "Keep backups from the last N days, 0 to disable time-based cleanup (default: 0)",
|
||||||
"cli.help.list": "View backup history",
|
"cli.help.save.password": "Encryption password (7z format only, can also be set in config.json)",
|
||||||
"cli.help.verify": "Verify backup file integrity",
|
"cli.help.watch": "Run backups periodically",
|
||||||
"cli.help.verify.file": "Backup file to verify",
|
"cli.help.watch.interval": "Backup interval in minutes, default 60",
|
||||||
"cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)",
|
"cli.help.watch.keep": "Keep the most recent N backup files (default: 0)",
|
||||||
"cli.help.verify.password": "Decryption password (for encrypted backups)",
|
"cli.help.watch.keep_days": "Keep backups from the last N days (default: 0)",
|
||||||
"cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}",
|
"cli.help.watch.password": "Encryption password (7z format only)",
|
||||||
"cmd.verify.fast_failed": "Fast verification failed: {path}, file may be corrupted or tampered",
|
"cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)",
|
||||||
"cmd.verify.no_checksum": "No historical checksum found, falling back to full verify: {path}",
|
"cli.help.restore.file": "Backup file path",
|
||||||
"cmd.verify.checksum_mismatch": "Checksum mismatch",
|
"cli.help.restore.dir": "Target directory for restoration",
|
||||||
"cli.help.export": "Export all backup strategies to a JSON file",
|
"cli.help.restore.password": "Decryption password (for restoring encrypted 7z backups)",
|
||||||
"cli.help.export.file": "Export file path (default: sbackup_export.json)",
|
"cli.help.restore.list": "List archive contents without extracting",
|
||||||
"cli.help.import": "Import backup strategies from a JSON file",
|
"cli.help.list": "View backup history",
|
||||||
"cli.help.import.file": "JSON file to import",
|
"cli.help.verify": "Verify backup file integrity",
|
||||||
"cli.help.status": "View backup status dashboard",
|
"cli.help.verify.file": "Backup file to verify",
|
||||||
"cli.help.ignore": "Generate .sbackupignore exclude rules file",
|
"cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)",
|
||||||
"cli.help.ignore.preset": "Preset template: node / python / go / rust / java / general (default: general)",
|
"cli.help.verify.all": "Batch verify all historical backups",
|
||||||
"cli.help.ignore.output": "Output file path (default: .sbackupignore)",
|
"cli.help.verify.detail": "Show detailed verification info (size, format, checksum, etc.)",
|
||||||
"cli.help.ignore.list": "List all available presets",
|
"cli.help.verify.split": "Verify split file integrity (requires manifest file)",
|
||||||
"cmd.ignore.available_presets": "Available presets:",
|
"cli.help.verify.password": "Decryption password (for encrypted backups)",
|
||||||
"cmd.ignore.overwrite": "File {path} already exists, overwrite? (y/N): ",
|
"cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}",
|
||||||
"cmd.ignore.cancelled": "Cancelled.",
|
"cmd.verify.fast_failed": "Fast verification failed: {path}, file may be corrupted or tampered",
|
||||||
"cmd.ignore.generated": "Generated {preset} preset exclude rules: {path}",
|
"cmd.verify.no_checksum": "No historical checksum found, falling back to full verify: {path}",
|
||||||
"cli.help.report": "Generate backup report in Markdown format",
|
"cmd.verify.checksum_mismatch": "Checksum mismatch",
|
||||||
"cli.help.report.output": "Output file path (prints to terminal if not specified)",
|
"cmd.verify.no_history": "No backup history found.",
|
||||||
"cli.help.search": "Search filenames in backup files",
|
"cmd.verify.no_file": "Please specify a backup file path or use --all to verify all backups.",
|
||||||
"cli.help.search.pattern": "Search pattern (supports glob wildcards, e.g. *.txt)",
|
"cmd.verify.detail_header": "=== Backup Details: {path} ===",
|
||||||
"cli.help.search.in": "Specify backup file path (searches all strategies if not specified)",
|
"cmd.verify.detail_info": "Size: {size:.2f} MB | Files: {files} | Format: {fmt} | SHA256: {sha256}...",
|
||||||
"cli.help.search.password": "Decryption password (for encrypted backups)",
|
"cmd.verify.batch_success": "✓ Verified: {path}",
|
||||||
"cli.help.versions": "View backup version history (grouped by source)",
|
"cmd.verify.batch_failed": "✗ Failed: {path}",
|
||||||
"cli.help.versions.source": "Specify source directory (shows all if not specified)",
|
"cmd.verify.batch_summary": "Batch verification complete: {success} passed, {fail} failed",
|
||||||
"cmd.versions.header": "=== Backup Version History ===",
|
"cmd.verify.split_success": "Split integrity verified: {file} ({parts} parts)",
|
||||||
"cmd.versions.source": "📁 {source}",
|
"cmd.verify.split_failed": "Split integrity check failed: {file}",
|
||||||
"cmd.versions.empty": "No backup version records yet.",
|
"cmd.verify.split_error": "Split verification error: {error}",
|
||||||
"cmd.versions.total": "Total {count} versions, {sources} source directories",
|
"cli.help.export": "Export all backup strategies to a JSON file",
|
||||||
"cli.help.schedule": "Scheduled backup configuration management",
|
"cli.help.export.file": "Export file path (default: sbackup_export.json)",
|
||||||
"cli.help.schedule.action": "Schedule action: export",
|
"cli.help.import": "Import backup strategies from a JSON file",
|
||||||
"cli.help.schedule.export": "Export scheduled backup configuration file",
|
"cli.help.import.file": "JSON file to import",
|
||||||
"cli.help.schedule.type": "Schedule type: systemd / crontab (default: systemd)",
|
"cli.help.status": "View backup status dashboard",
|
||||||
"cli.help.schedule.interval": "Backup interval in minutes (default: 60)",
|
"cli.help.ignore": "Generate .sbackupignore exclude rules file",
|
||||||
"cli.help.schedule.output": "Output file path (prints to terminal if not specified)",
|
"cli.help.ignore.preset": "Preset template: node / python / go / rust / java / general (default: general)",
|
||||||
"cmd.schedule.exported": "Schedule config exported to: {path} ({type})",
|
"cli.help.ignore.output": "Output file path (default: .sbackupignore)",
|
||||||
"cmd.schedule.unknown_type": "Unknown schedule type: {type}",
|
"cli.help.ignore.list": "List all available presets",
|
||||||
"cli.help.webhook_cmd": "Webhook notification management",
|
"cmd.ignore.available_presets": "Available presets:",
|
||||||
"cli.help.webhook_cmd.action": "Webhook action: preset / list",
|
"cmd.ignore.overwrite": "File {path} already exists, overwrite? (y/N): ",
|
||||||
"cli.help.webhook_cmd.preset": "Set IM notification preset template",
|
"cmd.ignore.cancelled": "Cancelled.",
|
||||||
"cli.help.webhook_cmd.preset.name": "Preset name: dingtalk / feishu / wechat",
|
"cmd.ignore.generated": "Generated {preset} preset exclude rules: {path}",
|
||||||
"cli.help.webhook_cmd.list": "List all available IM notification presets",
|
"cli.help.report": "Generate backup report in Markdown format",
|
||||||
"cmd.webhook.preset_set": "Set {name} notification template:\n{template}",
|
"cli.help.report.output": "Output file path (prints to terminal if not specified)",
|
||||||
"cmd.webhook.preset_unknown": "Unknown preset: {name}",
|
"cli.help.search": "Search filenames in backup files",
|
||||||
"cmd.webhook.available_presets": "Available IM notification presets:",
|
"cli.help.search.pattern": "Search pattern (supports glob wildcards, e.g. *.txt)",
|
||||||
"cli.help.config_cmd": "Config file management (encrypt/decrypt sensitive fields)",
|
"cli.help.search.in": "Specify backup file path (searches all strategies if not specified)",
|
||||||
"cli.help.config_cmd.action": "Action: lock / unlock",
|
"cli.help.search.password": "Decryption password (for encrypted backups)",
|
||||||
"cli.help.config_cmd.lock": "Encrypt sensitive fields in config file with master password",
|
"cli.help.versions": "View backup version history (grouped by source)",
|
||||||
"cli.help.config_cmd.lock.password": "Master password (interactive input if not specified)",
|
"cli.help.versions.source": "Specify source directory (shows all if not specified)",
|
||||||
"cli.help.config_cmd.unlock": "Decrypt sensitive fields in config file with master password",
|
"cmd.versions.header": "=== Backup Version History ===",
|
||||||
"cli.help.config_cmd.unlock.password": "Master password (interactive input if not specified)",
|
"cmd.versions.source": "📁 {source}",
|
||||||
"cmd.config.locked": "Config file encrypted (sensitive fields protected)",
|
"cmd.versions.empty": "No backup version records yet.",
|
||||||
"cmd.config.unlocked": "Config file decrypted",
|
"cmd.versions.total": "Total {count} versions, {sources} source directories",
|
||||||
"cmd.config.already_locked": "Config file is already encrypted",
|
"cli.help.schedule": "Scheduled backup configuration management",
|
||||||
"cmd.config.not_locked": "Config file is not encrypted",
|
"cli.help.schedule.action": "Schedule action: export",
|
||||||
"cmd.config.enter_password": "Enter master password",
|
"cli.help.schedule.export": "Export scheduled backup configuration file",
|
||||||
"cmd.config.no_password": "Password cannot be empty",
|
"cli.help.schedule.type": "Schedule type: systemd / crontab (default: systemd)",
|
||||||
"cmd.config.wrong_password": "Wrong password, decryption failed",
|
"cli.help.schedule.interval": "Backup interval in minutes (default: 60)",
|
||||||
"cmd.config.lock_failed": "Encryption failed",
|
"cli.help.schedule.output": "Output file path (prints to terminal if not specified)",
|
||||||
"cli.help.clean": "Smart cleanup of backup files across all strategies",
|
"cmd.schedule.exported": "Schedule config exported to: {path} ({type})",
|
||||||
"cli.help.clean.keep": "Keep the most recent N backup files per strategy",
|
"cmd.schedule.unknown_type": "Unknown schedule type: {type}",
|
||||||
"cli.help.clean.keep_days": "Delete backup files older than N days",
|
"cli.help.webhook_cmd": "Webhook notification management",
|
||||||
"cli.help.clean.dry_run": "Preview files to be deleted without actually deleting",
|
"cli.help.webhook_cmd.action": "Webhook action: preset / list",
|
||||||
"cmd.clean.no_criteria": "Please specify cleanup criteria: --keep N or --keep-days N",
|
"cli.help.webhook_cmd.preset": "Set IM notification preset template",
|
||||||
"cmd.clean.nothing": "No backup files to clean up.",
|
"cli.help.webhook_cmd.preset.name": "Preset name: dingtalk / feishu / wechat",
|
||||||
"cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):",
|
"cli.help.webhook_cmd.list": "List all available IM notification presets",
|
||||||
"cmd.clean.summary": "Cleaned up {count} backup file(s).",
|
"cmd.webhook.preset_set": "Set {name} notification template:\n{template}",
|
||||||
"cmd.report.saved": "Backup report saved to: {path}",
|
"cmd.webhook.preset_unknown": "Unknown preset: {name}",
|
||||||
"cmd.search.results": "Found {count} matching file(s) in {path}:",
|
"cmd.webhook.available_presets": "Available IM notification presets:",
|
||||||
"cmd.search.no_results": "No files matching '{pattern}' found in {path}",
|
"cli.help.config_cmd": "Config file management (encrypt/decrypt sensitive fields)",
|
||||||
"cmd.search.no_results_global": "No files matching '{pattern}' found in any backup",
|
"cli.help.config_cmd.action": "Action: lock / unlock",
|
||||||
"cmd.export.success": "Exported {count} backup strategies to {path}",
|
"cli.help.config_cmd.lock": "Encrypt sensitive fields in config file with master password",
|
||||||
"cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing",
|
"cli.help.config_cmd.lock.password": "Master password (interactive input if not specified)",
|
||||||
"cmd.import.empty": "No valid backup strategies found in: {path}",
|
"cli.help.config_cmd.unlock": "Decrypt sensitive fields in config file with master password",
|
||||||
"cli.help.save.dry_run": "Preview files to be backed up without actually compressing",
|
"cli.help.config_cmd.unlock.password": "Master password (interactive input if not specified)",
|
||||||
"cli.help.save.verify": "Verify backup integrity after completion",
|
"cmd.config.locked": "Config file encrypted (sensitive fields protected)",
|
||||||
"cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)",
|
"cmd.config.unlocked": "Config file decrypted",
|
||||||
"cli.help.save.webhook": "POST backup results to this URL after completion",
|
"cmd.config.already_locked": "Config file is already encrypted",
|
||||||
"cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)",
|
"cmd.config.not_locked": "Config file is not encrypted",
|
||||||
"cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)",
|
"cmd.config.enter_password": "Enter master password",
|
||||||
"cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)",
|
"cmd.config.no_password": "Password cannot be empty",
|
||||||
"cli.help.save.split": "Split backup file into chunks (e.g. 100MB, 1GB), auto-splits when exceeded",
|
"cmd.config.wrong_password": "Wrong password, decryption failed",
|
||||||
"cli.help.save.tag": "Add a tag to this backup (e.g. daily, release)",
|
"cmd.config.lock_failed": "Encryption failed",
|
||||||
"cli.help.watch.dry_run": "Preview files to be backed up without actually compressing",
|
"cli.help.clean": "Smart cleanup of backup files across all strategies",
|
||||||
"cli.help.watch.verify": "Verify backup integrity after each backup",
|
"cli.help.clean.keep": "Keep the most recent N backup files per strategy",
|
||||||
"cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)",
|
"cli.help.clean.keep_days": "Delete backup files older than N days",
|
||||||
"cli.help.watch.webhook": "POST backup results to this URL after each backup",
|
"cli.help.clean.dry_run": "Preview files to be deleted without actually deleting",
|
||||||
"cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)",
|
"cmd.clean.no_criteria": "Please specify cleanup criteria: --keep N or --keep-days N",
|
||||||
"cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)",
|
"cmd.clean.nothing": "No backup files to clean up.",
|
||||||
"cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)",
|
"cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):",
|
||||||
"cli.help.watch.split": "Split backup file into chunks (e.g. 100MB, 1GB)",
|
"cmd.clean.summary": "Cleaned up {count} backup file(s).",
|
||||||
"cli.help.watch.tag": "Add a tag to this backup",
|
"cli.help.completion": "Generate shell completion script",
|
||||||
"cli.help.init": "Generate default config file template",
|
"cli.help.completion.shell": "Shell type: bash(default) / zsh / fish / powershell",
|
||||||
"cli.help.rm.all": "Delete all backup strategies",
|
"cmd.report.saved": "Backup report saved to: {path}",
|
||||||
"cli.help.edit": "Edit an existing backup strategy (only modifies specified fields)",
|
"cmd.search.results": "Found {count} matching file(s) in {path}:",
|
||||||
"cli.help.edit.source": "Source folder path (to locate the strategy to edit)",
|
"cmd.search.no_results": "No files matching '{pattern}' found in {path}",
|
||||||
"cli.help.edit.dest": "Change target folder path",
|
"cmd.search.no_results_global": "No files matching '{pattern}' found in any backup",
|
||||||
"cli.help.edit.ignore": "Change ignore patterns (comma separated)",
|
"cmd.export.success": "Exported {count} backup strategies to {path}",
|
||||||
"cli.help.edit.format": "Change packaging format: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z",
|
"cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing",
|
||||||
"cli.help.edit.name_template": "Change filename template (supports {name} {date} {time} variables)",
|
"cmd.import.empty": "No valid backup strategies found in: {path}",
|
||||||
"cli.help.info": "View backup file details (format, size, file count, checksum, etc.)",
|
"cli.help.save.dry_run": "Preview files to be backed up without actually compressing",
|
||||||
"cli.help.info.file": "Backup file path",
|
"cli.help.save.verify": "Verify backup integrity after completion",
|
||||||
"cli.help.info.password": "Decryption password (for encrypted 7z backups)",
|
"cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)",
|
||||||
"cmd.info.header": "=== Backup File Info ===",
|
"cli.help.save.webhook": "POST backup results to this URL after completion",
|
||||||
"cmd.info.path": "File path: {path}",
|
"cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)",
|
||||||
"cmd.info.format": "Compression format: {format}",
|
"cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)",
|
||||||
"cmd.info.size": "File size: {size:.2f} MB",
|
"cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)",
|
||||||
"cmd.info.files": "File count: {count}",
|
"cli.help.save.split": "Split backup file into chunks (e.g. 100MB, 1GB), auto-splits when exceeded",
|
||||||
"cmd.info.time": "Modified: {time}",
|
"cli.help.save.tag": "Add a tag to this backup (e.g. daily, release)",
|
||||||
"cmd.info.sha256": "SHA256: {checksum}",
|
"cli.help.watch.dry_run": "Preview files to be backed up without actually compressing",
|
||||||
"cmd.diff.header": "=== Diff Comparison ===\nBackup file: {backup}",
|
"cli.help.watch.verify": "Verify backup integrity after each backup",
|
||||||
"cmd.diff.added_header": "\nAdded files ({count}):",
|
"cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)",
|
||||||
"cmd.diff.removed_header": "\nRemoved files ({count}):",
|
"cli.help.watch.webhook": "POST backup results to this URL after each backup",
|
||||||
"cmd.diff.modified_header": "\nPossibly modified files ({count}):",
|
"cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)",
|
||||||
"cmd.diff.no_changes": "\nNo differences, source matches backup.",
|
"cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)",
|
||||||
"cmd.diff.summary": "\nDiff summary: +{added} added, -{removed} removed, ~{modified} modified",
|
"cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)",
|
||||||
"cmd.diff.no_backup": "No backup files found in target directory: {path}",
|
"cli.help.watch.split": "Split backup file into chunks (e.g. 100MB, 1GB)",
|
||||||
"cmd.diff.empty_backup": "Backup file is empty: {path}",
|
"cli.help.watch.tag": "Add a tag to this backup",
|
||||||
"cli.help.diff": "Compare source directory with the latest backup",
|
"cli.help.init": "Generate default config file template",
|
||||||
"cli.help.diff.source": "Source folder path",
|
"cli.help.rm.all": "Delete all backup strategies",
|
||||||
"cli.help.diff.file": "Specify backup file path (auto-finds latest if not specified)",
|
"cli.help.edit": "Edit an existing backup strategy (only modifies specified fields)",
|
||||||
"cli.help.diff.password": "Decryption password (for encrypted backups)",
|
"cli.help.edit.source": "Source folder path (to locate the strategy to edit)",
|
||||||
"cli.help.remote": "Remote backup file management (list/delete backups on SFTP or WebDAV)",
|
"cli.help.edit.dest": "Change target folder path",
|
||||||
"cli.help.remote.action": "Remote action: list / rm",
|
"cli.help.edit.ignore": "Change ignore patterns (comma separated)",
|
||||||
"cli.help.remote.list": "List remote backup files",
|
"cli.help.edit.format": "Change packaging format: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z",
|
||||||
"cli.help.remote.rm": "Delete a remote backup file",
|
"cli.help.edit.name_template": "Change filename template (supports {name} {date} {time} variables)",
|
||||||
"cli.help.remote.rm.filename": "Remote filename to delete",
|
"cli.help.info": "View backup file details (format, size, file count, checksum, etc.)",
|
||||||
"cli.help.remote.sftp": "Operate on SFTP server",
|
"cli.help.info.file": "Backup file path",
|
||||||
"cli.help.remote.webdav": "Operate on WebDAV server",
|
"cli.help.info.password": "Decryption password (for encrypted 7z backups)",
|
||||||
"cli.help.version": "Show version information",
|
"cmd.info.header": "=== Backup File Info ===",
|
||||||
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
|
"cmd.info.path": "File path: {path}",
|
||||||
"log.config.malformed": "Config file %s is malformed, using defaults",
|
"cmd.info.format": "Compression format: {format}",
|
||||||
"log.config.reset": "Config file %s is malformed, resetting to empty",
|
"cmd.info.size": "File size: {size:.2f} MB",
|
||||||
"log.config.mkdir.error": "Cannot create config directory %s: %s",
|
"cmd.info.files": "File count: {count}",
|
||||||
"log.config.write.error": "Cannot write config file %s: %s",
|
"cmd.info.time": "Modified: {time}",
|
||||||
"log.data.read": "Reading data file: %s",
|
"cmd.info.sha256": "SHA256: {checksum}",
|
||||||
"log.data.create": "Data file does not exist, creating: %s",
|
"cmd.diff.header": "=== Diff Comparison ===\nBackup file: {backup}",
|
||||||
"log.data.load": "Loading existing data file: %s",
|
"cmd.diff.added_header": "\nAdded files ({count}):",
|
||||||
"log.data.write": "Writing data file: %s",
|
"cmd.diff.removed_header": "\nRemoved files ({count}):",
|
||||||
"log.cleanup.delete": "Deleted old backup: %s",
|
"cmd.diff.modified_header": "\nPossibly modified files ({count}):",
|
||||||
"log.i18n.load.failed": "Failed to load locale {path}: {error}",
|
"cmd.diff.no_changes": "\nNo differences, source matches backup.",
|
||||||
"log.i18n.load.fallback_failed": "Failed to load fallback locale {path}: {error}",
|
"cmd.diff.summary": "\nDiff summary: +{added} added, -{removed} removed, ~{modified} modified",
|
||||||
"err.sftp.auth": "SFTP authentication failed: {host}, check username and password",
|
"cmd.diff.no_backup": "No backup files found in target directory: {path}",
|
||||||
"err.sftp.ssh": "SFTP connection error: {error}",
|
"cmd.diff.empty_backup": "Backup file is empty: {path}",
|
||||||
"err.sftp.connect": "Cannot connect to {host}:{port}: {error}",
|
"cli.help.diff": "Compare source directory with the latest backup",
|
||||||
"err.sftp.not_connected": "SFTP not connected, call connect() first",
|
"cli.help.diff.source": "Source folder path",
|
||||||
"err.sftp.local_not_found": "Local file not found: {path}",
|
"cli.help.diff.file": "Specify backup file path (auto-finds latest if not specified)",
|
||||||
"err.sftp.upload": "Upload failed {path}: {error}",
|
"cli.help.diff.password": "Decryption password (for encrypted backups)",
|
||||||
"err.sftp.mkdir": "Failed to create remote directory {path}: {error}",
|
"cli.help.remote": "Remote backup file management (list/delete backups on SFTP or WebDAV)",
|
||||||
"err.sftp.not_configured": "SFTP not configured, run: sbackup sftp config --host <address> --user <username>",
|
"cli.help.remote.action": "Remote action: list / rm",
|
||||||
"cmd.sftp.uploading": "Uploading: {file}",
|
"cli.help.remote.list": "List remote backup files",
|
||||||
"cmd.sftp.progress": "Upload",
|
"cli.help.remote.rm": "Delete a remote backup file",
|
||||||
"cmd.sftp.success": "Upload successful: {file}",
|
"cli.help.remote.rm.filename": "Remote filename to delete",
|
||||||
"cmd.sftp.parallel_upload": "Uploading {count} files in parallel...",
|
"cli.help.remote.sftp": "Operate on SFTP server",
|
||||||
"cmd.sftp.config_saved": "SFTP configuration saved to config.json",
|
"cli.help.remote.webdav": "Operate on WebDAV server",
|
||||||
"cmd.sftp.testing": "Testing connection to {host}...",
|
"cli.help.version": "Show version information",
|
||||||
"cmd.sftp.test_ok": "SFTP connection test successful!",
|
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
|
||||||
"cmd.sftp.test_fail": "SFTP connection test failed, check configuration",
|
"log.config.malformed": "Config file %s is malformed, using defaults",
|
||||||
"cli.help.save.sftp": "Upload to SFTP server after backup (auto-detects SSH key or uses password)",
|
"log.config.reset": "Config file %s is malformed, resetting to empty",
|
||||||
"cli.help.watch.sftp": "Upload to SFTP server after each backup",
|
"log.config.mkdir.error": "Cannot create config directory %s: %s",
|
||||||
"cli.help.sftp": "SFTP remote backup management (supports password and SSH key authentication)",
|
"log.config.write.error": "Cannot write config file %s: %s",
|
||||||
"cli.help.sftp.action": "SFTP action: config / test",
|
"log.data.read": "Reading data file: %s",
|
||||||
"cli.help.sftp.config": "Configure SFTP connection (supports password and SSH key authentication)",
|
"log.data.create": "Data file does not exist, creating: %s",
|
||||||
"cli.help.sftp.test": "Test if SFTP connection is available",
|
"log.data.load": "Loading existing data file: %s",
|
||||||
"cli.help.sftp.host": "SFTP server address (e.g., 192.168.1.100 or example.com)",
|
"log.data.write": "Writing data file: %s",
|
||||||
"cli.help.sftp.port": "SFTP port number (default: 22)",
|
"log.cleanup.delete": "Deleted old backup: %s",
|
||||||
"cli.help.sftp.user": "SFTP username",
|
"log.i18n.load.failed": "Failed to load locale {path}: {error}",
|
||||||
"cli.help.sftp.password": "SFTP password (password auth only)",
|
"log.i18n.load.fallback_failed": "Failed to load fallback locale {path}: {error}",
|
||||||
"cli.help.sftp.remote_path": "Remote target path (default: /, creates directory if not exists)",
|
"err.sftp.auth": "SFTP authentication failed: {host}, check username and password",
|
||||||
"cli.prompt.sftp.host": "SFTP server address:",
|
"err.sftp.ssh": "SFTP connection error: {error}",
|
||||||
"cli.prompt.sftp.port": "SFTP port (default 22):",
|
"err.sftp.connect": "Cannot connect to {host}:{port}: {error}",
|
||||||
"cli.prompt.sftp.user": "SFTP username:",
|
"err.sftp.not_connected": "SFTP not connected, call connect() first",
|
||||||
"cli.prompt.sftp.password": "SFTP password:",
|
"err.sftp.local_not_found": "Local file not found: {path}",
|
||||||
"cli.prompt.sftp.remote_path": "Remote path (default /):",
|
"err.sftp.upload": "Upload failed {path}: {error}",
|
||||||
"err.sftp.key_load": "Cannot load private key {path}, check file format (RSA/Ed25519/ECDSA supported)",
|
"err.sftp.mkdir": "Failed to create remote directory {path}: {error}",
|
||||||
"cli.help.sftp.key_file": "SSH private key file path (RSA/Ed25519/ECDSA supported, takes priority over password)",
|
"err.sftp.not_configured": "SFTP not configured, run: sbackup sftp config --host <address> --user <username>",
|
||||||
"cli.help.sftp.key_passphrase": "Private key passphrase (for encrypted keys, can be entered interactively)",
|
"cmd.sftp.uploading": "Uploading: {file}",
|
||||||
"cli.prompt.sftp.key_file": "SSH private key file path (leave empty for password auth):",
|
"cmd.sftp.progress": "Upload",
|
||||||
"cli.prompt.sftp.key_passphrase": "Private key passphrase (press Enter to skip):",
|
"cmd.sftp.success": "Upload successful: {file}",
|
||||||
"cmd.sftp.using_default_key": "Detected default SSH key, using: {path}",
|
"cmd.sftp.parallel_upload": "Uploading {count} files in parallel...",
|
||||||
"cmd.sftp.no_default_key": "No default SSH key found, please configure password or specify key path",
|
"cmd.sftp.config_saved": "SFTP configuration saved to config.json",
|
||||||
"err.sftp.key_needs_passphrase": "Private key {path} requires a passphrase, enter it or use --key-passphrase",
|
"cmd.sftp.testing": "Testing connection to {host}...",
|
||||||
"err.sftp.wrong_passphrase": "Wrong passphrase, please retry (press Enter to skip private key)",
|
"cmd.sftp.test_ok": "SFTP connection test successful!",
|
||||||
"cli.help.webdav": "WebDAV remote backup management (supports Jianguoyun, NextCloud, Synology, etc.)",
|
"cmd.sftp.test_fail": "SFTP connection test failed, check configuration",
|
||||||
"cli.help.webdav.action": "WebDAV action: config / test",
|
"cli.help.save.sftp": "Upload to SFTP server after backup (auto-detects SSH key or uses password)",
|
||||||
"cli.help.webdav.config": "Configure WebDAV connection parameters",
|
"cli.help.watch.sftp": "Upload to SFTP server after each backup",
|
||||||
"cli.help.webdav.test": "Test if WebDAV connection is available",
|
"cli.help.sftp": "SFTP remote backup management (supports password and SSH key authentication)",
|
||||||
"cli.help.webdav.url": "WebDAV server URL (e.g., https://dav.jianguoyun.com/dav/)",
|
"cli.help.sftp.action": "SFTP action: config / test",
|
||||||
"cli.help.webdav.user": "WebDAV username",
|
"cli.help.sftp.config": "Configure SFTP connection (supports password and SSH key authentication)",
|
||||||
"cli.help.webdav.password": "WebDAV password",
|
"cli.help.sftp.test": "Test if SFTP connection is available",
|
||||||
"cli.help.webdav.remote_path": "Remote target path (default: /, creates directory if not exists)",
|
"cli.help.sftp.host": "SFTP server address (e.g., 192.168.1.100 or example.com)",
|
||||||
"cli.help.save.webdav": "Upload to WebDAV server after backup",
|
"cli.help.sftp.port": "SFTP port number (default: 22)",
|
||||||
"cli.help.watch.webdav": "Upload to WebDAV server after each backup",
|
"cli.help.sftp.user": "SFTP username",
|
||||||
"cli.prompt.webdav.url": "WebDAV server URL:",
|
"cli.help.sftp.password": "SFTP password (password auth only)",
|
||||||
"cli.prompt.webdav.user": "WebDAV username:",
|
"cli.help.sftp.remote_path": "Remote target path (default: /, creates directory if not exists)",
|
||||||
"cli.prompt.webdav.password": "WebDAV password:",
|
"cli.prompt.sftp.host": "SFTP server address:",
|
||||||
"cli.prompt.webdav.remote_path": "Remote path (default /):",
|
"cli.prompt.sftp.port": "SFTP port (default 22):",
|
||||||
"cmd.webdav.config_saved": "WebDAV configuration saved to config.json",
|
"cli.prompt.sftp.user": "SFTP username:",
|
||||||
"cmd.webdav.testing": "Testing WebDAV connection to {url}...",
|
"cli.prompt.sftp.password": "SFTP password:",
|
||||||
"cmd.webdav.test_ok": "WebDAV connection test successful!",
|
"cli.prompt.sftp.remote_path": "Remote path (default /):",
|
||||||
"cmd.webdav.uploading": "Uploading to WebDAV: {file}",
|
"err.sftp.key_load": "Cannot load private key {path}, check file format (RSA/Ed25519/ECDSA supported)",
|
||||||
"cmd.webdav.success": "WebDAV upload successful: {file}",
|
"cli.help.sftp.key_file": "SSH private key file path (RSA/Ed25519/ECDSA supported, takes priority over password)",
|
||||||
"cmd.webdav.parallel_upload": "Uploading {count} files to WebDAV in parallel...",
|
"cli.help.sftp.key_passphrase": "Private key passphrase (for encrypted keys, can be entered interactively)",
|
||||||
"err.webdav.auth": "WebDAV authentication failed: {host}, check username and password",
|
"cli.prompt.sftp.key_file": "SSH private key file path (leave empty for password auth):",
|
||||||
"err.webdav.connect": "Cannot connect to WebDAV: {url}, error: {error}",
|
"cli.prompt.sftp.key_passphrase": "Private key passphrase (press Enter to skip):",
|
||||||
"err.webdav.ssh": "WebDAV connection error: {error}",
|
"cmd.sftp.using_default_key": "Detected default SSH key, using: {path}",
|
||||||
"err.webdav.not_configured": "WebDAV not configured, run: sbackup webdav config --url <address> --user <username>",
|
"cmd.sftp.no_default_key": "No default SSH key found, please configure password or specify key path",
|
||||||
"err.webdav.local_not_found": "Local file not found: {path}",
|
"err.sftp.key_needs_passphrase": "Private key {path} requires a passphrase, enter it or use --key-passphrase",
|
||||||
"err.webdav.upload": "Upload to WebDAV failed {path}: {error}",
|
"err.sftp.wrong_passphrase": "Wrong passphrase, please retry (press Enter to skip private key)",
|
||||||
"err.webdav.mkdir": "Failed to create WebDAV remote directory {path}: {error}",
|
"cli.help.webdav": "WebDAV remote backup management (supports Jianguoyun, NextCloud, Synology, etc.)",
|
||||||
"cli.help.remote": "Remote backup file management (list/delete backup files on remote server)",
|
"cli.help.webdav.action": "WebDAV action: config / test",
|
||||||
"cli.help.remote.action": "Remote action: list / rm",
|
"cli.help.webdav.config": "Configure WebDAV connection parameters",
|
||||||
"cli.help.remote.list": "List backup files on remote server",
|
"cli.help.webdav.test": "Test if WebDAV connection is available",
|
||||||
"cli.help.remote.rm": "Delete a backup file on remote server",
|
"cli.help.webdav.url": "WebDAV server URL (e.g., https://dav.jianguoyun.com/dav/)",
|
||||||
"cli.help.remote.rm.filename": "Remote file name to delete",
|
"cli.help.webdav.user": "WebDAV username",
|
||||||
"cli.help.remote.sftp": "Use SFTP protocol",
|
"cli.help.webdav.password": "WebDAV password",
|
||||||
"cli.help.remote.webdav": "Use WebDAV protocol",
|
"cli.help.webdav.remote_path": "Remote target path (default: /, creates directory if not exists)",
|
||||||
"cmd.remote.empty": "Remote directory is empty: {path}",
|
"cli.help.save.webdav": "Upload to WebDAV server after backup",
|
||||||
"cmd.remote.list_header": "Remote directory: {path} ({count} files)",
|
"cli.help.watch.webdav": "Upload to WebDAV server after each backup",
|
||||||
"cmd.remote.deleted": "Deleted remote file: {path}",
|
"cli.prompt.webdav.url": "WebDAV server URL:",
|
||||||
"cmd.remote.select_protocol": "Please specify protocol: --sftp or --webdav",
|
"cli.prompt.webdav.user": "WebDAV username:",
|
||||||
"cmd.remote.rm_requires_path": "Please specify the remote filename to delete",
|
"cli.prompt.webdav.password": "WebDAV password:",
|
||||||
"cli.help.remote.list_path": "Specify remote directory path (uses configured default if not specified)",
|
"cli.prompt.webdav.remote_path": "Remote path (default /):",
|
||||||
"err.sftp.remote_not_found": "Remote file not found: {path}",
|
"cmd.webdav.config_saved": "WebDAV configuration saved to config.json",
|
||||||
"err.sftp.delete": "Failed to delete remote file {path}: {error}",
|
"cmd.webdav.testing": "Testing WebDAV connection to {url}...",
|
||||||
"err.webdav.not_found": "Remote file not found: {path}",
|
"cmd.webdav.test_ok": "WebDAV connection test successful!",
|
||||||
"err.webdav.delete": "Failed to delete remote file {path}: {error}",
|
"cmd.webdav.uploading": "Uploading to WebDAV: {file}",
|
||||||
"err.webdav.list": "Failed to list remote files {path}: {error}",
|
"cmd.webdav.success": "WebDAV upload successful: {file}",
|
||||||
"restore.no_match": "No files matching \"{pattern}\"",
|
"cmd.webdav.parallel_upload": "Uploading {count} files to WebDAV in parallel...",
|
||||||
"cli.help.restore.select": "Selective restore: extract only files matching pattern (supports glob wildcards, e.g. \"*.py\" or \"src/main.py\")",
|
"err.webdav.auth": "WebDAV authentication failed: {host}, check username and password",
|
||||||
"cli.help.schedule.type.schtasks": "Windows Task Scheduler",
|
"err.webdav.connect": "Cannot connect to WebDAV: {url}, error: {error}",
|
||||||
"cmd.schedule.schtasks.generated": "Windows Task Scheduler command generated",
|
"err.webdav.ssh": "WebDAV connection error: {error}",
|
||||||
"cmd.hook.pre_running": "Running pre-backup hook: {command}",
|
"err.webdav.not_configured": "WebDAV not configured, run: sbackup webdav config --url <address> --user <username>",
|
||||||
"cmd.hook.post_running": "Running post-backup hook: {command}",
|
"err.webdav.local_not_found": "Local file not found: {path}",
|
||||||
"cmd.hook.failed": "Hook execution failed: {command} — {error}",
|
"err.webdav.upload": "Upload to WebDAV failed {path}: {error}",
|
||||||
"cli.help.save.pre_hook": "Command to run before backup (can be used multiple times)",
|
"err.webdav.mkdir": "Failed to create WebDAV remote directory {path}: {error}",
|
||||||
"cli.help.save.post_hook": "Command to run after backup (can be used multiple times)",
|
"err.webdav.invalid_scheme": "Unsupported URL scheme '{scheme}' -- only http and https are allowed.",
|
||||||
"cli.help.watch.pre_hook": "Command to run before backup (can be used multiple times)",
|
"err.webdav.private_ip": "WebDAV connection to internal/private address blocked for security: {host}",
|
||||||
"cli.help.watch.post_hook": "Command to run after backup (can be used multiple times)",
|
"cmd.remote.empty": "Remote directory is empty: {path}",
|
||||||
"restore.split.detected": "Split backup detected, {count} parts total",
|
"cmd.remote.list_header": "Remote directory: {path} ({count} files)",
|
||||||
"restore.split.missing": "Error: split part missing, expected {path}",
|
"cmd.remote.deleted": "Deleted remote file: {path}",
|
||||||
"restore.split.merged": "Split parts merged: {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"
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
"table.cell.default": "Predet.",
|
"table.cell.default": "Predet.",
|
||||||
"restore.progress": "Restaurando",
|
"restore.progress": "Restaurando",
|
||||||
"restore.success": "Restauración exitosa: {path} ({count} archivos)",
|
"restore.success": "Restauración exitosa: {path} ({count} archivos)",
|
||||||
|
"restore.skipped_unsafe": "Se omitieron {count} miembros de archivo no seguros (protección contra traversal de ruta)",
|
||||||
"cmd.watch.start": "Monitorización iniciada, intervalo {interval} min (pulse Ctrl+C para detener)",
|
"cmd.watch.start": "Monitorización iniciada, intervalo {interval} min (pulse Ctrl+C para detener)",
|
||||||
"cli.description": "Sbackup v{version} — Herramienta de copia de seguridad incremental ligera\n\nSbackup le ayuda a gestionar fácilmente las copias de seguridad de múltiples carpetas.\nUtiliza una estrategia de copia de seguridad incremental, comprimiendo únicamente los archivos modificados,\nsoportando reglas de exclusión personalizadas y ofreciendo una retroalimentación de progresión intuitiva.\n\nComandos disponibles :\n add Añadir una nueva estrategia de copia de seguridad (fuente -> destino)\n rm Eliminar una estrategia de copia de seguridad existente\n all Mostrar todas las estrategias de copia de seguridad configuradas\n save Ejecutar las tareas de copia de seguridad\n version Mostrar la información de versión",
|
"cli.description": "Sbackup v{version} — Herramienta de copia de seguridad incremental ligera\n\nSbackup le ayuda a gestionar fácilmente las copias de seguridad de múltiples carpetas.\nUtiliza una estrategia de copia de seguridad incremental, comprimiendo únicamente los archivos modificados,\nsoportando reglas de exclusión personalizadas y ofreciendo una retroalimentación de progresión intuitiva.\n\nComandos disponibles :\n add Añadir una nueva estrategia de copia de seguridad (fuente -> destino)\n rm Eliminar una estrategia de copia de seguridad existente\n all Mostrar todas las estrategias de copia de seguridad configuradas\n save Ejecutar las tareas de copia de seguridad\n version Mostrar la información de versión",
|
||||||
"cli.epilog": "Ejemplos:\n Agregar estrategia de respaldo:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ejecutar respaldo (7z cifrado):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (detección automática, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (contraseña):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (clave privada):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (clave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
"cli.epilog": "Ejemplos:\n Agregar estrategia de respaldo:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ejecutar respaldo (7z cifrado):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (detección automática, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (contraseña):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (clave privada):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (clave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||||
@@ -107,6 +108,25 @@
|
|||||||
"cli.help.list": "Ver historial de copias de seguridad",
|
"cli.help.list": "Ver historial de copias de seguridad",
|
||||||
"cli.help.verify": "Verificar integridad del archivo de copia de seguridad",
|
"cli.help.verify": "Verificar integridad del archivo de copia de seguridad",
|
||||||
"cli.help.verify.file": "Archivo de copia de seguridad a verificar",
|
"cli.help.verify.file": "Archivo de copia de seguridad a verificar",
|
||||||
|
"cli.help.verify.fast": "Verificación rápida (comparar SHA256 histórico, sin extracción)",
|
||||||
|
"cli.help.verify.all": "Verificar todas las copias de seguridad históricas en lote",
|
||||||
|
"cli.help.verify.detail": "Mostrar información detallada de verificación (tamaño, formato, suma de verificación, etc.)",
|
||||||
|
"cli.help.verify.split": "Verificar integridad de archivos divididos (requiere archivo de manifiesto)",
|
||||||
|
"cli.help.verify.password": "Contraseña de descifrado (para copias cifradas)",
|
||||||
|
"cmd.verify.fast_success": "Verificación rápida aprobada: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "Verificación rápida fallida: {path}, el archivo podría estar dañado o manipulado",
|
||||||
|
"cmd.verify.no_checksum": "No se encontró suma de verificación histórica, verificación completa: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "La suma de verificación no coincide",
|
||||||
|
"cmd.verify.no_history": "No se encontró historial de copias de seguridad.",
|
||||||
|
"cmd.verify.no_file": "Especifique la ruta del archivo o use --all para verificar todas las copias.",
|
||||||
|
"cmd.verify.detail_header": "=== Detalles de copia de seguridad: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "Tamaño: {size:.2f} MB | Archivos: {files} | Formato: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ Verificado: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ Fallido: {path}",
|
||||||
|
"cmd.verify.batch_summary": "Verificación en lote completada: {success} aprobados, {fail} fallidos",
|
||||||
|
"cmd.verify.split_success": "Integridad de archivos divididos verificada: {file} ({parts} partes)",
|
||||||
|
"cmd.verify.split_failed": "Verificación de integridad de archivos divididos fallida: {file}",
|
||||||
|
"cmd.verify.split_error": "Error de verificación de archivos divididos: {error}",
|
||||||
"cli.help.export": "Export all backup strategies to a JSON file",
|
"cli.help.export": "Export all backup strategies to a JSON file",
|
||||||
"cli.help.export.file": "Export file path (default: sbackup_export.json)",
|
"cli.help.export.file": "Export file path (default: sbackup_export.json)",
|
||||||
"cli.help.import": "Import backup strategies from a JSON file",
|
"cli.help.import": "Import backup strategies from a JSON file",
|
||||||
@@ -231,5 +251,13 @@
|
|||||||
"cmd.diff.modified_header": "Posiblemente modificados ({count}):",
|
"cmd.diff.modified_header": "Posiblemente modificados ({count}):",
|
||||||
"cmd.diff.no_backup": "No se encontraron archivos de copia de seguridad en {path}",
|
"cmd.diff.no_backup": "No se encontraron archivos de copia de seguridad en {path}",
|
||||||
"cmd.diff.empty_backup": "El archivo de copia de seguridad esta vacio: {path}",
|
"cmd.diff.empty_backup": "El archivo de copia de seguridad esta vacio: {path}",
|
||||||
"cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias."
|
"cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias.",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"table.cell.default": "Défaut",
|
"table.cell.default": "Défaut",
|
||||||
"restore.progress": "Restauration en cours",
|
"restore.progress": "Restauration en cours",
|
||||||
"restore.success": "Restauration réussie : {path} ({count} fichiers)",
|
"restore.success": "Restauration réussie : {path} ({count} fichiers)",
|
||||||
|
"restore.skipped_unsafe": "Restauré {count} membres d'archive non sûrs (protection contre le traversée de chemin)",
|
||||||
"cmd.watch.start": "Surveillance démarrée, intervalle {interval} min (appuyez sur Ctrl+C pour arrêter)",
|
"cmd.watch.start": "Surveillance démarrée, intervalle {interval} min (appuyez sur Ctrl+C pour arrêter)",
|
||||||
"cli.description": "Sbackup v{version} — Outil de sauvegarde incrémentielle léger\n\nSbackup vous aide à gérer facilement les sauvegardes de plusieurs dossiers.\nIl utilise une stratégie de sauvegarde incrémentielle, compressant uniquement les fichiers modifiés,\nsupportant des règles d'exclusion personnalisées et offrant un retour de progression intuitif.\n\nCommandes disponibles :\n add Ajouter une nouvelle stratégie de sauvegarde (source -> destination)\n rm Supprimer une stratégie de sauvegarde existante\n all Afficher toutes les stratégies de sauvegarde configurées\n save Exécuter les tâches de sauvegarde\n version Afficher les informations de version",
|
"cli.description": "Sbackup v{version} — Outil de sauvegarde incrémentielle léger\n\nSbackup vous aide à gérer facilement les sauvegardes de plusieurs dossiers.\nIl utilise une stratégie de sauvegarde incrémentielle, compressant uniquement les fichiers modifiés,\nsupportant des règles d'exclusion personnalisées et offrant un retour de progression intuitif.\n\nCommandes disponibles :\n add Ajouter une nouvelle stratégie de sauvegarde (source -> destination)\n rm Supprimer une stratégie de sauvegarde existante\n all Afficher toutes les stratégies de sauvegarde configurées\n save Exécuter les tâches de sauvegarde\n version Afficher les informations de version",
|
||||||
"cli.epilog": "Exemples:\n Ajouter une stratégie de sauvegarde:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ajouter avec format au niveau de l'entrée:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Supprimer une stratégie:\n sbackup rm F:/my_folder\n\n Voir toutes les stratégies:\n sbackup all\n\n Exécuter la sauvegarde (ZIP):\n sbackup save\n\n Sauvegarde chiffrée (7z):\n sbackup --format 7z save --password 123456\n\n Garder les 5 dernières sauvegardes:\n sbackup save --keep 5\n\n Surveillance (toutes les 60 min):\n sbackup watch --interval 60\n\n Restaurer:\n sbackup restore backup.7z F:/restored\n\n SFTP distant (détection auto, recommandé):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP distant (mot de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP distant (clé privée):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP distant (clé + phrase de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP surveillance et téléchargement:\n sbackup watch --interval 60 --sftp",
|
"cli.epilog": "Exemples:\n Ajouter une stratégie de sauvegarde:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ajouter avec format au niveau de l'entrée:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Supprimer une stratégie:\n sbackup rm F:/my_folder\n\n Voir toutes les stratégies:\n sbackup all\n\n Exécuter la sauvegarde (ZIP):\n sbackup save\n\n Sauvegarde chiffrée (7z):\n sbackup --format 7z save --password 123456\n\n Garder les 5 dernières sauvegardes:\n sbackup save --keep 5\n\n Surveillance (toutes les 60 min):\n sbackup watch --interval 60\n\n Restaurer:\n sbackup restore backup.7z F:/restored\n\n SFTP distant (détection auto, recommandé):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP distant (mot de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP distant (clé privée):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP distant (clé + phrase de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP surveillance et téléchargement:\n sbackup watch --interval 60 --sftp",
|
||||||
@@ -116,6 +117,25 @@
|
|||||||
"cli.help.list": "Voir l'historique des sauvegardes",
|
"cli.help.list": "Voir l'historique des sauvegardes",
|
||||||
"cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde",
|
"cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde",
|
||||||
"cli.help.verify.file": "Fichier de sauvegarde à vérifier",
|
"cli.help.verify.file": "Fichier de sauvegarde à vérifier",
|
||||||
|
"cli.help.verify.fast": "Vérification rapide (comparer SHA256 historique, sans extraction)",
|
||||||
|
"cli.help.verify.all": "Vérifier toutes les sauvegardes historiques en lot",
|
||||||
|
"cli.help.verify.detail": "Afficher les détails de vérification (taille, format, somme de contrôle, etc.)",
|
||||||
|
"cli.help.verify.split": "Vérifier l'intégrité des fichiers divisés (nécessite le fichier manifeste)",
|
||||||
|
"cli.help.verify.password": "Mot de passe de déchiffrement (pour les sauvegardes chiffrées)",
|
||||||
|
"cmd.verify.fast_success": "Vérification rapide réussie: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "Vérification rapide échouée: {path}, le fichier est peut-être corrompu ou altéré",
|
||||||
|
"cmd.verify.no_checksum": "Aucune somme de contrôle historique trouvée, vérification complète: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "La somme de contrôle ne correspond pas",
|
||||||
|
"cmd.verify.no_history": "Aucun historique de sauvegarde trouvé.",
|
||||||
|
"cmd.verify.no_file": "Veuillez spécifier le chemin du fichier ou utiliser --all pour vérifier toutes les sauvegardes.",
|
||||||
|
"cmd.verify.detail_header": "=== Détails de la sauvegarde: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "Taille: {size:.2f} MO | Fichiers: {files} | Format: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ Vérifié: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ Échoué: {path}",
|
||||||
|
"cmd.verify.batch_summary": "Vérification en lot terminée: {success} réussis, {fail} échoués",
|
||||||
|
"cmd.verify.split_success": "Intégrité des fichiers divisés vérifiée: {file} ({parts} parties)",
|
||||||
|
"cmd.verify.split_failed": "Vérification d'intégrité des fichiers divisés échouée: {file}",
|
||||||
|
"cmd.verify.split_error": "Erreur de vérification des fichiers divisés: {error}",
|
||||||
"cli.help.info": "Voir les détails du fichier de sauvegarde (format, taille, nombre de fichiers, somme de contrôle, etc.)",
|
"cli.help.info": "Voir les détails du fichier de sauvegarde (format, taille, nombre de fichiers, somme de contrôle, etc.)",
|
||||||
"cli.help.info.file": "Chemin du fichier de sauvegarde",
|
"cli.help.info.file": "Chemin du fichier de sauvegarde",
|
||||||
"cli.help.info.password": "Mot de passe de déchiffrement (pour les sauvegardes 7z chiffrées)",
|
"cli.help.info.password": "Mot de passe de déchiffrement (pour les sauvegardes 7z chiffrées)",
|
||||||
@@ -250,5 +270,13 @@
|
|||||||
"err.webdav.not_configured": "WebDAV non configuré, exécutez: sbackup webdav config --url <adresse> --user <nom_utilisateur>",
|
"err.webdav.not_configured": "WebDAV non configuré, exécutez: sbackup webdav config --url <adresse> --user <nom_utilisateur>",
|
||||||
"err.webdav.local_not_found": "Fichier local introuvable: {path}",
|
"err.webdav.local_not_found": "Fichier local introuvable: {path}",
|
||||||
"err.webdav.upload": "Échec du téléchargement vers WebDAV {path}: {error}",
|
"err.webdav.upload": "Échec du téléchargement vers WebDAV {path}: {error}",
|
||||||
"err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path}: {error}"
|
"err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path}: {error}",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"table.cell.default": "デフォルト",
|
"table.cell.default": "デフォルト",
|
||||||
"restore.progress": "復元中",
|
"restore.progress": "復元中",
|
||||||
"restore.success": "復元成功: {path} ({count} ファイル)",
|
"restore.success": "復元成功: {path} ({count} ファイル)",
|
||||||
|
"restore.skipped_unsafe": "安全でないアーカイブメンバー {count} 件をスキップしました(パストラバーサル保護)",
|
||||||
"cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)",
|
"cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)",
|
||||||
"cli.description": "Sbackup v{version} — 軽量インクリメンタルバックアップツール\n\nSbackup は複数フォルダのバックアップタスクを簡単に管理します。\nインクリメンタルバックアップ戦略を採用し、変更されたファイルのみを圧縮し、\nカスタム除外ルールをサポートし、直感的な進捗フィードバックを提供します。\n\n使用可能なコマンド:\n add 新しいバックアップ戦略を追加(ソース -> 目標)\n rm 既存のバックアップ戦略を削除\n all 設定済みのすべてのバックアップ戦略を表示\n save バックアップタスクを実行\n version バージョン情報を表示",
|
"cli.description": "Sbackup v{version} — 軽量インクリメンタルバックアップツール\n\nSbackup は複数フォルダのバックアップタスクを簡単に管理します。\nインクリメンタルバックアップ戦略を採用し、変更されたファイルのみを圧縮し、\nカスタム除外ルールをサポートし、直感的な進捗フィードバックを提供します。\n\n使用可能なコマンド:\n add 新しいバックアップ戦略を追加(ソース -> 目標)\n rm 既存のバックアップ戦略を削除\n all 設定済みのすべてのバックアップ戦略を表示\n save バックアップタスクを実行\n version バージョン情報を表示",
|
||||||
"cli.epilog": "例:\n バックアップ戦略を追加:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 暗号化バックアップ:\n sbackup --format 7z save --password 123456\n\n 復元:\n sbackup restore backup.7z F:/restored\n\n SFTP リモート (自動検出、推奨):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP リモート (パスワード):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP リモート (キー):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP リモート (キー + パスフレーズ):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
"cli.epilog": "例:\n バックアップ戦略を追加:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 暗号化バックアップ:\n sbackup --format 7z save --password 123456\n\n 復元:\n sbackup restore backup.7z F:/restored\n\n SFTP リモート (自動検出、推奨):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP リモート (パスワード):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP リモート (キー):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP リモート (キー + パスフレーズ):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||||
@@ -116,6 +117,25 @@
|
|||||||
"cli.help.list": "バックアップ履歴を表示",
|
"cli.help.list": "バックアップ履歴を表示",
|
||||||
"cli.help.verify": "バックアップファイルの整合性を検証",
|
"cli.help.verify": "バックアップファイルの整合性を検証",
|
||||||
"cli.help.verify.file": "検証するバックアップファイル",
|
"cli.help.verify.file": "検証するバックアップファイル",
|
||||||
|
"cli.help.verify.fast": "高速検証(履歴SHA256比較、展開不要)",
|
||||||
|
"cli.help.verify.all": "すべての履歴バックアップを一括検証",
|
||||||
|
"cli.help.verify.detail": "詳細な検証情報を表示(サイズ、形式、チェックサムなど)",
|
||||||
|
"cli.help.verify.split": "分割ファイルの整合性を検証(マニフェストファイルが必要)",
|
||||||
|
"cli.help.verify.password": "復号化パスワード(暗号化バックアップ用)",
|
||||||
|
"cmd.verify.fast_success": "高速検証成功: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "高速検証失敗: {path}、ファイルが破損または改ざんされている可能性があります",
|
||||||
|
"cmd.verify.no_checksum": "履歴チェックサムが見つかりません、完全検証にフォールバック: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "チェックサムが一致しません",
|
||||||
|
"cmd.verify.no_history": "バックアップ履歴が見つかりません。",
|
||||||
|
"cmd.verify.no_file": "バックアップファイルを指定するか、--allですべてのバックアップを検証してください。",
|
||||||
|
"cmd.verify.detail_header": "=== バックアップ詳細: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "サイズ: {size:.2f} MB | ファイル数: {files} | 形式: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ 検証済み: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ 失敗: {path}",
|
||||||
|
"cmd.verify.batch_summary": "一括検証完了: {success}件成功、{fail}件失敗",
|
||||||
|
"cmd.verify.split_success": "分割ファイル整合性検証成功: {file} ({parts} パート)",
|
||||||
|
"cmd.verify.split_failed": "分割ファイル整合性検証失敗: {file}",
|
||||||
|
"cmd.verify.split_error": "分割ファイル検証エラー: {error}",
|
||||||
"cli.help.info": "バックアップファイルの詳細を表示(形式、サイズ、ファイル数、チェックサム)",
|
"cli.help.info": "バックアップファイルの詳細を表示(形式、サイズ、ファイル数、チェックサム)",
|
||||||
"cli.help.info.file": "バックアップファイルのパス",
|
"cli.help.info.file": "バックアップファイルのパス",
|
||||||
"cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)",
|
"cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)",
|
||||||
@@ -260,5 +280,13 @@
|
|||||||
"err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>",
|
"err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>",
|
||||||
"err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}",
|
"err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}",
|
||||||
"err.webdav.upload": "WebDAVへのアップロード失敗 {path}: {error}",
|
"err.webdav.upload": "WebDAVへのアップロード失敗 {path}: {error}",
|
||||||
"err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}"
|
"err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"table.cell.default": "기본값",
|
"table.cell.default": "기본값",
|
||||||
"restore.progress": "복원 중",
|
"restore.progress": "복원 중",
|
||||||
"restore.success": "복원 성공: {path} ({count} 파일)",
|
"restore.success": "복원 성공: {path} ({count} 파일)",
|
||||||
|
"restore.skipped_unsafe": "안전하지 않은 아카이브 멤버 {count}개 건너뜀 (경로 순회 방지)",
|
||||||
"cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)",
|
"cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)",
|
||||||
"cli.description": "Sbackup v{version} — 경량 증분 백업 도구\n\nSbackup는 여러 폴더의 백업 작업을 쉽게 관리합니다.\n증분 백업 전략을 사용하여 변경된 파일만 압축하고,\n사용자 정의 제외 규칙을 지원하며 직관적인 진행 피드백을 제공합니다.\n\n사용 가능한 명령:\n add 새 백업 전략 추가 (원본 -> 대상)\n rm 기존 백업 전략 삭제\n all 구성된 모든 백업 전략 표시\n save 백업 작업 실행\n version 버전 정보 표시",
|
"cli.description": "Sbackup v{version} — 경량 증분 백업 도구\n\nSbackup는 여러 폴더의 백업 작업을 쉽게 관리합니다.\n증분 백업 전략을 사용하여 변경된 파일만 압축하고,\n사용자 정의 제외 규칙을 지원하며 직관적인 진행 피드백을 제공합니다.\n\n사용 가능한 명령:\n add 새 백업 전략 추가 (원본 -> 대상)\n rm 기존 백업 전략 삭제\n all 구성된 모든 백업 전략 표시\n save 백업 작업 실행\n version 버전 정보 표시",
|
||||||
"cli.epilog": "예제:\n 백업 전략 추가:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 암호화 백업:\n sbackup --format 7z save --password 123456\n\n 복원:\n sbackup restore backup.7z F:/restored\n\n SFTP 원격 (자동 감지, 권장):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 원격 (비밀번호):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 원격 (키):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 원격 (키 + 암호문):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
"cli.epilog": "예제:\n 백업 전략 추가:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 암호화 백업:\n sbackup --format 7z save --password 123456\n\n 복원:\n sbackup restore backup.7z F:/restored\n\n SFTP 원격 (자동 감지, 권장):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 원격 (비밀번호):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 원격 (키):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 원격 (키 + 암호문):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||||
@@ -116,6 +117,25 @@
|
|||||||
"cli.help.list": "백업 기록 보기",
|
"cli.help.list": "백업 기록 보기",
|
||||||
"cli.help.verify": "백업 파일 무결성 검증",
|
"cli.help.verify": "백업 파일 무결성 검증",
|
||||||
"cli.help.verify.file": "검증할 백업 파일",
|
"cli.help.verify.file": "검증할 백업 파일",
|
||||||
|
"cli.help.verify.fast": "빠른 검증 (SHA256 비교, 압축 해제 불필요)",
|
||||||
|
"cli.help.verify.all": "모든 백업 기록 일괄 검증",
|
||||||
|
"cli.help.verify.detail": "상세 검증 정보 표시 (크기, 형식, 체크섬 등)",
|
||||||
|
"cli.help.verify.split": "분할 파일 무결성 검증 (매니페스트 파일 필요)",
|
||||||
|
"cli.help.verify.password": "복호화 비밀번호 (암호화된 백업용)",
|
||||||
|
"cmd.verify.fast_success": "빠른 검증 성공: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "빠른 검증 실패: {path}, 파일이 손상되었거나 변조되었을 수 있습니다",
|
||||||
|
"cmd.verify.no_checksum": "기록된 체크섬을 찾을 수 없어 전체 검증으로 대체: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "체크섬 불일치",
|
||||||
|
"cmd.verify.no_history": "백업 기록을 찾을 수 없습니다.",
|
||||||
|
"cmd.verify.no_file": "백업 파일을 지정하거나 --all로 모든 백업을 검증하세요.",
|
||||||
|
"cmd.verify.detail_header": "=== 백업 상세: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "크기: {size:.2f} MB | 파일 수: {files} | 형식: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ 검증 완료: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ 실패: {path}",
|
||||||
|
"cmd.verify.batch_summary": "일괄 검증 완료: {success}개 성공, {fail}개 실패",
|
||||||
|
"cmd.verify.split_success": "분할 파일 무결성 검증 성공: {file} ({parts}개 파트)",
|
||||||
|
"cmd.verify.split_failed": "분할 파일 무결성 검증 실패: {file}",
|
||||||
|
"cmd.verify.split_error": "분할 파일 검증 오류: {error}",
|
||||||
"cli.help.info": "백업 파일 상세 정보 보기 (형식, 크기, 파일 수, 체크섬)",
|
"cli.help.info": "백업 파일 상세 정보 보기 (형식, 크기, 파일 수, 체크섬)",
|
||||||
"cli.help.info.file": "백업 파일 경로",
|
"cli.help.info.file": "백업 파일 경로",
|
||||||
"cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)",
|
"cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)",
|
||||||
@@ -260,5 +280,13 @@
|
|||||||
"err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>",
|
"err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>",
|
||||||
"err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}",
|
"err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}",
|
||||||
"err.webdav.upload": "WebDAV 업로드 실패 {path}: {error}",
|
"err.webdav.upload": "WebDAV 업로드 실패 {path}: {error}",
|
||||||
"err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}"
|
"err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"table.cell.default": "Padrão",
|
"table.cell.default": "Padrão",
|
||||||
"restore.progress": "Restaurando",
|
"restore.progress": "Restaurando",
|
||||||
"restore.success": "Restauração concluída: {path} ({count} arquivos)",
|
"restore.success": "Restauração concluída: {path} ({count} arquivos)",
|
||||||
|
"restore.skipped_unsafe": "Ignorados {count} membros de arquivo inseguros (proteção contra path traversal)",
|
||||||
"cmd.watch.start": "Monitoramento iniciado, intervalo {interval} min (Ctrl+C para parar)",
|
"cmd.watch.start": "Monitoramento iniciado, intervalo {interval} min (Ctrl+C para parar)",
|
||||||
"cli.description": "Sbackup v{version} — Ferramenta de backup incremental leve\n\nSbackup ajuda você a gerenciar facilmente backups de múltiplas pastas.\nEle usa uma estratégia de backup incremental, comprimindo apenas arquivos modificados,\nsuportando regras de exclusão personalizadas e oferecendo feedback de progresso intuitivo.\n\nComandos disponíveis:\n add Adicionar nova estratégia de backup (origem -> destino)\n rm Remover estratégia de backup existente\n all Mostrar todas as estratégias de backup configuradas\n save Executar tarefas de backup\n version Mostrar informações de versão",
|
"cli.description": "Sbackup v{version} — Ferramenta de backup incremental leve\n\nSbackup ajuda você a gerenciar facilmente backups de múltiplas pastas.\nEle usa uma estratégia de backup incremental, comprimindo apenas arquivos modificados,\nsuportando regras de exclusão personalizadas e oferecendo feedback de progresso intuitivo.\n\nComandos disponíveis:\n add Adicionar nova estratégia de backup (origem -> destino)\n rm Remover estratégia de backup existente\n all Mostrar todas as estratégias de backup configuradas\n save Executar tarefas de backup\n version Mostrar informações de versão",
|
||||||
"cli.epilog": "Exemplos:\n Adicionar estratégia de backup:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup criptografado (7z):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (auto-detecção, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (senha):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (chave):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (chave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
"cli.epilog": "Exemplos:\n Adicionar estratégia de backup:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup criptografado (7z):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (auto-detecção, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (senha):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (chave):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (chave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||||
@@ -116,6 +117,25 @@
|
|||||||
"cli.help.list": "Ver histórico de backups",
|
"cli.help.list": "Ver histórico de backups",
|
||||||
"cli.help.verify": "Verificar integridade do arquivo de backup",
|
"cli.help.verify": "Verificar integridade do arquivo de backup",
|
||||||
"cli.help.verify.file": "Arquivo de backup para verificar",
|
"cli.help.verify.file": "Arquivo de backup para verificar",
|
||||||
|
"cli.help.verify.fast": "Verificação rápida (comparar SHA256 histórico, sem extração)",
|
||||||
|
"cli.help.verify.all": "Verificar todos os backups históricos em lote",
|
||||||
|
"cli.help.verify.detail": "Mostrar informações detalhadas de verificação (tamanho, formato, soma de verificação, etc.)",
|
||||||
|
"cli.help.verify.split": "Verificar integridade de arquivos divididos (requer arquivo de manifesto)",
|
||||||
|
"cli.help.verify.password": "Senha de descriptografia (para backups criptografados)",
|
||||||
|
"cmd.verify.fast_success": "Verificação rápida aprovada: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "Verificação rápida falhou: {path}, arquivo pode estar corrompido ou adulterado",
|
||||||
|
"cmd.verify.no_checksum": "Soma de verificação histórica não encontrada, verificação completa: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "Soma de verificação não corresponde",
|
||||||
|
"cmd.verify.no_history": "Nenhum histórico de backup encontrado.",
|
||||||
|
"cmd.verify.no_file": "Especifique o caminho do arquivo ou use --all para verificar todos os backups.",
|
||||||
|
"cmd.verify.detail_header": "=== Detalhes do backup: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "Tamanho: {size:.2f} MB | Arquivos: {files} | Formato: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ Verificado: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ Falhou: {path}",
|
||||||
|
"cmd.verify.batch_summary": "Verificação em lote concluída: {success} aprovados, {fail} falharam",
|
||||||
|
"cmd.verify.split_success": "Integridade de arquivos divididos verificada: {file} ({parts} partes)",
|
||||||
|
"cmd.verify.split_failed": "Verificação de integridade de arquivos divididos falhou: {file}",
|
||||||
|
"cmd.verify.split_error": "Erro na verificação de arquivos divididos: {error}",
|
||||||
"cli.help.info": "Ver detalhes do arquivo de backup (formato, tamanho, quantidade de arquivos, soma de verificação)",
|
"cli.help.info": "Ver detalhes do arquivo de backup (formato, tamanho, quantidade de arquivos, soma de verificação)",
|
||||||
"cli.help.info.file": "Caminho do arquivo de backup",
|
"cli.help.info.file": "Caminho do arquivo de backup",
|
||||||
"cli.help.info.password": "Senha de descriptografia (para backups 7z criptografados)",
|
"cli.help.info.password": "Senha de descriptografia (para backups 7z criptografados)",
|
||||||
@@ -260,5 +280,13 @@
|
|||||||
"err.webdav.not_configured": "WebDAV não configurado, execute: sbackup webdav config --url <endereço> --user <usuário>",
|
"err.webdav.not_configured": "WebDAV não configurado, execute: sbackup webdav config --url <endereço> --user <usuário>",
|
||||||
"err.webdav.local_not_found": "Arquivo local não encontrado: {path}",
|
"err.webdav.local_not_found": "Arquivo local não encontrado: {path}",
|
||||||
"err.webdav.upload": "Upload para WebDAV falhou {path}: {error}",
|
"err.webdav.upload": "Upload para WebDAV falhou {path}: {error}",
|
||||||
"err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}"
|
"err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
"table.cell.default": "По умолч.",
|
"table.cell.default": "По умолч.",
|
||||||
"restore.progress": "Восстановление",
|
"restore.progress": "Восстановление",
|
||||||
"restore.success": "Восстановление выполнено: {path} ({count} файлов)",
|
"restore.success": "Восстановление выполнено: {path} ({count} файлов)",
|
||||||
|
"restore.skipped_unsafe": "Пропущено {count} небезопасных элементов архива (защита от обхода пути)",
|
||||||
"cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)",
|
"cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)",
|
||||||
"cli.description": "Sbackup v{version} — Лёгкий инструмент инкрементального резервного копирования\n\nSbackup помогает легко управлять резервным копированием нескольких папок.\nОн использует стратегию инкрементального копирования, сжимая только изменённые файлы,\nподдерживает пользовательские правила исключения и предоставляет наглядный ход выполнения.\n\nДоступные команды:\n add Добавить новую стратегию резервного копирования (источник -> цель)\n rm Удалить существующую стратегию\n all Показать все настроенные стратегии\n save Выполнить резервное копирование\n version Показать информацию о версии",
|
"cli.description": "Sbackup v{version} — Лёгкий инструмент инкрементального резервного копирования\n\nSbackup помогает легко управлять резервным копированием нескольких папок.\nОн использует стратегию инкрементального копирования, сжимая только изменённые файлы,\nподдерживает пользовательские правила исключения и предоставляет наглядный ход выполнения.\n\nДоступные команды:\n add Добавить новую стратегию резервного копирования (источник -> цель)\n rm Удалить существующую стратегию\n all Показать все настроенные стратегии\n save Выполнить резервное копирование\n version Показать информацию о версии",
|
||||||
"cli.epilog": "Примеры:\n Добавить стратегию:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Резервное копирование (7z шифрование):\n sbackup --format 7z save --password 123456\n\n Восстановить:\n sbackup restore backup.7z F:/restored\n\n SFTP (авто-детект ключа, рекомендуется):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP (пароль):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP (ключ): \n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP (ключ + фраза):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
"cli.epilog": "Примеры:\n Добавить стратегию:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Резервное копирование (7z шифрование):\n sbackup --format 7z save --password 123456\n\n Восстановить:\n sbackup restore backup.7z F:/restored\n\n SFTP (авто-детект ключа, рекомендуется):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP (пароль):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP (ключ): \n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP (ключ + фраза):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp",
|
||||||
@@ -116,6 +117,25 @@
|
|||||||
"cli.help.list": "Просмотреть историю резервного копирования",
|
"cli.help.list": "Просмотреть историю резервного копирования",
|
||||||
"cli.help.verify": "Проверить целостность файла резервной копии",
|
"cli.help.verify": "Проверить целостность файла резервной копии",
|
||||||
"cli.help.verify.file": "Файл резервной копии для проверки",
|
"cli.help.verify.file": "Файл резервной копии для проверки",
|
||||||
|
"cli.help.verify.fast": "Быстрая проверка (сравнение SHA256, без распаковки)",
|
||||||
|
"cli.help.verify.all": "Пакетная проверка всех исторических резервных копий",
|
||||||
|
"cli.help.verify.detail": "Показать подробную информацию (размер, формат, контрольная сумма и т.д.)",
|
||||||
|
"cli.help.verify.split": "Проверить целостность разделенных файлов (требуется файл манифеста)",
|
||||||
|
"cli.help.verify.password": "Пароль расшифровки (для зашифрованных копий)",
|
||||||
|
"cmd.verify.fast_success": "Быстрая проверка пройдена: {path}\nSHA256: {sha256}",
|
||||||
|
"cmd.verify.fast_failed": "Быстрая проверка не пройдена: {path}, файл может быть поврежден или изменен",
|
||||||
|
"cmd.verify.no_checksum": "Историческая контрольная сумма не найдена, полная проверка: {path}",
|
||||||
|
"cmd.verify.checksum_mismatch": "Контрольная сумма не совпадает",
|
||||||
|
"cmd.verify.no_history": "История резервных копий не найдена.",
|
||||||
|
"cmd.verify.no_file": "Укажите путь к файлу или используйте --all для проверки всех копий.",
|
||||||
|
"cmd.verify.detail_header": "=== Детали резервной копии: {path} ===",
|
||||||
|
"cmd.verify.detail_info": "Размер: {size:.2f} МБ | Файлов: {files} | Формат: {fmt} | SHA256: {sha256}...",
|
||||||
|
"cmd.verify.batch_success": "✓ Проверено: {path}",
|
||||||
|
"cmd.verify.batch_failed": "✗ Ошибка: {path}",
|
||||||
|
"cmd.verify.batch_summary": "Пакетная проверка завершена: {success} успешно, {fail} с ошибками",
|
||||||
|
"cmd.verify.split_success": "Целостность разделенных файлов подтверждена: {file} ({parts} частей)",
|
||||||
|
"cmd.verify.split_failed": "Проверка целостности разделенных файлов не пройдена: {file}",
|
||||||
|
"cmd.verify.split_error": "Ошибка проверки разделенных файлов: {error}",
|
||||||
"cli.help.info": "Просмотреть подробную информацию о файле резервной копии (формат, размер, количество файлов, контрольная сумма)",
|
"cli.help.info": "Просмотреть подробную информацию о файле резервной копии (формат, размер, количество файлов, контрольная сумма)",
|
||||||
"cli.help.info.file": "Путь к файлу резервной копии",
|
"cli.help.info.file": "Путь к файлу резервной копии",
|
||||||
"cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)",
|
"cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)",
|
||||||
@@ -260,5 +280,13 @@
|
|||||||
"err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>",
|
"err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>",
|
||||||
"err.webdav.local_not_found": "Локальный файл не найден: {path}",
|
"err.webdav.local_not_found": "Локальный файл не найден: {path}",
|
||||||
"err.webdav.upload": "Ошибка загрузки на WebDAV {path}: {error}",
|
"err.webdav.upload": "Ошибка загрузки на WebDAV {path}: {error}",
|
||||||
"err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}"
|
"err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}",
|
||||||
|
"cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)",
|
||||||
|
"cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)",
|
||||||
|
"cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)",
|
||||||
|
"log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)",
|
||||||
|
"log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)",
|
||||||
|
"log.monitor.source_missing": "Monitor source directory not found, skipping: {path}",
|
||||||
|
"log.monitor.watching": "Now monitoring: {path}",
|
||||||
|
"log.monitor.no_sources": "No valid source directories to monitor, monitor not started"
|
||||||
}
|
}
|
||||||
+389
-360
@@ -1,362 +1,391 @@
|
|||||||
{
|
{
|
||||||
"cmd.add.success": "备份策略添加成功: {source} -> {dest}",
|
"cmd.add.success": "备份策略添加成功: {source} -> {dest}",
|
||||||
"cmd.rm.success": "备份策略删除成功: {path}",
|
"cmd.rm.success": "备份策略删除成功: {path}",
|
||||||
"cmd.edit.success": "备份策略编辑成功: {path}",
|
"cmd.edit.success": "备份策略编辑成功: {path}",
|
||||||
"cmd.edit.nothing": "未指定任何修改参数。",
|
"cmd.edit.nothing": "未指定任何修改参数。",
|
||||||
"err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。",
|
"err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。",
|
||||||
"err.file.not_found": "错误:备份文件 {path} 不存在。",
|
"err.file.not_found": "错误:备份文件 {path} 不存在。",
|
||||||
"err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。",
|
"err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。",
|
||||||
"info.already.added": "提示:{path} 已经添加过了,请勿重复添加。",
|
"info.already.added": "提示:{path} 已经添加过了,请勿重复添加。",
|
||||||
"warn.no.strategy.found": "警告:未找到 {path} 的备份策略。",
|
"warn.no.strategy.found": "警告:未找到 {path} 的备份策略。",
|
||||||
"warn.source.missing": "警告:源文件夹不存在: {path}",
|
"warn.source.missing": "警告:源文件夹不存在: {path}",
|
||||||
"warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。",
|
"warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。",
|
||||||
"warn.json.backup": "警告:已备份损坏的数据文件到 {path}",
|
"warn.json.backup": "警告:已备份损坏的数据文件到 {path}",
|
||||||
"warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。",
|
"warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。",
|
||||||
"compress.success": "成功备份: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} 个文件)",
|
"warn.password_in_cli": "警告:通过 {arg} 传递的密码会暴露在进程列表中,建议使用交互式输入。",
|
||||||
"compress.progress": "正在压缩",
|
"warn.webdav.http_plaintext": "警告:使用明文 HTTP 连接 WebDAV ({url}),凭据将以明文传输。建议使用 HTTPS。",
|
||||||
"compress.unit": "文件",
|
"compress.success": "成功备份: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} 个文件)",
|
||||||
"err.permission": "权限不足:无法写入 '{path}'",
|
"compress.progress": "正在压缩",
|
||||||
"err.os": "系统错误:{error}",
|
"compress.unit": "文件",
|
||||||
"err.unknown": "未知错误:{error}",
|
"err.permission": "权限不足:无法写入 '{path}'",
|
||||||
"err.unknown.format": "错误:无法识别的备份文件格式 {path}",
|
"err.os": "系统错误:{error}",
|
||||||
"err.argparse.invalid_choice": "无效的选择: ",
|
"err.unknown": "未知错误:{error}",
|
||||||
"err.argparse.choose_from": "可选值",
|
"err.unknown.format": "错误:无法识别的备份文件格式 {path}",
|
||||||
"err.argparse.invalid_float": "无效的浮点数值: ",
|
"err.argparse.invalid_choice": "无效的选择: ",
|
||||||
"err.argparse.invalid_int": "无效的整数值: ",
|
"err.argparse.choose_from": "可选值",
|
||||||
"err.argparse.unrecognized_args": "未识别的参数: ",
|
"err.argparse.invalid_float": "无效的浮点数值: ",
|
||||||
"err.argparse.required": "必需参数",
|
"err.argparse.invalid_int": "无效的整数值: ",
|
||||||
"warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}",
|
"err.argparse.unrecognized_args": "未识别的参数: ",
|
||||||
"warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6",
|
"err.argparse.required": "必需参数",
|
||||||
"cmd.save.uptodate": "所有备份策略均已是最新,无需备份。",
|
"err.lock.conflict": "错误:另一个备份操作正在运行(锁文件已存在)。如果确定没有其他进程在运行,请手动删除锁文件。",
|
||||||
"cmd.save.completed": "备份完成,共处理 {count} 个策略。",
|
"warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}",
|
||||||
"cmd.all.empty": "没有配置任何备份策略。",
|
"warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6",
|
||||||
"cmd.list.empty": "暂无备份历史记录。",
|
"cmd.save.uptodate": "所有备份策略均已是最新,无需备份。",
|
||||||
"cmd.verify.checking": "正在校验备份文件: {path}",
|
"cmd.save.completed": "备份完成,共处理 {count} 个策略。",
|
||||||
"cmd.verify.success": "校验通过: {path} ({count} 个文件)",
|
"cmd.all.empty": "没有配置任何备份策略。",
|
||||||
"cmd.verify.failed": "校验失败: {path} — 预期 {expected} 个文件,实际 {actual} 个",
|
"cmd.list.empty": "暂无备份历史记录。",
|
||||||
"cmd.rm.all.confirm": "确定要删除所有 {count} 个备份策略吗?(y/N): ",
|
"cmd.verify.checking": "正在校验备份文件: {path}",
|
||||||
"cmd.rm.all.confirm_words": "y,yes,是",
|
"cmd.verify.success": "校验通过: {path} ({count} 个文件)",
|
||||||
"cmd.rm.all.success": "已删除所有 {count} 个备份策略。",
|
"cmd.verify.failed": "校验失败: {path} — 预期 {expected} 个文件,实际 {actual} 个",
|
||||||
"cmd.rm.all.empty": "没有可删除的备份策略。",
|
"cmd.rm.all.confirm": "确定要删除所有 {count} 个备份策略吗?(y/N): ",
|
||||||
"cmd.init.success": "配置文件已生成: {path}",
|
"cmd.rm.all.confirm_words": "y,yes,是",
|
||||||
"cmd.init.exists": "配置文件已存在: {path},跳过生成。",
|
"cmd.rm.all.success": "已删除所有 {count} 个备份策略。",
|
||||||
"warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)",
|
"cmd.rm.all.empty": "没有可删除的备份策略。",
|
||||||
"cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒",
|
"cmd.init.success": "配置文件已生成: {path}",
|
||||||
"cmd.dry_run.header": "预览模式(不会实际执行备份):",
|
"cmd.init.exists": "配置文件已存在: {path},跳过生成。",
|
||||||
"cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB",
|
"warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)",
|
||||||
"cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB",
|
"cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒",
|
||||||
"cmd.dry_run.nothing": "没有需要备份的策略(所有策略均已是最新)。",
|
"cmd.upload.parallel": "正在并行上传到 SFTP 和 WebDAV...",
|
||||||
"cmd.verify.partial_fail": "警告: {count} 个备份文件校验失败!",
|
"cmd.dry_run.header": "预览模式(不会实际执行备份):",
|
||||||
"cmd.status.header": "═══ 备份状态仪表盘 ═══",
|
"cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB",
|
||||||
"cmd.status.strategies": "策略总数: {count}",
|
"cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB",
|
||||||
"cmd.status.history": "历史记录: {count} 条",
|
"cmd.dry_run.nothing": "没有需要备份的策略(所有策略均已是最新)。",
|
||||||
"cmd.status.total_size": "备份总大小: {size:.2f} MB",
|
"cmd.verify.partial_fail": "警告: {count} 个备份文件校验失败!",
|
||||||
"cmd.status.avg_ratio": "平均压缩率: {ratio:.1f}%",
|
"cmd.status.header": "═══ 备份状态仪表盘 ═══",
|
||||||
"cmd.status.strategy.header": "─── 策略详情 ───",
|
"cmd.status.strategies": "策略总数: {count}",
|
||||||
"cmd.status.strategy.item": " {source} -> {target}",
|
"cmd.status.history": "历史记录: {count} 条",
|
||||||
"cmd.status.strategy.last_backup": " 上次备份: {time}",
|
"cmd.status.total_size": "备份总大小: {size:.2f} MB",
|
||||||
"cmd.status.strategy.never": " 上次备份: 从未备份",
|
"cmd.status.avg_ratio": "平均压缩率: {ratio:.1f}%",
|
||||||
"cmd.status.strategy.format": " 格式: {format}",
|
"cmd.status.strategy.header": "─── 策略详情 ───",
|
||||||
"cmd.status.strategy.size": " 目标大小: {size:.2f} MB",
|
"cmd.status.strategy.item": " {source} -> {target}",
|
||||||
"log.parallel.backup": "并行备份 %d 个策略",
|
"cmd.status.strategy.last_backup": " 上次备份: {time}",
|
||||||
"restore.list.title": "备份内容: {path}",
|
"cmd.status.strategy.never": " 上次备份: 从未备份",
|
||||||
"restore.list.empty": "备份文件为空: {path}",
|
"cmd.status.strategy.format": " 格式: {format}",
|
||||||
"log.ignore.loaded": "已加载忽略规则文件: %s",
|
"cmd.status.strategy.size": " 目标大小: {size:.2f} MB",
|
||||||
"exit.message": "\n已退出。",
|
"log.parallel.backup": "并行备份 %d 个策略",
|
||||||
"table.header.source": "源文件夹路径",
|
"restore.list.title": "备份内容: {path}",
|
||||||
"table.header.dest": "目标文件夹路径",
|
"restore.list.empty": "备份文件为空: {path}",
|
||||||
"table.header.ignore": "忽略模式",
|
"log.ignore.loaded": "已加载忽略规则文件: %s",
|
||||||
"table.header.time": "备份时间",
|
"exit.message": "\n已退出。",
|
||||||
"table.header.size": "大小(MB)",
|
"table.header.source": "源文件夹路径",
|
||||||
"table.header.ratio": "压缩率",
|
"table.header.dest": "目标文件夹路径",
|
||||||
"table.header.files": "文件数",
|
"table.header.ignore": "忽略模式",
|
||||||
"table.cell.none": "无",
|
"table.header.time": "备份时间",
|
||||||
"table.header.format": "格式",
|
"table.header.size": "大小(MB)",
|
||||||
"table.cell.default": "默认",
|
"table.header.ratio": "压缩率",
|
||||||
"restore.progress": "正在还原",
|
"table.header.files": "文件数",
|
||||||
"restore.success": "还原成功: {path} ({count} 个文件)",
|
"table.cell.none": "无",
|
||||||
"cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)",
|
"table.header.format": "格式",
|
||||||
"cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息",
|
"table.cell.default": "默认",
|
||||||
"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",
|
"restore.progress": "正在还原",
|
||||||
"cli.help.debug": "开启调试模式,输出详细的运行日志和状态信息(可放在子命令之后)",
|
"restore.success": "还原成功: {path} ({count} 个文件)",
|
||||||
"cli.help.help": "显示此帮助信息并退出",
|
"restore.skipped_unsafe": "已跳过 {count} 个不安全的归档成员(路径遍历防护)",
|
||||||
"cli.help.lang": "设置界面语言: zh_CN(中) / en_US(英) / fr_FR(法) / es_ES(西) / ru_RU(俄) / de_DE(德) / ja_JP(日) / pt_BR(葡) / ko_KR(韩)",
|
"err.invalid_filename": "无效的文件名: {filename}(不允许路径分隔符和 .. 序列)",
|
||||||
"cli.help.format": "设置打包格式: zip(默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(支持加密)",
|
"err.sftp.no_host_key": "无法获取主机 {host} 的密钥",
|
||||||
"cli.help.follow_symlinks": "跟随符号链接(默认不跟随,避免循环链接)",
|
"err.sftp.host_key_mismatch": "主机 {host} 的密钥与 known_hosts 不匹配(可能遭受中间人攻击)",
|
||||||
"cli.help.incremental": "文件级增量备份(仅压缩实际变化的文件,大幅减少备份时间)",
|
"warn.sftp.no_known_hosts": "警告: 未找到 known_hosts 文件,无法验证 {host} 的主机密钥,连接可能不安全",
|
||||||
"cli.help.checksum": "使用文件内容 SHA256 校验和检测变化(比 mtime 更精确,但较慢)",
|
"err.sftp.mkdir_max_depth": "远程目录层级过深或存在循环: {path}",
|
||||||
"cli.help.subcommands": "选择要执行的命令",
|
"err.webdav.xml_too_large": "WebDAV 响应过大({size} 字节),可能存在 XML 炸弹攻击",
|
||||||
"cli.help.add": "添加新的备份策略",
|
"cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)",
|
||||||
"cli.help.add.source": "源文件夹路径: 需要被备份的目录",
|
"cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息",
|
||||||
"cli.help.add.dest": "目标文件夹路径: 存放生成的备份文件的目录",
|
"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.add.ignore": "需要忽略的文件或文件夹名称 (使用逗号分隔,默认: .git,__pycache__)",
|
"cli.help.debug": "开启调试模式,输出详细的运行日志和状态信息(可放在子命令之后)",
|
||||||
"cli.help.add.format": "条目级打包格式(仅作用于该备份策略,不指定则使用全局默认): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z",
|
"cli.help.help": "显示此帮助信息并退出",
|
||||||
"cli.help.add.name_template": "条目级文件名模板(支持 {name} {date} {time} 变量,不指定则使用全局默认)",
|
"cli.help.lang": "设置界面语言: zh_CN(中) / en_US(英) / fr_FR(法) / es_ES(西) / ru_RU(俄) / de_DE(德) / ja_JP(日) / pt_BR(葡) / ko_KR(韩)",
|
||||||
"cli.help.add.from_gitignore": "从 .gitignore 文件导入忽略规则",
|
"cli.help.format": "设置打包格式: zip(默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(支持加密)",
|
||||||
"cmd.add.gitignore_imported": "已从 {path} 导入 {count} 条忽略规则",
|
"cli.help.follow_symlinks": "跟随符号链接(默认不跟随,避免循环链接)",
|
||||||
"cmd.add.gitignore_empty": "未从 {path} 导入任何规则",
|
"cli.help.incremental": "文件级增量备份(仅压缩实际变化的文件,大幅减少备份时间)",
|
||||||
"cli.help.rm": "删除备份策略",
|
"cli.help.checksum": "使用文件内容 SHA256 校验和检测变化(比 mtime 更精确,但较慢)",
|
||||||
"cli.help.rm.path": "需要删除备份策略的源文件夹路径",
|
"cli.help.subcommands": "选择要执行的命令",
|
||||||
"cli.help.all": "查看所有备份策略",
|
"cli.help.add": "添加新的备份策略",
|
||||||
"cli.help.save": "执行所有备份策略",
|
"cli.help.add.source": "源文件夹路径: 需要被备份的目录",
|
||||||
"cli.help.save.keep": "保留最近 N 个备份文件,0 表示不清理(默认: 0)",
|
"cli.help.add.dest": "目标文件夹路径: 存放生成的备份文件的目录",
|
||||||
"cli.help.save.keep_days": "保留最近 N 天的备份文件,0 表示不按时间清理(默认: 0)",
|
"cli.help.add.ignore": "需要忽略的文件或文件夹名称 (使用逗号分隔,默认: .git,__pycache__)",
|
||||||
"cli.help.save.password": "加密密码(仅 7z 格式支持,也可在 config.json 中配置)",
|
"cli.help.add.format": "条目级打包格式(仅作用于该备份策略,不指定则使用全局默认): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z",
|
||||||
"cli.help.watch": "定时执行备份",
|
"cli.help.add.name_template": "条目级文件名模板(支持 {name} {date} {time} 变量,不指定则使用全局默认)",
|
||||||
"cli.help.watch.interval": "备份间隔(分钟),默认 60",
|
"cli.help.add.from_gitignore": "从 .gitignore 文件导入忽略规则",
|
||||||
"cli.help.watch.keep": "保留最近 N 个备份文件(默认: 0)",
|
"cmd.add.gitignore_imported": "已从 {path} 导入 {count} 条忽略规则",
|
||||||
"cli.help.watch.keep_days": "保留最近 N 天的备份文件(默认: 0)",
|
"cmd.add.gitignore_empty": "未从 {path} 导入任何规则",
|
||||||
"cli.help.watch.password": "加密密码(仅 7z 格式支持)",
|
"cli.help.rm": "删除备份策略",
|
||||||
"cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)",
|
"cli.help.rm.path": "需要删除备份策略的源文件夹路径",
|
||||||
"cli.help.restore.file": "备份文件路径",
|
"cli.help.all": "查看所有备份策略",
|
||||||
"cli.help.restore.dir": "还原目标目录",
|
"cli.help.save": "执行所有备份策略",
|
||||||
"cli.help.restore.password": "解密密码(还原加密的 7z 备份时使用)",
|
"cli.help.save.keep": "保留最近 N 个备份文件,0 表示不清理(默认: 0)",
|
||||||
"cli.help.restore.list": "仅列出备份内容,不解压",
|
"cli.help.save.keep_days": "保留最近 N 天的备份文件,0 表示不按时间清理(默认: 0)",
|
||||||
"cli.help.list": "查看备份历史记录",
|
"cli.help.save.password": "加密密码(仅 7z 格式支持,也可在 config.json 中配置)",
|
||||||
"cli.help.verify": "校验备份文件完整性",
|
"cli.help.watch": "定时执行备份",
|
||||||
"cli.help.verify.file": "要校验的备份文件路径",
|
"cli.help.watch.interval": "备份间隔(分钟),默认 60",
|
||||||
"cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)",
|
"cli.help.watch.keep": "保留最近 N 个备份文件(默认: 0)",
|
||||||
"cli.help.verify.password": "解密密码(校验加密备份时使用)",
|
"cli.help.watch.keep_days": "保留最近 N 天的备份文件(默认: 0)",
|
||||||
"cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}",
|
"cli.help.watch.password": "加密密码(仅 7z 格式支持)",
|
||||||
"cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改",
|
"cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)",
|
||||||
"cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}",
|
"cli.help.restore.file": "备份文件路径",
|
||||||
"cmd.verify.checksum_mismatch": "校验和不匹配",
|
"cli.help.restore.dir": "还原目标目录",
|
||||||
"cli.help.export": "导出所有备份策略到 JSON 文件",
|
"cli.help.restore.password": "解密密码(还原加密的 7z 备份时使用)",
|
||||||
"cli.help.export.file": "导出文件路径(默认: sbackup_export.json)",
|
"cli.help.restore.list": "仅列出备份内容,不解压",
|
||||||
"cli.help.import": "从 JSON 文件导入备份策略",
|
"cli.help.list": "查看备份历史记录",
|
||||||
"cli.help.import.file": "要导入的 JSON 文件路径",
|
"cli.help.verify": "校验备份文件完整性",
|
||||||
"cli.help.status": "查看备份状态仪表盘",
|
"cli.help.verify.file": "要校验的备份文件路径",
|
||||||
"cli.help.ignore": "生成 .sbackupignore 排除规则文件",
|
"cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)",
|
||||||
"cli.help.ignore.preset": "预设模板: node / python / go / rust / java / general(默认: general)",
|
"cli.help.verify.all": "批量验证所有历史备份",
|
||||||
"cli.help.ignore.output": "输出文件路径(默认: .sbackupignore)",
|
"cli.help.verify.detail": "显示详细验证信息(大小、格式、校验和等)",
|
||||||
"cli.help.ignore.list": "列出所有可用预设",
|
"cli.help.verify.split": "验证分卷文件完整性(需要清单文件)",
|
||||||
"cmd.ignore.available_presets": "可用预设:",
|
"cli.help.verify.password": "解密密码(校验加密备份时使用)",
|
||||||
"cmd.ignore.overwrite": "文件 {path} 已存在,是否覆盖?(y/N): ",
|
"cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}",
|
||||||
"cmd.ignore.cancelled": "已取消。",
|
"cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改",
|
||||||
"cmd.ignore.generated": "已生成 {preset} 预设的排除规则: {path}",
|
"cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}",
|
||||||
"cli.help.report": "生成 Markdown 格式的备份报告",
|
"cmd.verify.checksum_mismatch": "校验和不匹配",
|
||||||
"cli.help.report.output": "输出文件路径(不指定则打印到终端)",
|
"cmd.verify.no_history": "没有备份历史记录。",
|
||||||
"cli.help.search": "在备份文件中搜索文件名",
|
"cmd.verify.no_file": "请指定备份文件路径或使用 --all 验证所有备份。",
|
||||||
"cli.help.search.pattern": "搜索模式(支持 glob 通配符,如 *.txt)",
|
"cmd.verify.detail_header": "═══ 备份详情: {path} ═══",
|
||||||
"cli.help.search.in": "指定备份文件路径(不指定则搜索所有策略的最新备份)",
|
"cmd.verify.detail_info": "大小: {size:.2f} MB | 文件数: {files} | 格式: {fmt} | SHA256: {sha256}...",
|
||||||
"cli.help.search.password": "解密密码(搜索加密备份时使用)",
|
"cmd.verify.batch_success": "✓ 校验通过: {path}",
|
||||||
"cli.help.versions": "查看备份版本历史(按源目录分组)",
|
"cmd.verify.batch_failed": "✗ 校验失败: {path}",
|
||||||
"cli.help.versions.source": "指定源目录路径(不指定则显示全部)",
|
"cmd.verify.batch_summary": "批量验证完成: {success} 个通过, {fail} 个失败",
|
||||||
"cmd.versions.header": "═══ 备份版本历史 ═══",
|
"cmd.verify.split_success": "分卷完整性验证通过: {file} ({parts} 个分卷)",
|
||||||
"cmd.versions.source": "📁 {source}",
|
"cmd.verify.split_failed": "分卷完整性验证失败: {file}",
|
||||||
"cmd.versions.empty": "暂无备份版本记录。",
|
"cmd.verify.split_error": "分卷验证错误: {error}",
|
||||||
"cmd.versions.total": "共 {count} 个版本,{sources} 个源目录",
|
"cli.help.export": "导出所有备份策略到 JSON 文件",
|
||||||
"cli.help.schedule": "定时调度配置管理",
|
"cli.help.export.file": "导出文件路径(默认: sbackup_export.json)",
|
||||||
"cli.help.schedule.action": "调度操作: export(导出配置)",
|
"cli.help.import": "从 JSON 文件导入备份策略",
|
||||||
"cli.help.schedule.export": "导出定时调度配置文件",
|
"cli.help.import.file": "要导入的 JSON 文件路径",
|
||||||
"cli.help.schedule.type": "调度类型: systemd / crontab(默认: systemd)",
|
"cli.help.status": "查看备份状态仪表盘",
|
||||||
"cli.help.schedule.interval": "备份间隔(分钟,默认: 60)",
|
"cli.help.ignore": "生成 .sbackupignore 排除规则文件",
|
||||||
"cli.help.schedule.output": "输出文件路径(不指定则打印到终端)",
|
"cli.help.ignore.preset": "预设模板: node / python / go / rust / java / general(默认: general)",
|
||||||
"cmd.schedule.exported": "调度配置已导出到: {path} ({type})",
|
"cli.help.ignore.output": "输出文件路径(默认: .sbackupignore)",
|
||||||
"cmd.schedule.unknown_type": "未知调度类型: {type}",
|
"cli.help.ignore.list": "列出所有可用预设",
|
||||||
"cli.help.webhook_cmd": "Webhook 通知管理",
|
"cmd.ignore.available_presets": "可用预设:",
|
||||||
"cli.help.webhook_cmd.action": "Webhook 操作: preset(设置预设) / list(列出预设)",
|
"cmd.ignore.overwrite": "文件 {path} 已存在,是否覆盖?(y/N): ",
|
||||||
"cli.help.webhook_cmd.preset": "设置 IM 通知预设模板",
|
"cmd.ignore.cancelled": "已取消。",
|
||||||
"cli.help.webhook_cmd.preset.name": "预设名称: dingtalk(钉钉) / feishu(飞书) / wechat(企业微信)",
|
"cmd.ignore.generated": "已生成 {preset} 预设的排除规则: {path}",
|
||||||
"cli.help.webhook_cmd.list": "列出所有可用的 IM 通知预设",
|
"cli.help.report": "生成 Markdown 格式的备份报告",
|
||||||
"cmd.webhook.preset_set": "已设置 {name} 通知模板:\n{template}",
|
"cli.help.report.output": "输出文件路径(不指定则打印到终端)",
|
||||||
"cmd.webhook.preset_unknown": "未知预设: {name}",
|
"cli.help.search": "在备份文件中搜索文件名",
|
||||||
"cmd.webhook.available_presets": "可用 IM 通知预设:",
|
"cli.help.search.pattern": "搜索模式(支持 glob 通配符,如 *.txt)",
|
||||||
"cli.help.config_cmd": "配置文件管理(加密/解密敏感字段)",
|
"cli.help.search.in": "指定备份文件路径(不指定则搜索所有策略的最新备份)",
|
||||||
"cli.help.config_cmd.action": "操作: lock(加密) / unlock(解密)",
|
"cli.help.search.password": "解密密码(搜索加密备份时使用)",
|
||||||
"cli.help.config_cmd.lock": "用主密码加密配置文件中的敏感字段",
|
"cli.help.versions": "查看备份版本历史(按源目录分组)",
|
||||||
"cli.help.config_cmd.lock.password": "主密码(不指定则交互式输入)",
|
"cli.help.versions.source": "指定源目录路径(不指定则显示全部)",
|
||||||
"cli.help.config_cmd.unlock": "用主密码解密配置文件中的敏感字段",
|
"cmd.versions.header": "═══ 备份版本历史 ═══",
|
||||||
"cli.help.config_cmd.unlock.password": "主密码(不指定则交互式输入)",
|
"cmd.versions.source": "📁 {source}",
|
||||||
"cmd.config.locked": "配置文件已加密(敏感字段已保护)",
|
"cmd.versions.empty": "暂无备份版本记录。",
|
||||||
"cmd.config.unlocked": "配置文件已解密",
|
"cmd.versions.total": "共 {count} 个版本,{sources} 个源目录",
|
||||||
"cmd.config.already_locked": "配置文件已经加密过了",
|
"cli.help.schedule": "定时调度配置管理",
|
||||||
"cmd.config.not_locked": "配置文件未加密",
|
"cli.help.schedule.action": "调度操作: export(导出配置)",
|
||||||
"cmd.config.enter_password": "请输入主密码",
|
"cli.help.schedule.export": "导出定时调度配置文件",
|
||||||
"cmd.config.no_password": "密码不能为空",
|
"cli.help.schedule.type": "调度类型: systemd / crontab(默认: systemd)",
|
||||||
"cmd.config.wrong_password": "密码错误,解密失败",
|
"cli.help.schedule.interval": "备份间隔(分钟,默认: 60)",
|
||||||
"cmd.config.lock_failed": "加密失败",
|
"cli.help.schedule.output": "输出文件路径(不指定则打印到终端)",
|
||||||
"cli.help.clean": "智能清理所有策略的备份文件",
|
"cmd.schedule.exported": "调度配置已导出到: {path} ({type})",
|
||||||
"cli.help.clean.keep": "每个策略保留最近 N 个备份文件",
|
"cmd.schedule.unknown_type": "未知调度类型: {type}",
|
||||||
"cli.help.clean.keep_days": "删除 N 天前的备份文件",
|
"cli.help.webhook_cmd": "Webhook 通知管理",
|
||||||
"cli.help.clean.dry_run": "仅预览将删除的文件,不实际删除",
|
"cli.help.webhook_cmd.action": "Webhook 操作: preset(设置预设) / list(列出预设)",
|
||||||
"cmd.clean.no_criteria": "请指定清理条件: --keep N 或 --keep-days N",
|
"cli.help.webhook_cmd.preset": "设置 IM 通知预设模板",
|
||||||
"cmd.clean.nothing": "没有需要清理的备份文件。",
|
"cli.help.webhook_cmd.preset.name": "预设名称: dingtalk(钉钉) / feishu(飞书) / wechat(企业微信)",
|
||||||
"cmd.clean.dry_run_header": "预览模式(不会实际删除):",
|
"cli.help.webhook_cmd.list": "列出所有可用的 IM 通知预设",
|
||||||
"cmd.clean.summary": "共清理 {count} 个备份文件。",
|
"cmd.webhook.preset_set": "已设置 {name} 通知模板:\n{template}",
|
||||||
"cmd.report.saved": "备份报告已保存到: {path}",
|
"cmd.webhook.preset_unknown": "未知预设: {name}",
|
||||||
"cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:",
|
"cmd.webhook.available_presets": "可用 IM 通知预设:",
|
||||||
"cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件",
|
"cli.help.config_cmd": "配置文件管理(加密/解密敏感字段)",
|
||||||
"cmd.search.no_results_global": "在所有备份中未找到匹配 '{pattern}' 的文件",
|
"cli.help.config_cmd.action": "操作: lock(加密) / unlock(解密)",
|
||||||
"cmd.export.success": "已导出 {count} 个备份策略到 {path}",
|
"cli.help.config_cmd.lock": "用主密码加密配置文件中的敏感字段",
|
||||||
"cmd.import.success": "已导入 {count} 个备份策略,跳过 {skipped} 个已存在的策略",
|
"cli.help.config_cmd.lock.password": "主密码(不指定则交互式输入)",
|
||||||
"cmd.import.empty": "导入文件中没有有效的备份策略: {path}",
|
"cli.help.config_cmd.unlock": "用主密码解密配置文件中的敏感字段",
|
||||||
"cli.help.save.dry_run": "仅预览将备份的文件,不实际执行压缩",
|
"cli.help.config_cmd.unlock.password": "主密码(不指定则交互式输入)",
|
||||||
"cli.help.save.verify": "备份完成后自动校验备份文件完整性",
|
"cmd.config.locked": "配置文件已加密(敏感字段已保护)",
|
||||||
"cli.help.save.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)",
|
"cmd.config.unlocked": "配置文件已解密",
|
||||||
"cli.help.save.webhook": "备份完成后 POST 结果到此 URL",
|
"cmd.config.already_locked": "配置文件已经加密过了",
|
||||||
"cli.help.save.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)",
|
"cmd.config.not_locked": "配置文件未加密",
|
||||||
"cli.help.save.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)",
|
"cmd.config.enter_password": "请输入主密码",
|
||||||
"cli.help.save.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)",
|
"cmd.config.no_password": "密码不能为空",
|
||||||
"cli.help.save.split": "备份文件分卷大小(如 100MB, 1GB),超过此大小自动分割",
|
"cmd.config.wrong_password": "密码错误,解密失败",
|
||||||
"cli.help.save.tag": "为本次备份添加标签(如 daily、release)",
|
"cmd.config.lock_failed": "加密失败",
|
||||||
"cli.help.watch.dry_run": "仅预览将备份的文件,不实际执行压缩",
|
"cli.help.clean": "智能清理所有策略的备份文件",
|
||||||
"cli.help.watch.verify": "每次备份完成后自动校验备份文件完整性",
|
"cli.help.clean.keep": "每个策略保留最近 N 个备份文件",
|
||||||
"cli.help.watch.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)",
|
"cli.help.clean.keep_days": "删除 N 天前的备份文件",
|
||||||
"cli.help.watch.webhook": "每次备份完成后 POST 结果到此 URL",
|
"cli.help.clean.dry_run": "仅预览将删除的文件,不实际删除",
|
||||||
"cli.help.watch.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)",
|
"cmd.clean.no_criteria": "请指定清理条件: --keep N 或 --keep-days N",
|
||||||
"cli.help.watch.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)",
|
"cmd.clean.nothing": "没有需要清理的备份文件。",
|
||||||
"cli.help.watch.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)",
|
"cmd.clean.dry_run_header": "预览模式(不会实际删除):",
|
||||||
"cli.help.watch.split": "备份文件分卷大小(如 100MB, 1GB)",
|
"cmd.clean.summary": "共清理 {count} 个备份文件。",
|
||||||
"cli.help.watch.tag": "为本次备份添加标签",
|
"cli.help.completion": "生成 Shell 自动补全脚本",
|
||||||
"cli.help.init": "生成默认配置文件模板",
|
"cli.help.completion.shell": "Shell 类型: bash(默认) / zsh / fish / powershell",
|
||||||
"cli.help.rm.all": "删除所有备份策略",
|
"cmd.report.saved": "备份报告已保存到: {path}",
|
||||||
"cli.help.edit": "编辑已有备份策略(仅修改指定的字段)",
|
"cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:",
|
||||||
"cli.help.edit.source": "源文件夹路径(用于定位要编辑的策略)",
|
"cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件",
|
||||||
"cli.help.edit.dest": "修改目标文件夹路径",
|
"cmd.search.no_results_global": "在所有备份中未找到匹配 '{pattern}' 的文件",
|
||||||
"cli.help.edit.ignore": "修改忽略模式(逗号分隔)",
|
"cmd.export.success": "已导出 {count} 个备份策略到 {path}",
|
||||||
"cli.help.edit.format": "修改打包格式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z",
|
"cmd.import.success": "已导入 {count} 个备份策略,跳过 {skipped} 个已存在的策略",
|
||||||
"cli.help.edit.name_template": "修改文件名模板(支持 {name} {date} {time} 变量)",
|
"cmd.import.empty": "导入文件中没有有效的备份策略: {path}",
|
||||||
"cli.help.info": "查看备份文件详细信息(格式、大小、文件数、校验和等)",
|
"cli.help.save.dry_run": "仅预览将备份的文件,不实际执行压缩",
|
||||||
"cli.help.info.file": "备份文件路径",
|
"cli.help.save.verify": "备份完成后自动校验备份文件完整性",
|
||||||
"cli.help.info.password": "解密密码(加密的 7z 备份使用)",
|
"cli.help.save.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)",
|
||||||
"cmd.info.header": "═══ 备份文件信息 ═══",
|
"cli.help.save.webhook": "备份完成后 POST 结果到此 URL",
|
||||||
"cmd.info.path": "文件路径: {path}",
|
"cli.help.save.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)",
|
||||||
"cmd.info.format": "压缩格式: {format}",
|
"cli.help.save.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)",
|
||||||
"cmd.info.size": "文件大小: {size:.2f} MB",
|
"cli.help.save.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)",
|
||||||
"cmd.info.files": "文件数量: {count}",
|
"cli.help.save.split": "备份文件分卷大小(如 100MB, 1GB),超过此大小自动分割",
|
||||||
"cmd.info.time": "修改时间: {time}",
|
"cli.help.save.tag": "为本次备份添加标签(如 daily、release)",
|
||||||
"cmd.info.sha256": "SHA256: {checksum}",
|
"cli.help.watch.dry_run": "仅预览将备份的文件,不实际执行压缩",
|
||||||
"cmd.diff.header": "═══ 差异对比 ═══\n备份文件: {backup}",
|
"cli.help.watch.verify": "每次备份完成后自动校验备份文件完整性",
|
||||||
"cmd.diff.added_header": "\n新增文件 ({count}):",
|
"cli.help.watch.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)",
|
||||||
"cmd.diff.removed_header": "\n删除文件 ({count}):",
|
"cli.help.watch.webhook": "每次备份完成后 POST 结果到此 URL",
|
||||||
"cmd.diff.modified_header": "\n可能修改的文件 ({count}):",
|
"cli.help.watch.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)",
|
||||||
"cmd.diff.no_changes": "\n无差异,源目录与备份一致。",
|
"cli.help.watch.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)",
|
||||||
"cmd.diff.summary": "\n差异摘要: +{added} 新增, -{removed} 删除, ~{modified} 修改",
|
"cli.help.watch.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)",
|
||||||
"cmd.diff.no_backup": "目标目录中未找到备份文件: {path}",
|
"cli.help.watch.split": "备份文件分卷大小(如 100MB, 1GB)",
|
||||||
"cmd.diff.empty_backup": "备份文件为空: {path}",
|
"cli.help.watch.tag": "为本次备份添加标签",
|
||||||
"cli.help.diff": "对比源目录与最近一次备份的差异",
|
"cli.help.init": "生成默认配置文件模板",
|
||||||
"cli.help.diff.source": "源文件夹路径",
|
"cli.help.rm.all": "删除所有备份策略",
|
||||||
"cli.help.diff.file": "指定备份文件路径(不指定则自动查找最新备份)",
|
"cli.help.edit": "编辑已有备份策略(仅修改指定的字段)",
|
||||||
"cli.help.diff.password": "解密密码(对比加密备份时使用)",
|
"cli.help.edit.source": "源文件夹路径(用于定位要编辑的策略)",
|
||||||
"cli.help.remote": "远程备份文件管理(列出/删除 SFTP 或 WebDAV 上的备份文件)",
|
"cli.help.edit.dest": "修改目标文件夹路径",
|
||||||
"cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)",
|
"cli.help.edit.ignore": "修改忽略模式(逗号分隔)",
|
||||||
"cli.help.remote.list": "列出远程备份文件",
|
"cli.help.edit.format": "修改打包格式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z",
|
||||||
"cli.help.remote.rm": "删除远程备份文件",
|
"cli.help.edit.name_template": "修改文件名模板(支持 {name} {date} {time} 变量)",
|
||||||
"cli.help.remote.rm.filename": "要删除的远程文件名",
|
"cli.help.info": "查看备份文件详细信息(格式、大小、文件数、校验和等)",
|
||||||
"cli.help.remote.sftp": "操作 SFTP 服务器",
|
"cli.help.info.file": "备份文件路径",
|
||||||
"cli.help.remote.webdav": "操作 WebDAV 服务器",
|
"cli.help.info.password": "解密密码(加密的 7z 备份使用)",
|
||||||
"cli.help.version": "查看版本信息",
|
"cmd.info.header": "═══ 备份文件信息 ═══",
|
||||||
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
|
"cmd.info.path": "文件路径: {path}",
|
||||||
"log.config.malformed": "配置文件 %s 格式错误,使用默认配置",
|
"cmd.info.format": "压缩格式: {format}",
|
||||||
"log.config.reset": "配置文件 %s 格式错误,重置为空",
|
"cmd.info.size": "文件大小: {size:.2f} MB",
|
||||||
"log.config.mkdir.error": "无法创建配置目录 %s: %s",
|
"cmd.info.files": "文件数量: {count}",
|
||||||
"log.config.write.error": "无法写入配置文件 %s: %s",
|
"cmd.info.time": "修改时间: {time}",
|
||||||
"log.data.read": "读取数据文件: %s",
|
"cmd.info.sha256": "SHA256: {checksum}",
|
||||||
"log.data.create": "数据文件不存在,创建新文件: %s",
|
"cmd.diff.header": "═══ 差异对比 ═══\n备份文件: {backup}",
|
||||||
"log.data.load": "加载现有数据文件: %s",
|
"cmd.diff.added_header": "\n新增文件 ({count}):",
|
||||||
"log.data.write": "写入数据文件: %s",
|
"cmd.diff.removed_header": "\n删除文件 ({count}):",
|
||||||
"log.cleanup.delete": "已删除旧备份: %s",
|
"cmd.diff.modified_header": "\n可能修改的文件 ({count}):",
|
||||||
"log.i18n.load.failed": "无法加载语言包 {path}: {error}",
|
"cmd.diff.no_changes": "\n无差异,源目录与备份一致。",
|
||||||
"log.i18n.load.fallback_failed": "无法加载回退语言包 {path}: {error}",
|
"cmd.diff.summary": "\n差异摘要: +{added} 新增, -{removed} 删除, ~{modified} 修改",
|
||||||
"err.sftp.auth": "SFTP 认证失败: {host},请检查用户名和密码",
|
"cmd.diff.no_backup": "目标目录中未找到备份文件: {path}",
|
||||||
"err.sftp.ssh": "SFTP 连接错误: {error}",
|
"cmd.diff.empty_backup": "备份文件为空: {path}",
|
||||||
"err.sftp.connect": "无法连接到 {host}:{port}: {error}",
|
"cli.help.diff": "对比源目录与最近一次备份的差异",
|
||||||
"err.sftp.not_connected": "SFTP 未连接,请先调用 connect()",
|
"cli.help.diff.source": "源文件夹路径",
|
||||||
"err.sftp.local_not_found": "本地文件不存在: {path}",
|
"cli.help.diff.file": "指定备份文件路径(不指定则自动查找最新备份)",
|
||||||
"err.sftp.upload": "上传文件失败 {path}: {error}",
|
"cli.help.diff.password": "解密密码(对比加密备份时使用)",
|
||||||
"err.sftp.mkdir": "创建远程目录失败 {path}: {error}",
|
"cli.help.remote": "远程备份文件管理(列出/删除 SFTP 或 WebDAV 上的备份文件)",
|
||||||
"err.sftp.not_configured": "SFTP 未配置,请先运行: sbackup sftp config --host <地址> --user <用户名>",
|
"cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)",
|
||||||
"cmd.sftp.uploading": "正在上传: {file}",
|
"cli.help.remote.list": "列出远程备份文件",
|
||||||
"cmd.sftp.progress": "上传进度",
|
"cli.help.remote.rm": "删除远程备份文件",
|
||||||
"cmd.sftp.success": "上传成功: {file}",
|
"cli.help.remote.rm.filename": "要删除的远程文件名",
|
||||||
"cmd.sftp.parallel_upload": "并行上传 {count} 个文件...",
|
"cli.help.remote.sftp": "操作 SFTP 服务器",
|
||||||
"cmd.sftp.config_saved": "SFTP 配置已保存到 config.json",
|
"cli.help.remote.webdav": "操作 WebDAV 服务器",
|
||||||
"cmd.sftp.testing": "正在测试连接到 {host}...",
|
"cli.help.version": "查看版本信息",
|
||||||
"cmd.sftp.test_ok": "SFTP 连接测试成功!",
|
"cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n",
|
||||||
"cmd.sftp.test_fail": "SFTP 连接测试失败,请检查配置",
|
"log.config.malformed": "配置文件 %s 格式错误,使用默认配置",
|
||||||
"cli.help.save.sftp": "备份完成后上传到 SFTP 服务器(自动检测私钥或使用密码认证)",
|
"log.config.reset": "配置文件 %s 格式错误,重置为空",
|
||||||
"cli.help.watch.sftp": "每次备份后上传到 SFTP 服务器",
|
"log.config.mkdir.error": "无法创建配置目录 %s: %s",
|
||||||
"cli.help.sftp": "SFTP 远程备份管理(支持密码认证和 SSH 私钥认证)",
|
"log.config.write.error": "无法写入配置文件 %s: %s",
|
||||||
"cli.help.sftp.action": "SFTP 操作: config(配置) / test(测试连接)",
|
"log.data.read": "读取数据文件: %s",
|
||||||
"cli.help.sftp.config": "配置 SFTP 连接参数(支持密码认证和 SSH 私钥认证)",
|
"log.data.create": "数据文件不存在,创建新文件: %s",
|
||||||
"cli.help.sftp.test": "测试 SFTP 连接是否可用",
|
"log.data.load": "加载现有数据文件: %s",
|
||||||
"cli.help.sftp.host": "SFTP 服务器地址(例如: 192.168.1.100 或 example.com)",
|
"log.data.write": "写入数据文件: %s",
|
||||||
"cli.help.sftp.port": "SFTP 端口号(默认: 22)",
|
"log.cleanup.delete": "已删除旧备份: %s",
|
||||||
"cli.help.sftp.user": "SFTP 用户名",
|
"log.i18n.load.failed": "无法加载语言包 {path}: {error}",
|
||||||
"cli.help.sftp.password": "SFTP 密码(仅密码认证时使用)",
|
"log.i18n.load.fallback_failed": "无法加载回退语言包 {path}: {error}",
|
||||||
"cli.help.sftp.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)",
|
"err.sftp.auth": "SFTP 认证失败: {host},请检查用户名和密码",
|
||||||
"cli.prompt.sftp.host": "SFTP 服务器地址:",
|
"err.sftp.ssh": "SFTP 连接错误: {error}",
|
||||||
"cli.prompt.sftp.port": "SFTP 端口 (默认 22):",
|
"err.sftp.connect": "无法连接到 {host}:{port}: {error}",
|
||||||
"cli.prompt.sftp.user": "SFTP 用户名:",
|
"err.sftp.not_connected": "SFTP 未连接,请先调用 connect()",
|
||||||
"cli.prompt.sftp.password": "SFTP 密码:",
|
"err.sftp.local_not_found": "本地文件不存在: {path}",
|
||||||
"cli.prompt.sftp.remote_path": "远程路径 (默认 /):",
|
"err.sftp.upload": "上传文件失败 {path}: {error}",
|
||||||
"err.sftp.key_load": "无法加载私钥文件 {path},请确认文件格式正确(支持 RSA/Ed25519/ECDSA)",
|
"err.sftp.mkdir": "创建远程目录失败 {path}: {error}",
|
||||||
"cli.help.sftp.key_file": "SSH 私钥文件路径(支持 RSA/Ed25519/ECDSA,优先于密码认证)",
|
"err.sftp.not_configured": "SFTP 未配置,请先运行: sbackup sftp config --host <地址> --user <用户名>",
|
||||||
"cli.help.sftp.key_passphrase": "私钥密码短语(私钥有密码保护时使用,可通过交互式输入)",
|
"cmd.sftp.uploading": "正在上传: {file}",
|
||||||
"cli.prompt.sftp.key_file": "SSH 私钥文件路径 (留空使用密码认证):",
|
"cmd.sftp.progress": "上传进度",
|
||||||
"cli.prompt.sftp.key_passphrase": "私钥密码短语 (直接回车放弃):",
|
"cmd.sftp.success": "上传成功: {file}",
|
||||||
"cmd.sftp.using_default_key": "检测到默认 SSH 私钥,将使用: {path}",
|
"cmd.sftp.parallel_upload": "并行上传 {count} 个文件...",
|
||||||
"cmd.sftp.no_default_key": "未找到默认 SSH 私钥,请配置密码或指定私钥路径",
|
"cmd.sftp.config_saved": "SFTP 配置已保存到 config.json",
|
||||||
"err.sftp.key_needs_passphrase": "私钥 {path} 需要密码短语,请输入或使用 --key-passphrase 参数指定",
|
"cmd.sftp.testing": "正在测试连接到 {host}...",
|
||||||
"err.sftp.wrong_passphrase": "密码短语错误,请重试(直接回车放弃使用私钥)",
|
"cmd.sftp.test_ok": "SFTP 连接测试成功!",
|
||||||
"cli.help.webdav": "WebDAV 远程备份管理(支持坚果云、NextCloud、群晖等)",
|
"cmd.sftp.test_fail": "SFTP 连接测试失败,请检查配置",
|
||||||
"cli.help.webdav.action": "WebDAV 操作: config(配置) / test(测试连接)",
|
"cli.help.save.sftp": "备份完成后上传到 SFTP 服务器(自动检测私钥或使用密码认证)",
|
||||||
"cli.help.webdav.config": "配置 WebDAV 连接参数",
|
"cli.help.watch.sftp": "每次备份后上传到 SFTP 服务器",
|
||||||
"cli.help.webdav.test": "测试 WebDAV 连接是否可用",
|
"cli.help.sftp": "SFTP 远程备份管理(支持密码认证和 SSH 私钥认证)",
|
||||||
"cli.help.webdav.url": "WebDAV 服务器 URL(例如: https://dav.jianguoyun.com/dav/)",
|
"cli.help.sftp.action": "SFTP 操作: config(配置) / test(测试连接)",
|
||||||
"cli.help.webdav.user": "WebDAV 用户名",
|
"cli.help.sftp.config": "配置 SFTP 连接参数(支持密码认证和 SSH 私钥认证)",
|
||||||
"cli.help.webdav.password": "WebDAV 密码",
|
"cli.help.sftp.test": "测试 SFTP 连接是否可用",
|
||||||
"cli.help.webdav.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)",
|
"cli.help.sftp.host": "SFTP 服务器地址(例如: 192.168.1.100 或 example.com)",
|
||||||
"cli.help.save.webdav": "备份完成后上传到 WebDAV 服务器",
|
"cli.help.sftp.port": "SFTP 端口号(默认: 22)",
|
||||||
"cli.help.watch.webdav": "每次备份后上传到 WebDAV 服务器",
|
"cli.help.sftp.user": "SFTP 用户名",
|
||||||
"cli.prompt.webdav.url": "WebDAV 服务器 URL:",
|
"cli.help.sftp.password": "SFTP 密码(仅密码认证时使用)",
|
||||||
"cli.prompt.webdav.user": "WebDAV 用户名:",
|
"cli.help.sftp.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)",
|
||||||
"cli.prompt.webdav.password": "WebDAV 密码:",
|
"cli.prompt.sftp.host": "SFTP 服务器地址:",
|
||||||
"cli.prompt.webdav.remote_path": "远程路径 (默认 /):",
|
"cli.prompt.sftp.port": "SFTP 端口 (默认 22):",
|
||||||
"cmd.webdav.config_saved": "WebDAV 配置已保存到 config.json",
|
"cli.prompt.sftp.user": "SFTP 用户名:",
|
||||||
"cmd.webdav.testing": "正在测试 WebDAV 连接到 {url}...",
|
"cli.prompt.sftp.password": "SFTP 密码:",
|
||||||
"cmd.webdav.test_ok": "WebDAV 连接测试成功!",
|
"cli.prompt.sftp.remote_path": "远程路径 (默认 /):",
|
||||||
"cmd.webdav.uploading": "正在上传到 WebDAV: {file}",
|
"err.sftp.key_load": "无法加载私钥文件 {path},请确认文件格式正确(支持 RSA/Ed25519/ECDSA)",
|
||||||
"cmd.webdav.success": "WebDAV 上传成功: {file}",
|
"cli.help.sftp.key_file": "SSH 私钥文件路径(支持 RSA/Ed25519/ECDSA,优先于密码认证)",
|
||||||
"cmd.webdav.parallel_upload": "并行上传 {count} 个文件到 WebDAV...",
|
"cli.help.sftp.key_passphrase": "私钥密码短语(私钥有密码保护时使用,可通过交互式输入)",
|
||||||
"err.webdav.auth": "WebDAV 认证失败: {host},请检查用户名和密码",
|
"cli.prompt.sftp.key_file": "SSH 私钥文件路径 (留空使用密码认证):",
|
||||||
"err.webdav.connect": "无法连接到 WebDAV: {url},错误: {error}",
|
"cli.prompt.sftp.key_passphrase": "私钥密码短语 (直接回车放弃):",
|
||||||
"err.webdav.ssh": "WebDAV 连接错误: {error}",
|
"cmd.sftp.using_default_key": "检测到默认 SSH 私钥,将使用: {path}",
|
||||||
"err.webdav.not_configured": "WebDAV 未配置,请先运行: sbackup webdav config --url <地址> --user <用户名>",
|
"cmd.sftp.no_default_key": "未找到默认 SSH 私钥,请配置密码或指定私钥路径",
|
||||||
"err.webdav.local_not_found": "本地文件不存在: {path}",
|
"err.sftp.key_needs_passphrase": "私钥 {path} 需要密码短语,请输入或使用 --key-passphrase 参数指定",
|
||||||
"err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}",
|
"err.sftp.wrong_passphrase": "密码短语错误,请重试(直接回车放弃使用私钥)",
|
||||||
"err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}",
|
"cli.help.webdav": "WebDAV 远程备份管理(支持坚果云、NextCloud、群晖等)",
|
||||||
"cli.help.remote": "远程备份文件管理(列出/删除远程服务器上的备份文件)",
|
"cli.help.webdav.action": "WebDAV 操作: config(配置) / test(测试连接)",
|
||||||
"cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)",
|
"cli.help.webdav.config": "配置 WebDAV 连接参数",
|
||||||
"cli.help.remote.list": "列出远程服务器上的备份文件",
|
"cli.help.webdav.test": "测试 WebDAV 连接是否可用",
|
||||||
"cli.help.remote.rm": "删除远程服务器上的备份文件",
|
"cli.help.webdav.url": "WebDAV 服务器 URL(例如: https://dav.jianguoyun.com/dav/)",
|
||||||
"cli.help.remote.rm.filename": "要删除的远程文件名",
|
"cli.help.webdav.user": "WebDAV 用户名",
|
||||||
"cli.help.remote.sftp": "使用 SFTP 协议",
|
"cli.help.webdav.password": "WebDAV 密码",
|
||||||
"cli.help.remote.webdav": "使用 WebDAV 协议",
|
"cli.help.webdav.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)",
|
||||||
"cmd.remote.empty": "远程目录为空: {path}",
|
"cli.help.save.webdav": "备份完成后上传到 WebDAV 服务器",
|
||||||
"cmd.remote.list_header": "远程目录: {path} ({count} 个文件)",
|
"cli.help.watch.webdav": "每次备份后上传到 WebDAV 服务器",
|
||||||
"cmd.remote.deleted": "已删除远程文件: {path}",
|
"cli.prompt.webdav.url": "WebDAV 服务器 URL:",
|
||||||
"cmd.remote.select_protocol": "请指定协议: --sftp 或 --webdav",
|
"cli.prompt.webdav.user": "WebDAV 用户名:",
|
||||||
"cmd.remote.rm_requires_path": "请指定要删除的远程文件名",
|
"cli.prompt.webdav.password": "WebDAV 密码:",
|
||||||
"cli.help.remote.list_path": "指定远程目录路径(不指定则使用配置的默认路径)",
|
"cli.prompt.webdav.remote_path": "远程路径 (默认 /):",
|
||||||
"err.sftp.remote_not_found": "远程文件不存在: {path}",
|
"cmd.webdav.config_saved": "WebDAV 配置已保存到 config.json",
|
||||||
"err.sftp.delete": "删除远程文件失败 {path}: {error}",
|
"cmd.webdav.testing": "正在测试 WebDAV 连接到 {url}...",
|
||||||
"err.webdav.not_found": "远程文件不存在: {path}",
|
"cmd.webdav.test_ok": "WebDAV 连接测试成功!",
|
||||||
"err.webdav.delete": "删除远程文件失败 {path}: {error}",
|
"cmd.webdav.uploading": "正在上传到 WebDAV: {file}",
|
||||||
"err.webdav.list": "列出远程文件失败 {path}: {error}",
|
"cmd.webdav.success": "WebDAV 上传成功: {file}",
|
||||||
"restore.no_match": "没有匹配 \"{pattern}\" 的文件",
|
"cmd.webdav.parallel_upload": "并行上传 {count} 个文件到 WebDAV...",
|
||||||
"cli.help.restore.select": "选择性还原:仅提取匹配模式的文件(支持 glob 通配符,如 \"*.py\" 或 \"src/main.py\")",
|
"err.webdav.auth": "WebDAV 认证失败: {host},请检查用户名和密码",
|
||||||
"cli.help.schedule.type.schtasks": "Windows 计划任务",
|
"err.webdav.connect": "无法连接到 WebDAV: {url},错误: {error}",
|
||||||
"cmd.schedule.schtasks.generated": "Windows 计划任务命令已生成",
|
"err.webdav.ssh": "WebDAV 连接错误: {error}",
|
||||||
"cmd.hook.pre_running": "正在执行前置钩子: {command}",
|
"err.webdav.not_configured": "WebDAV 未配置,请先运行: sbackup webdav config --url <地址> --user <用户名>",
|
||||||
"cmd.hook.post_running": "正在执行后置钩子: {command}",
|
"err.webdav.local_not_found": "本地文件不存在: {path}",
|
||||||
"cmd.hook.failed": "钩子执行失败: {command} — {error}",
|
"err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}",
|
||||||
"cli.help.save.pre_hook": "备份前执行的命令(可多次使用)",
|
"err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}",
|
||||||
"cli.help.save.post_hook": "备份后执行的命令(可多次使用)",
|
"err.webdav.invalid_scheme": "不支持的 URL 协议 '{scheme}',仅允许 http 和 https。",
|
||||||
"cli.help.watch.pre_hook": "备份前执行的命令(可多次使用)",
|
"err.webdav.private_ip": "已阻止连接到内网地址: {host}",
|
||||||
"cli.help.watch.post_hook": "备份后执行的命令(可多次使用)",
|
"cmd.remote.empty": "远程目录为空: {path}",
|
||||||
"restore.split.detected": "检测到分卷备份,共 {count} 个分卷",
|
"cmd.remote.list_header": "远程目录: {path} ({count} 个文件)",
|
||||||
"restore.split.missing": "错误:分卷文件缺失,期望 {path}",
|
"cmd.remote.deleted": "已删除远程文件: {path}",
|
||||||
"restore.split.merged": "分卷合并完成: {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": "没有有效的监控源目录,监控未启动"
|
||||||
}
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
"""备份锁模块:防止并发冲突
|
||||||
|
|
||||||
|
使用原子文件操作(tempfile + os.rename)实现跨平台进程锁,
|
||||||
|
确保同一时间只有一个 sbackup 实例在执行备份。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import atexit
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupLockError(Exception):
|
||||||
|
"""备份锁异常"""
|
||||||
|
|
||||||
|
|
||||||
|
class BackupLock:
|
||||||
|
"""跨平台备份进程锁
|
||||||
|
|
||||||
|
基于 tempfile.mkstemp() + os.rename() 的原子性实现。
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
lock = BackupLock(lock_dir)
|
||||||
|
if lock.acquire():
|
||||||
|
try:
|
||||||
|
# do backup
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
else:
|
||||||
|
print("Another backup is running")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, lock_dir: str):
|
||||||
|
self._lock_path = os.path.join(lock_dir, ".backup.lock")
|
||||||
|
self._staging_path: str | None = None
|
||||||
|
self._locked = False
|
||||||
|
|
||||||
|
def acquire(self) -> bool:
|
||||||
|
"""尝试获取锁,成功返回 True"""
|
||||||
|
if self._locked:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 清理过期锁
|
||||||
|
self._clean_stale_lock()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 创建临时 staging 文件
|
||||||
|
fd, self._staging_path = tempfile.mkstemp(
|
||||||
|
dir=os.path.dirname(self._lock_path) or ".",
|
||||||
|
prefix=".backup.lock.",
|
||||||
|
)
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
# 原子 rename(Windows 上目标存在时失败)
|
||||||
|
os.rename(self._staging_path, self._lock_path)
|
||||||
|
|
||||||
|
self._locked = True
|
||||||
|
atexit.register(self.release)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
self._cleanup_staging()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
"""释放锁"""
|
||||||
|
if not self._locked:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._lock_path):
|
||||||
|
os.unlink(self._lock_path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug("Failed to remove lock file: %s", e)
|
||||||
|
self._locked = False
|
||||||
|
# 注销 atexit handler(Python 3.12+ 支持 unregister)
|
||||||
|
try:
|
||||||
|
atexit.unregister(self.release)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _clean_stale_lock(self) -> None:
|
||||||
|
"""检查并清理过期锁文件"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(self._lock_path):
|
||||||
|
return
|
||||||
|
with open(self._lock_path) as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
pid = int(content)
|
||||||
|
if not _is_pid_alive(pid):
|
||||||
|
os.unlink(self._lock_path)
|
||||||
|
logger.info("Removed stale lock from dead PID %d", pid)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
try:
|
||||||
|
os.unlink(self._lock_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cleanup_staging(self) -> None:
|
||||||
|
if self._staging_path and os.path.exists(self._staging_path):
|
||||||
|
try:
|
||||||
|
os.unlink(self._staging_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._staging_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pid_alive(pid: int) -> bool:
|
||||||
|
"""跨平台检查 PID 是否存活"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
handle = ctypes.windll.kernel32.OpenProcess(0x0400, False, pid)
|
||||||
|
if handle:
|
||||||
|
ctypes.windll.kernel32.CloseHandle(handle)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
# Unix: signal 0 仅检查进程是否存在
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
文件系统监控模块:基于 watchdog 的实时备份触发
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
|
from sbackup.i18n import t
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _BackupEventHandler(FileSystemEventHandler):
|
||||||
|
"""watchdog 事件处理器:目录变化时触发回调"""
|
||||||
|
|
||||||
|
def __init__(self, on_change_callback):
|
||||||
|
super().__init__()
|
||||||
|
self._on_change = on_change_callback
|
||||||
|
|
||||||
|
def on_any_event(self, event):
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
self._on_change(event.src_path)
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystemMonitor:
|
||||||
|
"""文件系统监控器:监听策略源目录变化,防抖后触发备份
|
||||||
|
|
||||||
|
使用方式::
|
||||||
|
|
||||||
|
monitor = FileSystemMonitor(source_dirs, debounce_seconds=30)
|
||||||
|
monitor.set_backup_callback(lambda: manager.execute_backups(...))
|
||||||
|
monitor.start()
|
||||||
|
# ... 等待 ...
|
||||||
|
monitor.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source_dirs: dict[str, str],
|
||||||
|
debounce_seconds: float = 30,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
:param source_dirs: {源目录绝对路径: 目标目录}(仅用于确定监控范围)
|
||||||
|
:param debounce_seconds: 防抖秒数,文件最后一次变化后等待此时间再触发备份
|
||||||
|
"""
|
||||||
|
self._source_dirs = source_dirs
|
||||||
|
self._debounce = debounce_seconds
|
||||||
|
self._observer: Observer | None = None
|
||||||
|
self._dirty: set[str] = set()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._debounce_timer: threading.Timer | None = None
|
||||||
|
self._backup_callback = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def set_backup_callback(self, callback) -> None:
|
||||||
|
"""设置备份回调函数(变化触发时调用)"""
|
||||||
|
self._backup_callback = callback
|
||||||
|
|
||||||
|
def _on_file_change(self, path_str: str) -> None:
|
||||||
|
"""文件变化时标记对应源目录为 dirty 并重置防抖计时器"""
|
||||||
|
path = Path(path_str)
|
||||||
|
matched_source = None
|
||||||
|
for source in self._source_dirs:
|
||||||
|
try:
|
||||||
|
path.relative_to(Path(source))
|
||||||
|
matched_source = source
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if matched_source is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._dirty.add(matched_source)
|
||||||
|
count = len(self._dirty)
|
||||||
|
|
||||||
|
logger.debug(t("log.monitor.change", path=path_str, dirty_count=count))
|
||||||
|
self._reset_debounce()
|
||||||
|
|
||||||
|
def _reset_debounce(self) -> None:
|
||||||
|
"""重置防抖计时器:取消旧计时,启动新计时"""
|
||||||
|
with self._lock:
|
||||||
|
if self._debounce_timer is not None:
|
||||||
|
self._debounce_timer.cancel()
|
||||||
|
self._debounce_timer = threading.Timer(self._debounce, self._trigger_backup)
|
||||||
|
self._debounce_timer.daemon = True
|
||||||
|
self._debounce_timer.start()
|
||||||
|
|
||||||
|
def _trigger_backup(self) -> None:
|
||||||
|
"""防抖超时后触发备份回调"""
|
||||||
|
with self._lock:
|
||||||
|
if not self._dirty:
|
||||||
|
return
|
||||||
|
dirty_count = len(self._dirty)
|
||||||
|
self._dirty.clear()
|
||||||
|
|
||||||
|
logger.debug(t("log.monitor.trigger", dirty_count=dirty_count))
|
||||||
|
if self._backup_callback:
|
||||||
|
try:
|
||||||
|
self._backup_callback()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("FileSystemMonitor 回调执行异常")
|
||||||
|
|
||||||
|
def get_dirty_sources(self) -> list[str]:
|
||||||
|
"""获取当前标记为 dirty 的源目录列表(用于调试)"""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._dirty)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""启动文件系统监控"""
|
||||||
|
if self._observer is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._observer = Observer()
|
||||||
|
scheduled = 0
|
||||||
|
for source in self._source_dirs:
|
||||||
|
if not os.path.isdir(source):
|
||||||
|
logger.warning(t("log.monitor.source_missing", path=source))
|
||||||
|
continue
|
||||||
|
handler = _BackupEventHandler(self._on_file_change)
|
||||||
|
self._observer.schedule(handler, source, recursive=True)
|
||||||
|
scheduled += 1
|
||||||
|
logger.debug(t("log.monitor.watching", path=source))
|
||||||
|
|
||||||
|
if scheduled == 0:
|
||||||
|
logger.warning(t("log.monitor.no_sources"))
|
||||||
|
self._observer = None
|
||||||
|
return
|
||||||
|
|
||||||
|
self._observer.start()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""停止文件系统监控"""
|
||||||
|
self._running = False
|
||||||
|
with self._lock:
|
||||||
|
if self._debounce_timer is not None:
|
||||||
|
self._debounce_timer.cancel()
|
||||||
|
self._debounce_timer = None
|
||||||
|
|
||||||
|
if self._observer is not None:
|
||||||
|
self._observer.stop()
|
||||||
|
try:
|
||||||
|
self._observer.join(timeout=5)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
self._observer = None
|
||||||
|
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""检查监控是否在运行"""
|
||||||
|
return (
|
||||||
|
self._running and self._observer is not None and self._observer.is_alive()
|
||||||
|
)
|
||||||
+50
-3
@@ -44,7 +44,7 @@ class SFTPClient:
|
|||||||
self._sftp = None
|
self._sftp = None
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
"""建立 SFTP 连接(优先使用私钥认证)"""
|
"""建立 SFTP 连接(优先使用私钥认证,验证主机密钥)"""
|
||||||
import paramiko
|
import paramiko
|
||||||
|
|
||||||
# 展开 ~ 为用户目录(跨平台兼容)
|
# 展开 ~ 为用户目录(跨平台兼容)
|
||||||
@@ -52,6 +52,47 @@ class SFTPClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._transport = paramiko.Transport((self.host, self.port))
|
self._transport = paramiko.Transport((self.host, self.port))
|
||||||
|
|
||||||
|
# 主机密钥验证:加载系统 known_hosts
|
||||||
|
host_keys = paramiko.HostKeys()
|
||||||
|
known_hosts_paths = [
|
||||||
|
os.path.expanduser("~/.ssh/known_hosts"),
|
||||||
|
"/etc/ssh/ssh_known_hosts",
|
||||||
|
]
|
||||||
|
for kh_path in known_hosts_paths:
|
||||||
|
if os.path.isfile(kh_path):
|
||||||
|
try:
|
||||||
|
host_keys.load(kh_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if host_keys:
|
||||||
|
# 有已知主机密钥,使用严格验证
|
||||||
|
remote_key = self._transport.get_remote_server_key()
|
||||||
|
if remote_key is None:
|
||||||
|
self.disconnect()
|
||||||
|
raise SFTPError(t("err.sftp.no_host_key", host=self.host))
|
||||||
|
host_key_entry = host_keys.lookup(self.host)
|
||||||
|
if host_key_entry is None:
|
||||||
|
# 主机不在已知列表中,警告但允许连接(首次连接)
|
||||||
|
logger.warning(
|
||||||
|
"SFTP: 主机 %s 不在 known_hosts 中(首次连接)", self.host
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
host_keys.check(self.host, remote_key)
|
||||||
|
except paramiko.SSHException:
|
||||||
|
self.disconnect()
|
||||||
|
raise SFTPError(t("err.sftp.host_key_mismatch", host=self.host))
|
||||||
|
else:
|
||||||
|
# 没有 known_hosts 文件,跳过验证(向后兼容)
|
||||||
|
logger.debug("SFTP: 未找到 known_hosts 文件,跳过主机密钥验证")
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(
|
||||||
|
t("warn.sftp.no_known_hosts", host=self.host),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
if key_file_expanded and os.path.isfile(key_file_expanded):
|
if key_file_expanded and os.path.isfile(key_file_expanded):
|
||||||
# 私钥认证
|
# 私钥认证
|
||||||
pkey = self._load_private_key(key_file_expanded, self.key_passphrase)
|
pkey = self._load_private_key(key_file_expanded, self.key_passphrase)
|
||||||
@@ -150,6 +191,10 @@ class SFTPClient:
|
|||||||
finally:
|
finally:
|
||||||
self._transport = None
|
self._transport = None
|
||||||
|
|
||||||
|
# 清除敏感凭据,防止内存泄漏
|
||||||
|
self.password = ""
|
||||||
|
self.key_passphrase = ""
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.connect()
|
self.connect()
|
||||||
return self
|
return self
|
||||||
@@ -190,7 +235,7 @@ class SFTPClient:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise SFTPError(t("err.sftp.upload", path=filename, error=str(e)))
|
raise SFTPError(t("err.sftp.upload", path=filename, error=str(e)))
|
||||||
|
|
||||||
def _ensure_remote_dir(self, remote_path: str) -> None:
|
def _ensure_remote_dir(self, remote_path: str, max_depth: int = 50) -> None:
|
||||||
"""确保远程目录存在,不存在则逐级创建"""
|
"""确保远程目录存在,不存在则逐级创建"""
|
||||||
if self._sftp is None:
|
if self._sftp is None:
|
||||||
return
|
return
|
||||||
@@ -203,7 +248,9 @@ class SFTPClient:
|
|||||||
# 递归创建父目录
|
# 递归创建父目录
|
||||||
parent = os.path.dirname(remote_path).replace("\\", "/")
|
parent = os.path.dirname(remote_path).replace("\\", "/")
|
||||||
if parent and parent != remote_path:
|
if parent and parent != remote_path:
|
||||||
self._ensure_remote_dir(parent)
|
if max_depth <= 0:
|
||||||
|
raise SFTPError(t("err.sftp.mkdir_max_depth", path=remote_path))
|
||||||
|
self._ensure_remote_dir(parent, max_depth - 1)
|
||||||
try:
|
try:
|
||||||
self._sftp.mkdir(remote_path)
|
self._sftp.mkdir(remote_path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
+50
-1
@@ -4,9 +4,13 @@ WebDAV 远程备份模块:基于 HTTP 的文件上传(支持坚果云、Next
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
|
||||||
from sbackup.i18n import t
|
from sbackup.i18n import t
|
||||||
@@ -24,6 +28,39 @@ class WebDAVClient:
|
|||||||
"""WebDAV 客户端,基于 urllib 实现,零额外依赖"""
|
"""WebDAV 客户端,基于 urllib 实现,零额外依赖"""
|
||||||
|
|
||||||
def __init__(self, url: str, user: str, password: str):
|
def __init__(self, url: str, user: str, password: str):
|
||||||
|
# SSRF 防护:验证 URL scheme 和内部 IP
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise WebDAVError(
|
||||||
|
t("err.webdav.invalid_scheme", scheme=parsed.scheme or "missing")
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP 明文警告
|
||||||
|
if parsed.scheme == "http":
|
||||||
|
print(
|
||||||
|
t("warn.webdav.http_plaintext", url=url),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 阻止内网地址
|
||||||
|
if parsed.hostname:
|
||||||
|
try:
|
||||||
|
host_ip = socket.getaddrinfo(
|
||||||
|
parsed.hostname, None, type=socket.SOCK_STREAM
|
||||||
|
)[0][4][0]
|
||||||
|
ip = ipaddress.ip_address(host_ip)
|
||||||
|
if (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
):
|
||||||
|
raise WebDAVError(t("err.webdav.private_ip", host=parsed.hostname))
|
||||||
|
except WebDAVError:
|
||||||
|
raise
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
self.url = url.rstrip("/")
|
self.url = url.rstrip("/")
|
||||||
self.user = user
|
self.user = user
|
||||||
self.password = password
|
self.password = password
|
||||||
@@ -92,7 +129,14 @@ class WebDAVClient:
|
|||||||
content_type="application/xml",
|
content_type="application/xml",
|
||||||
)
|
)
|
||||||
req.add_header("Depth", "0")
|
req.add_header("Depth", "0")
|
||||||
urllib.request.urlopen(req, timeout=15)
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
|
||||||
|
# XML 炸弹防护:限制响应大小
|
||||||
|
MAX_CONNECT_XML_SIZE = 1 * 1024 * 1024 # 1MB
|
||||||
|
xml_data = resp.read(MAX_CONNECT_XML_SIZE + 1)
|
||||||
|
if len(xml_data) > MAX_CONNECT_XML_SIZE:
|
||||||
|
raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data)))
|
||||||
|
|
||||||
logger.debug("WebDAV 连接成功: %s", self.url)
|
logger.debug("WebDAV 连接成功: %s", self.url)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code == 401:
|
if e.code == 401:
|
||||||
@@ -167,6 +211,11 @@ class WebDAVClient:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(e)))
|
raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(e)))
|
||||||
|
|
||||||
|
# XML 炸弹防护:限制响应大小
|
||||||
|
MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
if len(xml_data) > MAX_XML_SIZE:
|
||||||
|
raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data)))
|
||||||
|
|
||||||
files = []
|
files = []
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(xml_data)
|
root = ET.fromstring(xml_data)
|
||||||
|
|||||||
@@ -920,10 +920,12 @@ class TestEditStrategy(unittest.TestCase):
|
|||||||
class TestWebhook(unittest.TestCase):
|
class TestWebhook(unittest.TestCase):
|
||||||
"""测试 Webhook 增强功能"""
|
"""测试 Webhook 增强功能"""
|
||||||
|
|
||||||
@patch("urllib.request.urlopen")
|
@patch("urllib.request.OpenerDirector.open")
|
||||||
def test_send_webhook_single(self, mock_urlopen):
|
@patch("socket.getaddrinfo")
|
||||||
|
def test_send_webhook_single(self, mock_getaddrinfo, mock_open):
|
||||||
"""测试发送单个 webhook"""
|
"""测试发送单个 webhook"""
|
||||||
mock_urlopen.return_value = MagicMock()
|
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||||
|
mock_open.return_value = MagicMock()
|
||||||
BackupManager._send_webhook(
|
BackupManager._send_webhook(
|
||||||
"https://example.com/hook",
|
"https://example.com/hook",
|
||||||
status="success",
|
status="success",
|
||||||
@@ -931,12 +933,14 @@ class TestWebhook(unittest.TestCase):
|
|||||||
skipped=1,
|
skipped=1,
|
||||||
elapsed=5.0,
|
elapsed=5.0,
|
||||||
)
|
)
|
||||||
mock_urlopen.assert_called_once()
|
mock_open.assert_called_once()
|
||||||
|
|
||||||
@patch("urllib.request.urlopen")
|
@patch("urllib.request.OpenerDirector.open")
|
||||||
def test_send_webhook_with_template(self, mock_urlopen):
|
@patch("socket.getaddrinfo")
|
||||||
|
def test_send_webhook_with_template(self, mock_getaddrinfo, mock_open):
|
||||||
"""测试自定义模板 webhook"""
|
"""测试自定义模板 webhook"""
|
||||||
mock_urlopen.return_value = MagicMock()
|
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||||
|
mock_open.return_value = MagicMock()
|
||||||
BackupManager._send_webhook(
|
BackupManager._send_webhook(
|
||||||
"https://example.com/hook",
|
"https://example.com/hook",
|
||||||
status="success",
|
status="success",
|
||||||
@@ -945,18 +949,20 @@ class TestWebhook(unittest.TestCase):
|
|||||||
elapsed=5.0,
|
elapsed=5.0,
|
||||||
template='{"event":"backup","status":"{status}"}',
|
template='{"event":"backup","status":"{status}"}',
|
||||||
)
|
)
|
||||||
req = mock_urlopen.call_args[0][0]
|
req = mock_open.call_args[0][0]
|
||||||
import json
|
import json
|
||||||
|
|
||||||
payload = json.loads(req.data.decode("utf-8"))
|
payload = json.loads(req.data.decode("utf-8"))
|
||||||
self.assertEqual(payload["status"], "success")
|
self.assertEqual(payload["status"], "success")
|
||||||
|
|
||||||
@patch("urllib.request.urlopen")
|
@patch("urllib.request.OpenerDirector.open")
|
||||||
def test_send_webhook_retries(self, mock_urlopen):
|
@patch("socket.getaddrinfo")
|
||||||
|
def test_send_webhook_retries(self, mock_getaddrinfo, mock_open):
|
||||||
"""测试 webhook 重试机制"""
|
"""测试 webhook 重试机制"""
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
mock_urlopen.side_effect = urllib.error.URLError("timeout")
|
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||||
|
mock_open.side_effect = urllib.error.URLError("timeout")
|
||||||
BackupManager._send_webhook(
|
BackupManager._send_webhook(
|
||||||
"https://example.com/hook",
|
"https://example.com/hook",
|
||||||
status="success",
|
status="success",
|
||||||
@@ -965,12 +971,14 @@ class TestWebhook(unittest.TestCase):
|
|||||||
elapsed=1.0,
|
elapsed=1.0,
|
||||||
retries=3,
|
retries=3,
|
||||||
)
|
)
|
||||||
self.assertEqual(mock_urlopen.call_count, 3)
|
self.assertEqual(mock_open.call_count, 3)
|
||||||
|
|
||||||
@patch("urllib.request.urlopen")
|
@patch("urllib.request.OpenerDirector.open")
|
||||||
def test_send_webhooks_multiple(self, mock_urlopen):
|
@patch("socket.getaddrinfo")
|
||||||
|
def test_send_webhooks_multiple(self, mock_getaddrinfo, mock_open):
|
||||||
"""测试多个 webhook URL"""
|
"""测试多个 webhook URL"""
|
||||||
mock_urlopen.return_value = MagicMock()
|
mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))]
|
||||||
|
mock_open.return_value = MagicMock()
|
||||||
BackupManager._send_webhooks(
|
BackupManager._send_webhooks(
|
||||||
["https://hook1.example.com", "https://hook2.example.com"],
|
["https://hook1.example.com", "https://hook2.example.com"],
|
||||||
status="success",
|
status="success",
|
||||||
@@ -978,7 +986,7 @@ class TestWebhook(unittest.TestCase):
|
|||||||
skipped=0,
|
skipped=0,
|
||||||
elapsed=1.0,
|
elapsed=1.0,
|
||||||
)
|
)
|
||||||
self.assertEqual(mock_urlopen.call_count, 2)
|
self.assertEqual(mock_open.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
class TestDiffBackup(unittest.TestCase):
|
class TestDiffBackup(unittest.TestCase):
|
||||||
|
|||||||
@@ -577,13 +577,12 @@ class TestSplitAndMergeFiles(unittest.TestCase):
|
|||||||
self.assertEqual(len(parts), 1)
|
self.assertEqual(len(parts), 1)
|
||||||
|
|
||||||
def test_merge_empty_list(self):
|
def test_merge_empty_list(self):
|
||||||
"""空列表合并创建空文件"""
|
"""空列表合并不创建文件并返回 False"""
|
||||||
from sbackup.compression import merge_files
|
from sbackup.compression import merge_files
|
||||||
|
|
||||||
output_path = os.path.join(self.test_dir, "empty.bin")
|
output_path = os.path.join(self.test_dir, "empty.bin")
|
||||||
result = merge_files([], output_path)
|
result = merge_files([], output_path)
|
||||||
self.assertTrue(result)
|
self.assertFalse(result)
|
||||||
self.assertEqual(os.path.getsize(output_path), 0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebhookPreset(unittest.TestCase):
|
class TestWebhookPreset(unittest.TestCase):
|
||||||
@@ -617,8 +616,11 @@ class TestParseGitignore(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.test_dir = tempfile.mkdtemp()
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.original_cwd = os.getcwd()
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
os.chdir(self.original_cwd)
|
||||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
|
||||||
def test_parse_gitignore_basic(self):
|
def test_parse_gitignore_basic(self):
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""单元测试 for sbackup.completion 模块"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from sbackup.completion import (
|
||||||
|
generate_bash,
|
||||||
|
generate_zsh,
|
||||||
|
generate_fish,
|
||||||
|
generate_powershell,
|
||||||
|
generate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompletion(unittest.TestCase):
|
||||||
|
"""测试 Shell 自动补全脚本生成"""
|
||||||
|
|
||||||
|
def test_generate_bash_returns_string(self):
|
||||||
|
"""Bash 补全脚本应返回字符串"""
|
||||||
|
script = generate_bash()
|
||||||
|
self.assertIsInstance(script, str)
|
||||||
|
self.assertIn("complete -F", script)
|
||||||
|
self.assertIn("sbackup", script)
|
||||||
|
|
||||||
|
def test_generate_bash_contains_subcommands(self):
|
||||||
|
"""Bash 补全应包含子命令"""
|
||||||
|
script = generate_bash()
|
||||||
|
for cmd in ("save", "watch", "add", "restore", "sftp", "webdav"):
|
||||||
|
self.assertIn(cmd, script)
|
||||||
|
|
||||||
|
def test_generate_zsh_returns_string(self):
|
||||||
|
"""Zsh 补全脚本应返回字符串"""
|
||||||
|
script = generate_zsh()
|
||||||
|
self.assertIsInstance(script, str)
|
||||||
|
self.assertIn("#compdef", script)
|
||||||
|
self.assertIn("sbackup", script)
|
||||||
|
|
||||||
|
def test_generate_zsh_contains_subcommands(self):
|
||||||
|
"""Zsh 补全应包含子命令"""
|
||||||
|
script = generate_zsh()
|
||||||
|
for cmd in ("save", "watch", "add", "restore"):
|
||||||
|
self.assertIn(cmd, script)
|
||||||
|
|
||||||
|
def test_generate_fish_returns_string(self):
|
||||||
|
"""Fish 补全脚本应返回字符串"""
|
||||||
|
script = generate_fish()
|
||||||
|
self.assertIsInstance(script, str)
|
||||||
|
self.assertIn("complete -c sbackup", script)
|
||||||
|
|
||||||
|
def test_generate_fish_contains_subcommands(self):
|
||||||
|
"""Fish 补全应包含子命令"""
|
||||||
|
script = generate_fish()
|
||||||
|
for cmd in ("save", "watch", "add"):
|
||||||
|
self.assertIn(cmd, script)
|
||||||
|
|
||||||
|
def test_generate_powershell_returns_string(self):
|
||||||
|
"""PowerShell 补全脚本应返回字符串"""
|
||||||
|
script = generate_powershell()
|
||||||
|
self.assertIsInstance(script, str)
|
||||||
|
self.assertIn("Register-ArgumentCompleter", script)
|
||||||
|
self.assertIn("sbackup", script)
|
||||||
|
|
||||||
|
def test_generate_powershell_contains_flags(self):
|
||||||
|
"""PowerShell 补全应包含 flag"""
|
||||||
|
script = generate_powershell()
|
||||||
|
self.assertIn("--debug", script)
|
||||||
|
self.assertIn("--lang", script)
|
||||||
|
|
||||||
|
def test_generate_dispatch_bash(self):
|
||||||
|
"""generate('bash') 应分发到 generate_bash"""
|
||||||
|
result = generate("bash")
|
||||||
|
self.assertIn("complete -F", result)
|
||||||
|
|
||||||
|
def test_generate_dispatch_zsh(self):
|
||||||
|
"""generate('zsh') 应分发到 generate_zsh"""
|
||||||
|
result = generate("zsh")
|
||||||
|
self.assertIn("#compdef", result)
|
||||||
|
|
||||||
|
def test_generate_dispatch_fish(self):
|
||||||
|
"""generate('fish') 应分发到 generate_fish"""
|
||||||
|
result = generate("fish")
|
||||||
|
self.assertIn("complete -c sbackup", result)
|
||||||
|
|
||||||
|
def test_generate_dispatch_powershell(self):
|
||||||
|
"""generate('powershell') 应分发到 generate_powershell"""
|
||||||
|
result = generate("powershell")
|
||||||
|
self.assertIn("Register-ArgumentCompleter", result)
|
||||||
|
|
||||||
|
def test_generate_invalid_shell(self):
|
||||||
|
"""无效的 shell 名称应抛出 ValueError"""
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
generate("invalid_shell")
|
||||||
|
|
||||||
|
def test_generate_case_insensitive(self):
|
||||||
|
"""生成 shell 名应忽略大小写"""
|
||||||
|
bash1 = generate("BASH")
|
||||||
|
bash2 = generate("bash")
|
||||||
|
self.assertEqual(bash1, bash2)
|
||||||
|
|
||||||
|
def test_all_generators_have_basic_structure(self):
|
||||||
|
"""所有 shell 的补全脚本都应包含 sbackup 引用"""
|
||||||
|
for shell in ("bash", "zsh", "fish", "powershell"):
|
||||||
|
script = generate(shell)
|
||||||
|
self.assertIsInstance(script, str)
|
||||||
|
self.assertGreater(len(script), 50, f"{shell} script is too short")
|
||||||
|
|
||||||
|
def test_zsh_contains_format_choices(self):
|
||||||
|
"""Zsh 补全应列出格式选项"""
|
||||||
|
script = generate_zsh()
|
||||||
|
for fmt in ("zip", "tar", "7z"):
|
||||||
|
self.assertIn(fmt, script)
|
||||||
|
|
||||||
|
def test_fish_contains_global_flags(self):
|
||||||
|
"""Fish 补全应包含全局 flag"""
|
||||||
|
script = generate_fish()
|
||||||
|
self.assertIn("-l debug", script)
|
||||||
|
self.assertIn("-l format", script)
|
||||||
|
self.assertIn("-l lang", script)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""单元测试 for sbackup.lock 模块"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from sbackup.lock import BackupLock
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupLock(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_acquire_and_release(self):
|
||||||
|
"""测试获取和释放锁"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
self.assertTrue(lock._locked)
|
||||||
|
lock.release()
|
||||||
|
self.assertFalse(lock._locked)
|
||||||
|
self.assertFalse(os.path.exists(lock._lock_path))
|
||||||
|
|
||||||
|
def test_acquire_exclusive(self):
|
||||||
|
"""测试锁的排他性:第二个获取应该失败"""
|
||||||
|
lock1 = BackupLock(self.test_dir)
|
||||||
|
lock2 = BackupLock(self.test_dir)
|
||||||
|
|
||||||
|
self.assertTrue(lock1.acquire())
|
||||||
|
self.assertFalse(lock2.acquire())
|
||||||
|
|
||||||
|
lock1.release()
|
||||||
|
|
||||||
|
# 释放后第二个可以获取
|
||||||
|
self.assertTrue(lock2.acquire())
|
||||||
|
lock2.release()
|
||||||
|
|
||||||
|
def test_double_acquire(self):
|
||||||
|
"""测试重复获取:同一个实例多次获取应返回 True"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
self.assertTrue(lock.acquire()) # 已锁定,直接返回 True
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def test_clean_stale_lock(self):
|
||||||
|
"""测试清理过期锁(死进程留下的锁文件)"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
lock_path = os.path.join(self.test_dir, ".backup.lock")
|
||||||
|
|
||||||
|
# 模拟一个死进程的锁文件
|
||||||
|
dead_pid = 999999999 # 不可能存在的 PID
|
||||||
|
with open(lock_path, "w") as f:
|
||||||
|
f.write(str(dead_pid))
|
||||||
|
|
||||||
|
# 尝试获取,应该清理并成功
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
self.assertTrue(os.path.exists(lock_path))
|
||||||
|
# 锁文件内容应该是当前进程的 PID
|
||||||
|
with open(lock_path) as f:
|
||||||
|
self.assertEqual(f.read().strip(), str(os.getpid()))
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def test_clean_corrupted_lock(self):
|
||||||
|
"""测试清理损坏的锁文件"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
lock_path = os.path.join(self.test_dir, ".backup.lock")
|
||||||
|
|
||||||
|
# 写入无效内容
|
||||||
|
with open(lock_path, "w") as f:
|
||||||
|
f.write("not_a_pid")
|
||||||
|
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def test_release_no_lock(self):
|
||||||
|
"""测试未获取锁时释放不报错"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
lock.release() # 不应抛异常
|
||||||
|
|
||||||
|
def test_context_manager_usage(self):
|
||||||
|
"""测试手动 try/finally 用法(测试 release 防护)"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
# 模拟异常情况下的 release
|
||||||
|
lock.release()
|
||||||
|
self.assertFalse(lock._locked)
|
||||||
|
|
||||||
|
def test_lock_file_location(self):
|
||||||
|
"""测试锁文件路径正确"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
expected = os.path.join(self.test_dir, ".backup.lock")
|
||||||
|
self.assertEqual(lock._lock_path, expected)
|
||||||
|
|
||||||
|
def test_successive_acquire_after_release(self):
|
||||||
|
"""测试释放后可以再次获取"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
lock.release()
|
||||||
|
self.assertTrue(lock.acquire())
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def test_stale_lock_with_alive_pid(self):
|
||||||
|
"""测试活进程的锁文件不会被清理"""
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
|
||||||
|
# 先获取锁
|
||||||
|
lock.acquire()
|
||||||
|
|
||||||
|
# 创建另一个锁实例,锁文件被活进程持有
|
||||||
|
lock2 = BackupLock(self.test_dir)
|
||||||
|
self.assertFalse(lock2.acquire())
|
||||||
|
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsPidAlive(unittest.TestCase):
|
||||||
|
"""测试 _is_pid_alive 函数"""
|
||||||
|
|
||||||
|
def test_own_pid_is_alive(self):
|
||||||
|
"""当前进程的 PID 应该存活"""
|
||||||
|
from sbackup.lock import _is_pid_alive
|
||||||
|
|
||||||
|
self.assertTrue(_is_pid_alive(os.getpid()))
|
||||||
|
|
||||||
|
def test_nonexistent_pid_is_dead(self):
|
||||||
|
"""不存在的 PID 应该返回 False"""
|
||||||
|
from sbackup.lock import _is_pid_alive
|
||||||
|
|
||||||
|
# 使用一个极不可能存在的 PID
|
||||||
|
self.assertFalse(_is_pid_alive(999999999))
|
||||||
+125
-2
@@ -763,5 +763,128 @@ class TestInitCommand(unittest.TestCase):
|
|||||||
self.assertIn("compression", data)
|
self.assertIn("compression", data)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
class TestCompletionCommand(unittest.TestCase):
|
||||||
unittest.main()
|
"""测试 completion 命令"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.original_argv = sys.argv.copy()
|
||||||
|
self._root_handlers = logging.root.handlers[:]
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
sys.argv = self.original_argv
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
logging.root.handlers = self._root_handlers
|
||||||
|
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_completion_bash(self, mock_print):
|
||||||
|
"""测试 completion bash 输出补全脚本"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "completion", "bash"]
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||||
|
self.assertIn("complete -F", printed)
|
||||||
|
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_completion_zsh(self, mock_print):
|
||||||
|
"""测试 completion zsh 输出补全脚本"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "completion", "zsh"]
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||||
|
self.assertIn("#compdef", printed)
|
||||||
|
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_completion_default_shell(self, mock_print):
|
||||||
|
"""测试 completion 无参数默认 bash"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "completion"]
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||||
|
self.assertIn("complete -F", printed)
|
||||||
|
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_completion_fish(self, mock_print):
|
||||||
|
"""测试 completion fish 输出补全脚本"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "completion", "fish"]
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||||
|
self.assertIn("complete -c sbackup", printed)
|
||||||
|
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_completion_powershell(self, mock_print):
|
||||||
|
"""测试 completion powershell 输出补全脚本"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "completion", "powershell"]
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
result = run()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
printed = " ".join(str(c) for c in mock_print.call_args_list)
|
||||||
|
self.assertIn("Register-ArgumentCompleter", printed)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLockIntegration(unittest.TestCase):
|
||||||
|
"""测试锁与 CLI 集成"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.test_dir = tempfile.mkdtemp()
|
||||||
|
self.original_argv = sys.argv.copy()
|
||||||
|
self._root_handlers = logging.root.handlers[:]
|
||||||
|
self._root_level = logging.root.level
|
||||||
|
self.data_path = os.path.join(self.test_dir, "sbackup.json")
|
||||||
|
with open(os.path.join(self.test_dir, "config.json"), "w") as f:
|
||||||
|
json.dump({"data_file": self.data_path}, f)
|
||||||
|
self.dest_dir = os.path.join(self.test_dir, "backup_dest")
|
||||||
|
os.makedirs(self.dest_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
sys.argv = self.original_argv
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
logging.root.handlers = self._root_handlers
|
||||||
|
logging.root.setLevel(self._root_level)
|
||||||
|
|
||||||
|
@patch("sbackup.cli.BackupManager.execute_backups")
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_save_acquires_lock(self, mock_print, mock_execute):
|
||||||
|
"""测试 save 命令获取锁"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "save"]
|
||||||
|
result = run()
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_execute.assert_called_once()
|
||||||
|
|
||||||
|
@patch("sbackup.cli.BackupManager.execute_backups")
|
||||||
|
@patch("builtins.print")
|
||||||
|
def test_save_lock_conflict(self, mock_print, mock_execute):
|
||||||
|
"""测试 save 锁冲突"""
|
||||||
|
os.chdir(self.test_dir)
|
||||||
|
from sbackup import run
|
||||||
|
|
||||||
|
# 手动创建锁文件,模拟另一个运行的进程
|
||||||
|
from sbackup.lock import BackupLock
|
||||||
|
|
||||||
|
lock = BackupLock(self.test_dir)
|
||||||
|
lock.acquire()
|
||||||
|
|
||||||
|
sys.argv = ["sbackup", "--lang", "en_US", "save"]
|
||||||
|
result = run()
|
||||||
|
# save 尝试获取锁应该失败(锁被我们持有)
|
||||||
|
self.assertNotEqual(result, 0)
|
||||||
|
mock_execute.assert_not_called()
|
||||||
|
lock.release()
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""
|
||||||
|
单元测试 for sbackup.monitor 模块
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time as _time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from sbackup.monitor import FileSystemMonitor, _BackupEventHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupEventHandler(unittest.TestCase):
|
||||||
|
"""测试 _BackupEventHandler 内部类"""
|
||||||
|
|
||||||
|
def test_on_any_event_ignores_directories(self):
|
||||||
|
"""测试目录事件被忽略"""
|
||||||
|
callback = MagicMock()
|
||||||
|
handler = _BackupEventHandler(callback)
|
||||||
|
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.is_directory = True
|
||||||
|
mock_event.src_path = "/some/dir"
|
||||||
|
|
||||||
|
handler.on_any_event(mock_event)
|
||||||
|
callback.assert_not_called()
|
||||||
|
|
||||||
|
def test_on_any_event_triggers_for_files(self):
|
||||||
|
"""测试文件事件触发回调"""
|
||||||
|
callback = MagicMock()
|
||||||
|
handler = _BackupEventHandler(callback)
|
||||||
|
|
||||||
|
mock_event = MagicMock()
|
||||||
|
mock_event.is_directory = False
|
||||||
|
mock_event.src_path = "/some/file.txt"
|
||||||
|
|
||||||
|
handler.on_any_event(mock_event)
|
||||||
|
callback.assert_called_once_with("/some/file.txt")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileSystemMonitor(unittest.TestCase):
|
||||||
|
"""测试 FileSystemMonitor 类"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmpdir.cleanup)
|
||||||
|
self.source = Path(self.tmpdir.name) / "src"
|
||||||
|
self.source.mkdir()
|
||||||
|
self.source_dirs = {str(self.source): "/backup"}
|
||||||
|
|
||||||
|
def test_init_stores_dirs_and_debounce(self):
|
||||||
|
"""测试初始化存储源目录和防抖参数"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=10)
|
||||||
|
assert monitor._source_dirs == self.source_dirs
|
||||||
|
assert monitor._debounce == 10
|
||||||
|
assert monitor._observer is None
|
||||||
|
assert monitor._running is False
|
||||||
|
assert monitor._backup_callback is None
|
||||||
|
|
||||||
|
def test_set_backup_callback(self):
|
||||||
|
"""测试设置备份回调"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs)
|
||||||
|
|
||||||
|
def noop():
|
||||||
|
pass
|
||||||
|
|
||||||
|
monitor.set_backup_callback(noop)
|
||||||
|
assert monitor._backup_callback is noop
|
||||||
|
|
||||||
|
def test_start_stop_lifecycle(self):
|
||||||
|
"""测试启动/停止生命周期"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=1)
|
||||||
|
try:
|
||||||
|
monitor.start()
|
||||||
|
# 等待 observer 线程启动
|
||||||
|
_time.sleep(0.2)
|
||||||
|
assert monitor.is_running()
|
||||||
|
finally:
|
||||||
|
monitor.stop()
|
||||||
|
assert not monitor.is_running()
|
||||||
|
|
||||||
|
def test_double_start_noop(self):
|
||||||
|
"""测试重复启动不创建多个 observer"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=1)
|
||||||
|
try:
|
||||||
|
monitor.start()
|
||||||
|
_time.sleep(0.2)
|
||||||
|
first_observer = monitor._observer
|
||||||
|
monitor.start()
|
||||||
|
assert monitor._observer is first_observer
|
||||||
|
finally:
|
||||||
|
monitor.stop()
|
||||||
|
|
||||||
|
def test_on_file_change_marks_dirty(self):
|
||||||
|
"""测试文件变化标记源目录为 dirty"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=60)
|
||||||
|
test_file = self.source / "test.txt"
|
||||||
|
test_file.write_text("hello")
|
||||||
|
|
||||||
|
monitor._on_file_change(str(test_file))
|
||||||
|
dirty = monitor.get_dirty_sources()
|
||||||
|
assert str(self.source) in dirty
|
||||||
|
|
||||||
|
def test_on_file_change_outside_source_ignored(self):
|
||||||
|
"""测试外部文件变化不标记 dirty"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=60)
|
||||||
|
outside_file = Path(self.tmpdir.name) / "outside.txt"
|
||||||
|
outside_file.write_text("hello")
|
||||||
|
|
||||||
|
monitor._on_file_change(str(outside_file))
|
||||||
|
dirty = monitor.get_dirty_sources()
|
||||||
|
assert len(dirty) == 0
|
||||||
|
|
||||||
|
def test_on_file_change_multiple_dirs(self):
|
||||||
|
"""测试多个源目录分别标记"""
|
||||||
|
src2 = Path(self.tmpdir.name) / "src2"
|
||||||
|
src2.mkdir()
|
||||||
|
dirs = {str(self.source): "/backup1", str(src2): "/backup2"}
|
||||||
|
|
||||||
|
monitor = FileSystemMonitor(dirs, debounce_seconds=60)
|
||||||
|
(self.source / "a.txt").write_text("a")
|
||||||
|
(src2 / "b.txt").write_text("b")
|
||||||
|
|
||||||
|
monitor._on_file_change(str(self.source / "a.txt"))
|
||||||
|
monitor._on_file_change(str(src2 / "b.txt"))
|
||||||
|
|
||||||
|
dirty = monitor.get_dirty_sources()
|
||||||
|
assert str(self.source) in dirty
|
||||||
|
assert str(src2) in dirty
|
||||||
|
assert len(dirty) == 2
|
||||||
|
|
||||||
|
def test_debounce_reset_on_multiple_changes(self):
|
||||||
|
"""测试多次变化时防抖计时器重置"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=10)
|
||||||
|
cb = MagicMock()
|
||||||
|
monitor.set_backup_callback(cb)
|
||||||
|
|
||||||
|
f1 = self.source / "a.txt"
|
||||||
|
f2 = self.source / "b.txt"
|
||||||
|
f1.write_text("a")
|
||||||
|
f2.write_text("b")
|
||||||
|
|
||||||
|
# 第一次变化启动计时
|
||||||
|
monitor._on_file_change(str(f1))
|
||||||
|
timer1 = monitor._debounce_timer
|
||||||
|
assert timer1 is not None
|
||||||
|
|
||||||
|
# 第二次变化应取消旧计时并启动新计时
|
||||||
|
monitor._on_file_change(str(f2))
|
||||||
|
timer2 = monitor._debounce_timer
|
||||||
|
assert timer2 is not None
|
||||||
|
assert timer2 is not timer1 # 不是同一个 timer 对象
|
||||||
|
|
||||||
|
def test_trigger_backup_calls_callback(self):
|
||||||
|
"""测试防抖超时后触发回调"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||||
|
cb = MagicMock()
|
||||||
|
monitor.set_backup_callback(cb)
|
||||||
|
|
||||||
|
(self.source / "test.txt").write_text("data")
|
||||||
|
monitor._on_file_change(str(self.source / "test.txt"))
|
||||||
|
|
||||||
|
# 等待防抖触发
|
||||||
|
_time.sleep(0.3)
|
||||||
|
cb.assert_called_once()
|
||||||
|
|
||||||
|
def test_trigger_backup_no_dirty_noop(self):
|
||||||
|
"""测试没有 dirty 目录时不触发回调"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||||
|
cb = MagicMock()
|
||||||
|
monitor.set_backup_callback(cb)
|
||||||
|
|
||||||
|
monitor._trigger_backup()
|
||||||
|
cb.assert_not_called()
|
||||||
|
|
||||||
|
def test_trigger_backup_clears_dirty(self):
|
||||||
|
"""测试触发备份后清除 dirty 标记"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||||
|
cb = MagicMock()
|
||||||
|
monitor.set_backup_callback(cb)
|
||||||
|
|
||||||
|
(self.source / "test.txt").write_text("data")
|
||||||
|
monitor._on_file_change(str(self.source / "test.txt"))
|
||||||
|
_time.sleep(0.3)
|
||||||
|
|
||||||
|
assert len(monitor.get_dirty_sources()) == 0
|
||||||
|
|
||||||
|
def test_callback_exception_caught(self):
|
||||||
|
"""测试回调异常不影响监控器"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||||
|
|
||||||
|
def failing_cb():
|
||||||
|
raise RuntimeError("test error")
|
||||||
|
|
||||||
|
monitor.set_backup_callback(failing_cb)
|
||||||
|
|
||||||
|
(self.source / "test.txt").write_text("data")
|
||||||
|
monitor._on_file_change(str(self.source / "test.txt"))
|
||||||
|
|
||||||
|
# 不应抛出异常
|
||||||
|
_time.sleep(0.3)
|
||||||
|
assert monitor._dirty == set()
|
||||||
|
|
||||||
|
def test_start_from_missing_source_dir(self):
|
||||||
|
"""测试源目录不存在时优雅处理"""
|
||||||
|
missing = str(Path(self.tmpdir.name) / "nonexistent")
|
||||||
|
monitor = FileSystemMonitor({missing: "/backup"}, debounce_seconds=1)
|
||||||
|
|
||||||
|
monitor.start()
|
||||||
|
assert not monitor.is_running()
|
||||||
|
assert monitor._observer is None
|
||||||
|
|
||||||
|
def test_stop_cancels_pending_timer(self):
|
||||||
|
"""测试 stop 取消待处理的防抖计时器"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=30)
|
||||||
|
cb = MagicMock()
|
||||||
|
monitor.set_backup_callback(cb)
|
||||||
|
|
||||||
|
(self.source / "test.txt").write_text("data")
|
||||||
|
monitor._on_file_change(str(self.source / "test.txt"))
|
||||||
|
assert monitor._debounce_timer is not None
|
||||||
|
|
||||||
|
monitor.stop()
|
||||||
|
assert monitor._debounce_timer is None
|
||||||
|
cb.assert_not_called()
|
||||||
|
|
||||||
|
def test_concurrent_file_changes_thread_safety(self):
|
||||||
|
"""测试多线程并发文件变化的线程安全性"""
|
||||||
|
monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1)
|
||||||
|
cb = MagicMock()
|
||||||
|
monitor.set_backup_callback(cb)
|
||||||
|
|
||||||
|
def write_file(i):
|
||||||
|
f = self.source / f"file_{i}.txt"
|
||||||
|
f.write_text(str(i))
|
||||||
|
monitor._on_file_change(str(f))
|
||||||
|
|
||||||
|
threads = []
|
||||||
|
for i in range(10):
|
||||||
|
t = threading.Thread(target=write_file, args=(i,))
|
||||||
|
threads.append(t)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
_time.sleep(0.3)
|
||||||
|
# 回调应至少被调用一次
|
||||||
|
assert cb.call_count >= 1
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
@@ -228,9 +229,14 @@ class TestPrePostHooks(unittest.TestCase):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
(Path(self.source_dir) / "new.txt").write_text("new")
|
(Path(self.source_dir) / "new.txt").write_text("new")
|
||||||
|
|
||||||
# 使用 echo 创建标记文件(Windows cmd 兼容)
|
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||||
marker = self.hook_marker.replace("\\", "/")
|
hook_cmd = (
|
||||||
hook_cmd = f'echo done > "{marker}"'
|
'"'
|
||||||
|
+ sys.executable
|
||||||
|
+ '" -c "open(r\''
|
||||||
|
+ self.hook_marker
|
||||||
|
+ "', 'w').write('done')\""
|
||||||
|
)
|
||||||
|
|
||||||
self.manager.execute_backups(pre_hooks=[hook_cmd])
|
self.manager.execute_backups(pre_hooks=[hook_cmd])
|
||||||
self.assertTrue(os.path.exists(self.hook_marker), "前置钩子应被执行")
|
self.assertTrue(os.path.exists(self.hook_marker), "前置钩子应被执行")
|
||||||
@@ -241,8 +247,14 @@ class TestPrePostHooks(unittest.TestCase):
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
(Path(self.source_dir) / "new.txt").write_text("new")
|
(Path(self.source_dir) / "new.txt").write_text("new")
|
||||||
|
|
||||||
marker = self.hook_marker.replace("\\", "/")
|
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||||
hook_cmd = f'echo done > "{marker}"'
|
hook_cmd = (
|
||||||
|
'"'
|
||||||
|
+ sys.executable
|
||||||
|
+ '" -c "open(r\''
|
||||||
|
+ self.hook_marker
|
||||||
|
+ "', 'w').write('done')\""
|
||||||
|
)
|
||||||
|
|
||||||
self.manager.execute_backups(post_hooks=[hook_cmd])
|
self.manager.execute_backups(post_hooks=[hook_cmd])
|
||||||
self.assertTrue(os.path.exists(self.hook_marker), "后置钩子应被执行")
|
self.assertTrue(os.path.exists(self.hook_marker), "后置钩子应被执行")
|
||||||
@@ -282,10 +294,16 @@ class TestPrePostHooks(unittest.TestCase):
|
|||||||
|
|
||||||
marker1 = os.path.join(self.test_dir, "hook1.txt")
|
marker1 = os.path.join(self.test_dir, "hook1.txt")
|
||||||
marker2 = os.path.join(self.test_dir, "hook2.txt")
|
marker2 = os.path.join(self.test_dir, "hook2.txt")
|
||||||
m1 = marker1.replace("\\", "/")
|
|
||||||
m2 = marker2.replace("\\", "/")
|
|
||||||
|
|
||||||
self.manager.execute_backups(pre_hooks=[f'echo 1 > "{m1}"', f'echo 2 > "{m2}"'])
|
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||||
|
hook1 = (
|
||||||
|
'"' + sys.executable + '" -c "open(r\'' + marker1 + "', 'w').write('1')\""
|
||||||
|
)
|
||||||
|
hook2 = (
|
||||||
|
'"' + sys.executable + '" -c "open(r\'' + marker2 + "', 'w').write('2')\""
|
||||||
|
)
|
||||||
|
|
||||||
|
self.manager.execute_backups(pre_hooks=[hook1, hook2])
|
||||||
self.assertTrue(os.path.exists(marker1))
|
self.assertTrue(os.path.exists(marker1))
|
||||||
self.assertTrue(os.path.exists(marker2))
|
self.assertTrue(os.path.exists(marker2))
|
||||||
|
|
||||||
@@ -297,8 +315,15 @@ class TestRunHooks(unittest.TestCase):
|
|||||||
"""有效命令执行成功"""
|
"""有效命令执行成功"""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
marker = os.path.join(tmpdir, "marker.txt")
|
marker = os.path.join(tmpdir, "marker.txt")
|
||||||
m = marker.replace("\\", "/")
|
# 使用 Python 创建标记文件(shlex.split + shell=False 兼容)
|
||||||
BackupManager._run_hooks([f'echo ok > "{m}"'], "pre")
|
hook_cmd = (
|
||||||
|
'"'
|
||||||
|
+ sys.executable
|
||||||
|
+ '" -c "open(r\''
|
||||||
|
+ marker
|
||||||
|
+ "', 'w').write('ok')\""
|
||||||
|
)
|
||||||
|
BackupManager._run_hooks([hook_cmd], "pre")
|
||||||
self.assertTrue(os.path.exists(marker))
|
self.assertTrue(os.path.exists(marker))
|
||||||
|
|
||||||
def test_run_hooks_with_empty_list(self):
|
def test_run_hooks_with_empty_list(self):
|
||||||
|
|||||||
@@ -403,8 +403,12 @@ class TestSFTPClient(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
client.connect()
|
client.connect()
|
||||||
|
|
||||||
# 验证 expanduser 被调用
|
# 验证 expanduser 被调用(包括 key_file 和 known_hosts 路径)
|
||||||
mock_expanduser.assert_called_once_with(self.key_file)
|
expanduser_calls = [str(c) for c in mock_expanduser.call_args_list]
|
||||||
|
self.assertTrue(
|
||||||
|
any(self.key_file in c for c in expanduser_calls),
|
||||||
|
f"expanduser should be called with key_file, got: {expanduser_calls}",
|
||||||
|
)
|
||||||
mock_transport.connect.assert_called_once_with(
|
mock_transport.connect.assert_called_once_with(
|
||||||
username=self.user, pkey=mock_pkey
|
username=self.user, pkey=mock_pkey
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -895,6 +895,7 @@ dependencies = [
|
|||||||
{ name = "paramiko" },
|
{ name = "paramiko" },
|
||||||
{ name = "py7zr" },
|
{ name = "py7zr" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm" },
|
||||||
|
{ name = "watchdog" },
|
||||||
{ name = "zstandard" },
|
{ name = "zstandard" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -908,6 +909,7 @@ requires-dist = [
|
|||||||
{ name = "paramiko", specifier = ">=4.0.0" },
|
{ name = "paramiko", specifier = ">=4.0.0" },
|
||||||
{ name = "py7zr", specifier = ">=1.1.0" },
|
{ name = "py7zr", specifier = ">=1.1.0" },
|
||||||
{ name = "tqdm", specifier = ">=4.67.3" },
|
{ name = "tqdm", specifier = ">=4.67.3" },
|
||||||
|
{ name = "watchdog", specifier = ">=6.0.0" },
|
||||||
{ name = "zstandard", specifier = ">=0.25.0" },
|
{ name = "zstandard", specifier = ">=0.25.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -944,6 +946,38 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchdog"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstandard"
|
name = "zstandard"
|
||||||
version = "0.25.0"
|
version = "0.25.0"
|
||||||
|
|||||||
Reference in new issue
Block a user