From 631c411602d8e7294f30fbb61d20c1510f9599c8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 17:28:45 +0800 Subject: [PATCH] fix: distinguish supported network backup mounts from local filesystems --- README.md | 8 ++++++-- VERSION | 2 +- backups.py | 33 ++++++++++++++++++++++++++++++--- test_backups.py | 19 ++++++++++++++++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4f227fc..8659a59 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,10 @@ JSON 导出升级为格式 version 2,包含人物、关系和当前账号有 1. SQLite online backup API 生成一致性副本,不停止视频服务;副本完整性检查后移除会话,计算 SHA-256 并以临时文件原子改名完成本机保存。 2. 本机目录为 `VISION_DATA/managed-backups`。副本包含所有家庭、密码散列、设备连接凭据、家谱、记事、照片和设置;**不包含录像文件,未对副本进行额外加密**,按敏感数据保管。恢复后须重新登录。 3. 网络目录必须是服务器已经挂载且服务账号可写的绝对目录。默认 systemd 服务只允许写 `/var/lib/zhaovision`,建议把网络盘挂载在其下的 `network-backup` 目录。服务启动后新增挂载需确认该挂载在服务的文件系统视图中可见;必要时重启应用服务。 -4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。仅检测独立挂载并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。 -5. 网络盘不存在或没有检测到挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。 +4. 本版不内置百度网盘登录/API。百度网页地址或分享链接不能作为备份路径;需要主机已有可用的网络挂载/同步服务。识别受支持的网络文件系统并确认网络目录副本字节校验一致,**不能证明百度云端已同步完成**。只有普通本机同步目录而无挂载时,可让外部同步程序读取本机备份目录,其云端状态由外部程序负责。 +5. 网络盘不存在或没有检测到受支持的网络挂载时明确报错;本机成功副本仍保留。本机保留策略独立执行,网络失败不会让本机备份无限累积。只清理本实例命名的旧副本,不清理目录内其他文件。同一时间只运行一次,计划每天最多尝试一次,失败可手动重试。 6. 数据库恢复由主机管理员进行:停止 `zhaovision` 和 `zhaovision-media`,先保存当前完整数据目录;校验选定副本的 SHA-256 与 SQLite integrity_check,将它恢复为 `/var/lib/zhaovision/vision.db` 并正确处理旧数据库的 WAL/SHM 文件,修正服务账号所有权,再启动服务。选用不早于副本生成版本的程序;数据库副本不会恢复录像文件或 `/etc/zhaovision.env`、Tailscale/反向代理配置。 + +### v0.1.26 备份目录识别修正 + +Linux 按目标目录最近一层挂载识别网络文件系统,支持 NFS、SMB、WebDAV、SSHFS、rclone、s3fs/goofys;本机 ext4、tmpfs 等不作为网络目标。Windows 可使用已连接的 UNC 共享目录。网络类型校验不代替云服务同步状态确认。 diff --git a/VERSION b/VERSION index 0e7400f..7db2672 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.25 +0.1.26 diff --git a/backups.py b/backups.py index 692289b..4ef4e35 100644 --- a/backups.py +++ b/backups.py @@ -3,7 +3,7 @@ import datetime as dt from contextlib import closing import hashlib import os -from pathlib import Path +from pathlib import Path, PurePosixPath import re import secrets import shutil @@ -27,6 +27,33 @@ class Backups: last=self.a.setting('backup_last', None), history=self.a.setting('backup_history', [])[-20:][::-1]) + @staticmethod + def network_mount(folder): + if os.name == 'nt': + return folder.drive.startswith('\\\\') + try: + records = Path('/proc/self/mountinfo').read_text().splitlines() + except OSError: + return False + return Backups.linux_network_mount(PurePosixPath(str(folder)), records) + + @staticmethod + def linux_network_mount(folder, records): + matches = [] + for line in records: + left, separator, right = line.partition(' - ') + fields, detail = left.split(), right.split() + if not separator or len(fields) < 5 or not detail: + continue + mount = PurePosixPath(re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), fields[4])) + if folder.is_relative_to(mount): + matches.append((len(mount.parts), detail[0])) + # The nearest mount wins: a tmpfs inside an NFS share is still local. + filesystem = max(matches, default=(0, ''), key=lambda x:x[0])[1] + return filesystem in {'cifs', 'smb3', 'nfs', 'nfs4', 'davfs', + 'fuse.sshfs', 'fuse.rclone', 'fuse.davfs', 'fuse.davfs2', + 'fuse.wdfs', 'fuse.s3fs', 'fuse.goofys'} + def network_folder(self, text): if not text: return None @@ -39,8 +66,8 @@ class Backups: if not folder.is_dir(): raise self.a.Problem('备份目录不存在,请先在服务器挂载网络存储并授予服务账号写入权限') # A disconnected mount must not silently become a local-only cloud backup. - if not any(os.path.ismount(p) for p in (folder, *folder.parents) if p != Path(p.anchor)): - raise self.a.Problem('该目录尚未检测到独立挂载;请先连接网络盘,不能把本机目录当作云端成功') + if not self.network_mount(folder): + raise self.a.Problem('该目录未检测到支持的网络挂载,请先连接 NFS、SMB、WebDAV 或 rclone 网络盘') if folder == (self.a.DATA/'managed-backups').resolve(): raise self.a.Problem('网络副本与本机备份目录不能相同') return folder diff --git a/test_backups.py b/test_backups.py index fec100c..d3d1f5b 100644 --- a/test_backups.py +++ b/test_backups.py @@ -1,5 +1,6 @@ import datetime as dt from contextlib import closing +from pathlib import PurePosixPath import sqlite3 import unittest from unittest.mock import patch @@ -44,7 +45,7 @@ class BackupTests(unittest.TestCase): def test_network_copy_checksum_and_retention_only_touch_owned_files(self): backup=self.source_file();network=app.DATA/'network';network.mkdir();unrelated=network/'keep.sqlite';unrelated.write_text('unrelated') app.set_setting('backup_config',dict(backup.config(),networkPath=str(network),keep=1)) - with patch('backups.os.path.ismount',side_effect=lambda p:p==network.resolve()): + with patch.object(backup,'network_mount',return_value=True): first=backup.run();second=backup.run() self.assertTrue(first['networkOk'],first);self.assertTrue(second['networkOk'],second) self.assertTrue(unrelated.exists());self.assertFalse((network/first['file']).exists());self.assertTrue((network/second['file']).exists()) @@ -63,3 +64,19 @@ class BackupTests(unittest.TestCase): backup.running=True with self.assertRaises(app.Problem):backup.start() with self.assertRaises(app.Problem):backup.save(backup.config()) + + def test_local_filesystems_are_never_reported_as_network_mounts(self): + records=['1 0 8:1 / / rw - ext4 /dev/sda1 rw', + '2 1 0:1 / /tmp rw - tmpfs tmpfs rw', + '3 1 0:2 / /mnt/cloud rw - nfs4 nas:/share rw', + '4 3 0:3 / /mnt/cloud/local rw - tmpfs tmpfs rw'] + for path in ('/var/lib/backups','/tmp/backups','/mnt/cloud/local/backups'): + self.assertFalse(Backups.linux_network_mount(PurePosixPath(path),records)) + self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud/backups'),records)) + + def test_network_mount_paths_with_spaces_and_supported_fuse_types(self): + for filesystem in ('cifs','fuse.rclone','fuse.davfs','fuse.sshfs'): + records=['1 0 8:1 / / rw - ext4 /dev/sda1 rw', + '2 1 0:2 / /mnt/cloud\\040drive rw - '+filesystem+' remote rw'] + self.assertTrue(Backups.linux_network_mount(PurePosixPath('/mnt/cloud drive/backups'),records)) + self.assertFalse(Backups.linux_network_mount(PurePosixPath('/tmp'),['invalid data']))