要在操作系统层实现像访问文件夹一样方便地访问压缩包,我们可以通过虚拟文件系统技术(如 FUSE),将压缩包挂载为目录,直接读写。
什么是FUSE?
FUSE 是一种允许普通用户或开发者在 用户空间 (而非操作系统内核)中创建自定义文件系统的框架。它通过将文件系统的操作(如读、写、打开文件等)委托给用户态的程序来实现,而无需修改操作系统内核代码。
对于标题上面的需求,我们只需要利用 fuse 实现 zip 文件的挂载为目录,就可以实现了。
大象放冰箱的三步
按 大象放冰箱三步原则,我们可以得到:
- 获取压缩包的内容信息
- 挂载目录
- 像目录一样访问
准备
这里一切以 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 协议进行挂载。