Apr 15, 2025
4 min read
Rust,

如何实现访问一个压缩包好像访问文件夹一样方便?

本文介绍了如何使用 fuse 来实现访问一个压缩包

要在操作系统层实现像访问文件夹一样方便地访问压缩包,我们可以通过虚拟文件系统技术(如 FUSE),将压缩包挂载为目录,直接读写。

什么是FUSE?

FUSE 是一种允许普通用户或开发者在 用户空间 (而非操作系统内核)中创建自定义文件系统的框架。它通过将文件系统的操作(如读、写、打开文件等)委托给用户态的程序来实现,而无需修改操作系统内核代码。

对于标题上面的需求,我们只需要利用 fuse 实现 zip 文件的挂载为目录,就可以实现了。

大象放冰箱的三步

大象放冰箱三步原则,我们可以得到:

  1. 获取压缩包的内容信息
  2. 挂载目录
  3. 像目录一样访问

准备

这里一切以 linux 作为实验环境,windows 没有 fuse,macos 可以试试 macfuse,但我没测试过。

首先是 fuse 在 大部分 linux 发行版都是可用的(过早的内核不可用),以 ubuntu 为例子:

sudo apt-get install fuse3 libfuse3-dev

在 Rust 中实现

我们先创建一个项目:

cargo new zipfs

添加关于 zip、fuse 相关的依赖:

cargo add fuser zip libc anyhow

理论上,只要实现 fuser 的 Filesystem 接口,程序就实现了一个文件系统。为了简化,我们要求不多,只要求实现一个基于 zip 只读的文件系统。

万事先定义一个结构体:

pub struct ZipFS {
    attrs: HashMap<u64, FileAttr>,
    contents: HashMap<String, (usize, Vec<u8>)>,
}

attrs 记录文件的一些属性。contents 记录文件名、编号、文件内容,主要用来实现 cd,ls,cat 这几个命令。contents 内容从压缩包里取,因此我们需要在实例化上获取 zip 包的数据:

impl ZipFS {
    pub fn new<P: AsRef<Path>>(zip_path: P) -> anyhow::Result<Self> {
        let file = File::open(zip_path)?;
        let mut archive = ZipArchive::new(file)?;
        let mut attrs = HashMap::new();
        let mut contents = HashMap::new();

        for i in 0..archive.len() {
            let mut entry = archive.by_index(i)?;

            let path = entry.name().to_string();
            let mut cur = Cursor::new(vec![]);
            std::io::copy(&mut entry, &mut cur)?;
            contents.insert(path, (i + 2, cur.into_inner()));
            attrs.insert(
                i as u64 + 2,
                FileAttr {
                    ino: i as u64 + 2,
                    size: entry.size(),
                    ..FILE_ATTR
                },
            );
        }
        dbg!(&attrs);
        // let attrs = HashMap::new();
        // let contents = HashMap::new();
        Ok(Self { attrs, contents })
    }
}

接下来就是实现Filesystem 这个 trait 了。Filesystem 足足有 43 有个方法!但是别慌,由于我们只实现一个只读的文件系统,因此并不需要把所有方法都实现了,我们只需要实现lookup,getattr,read,readdir 这四个方法即可。

lookup

lookup 的功能是根据父节点 ID 和文件名查找文件系统中的条目,并通过 reply 返回结果。

fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
        let name = name.to_str().unwrap();
        println!("[DEBUG] lookup: parent={}, name={}", parent, name,); // 添加日志
        let ino = self
            .contents
            .iter()
            .find(|(x, _)| **x == name)
            .map(|(_, y)| y.0);
        let Some(ino) = ino else {
            reply.error(ENOENT);
            return;
        };

        let attr = self.attrs.iter().find(|x| *x.0 == (ino as u64));
        if let Some((_, attr)) = attr {
            reply.entry(&TTL, attr, 0);
        } else {
            reply.error(ENOENT);
        }
    }

getattr

getattr用于处理文件系统中的属性获取请求。

fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
        // println!("[DEBUG] getattr: ino={}", ino);
        match ino {
            1 => reply.attr(&TTL, &DIR_ATTR),
            2.. => reply.attr(&TTL, &FILE_ATTR),
            _ => reply.error(ENOENT),
        }
    }

read

read 主要用于读文件,它会 根据文件节点编号 ino 返回文件内容或错误信息。

fn read(
        &mut self,
        _req: &Request,
        ino: u64,
        _fh: u64,
        offset: i64,
        _size: u32,
        _flags: i32,
        _lock: Option<u64>,
        reply: ReplyData,
    ) {
        println!("[DEBUG] read: ino={}", ino);

        let content = self
            .contents
            .iter()
            .find(|(k, v)| v.0 == ino as usize)
            .map(|(_, v)| v.1.clone());

        if let Some(content) = content {
            reply.data(&content[offset as usize..]);
        } else {
            reply.error(ENOENT);
        }
    }

readdir

readdir 主要用于读取目录内容,

fn readdir(
        &mut self,
        _req: &Request,
        ino: u64,
        _fh: u64,
        offset: i64,
        mut reply: ReplyDirectory,
    ) {
        if ino != 1 {
            reply.error(ENOENT);
            return;
        }

        let mut entries = vec![
            (1, FileType::Directory, "."),
            (1, FileType::Directory, ".."),
        ];

        for (filename, entry) in self.contents.iter() {
            entries.push((entry.0, FileType::RegularFile, filename));
        }

        for (i, entry) in entries.into_iter().enumerate().skip(offset as usize) {
            // i + 1 means the index of the next entry
            if reply.add(entry.0 as u64, (i + 1) as i64, entry.1, entry.2) {
                break;
            }
        }
        reply.ok();
    }

挂载与调用

剩下的实情就简单了,我们先手动创建一个 zip 包来做实验:

echo 'test1' > test1.txt
echo 'i love rust' > test2.txt

zip test.zip test1.txt test2.txt

在主函数中挂载:

fn main() {
    let mountpoint = "/tmp/zip";
    let fs = ZipFS::new("./test.zip").unwrap();
    fuser::mount2(
        fs,
        mountpoint,
        &[
            MountOption::RO,
            MountOption::FSName("zipfs".to_string()),
            MountOption::AutoUnmount,
            MountOption::AllowRoot,
        ],
    )
    .unwrap();
}

需要注意的是,如果你不想手动取消挂载这种麻烦的事,最好加上MountOption::AutoUnmount。 打开终端,运行 cargo run,然后另外再打开一个终端:

cd /tmp/zip
ls
cat test2.txt

如果你看到i love rust 的内容,说明成功了。

何必这么麻烦?

事实上,这世界上不止一个人有这样的想法,真正的轮子,已经有人造过了。它就是 archivemount 在 ubuntu 上,我们只需要安装:

sudo apt install archivemount

然后直接对压缩包挂载:

archivemount test.zip /tmp/zip

# 直接访问 /tmp/zip
cd /tmp/zip && ls
(base) ➜  zip ls
test1.txt  test2.txt

fuse 有什么用?

事实上,很多云网盘客户端,或者一些对象存储客户端(oss、aws) 就是使用 fuse 把网络上的文件挂载到本地的。只不过它们不是对压缩包挂载,而是直接对 http 协议进行挂载。