ntfs镜像挂载mount

目标与现状

把「旧中国老照片」归档(Hyper-V 动态 VHDX)在 NixOS 上手动挂载、全程只读、mtime 不失真——判据是显示 1800 年代的拍摄年代,不能被夹成 1970;并要求在 Dolphin 左侧「存储设备」里出现两个固化的可点击设备(history-img / history-vhdx),默认卸载、挂载参数全部在 nix 里预先配好。

项值
镜像目录/s500data/DCIMData/1爱好/旧中国老照片_镜像文件/
归档(保真载体,Windows 侧用)China-modern-history_NTFS.vhdx
raw 形态(宿主用,稀疏)China-modern-history_NTFS.img
挂载点 1(来自 .img)/s500data/DCIMData/1爱好/history-img
挂载点 2(来自 .vhdx)/s500data/DCIMData/1爱好/history-vhdx
声明式模块/etc/nixos/modules/system/mount/China-modern-history_NTFS_img.nix
/etc/nixos/modules/system/mount/China-modern-history_NTFS_vhdx.nix
设备服务history-img-attach(.img → /dev/nbd1p1)、history-vhdx-attach(.vhdx → /dev/nbd0p1)
操作手册镜像目录下 操作命令.txt
侧车文件manifest_old-china-photo.csv(38,265 行)、photo-archive.vhdx.sha256
默认状态两个都未挂载(fstab noauto);开机只把镜像 attach 成块设备,不挂文件系统

双形态各自一等公民:宿主走 raw(内核 loop / nbd 内建);VHDX 形态留给 Windows 原生「附加 VHD」只读挂载。两者同一内容,等价性用 qemu-img compare 核对。只读是红线:宿主 ro、Windows 侧 Read-only——两边同时可写挂同一份即双写损坏。

相对上一版的变化:目录改名到镜像文件同级的 旧中国老照片_镜像文件/;挂载点由 /mnt/photo 变为两个以目标文件命名的固化挂载点;vhdx→img 自动转换已取消(改为手册里的手动命令);两个镜像都改为经 qemu-nbd 暴露块设备,以便在 Dolphin「存储设备」里出现并可点击挂卸。

前置事实(实测)

项实测值
VHDX 形态动态盘,虚拟 21.475 GB,块 32 MB,逻辑扇区 512,无父盘
分区表MBR,单分区 type=0x07(NTFS);分区起始 LBA 2048 → 偏移 1048576 字节
raw 形态表观 21.475 GB / 实占 9.129 GB(稀疏省 57.5%)
归档等价性qemu-img compare -f vhdx -F raw ⇒ Images are identical.
内容规模38,266 个文件;挂载视图顶层 80 项(img 与 vhdx 两形态一致)
侧车 manifest列 RelPath, Size, MTime, CTime, MTimeUtc;MTime 是本地 CST,MTimeUtc 才是 UTC
现场副本(非本归档)38,265 个文件 mtime 已被改写(1970 出现 947 次、无任何早于 1900)⇒ 归档才是保真载体
宿主能力内核 ntfs3 / loop / nbd 齐备(挂载本身不需要装任何包)
原 VHDX sha256846EC34810186A6AE14FB3F117D1C7743B55A7FE3ADDF7A96BFD10EB182DD77D

最终方案:两个「固化存储设备」

目标形态:Dolphin 左侧「存储设备」里出现 history-img 与 history-vhdx 两条,默认卸载,点一下即挂载(再点卸载),挂载点与参数全部由 nix 决定;命令行 sudo mount/umount "<挂载点>" 与点击完全等价。

环节做法为什么这么做(实测依据)
设备来源history-img-attach / history-vhdx-attach 两个 oneshot 服务开机用 qemu-nbd --read-only 把镜像暴露成 /dev/nbd1 / /dev/nbd0.vhdx 是容器格式,内核 loop 直接挂报「坏超级块」,必须 qemu-nbd;.img 本可纯 loop,但 KDE/udisks2 把 loop 设备一律 HintIgnore 隐藏,nbd 块设备才会出现在设备列表里
显示名udev 规则把 ID_FS_LABEL 改写为 history-img / history-vhdx设备名 = 文件系统卷标(镜像里是 PHOTOARCH);只改 udev 呈现名,镜像文件零改动。KDE 的 fstab 后端虽支持 x-gvfs-name,但 KF6 只收网络盘/fuse 盘(fstabhandling.cpp:_k_isFstabSupportedLocalFileSystem),本地 ntfs3 不收,那条路不通
挂载点/参数nix fileSystems."<挂载点>" → fstab 条目(noauto,user,ro,noatime,uid=1000,gid=100,umask=022)命令行 mount 读 fstab;Dolphin 点击走 udisks2 的 Mount,udisks2 同样取 fstab 条目——实测 udisksctl mount -b /dev/nbd1p1 即挂到 /s500data/DCIMData/1爱好/history-img 且参数与 fstab 一致
可见性判据无需额外配置Dolphin 侧栏设备谓词(kio-6.26.0 kfileplacesmodel.cpp:434):StorageVolume.ignored == false AND usage ∈ {FileSystem, Encrypted};nbd 设备实测 StorageVolume.ignored=false、usage=FileSystem ⇒ 会被列出
默认卸载noauto;attach ≠ 挂载开机只建立块设备,文件系统未挂载;需要时点击或 mount

实现过程

raw 形态生成(改为手动,不自动执行)

qemu-img convert -p -f vhdx -O raw -t none -T none \
  "/s500data/DCIMData/1爱好/旧中国老照片_镜像文件/China-modern-history_NTFS.vhdx" \
  "/s500data/DCIMData/1爱好/旧中国老照片_镜像文件/China-modern-history_NTFS.img"

实测 3.2 秒完成;-O raw 默认按零块识别跳过 ⇒ 输出保稀疏(表观 21.475 GB / 实占 9.129 GB),不需要额外加参数。转换后 sudo systemctl restart history-img-attach 让新镜像生效。

声明式模块(两个,均在 configuration.nix imports 里启用)

waitNbd0p1 = pkgs.writeShellScript "wait-nbd0p1" ''
  for _ in $(seq 1 100); do [ -b /dev/nbd0p1 ] && exit 0; sleep 0.1; done
  echo "/dev/nbd0p1 等待超时(10s),未出现分区设备" >&2; exit 1
'';

systemd.services.history-vhdx-attach = {
  description = "老照片 vhdx → 块设备 /dev/nbd0p1(Dolphin 设备列表 / 手动挂载)";
  wantedBy = [ "multi-user.target" ];
  serviceConfig.Type = "oneshot";
  serviceConfig.RemainAfterExit = true;
  serviceConfig.ExecStart = [
    "${pkgs.qemu-utils}/bin/qemu-nbd --read-only --format=vhdx --connect=/dev/nbd0 ${vhdx}"
    "${waitNbd0p1}"
  ];
  serviceConfig.ExecStop = "${pkgs.qemu-utils}/bin/qemu-nbd --disconnect /dev/nbd0";
};

services.udev.extraRules = ''
  SUBSYSTEM=="block", KERNEL=="nbd0p1", ENV{ID_FS_LABEL}="history-vhdx", ENV{ID_FS_LABEL_ENC}="history-vhdx"
'';

fileSystems."/s500data/DCIMData/1爱好/history-vhdx" = {
  device  = "/dev/nbd0p1";
  fsType  = "ntfs3";                         # 内核驱动(非 FUSE)
  options = [ "noauto" "user" "ro" "noatime" "uid=1000" "gid=100" "umask=022" ];
};

.img 模块结构相同,差异:--format=raw、设备 /dev/nbd1//dev/nbd1p1、显示名 history-img。生效:sudo nixos-rebuild switch。

单元内的有界等待是必需的:qemu-nbd --connect 返回后 /dev/nbdXp1 由内核/udev 异步创建,紧接着 mount 会报 fsconfig() failed: /dev/nbd0p1: Can't lookup blockdev;且不能写 After/Requires=dev-nbd0p1.device——该设备正是本单元创建的,会循环依赖死锁。

为什么必须是内核 ntfs3,而不是 ntfs-3g(FUSE)

归档里是 1860 年代的拍摄年代(mtime 保真是本项验收判据「不是 1970」)。内核 ntfs3 读写前置 1970 的时间戳不夹取,FUSE 路线有夹取风险。

实测证据

判据实测值
只读挂载形态/dev/nbd1p1 /s500data/DCIMData/1爱好/history-img ntfs3 ro,nosuid,nodev,noexec,noatime,uid=1000,gid=100,dmask=0022,fmask=0022
只读生效写入被拒:EROFS (30) Read-only file system
点击路径实测udisksctl mount -b /dev/nbd1p1 ⇒ Mounted /dev/nbd1p1 at /s500data/DCIMData/1爱好/history-img(挂载点与参数取自 nix/fstab,无需密码)
普通用户可挂mount "<挂载点>" 不带 sudo 成功(fstab user + NixOS mount/umount setuid root)
设备名udevadm info /dev/nbd1p1 ⇒ ID_FS_LABEL=history-img;Solid 侧 description=history-img、StorageVolume.ignored=false、usage=FileSystem ⇒ 命中 Dolphin 侧栏谓词
mtime 保真(两形态同值)同一张照片:history-img 与 history-vhdx 均 mtime=1860-05-13 02:47:33、size=186480;直方含 1840:1 / 1860:111 / 1870:46 / 1900:581 / 1924:2540 / 1930:5020…,未被夹成 1970
顶层条目数img 侧 80、vhdx 侧 80(一致)
manifest 对照用 MTimeUtc + Size 抽查 6 项(3 项 1860s + 3 项随机)⇒ 6/6 完全一致
raw 与 VHDX 等价qemu-img compare -f vhdx -F raw ⇒ Images are identical.
vhdx 全链路用干净设备 /dev/nbd2 复测:挂载成功、顶层 80 项、样本 mtime 1860-05-13 02:47:33、size 186480(与 img 完全一致)
默认不挂载rebuild 后 /proc/mounts 无 history 条目;两个 attach 服务 active (exited),/dev/nbd0p1、/dev/nbd1p1 就位

管理命令

用途命令
图形界面挂/卸Dolphin 左侧「存储设备」点 history-img / history-vhdx(再点一次卸载,无需密码)
命令行挂 / 卸sudo mount "/s500data/DCIMData/1爱好/history-img" / sudo umount "/s500data/DCIMData/1爱好/history-img"(vhdx 同理)
等价于点击的 udisks2 命令udisksctl mount -b /dev/nbd1p1 / udisksctl unmount -b /dev/nbd1p1
设备服务状态 / 断开 / 接回systemctl status|stop|start history-img-attach(vhdx 同名替换)
看设备与显示名ls -l /dev/nbd0p1 /dev/nbd1p1;udevadm info /dev/nbd1p1 | grep ID_FS_LABEL
设备详情udisksctl info -b /dev/nbd1p1
确认挂上了且是 rogrep history /proc/mounts 或 findmnt "/s500data/DCIMData/1爱好/history-img"
只读自检(预期被拒)touch "/s500data/DCIMData/1爱好/history-img/__probe__" ⇒ Read-only file system
看某个文件的 mtimestat -c '%y %s %n' "/s500data/DCIMData/1爱好/history-img/<路径>"
重建 raw(归档更新后)见「实现过程」的 qemu-img convert 命令,然后 sudo systemctl restart history-img-attach
等价性核对qemu-img compare -f vhdx -F raw <vhdx> <img> ⇒ Images are identical.
改配置后生效sudo nixos-rebuild switch(NixOS 的 /etc/fstab 只读,只能改 nix 模块)
nbd 设备卡住后复位sudo systemctl stop history-img-attach history-vhdx-attach → sudo rmmod nbd && sudo modprobe nbd max_part=16 → sudo systemctl start ...;rmmod 报「模块被占用」时重启一次即复位

踩坑记录

  • 整盘 raw 直接按 ntfs3 挂会失败:报 ntfs3(loop0): Primary boot signature is not NTFS —— NTFS 卷在 MBR 分区里,必须加 offset=(本档 = 1048576 = LBA 2048 × 512)。
  • .vhdx 不能用 losetup:容器格式(1MB 头 + 区域表 + 块映射表),内核 loop 看到的是容器字节 ⇒ 报「坏超级块」;必须 qemu-nbd --format=vhdx。
  • /dev/nbdXp1 是异步创建的:qemu-nbd --connect 返回后立刻 mount 会报 fsconfig() failed: ... Can't lookup blockdev ⇒ 单元内必须有界等待。
  • 不能写 After/Requires=dev-nbd0p1.device:该设备正是该单元自己创建的 ⇒ 循环依赖死锁 ⇒ 改为单元内 writeShellScript 有界轮询(10s 超时后明确报错退出,不静默继续)。
  • nbd 设备会被「卡住」:异常中止(单元被 cgroup 杀、反复 connect/disconnect 实验)后设备停在「size 已设、读全 EIO」状态,qemu-nbd --disconnect 也清不掉、rmmod nbd 报「模块被占用」⇒ 重启一次复位。本轮即把 /dev/nbd0 实验卡住,故用干净 /dev/nbd2 复测 vhdx 全链路通过。
  • udev 改名要触发才生效:设备已存在时新增规则需 udevadm control --reload && udevadm trigger --sysname-match=nbdXp1;开机路径由服务在规则就绪后创建设备,无需手工触发。
  • KDE 有「两个 ignored」别搞混:StorageAccess.ignored(未挂载且挂载点不在 /media、/run/media、$HOME 下时为 true)≠ StorageVolume.ignored(侧栏真正看的字段,nbd 设备为 false)。udisksctl info 的 HintIgnore 又是 udisks2 自己的一层。
  • KF6 Solid 的 fstab 后端不收本地盘:只收网络盘(nfs/cifs/smb3/fuse.sshfs/fuse.rclone 等)与 fuse.encfs/cryfs/gocryptfs/overlay ⇒ 挂载点写进 fstab 也不会出现在「存储设备」列表;能出现是因为 nbd 是 udisks2 管理的块设备。
  • manifest 的 MTime 是本地时间:与挂载视图会差 8 小时(CST vs NTFS 内部 UTC)⇒ 判读/对照一律用 MTimeUtc,否则会误判成「时间不一致」。
  • mtime 里出现 1970 的那 947 个文件是原始数据本身没有时间戳(与现场副本同数),不是本次挂载造成的夹取。
  • 稀疏不用手动保:qemu-img convert -O raw 默认按零块识别跳过(实测省 57.5%);加 -S 0 反而会关掉这层检测、把整盘写满。
  • NixOS 的 /etc/fstab 只读(store 生成)⇒ 改挂载只能改 nix 模块 + nixos-rebuild switch;手工 cat >> 会报「只读文件系统」。
  • Trilium 更新陷阱(本轮踩到):update_note 是整篇覆盖——只想改一处也必须提交完整正文,否则内容被截断。

边界

  • 本档只做只读;任何写入需求(补元数据、整理目录等)不在范围内。
  • Windows 侧的「附加 VHD」只读挂载与宿主 history-vhdx 是同一内容的两形态,可用于交叉核对同一张 1800 年代照片的「修改日期」。
  • 待一次重启:线上 /dev/nbd0 被本轮实验卡住,重启后 history-vhdx 与 history-img 完全同路径工作(img 侧已现行验证;vhdx 侧已用干净设备端到端验证)。
  • vhdx→raw 的自动转换已取消,改为手册中的手动命令;后续若要恢复自动,需重新引入带 ConditionPathExists 的 oneshot 单元。