Arch 自动挂载 ntfs

起因

因为硬盘空间有限, 所以我的游戏一般会放在 Windows 系统那边 (顺带在 Linux 下跑不了的时候也可以在 Windows 上玩). 但是默认情况下 Linux 不会自动挂载硬盘, 需要手动 mount 或者是在图形化的文件管理器里输入密码.

正常的时候倒是没什么, 但是前阵子基本上天天都在玩丝之歌, 每次重启系统都需要重新手动挂载分区就显得太繁琐了, 于是我打算自动化这个流程. (据说 Linux 用户多多少少都喜欢写点蹩脚的小脚本)

NTFS on Linux

NTFS(New Technology File System) 是 Windows 默认的文件系统格式, 现在的绝大多数基本所有的 Windows 都在使用这个格式.

但是因为设计思路的不同, Linux 所需的部分特性在 ntfs 中并没有相应的实现, 比如说 ntfs 就没有 ext4 等面向 Linux 文件系统中用来索引文件实际位置的 i-node(索引节点), 那么依靠 i-node 的部分文件系统特性也就用不了了, 比如说依赖 i-node 特性的硬链接, 但是 ntfs 也是有相似的功能, 只不过在设计思路上有所不同.

但是, 在计算计的世界里, 没有什么是加一层中间件解决不了的, 如果有那就再加一层. (比如说 winlator 套一层 box64 之后再套一层 wine, 然后就可以在手机上玩旮旯了)

ntfs-3g 就是我们所需的这层中间件, 用来处理 Linux 和 ntfs 逻辑的不同之处. (在 Linux 内核 5.15 版本开始, 加入了 ntfs3 这个内核驱动, 提供原生的读写支持, 性能比基于 FUSE 的 ntfs-3g 更高)

实现

尝试一: fstab

想到自动挂载, 最先能想到的是什么, 那肯定是 /etc/fstab 这个挂载目录了. 但是呢, 这个有一点问题, 因为我不想直接挂载在 /mnt 之类的地方, 我期望的目录是 /run/media/{用户名}/{文件系统 Label}.

但是 /run 并不是一个真正存在于你硬盘上的目录, 而是一个临时文件系统(tmpfs), 是存在于 RAM(内存) 中的. 也就是说重启之后这里的内容全部都会被清空, 这对于 /etc/fstab 明显是不友好的, 因为我要挂载的文件夹重启之后并不存在.

当然, 也可以在 /etc/fstab 中给这个文件系统加入 noautouser 这两个属性, 取消自动挂载和添加用户的挂载权限, 并创建一个开机的自动脚本在 /run 下创建文件夹, 但是不出意外的话会得到如下提示:

Unprivileged user can not mount NTFS block devices using the external FUSE
library. Either mount the volume as root, or rebuild NTFS-3G with integrated
FUSE support and make it setuid root. Please see more information at
https://github.com/tuxera/ntfs-3g/wiki/NTFS-3G-FAQ

也就是说挂载他还是需要手动使用 Root 权限才可以 (并且, 给他设置 setuid 也是无效的, 需要重新编译一个支持 setuid 的版本), 太麻烦了, 放弃这个方法.

至于 ntfs-3g 为什么不支持非 Root 挂载, 可以看官方的 FAQ, 第一个就是的. (github.com/tuxera/ntfs-3g/wiki/NTFS-3G-FAQ)

对了, 还有一部分, 关于自动创建文件夹的. 可以直接在 systemd 里创建一个服务, Service.Type 设置为 oneshot, 这样就可以自动执行一次性任务了.

尝试二: udev

udev 是 Linux 中的一套由于管理设备节点的管理系统, 负责监视系统中的硬件添加移除等事件.

在默认的情况下, 设备节点的名称取决于设备的接入顺序, 所以有时候经常可以看到 /dev/sda 在重启之后变成了 /dev/sdb 之类的问题. udev 加入后就可以使用一种更加便利的方式访问这些设备了, 比如说 /dev/disk 这个文件夹, 就是 udev 管理的, udev 默认会自动按照 UUID, label 等方式创建文件夹, 并在里面创建映射到实际设备的链接. 这样就算只知道 UUID 或是 label 等信息也可以找到对应的文件系统.

by-uuid

udev 的工作流程大致如下:

  1. 硬件接入
  2. 内核检测到设备, 生成 uevent
  3. udev 守护进程接收 uevent
  4. udev 根据 /etc/udev/rules.d/ (用户规则) 和 /lib/udev/rules.d (系统规则) 下的规则处理事件

查看 udev 守护进程的状态

(有 systemd 的前缀是因为 udev 是 systemd 的一部分, 这么多重要功能都是 systemd 提供的, 与其叫 Linux 系统, 不如直接叫 systemd 系统算了)

那么只需要在 /etc/udev/rules.d/ 下添加一个规则就可以自动新建一个文件夹并且自动挂载了.

但是想象很美好, 现实很残酷, udev 几乎没有 debug 的可能性 (也可能是我不会用). 无论我怎么改规则, 最后都只有一个失败的错误码.

就连 /usr/bin/bash -c 'command > xxx.log 2>&1' 这样的方式也不会留下日志, 无奈, 太高级了, 用不来. (应该不是我写错了把, 就两行, 一行创建文件夹, 一行挂载, 使用 && 连成一行, 每次都只有前一个会被执行, 一遇到挂载就报错, 但是我自己测试是没有任何问题的)

尝试三: 自定义脚本

最后的尝试了, 再不成功我也没办法了

首先, 我希望是 Rootless 的, 我可不希望我的脚本里出现 sudo, 那么就必须要依靠 setuid 了, 虽然上面也有提到过, 但我还是解释一下.

setuid 是 Linux/Unix 中的一种特殊的文件标志, 在执行时可以临时获得文件所有者的权限 (比如说 ROOT 权限), 常用的 sudo 就是一个带有 setuid 标志的程序.

sudo 的权限标志

带有这个特殊标志的程序, 在拥有者权限的执行权限那里标注的就不是正常的 x 了, 而是特殊的 s. 我们也可以用这个特殊的标志位来临时执行一些需要 root 权限的命令, 比如说挂载 ntfs. 因为 setuid 是不支持脚本使用的, 所以需要用一个编译型的语言来生成二进制程序.

我就选择使用 rust + nix 库来实现. (使用 c/c++ 其实更加简单, 只需要调用 unistd.h 提供的接口就可以实现提权)

use std::collections::HashMap;
use nix::unistd::{Gid, Uid};

pub fn get_all_label() -> HashMap<String, String> {
    let mut binding = std::process::Command::new("lsblk");
    let blks_out = binding
        .arg("-J") // 获得 JSON 格式的输出
        .arg("-o")
        .arg("NAME,PATH,UUID,FSTYPE,LABEL,SIZE,MOUNTPOINT")
        .output()
        .unwrap_or_else(|_| {
            eprint!("Usage error, add arg 'mount' or 'umount'");
            std::process::exit(1);
        });
    let blks_str: String = String::from_utf8(blks_out.stdout).unwrap();
    let blks_json: serde_json::Value = serde_json::from_str(&blks_str).unwrap();

    let mut result: HashMap<String, String> = HashMap::new();

    if let Some(blks) = blks_json["blockdevices"].as_array() {
        for blk in blks {
            if let Some(children) = blk.get("children").and_then(|v| v.as_array()) {
                for fs in children {
                    let uuid = fs.get("uuid").unwrap();
                    let mut label = fs.get("label").unwrap();

                    if label.is_null() {
                        label = uuid;
                    }

                    if uuid.is_string() && label.is_string() {
                        result.insert(
                            uuid.as_str().unwrap().to_string(),
                            label.as_str().unwrap().to_string(),
                        );
                    }
                }
            }
        }
    } else {
        eprint!("run lsblk error");
        std::process::exit(1);
    }

    result
}

pub fn _get_current_user() -> String {
    let output = std::process::Command::new("whoami")
        .output()
        .unwrap_or_else(|_| {
            eprint!("run 'whoami' failed");
            std::process::exit(1);
        });

    // trim!
    String::from_utf8(output.stdout).unwrap().trim().to_string()
}

pub fn generate_mount_point(base: &str, user: &str, label: &str) -> String {
    format!("{}/{}/{}", base, user, label)
}

pub fn ensure_dir_exists(path: &str) {
    if std::path::Path::new(path).exists() {
        return;
    }

    // mkdir -p path
    std::fs::create_dir_all(path).unwrap_or_else(|_| {
        eprintln!("failed to ceate dir: {}", path);
        std::process::exit(1);
    });
}

pub fn mount(device: &str, mount_point: &str, uid: Uid, gid: Gid) {
    let status = std::process::Command::new("mount")
        .arg("-t")
        .arg("ntfs-3g")
        .arg("-o")
        .arg(format!("uid={},gid={}", uid, gid))
        .arg(device)
        .arg(mount_point)
        .status()
        .expect("Failed run mount command");

    if !status.success() {
        eprintln!("Mount failed, exit code: {}", status.code().unwrap_or(-1));
        std::process::exit(1);
    }
}


fn main() {
    let target_uuids: Vec<&str> = vec!["8AE849CBE849B66B"]; // 替换这里
    let mount_point_root = "/run/media";

    // 获取当前用户信息
    let uid = nix::unistd::getuid();
    let gid = nix::unistd::getgid();
    let user_name = &nix::unistd::User::from_uid(uid).unwrap().unwrap().name;
    let label = get_all_label();

    // 获得 ROOT 权限
    nix::unistd::setuid(nix::unistd::ROOT).unwrap_or_else(|_| {
        eprintln!("Failed to get ROOT permission");
        std::process::exit(1);
    });

    // 挂载
    for uuid in &target_uuids {
        let device = format!("/dev/disk/by-uuid/{}", uuid);
        let mount_point =
            generate_mount_point(mount_point_root, user_name, label.get(*uuid).unwrap());

        ensure_dir_exists(mount_point.as_str());
        mount(device.as_str(), mount_point.as_str(), uid, gid);
        nix::unistd::chown(mount_point.as_str(), Some(uid), Some(gid)).unwrap();
    }
}

然后运行 cargo build --release 就可以生成我们需要的二进制文件了.

给他添加 setuid 标志:

sudo chown root:root /path/to/your_program
sudo chmod u+s /path/to/your_program

因为还要记录一下 log 方便后续定位错误, 写一段 shell 脚本:

#!/usr/bin/bash

# 获取脚本自身的一些信息
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do
    DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
    SOURCE="$(readlink "$SOURCE")"
    [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
SCRIPT_PATH="$SCRIPT_DIR/$(basename "$SOURCE")"

# 运行刚刚编译得到的二进制 (我这里叫 `ntfs-helper`, 注意是同文件夹下的)
function main() {
    if [ -e "${SCRIPT_DIR}/ntfs-helper" ]; then
        "${SCRIPT_DIR}/ntfs-helper" > "${SCRIPT_DIR}/ntfs-helper.log" 2>&1
    else
        echo "'${SCRIPT_DIR}/ntfs-helper' not found"
        return 1
    fi
}

# 如果运行失败, 使用 `kdialog` 在 KDE 通知中心里弹出一个通知
if ! main; then
    kdialog --passivepopup "user script '${SCRIPT_DIR}' run failed!"
    exit 1
fi

需要注意的一点是, 因为程序正常退出的返回值一般是 0, 所以 shell 脚本中 0 才为真, 其他都为假 (论我为什么不喜欢 shell 脚本)

别着急, 日志和提示有了, 现在还需要让他自动执行. 对于 KDE 而言, 可以直接在系统设置里面往下翻, 有一个自动启动的选项, 在这里就可以图形话的创建自动启动脚本. 或者你也可以像我一样自己写一个.

在 KDE 下, 只需要在 .config/autostart 下创建一个 xxx.desktop 就可以告诉 KDE 自动执行些什么任务. .desktop 文件是桌面入口文件(Desktop Entry File), 需要符合 freedesktop.org 标准, 使用 INI 文件格式.

类似于这样:

[Desktop Entry]
Type=Application
Name=Auto mount script
Comment=Some comment here
Exec=/path/to/your_script
Icon=utilities-terminal
Terminal=false
StartupNotify=false
X-KDE-AutostartScript=true

然后就可以重启电脑试试效果了.

顺带一提, 在 ~/.local/share/kio/servicemenus/ 中添加 .desktop 文件可以定制 Dolphin (KDE 文件管理器) 的右键菜单, 比如说我这里的 "open in vscode" 和 "open in zed".

Dolphin 的右键菜单

碎碎念时间!

至于上次文章里会有奇奇怪怪的注释嘛, 肯定是写代码写傻了, 顺手就加上了 (而且 HTML 注释那么难写).

到还是挺有意思的, 就不删了.


本来这次打算写另一个主题的, 关于 React 分页器组件自动滚动的一个 bug, 但是都写了一千多个字了, 去重新复现了一下发现复现不了了. 呜呜, 好难过, 一千多个字都够我水一篇的了.

依赖完全是一样的, 但是就是复现不了, 难道真的是我傻, 写错了? AI 告诉我是因为组件被卸载, 导致状态丢失. 但是我都写了这么久 React 了, 什么时候组件卸载还分不清吗. 果然还是大模型基于统计学概率的缺陷吗, 提到 "useState 没有效果但是换成 useContext 却可以", 就回答是组件卸载. 不知道为什么, 突然想起来一句: "思而不学则过拟合"


至于写的那个新玩意, 在 AI 的帮忙下已经成为史山了... 每个功能都有他自己的想法

下次再也不滥用 AI 了, 呜


最近还看到一张有意思的 meme

meme

有点太真实了, 之前我就是被 Nvidia 的驱动劝退的, 他那闭源驱动生不生效完全玄学 (关于现在的那个 nvidia-open 驱动, 其实他这个 open 是 openai 的 open 哦, 只 open 了内核模块). 并且前几个月我都还在使用 X11, 因为 NV 的驱动对 wayland 的兼容太差了, 像浏览器和 VSCode 这类内容比较多的软件, 会产生非常严重的延迟和拖尾, 用的头晕 (说起来 VSCode 不也是浏览器吗)

还有 Hyprland, 我是直接用了别人的 dotfiles, 但是那个配色, 好看是好看, 就是有点太压抑了, 用久了不是他要疯就是我要疯了. 我应该确实对颜色不是很敏感的, 但是看到那种浓郁的色彩搭配是真的觉得很压抑, 我还是喜欢清爽一点的, 最好还是深色模式.

硬盘加密, 我正在使用的 btrfs 文件系统没有内置的加密支持, 但是可以通过套娃的方式加密, 嫌麻烦没有弄.

差点忘了, So, f**k Nvida!


2025-10-23 更新: 修正 shell 脚本中的一处错误及个别错字