Move the files around

This commit is contained in:
Alexander
2026-05-13 20:34:14 +02:00
parent 90e9683076
commit 305d027c8b
113 changed files with 650 additions and 3569 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "musicfs-fuse"
version.workspace = true
edition.workspace = true
[dependencies]
musicfs-core = { path = "../musicfs-core" }
musicfs-cache = { path = "../musicfs-cache" }
musicfs-cas = { path = "../musicfs-cas" }
musicfs-search = { path = "../musicfs-search" }
fuser.workspace = true
tokio.workspace = true
tracing.workspace = true
moka.workspace = true
parking_lot.workspace = true
libc = "0.2"
[dev-dependencies]
tempfile.workspace = true
+613
View File
@@ -0,0 +1,613 @@
use crate::ops::SearchOps;
use fuser::{
FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyOpen,
Request,
};
use musicfs_cache::{VirtualNode, VirtualTree, ROOT_INODE};
use musicfs_cas::FileReader;
use musicfs_core::Result;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tokio::runtime::Handle;
use tracing::{debug, info, instrument, trace, warn};
const TTL: Duration = Duration::from_secs(1);
const BLOCK_SIZE: u32 = 512;
const SEARCH_QUERY_INODE_BASE: u64 = 0xFFFF_FFFF_0000_0100;
pub struct MusicFs {
tree: Arc<RwLock<VirtualTree>>,
reader: Option<Arc<FileReader>>,
runtime_handle: Handle,
search_ops: Option<SearchOps>,
query_inodes: RwLock<HashMap<String, u64>>,
inode_queries: RwLock<HashMap<u64, String>>,
next_query_inode: RwLock<u64>,
uid: u32,
gid: u32,
}
impl MusicFs {
pub fn new(tree: Arc<RwLock<VirtualTree>>, runtime_handle: Handle) -> Self {
Self {
tree,
reader: None,
runtime_handle,
search_ops: None,
query_inodes: RwLock::new(HashMap::new()),
inode_queries: RwLock::new(HashMap::new()),
next_query_inode: RwLock::new(SEARCH_QUERY_INODE_BASE),
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
}
}
pub fn with_reader(
tree: Arc<RwLock<VirtualTree>>,
reader: Arc<FileReader>,
runtime_handle: Handle,
) -> Self {
Self {
tree,
reader: Some(reader),
runtime_handle,
search_ops: None,
query_inodes: RwLock::new(HashMap::new()),
inode_queries: RwLock::new(HashMap::new()),
next_query_inode: RwLock::new(SEARCH_QUERY_INODE_BASE),
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
}
}
pub fn with_search(mut self, search_ops: SearchOps) -> Self {
self.search_ops = Some(search_ops);
self
}
fn get_or_create_query_inode(&self, query: &str) -> u64 {
let query_inodes = self.query_inodes.read();
if let Some(&inode) = query_inodes.get(query) {
return inode;
}
drop(query_inodes);
let mut query_inodes = self.query_inodes.write();
let mut inode_queries = self.inode_queries.write();
let mut next_inode = self.next_query_inode.write();
if let Some(&inode) = query_inodes.get(query) {
return inode;
}
let inode = *next_inode;
*next_inode += 1;
query_inodes.insert(query.to_string(), inode);
inode_queries.insert(inode, query.to_string());
inode
}
fn get_query_for_inode(&self, inode: u64) -> Option<String> {
self.inode_queries.read().get(&inode).cloned()
}
pub fn mount(self, mountpoint: &Path) -> Result<()> {
info!("Mounting MusicFS at {:?}", mountpoint);
let options = vec![
fuser::MountOption::RO,
fuser::MountOption::FSName("musicfs".to_string()),
fuser::MountOption::AutoUnmount,
fuser::MountOption::AllowOther,
];
fuser::mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?;
Ok(())
}
pub fn spawn_mount(self, mountpoint: &Path) -> Result<fuser::BackgroundSession> {
info!("Mounting MusicFS at {:?}", mountpoint);
let options = vec![
fuser::MountOption::RO,
fuser::MountOption::FSName("musicfs".to_string()),
fuser::MountOption::AutoUnmount,
fuser::MountOption::AllowOther,
];
let session =
fuser::spawn_mount2(self, mountpoint, &options).map_err(musicfs_core::Error::Io)?;
Ok(session)
}
fn node_to_attr(&self, node: &VirtualNode) -> FileAttr {
match node {
VirtualNode::Directory(dir) => FileAttr {
ino: dir.inode,
size: 0,
blocks: 0,
atime: dir.mtime,
mtime: dir.mtime,
ctime: dir.mtime,
crtime: dir.mtime,
kind: FileType::Directory,
perm: 0o755,
nlink: 2,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: BLOCK_SIZE,
flags: 0,
},
VirtualNode::File(file) => FileAttr {
ino: file.inode,
size: file.size,
blocks: (file.size + BLOCK_SIZE as u64 - 1) / BLOCK_SIZE as u64,
atime: file.mtime,
mtime: file.mtime,
ctime: file.mtime,
crtime: file.mtime,
kind: FileType::RegularFile,
perm: 0o644,
nlink: 1,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: BLOCK_SIZE,
flags: 0,
},
}
}
}
impl Filesystem for MusicFs {
fn init(
&mut self,
_req: &Request<'_>,
_config: &mut fuser::KernelConfig,
) -> std::result::Result<(), libc::c_int> {
info!("MusicFS initialized");
Ok(())
}
fn destroy(&mut self) {
info!("MusicFS destroyed");
}
#[instrument(level = "debug", skip(self, reply))]
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
let name_str = name.to_string_lossy();
if parent == ROOT_INODE && SearchOps::is_search_dir_name(&name_str) {
trace!(parent, name = %name_str, "search_dir_name matched");
if let Some(ref search_ops) = self.search_ops {
search_ops.lookup_search_dir(reply);
return;
}
}
if parent == SearchOps::search_dir_inode() {
trace!(parent, name = %name_str, "search_dir_inode matched");
if let Some(ref search_ops) = self.search_ops {
let inode = self.get_or_create_query_inode(&name_str);
search_ops.lookup_query_dir(&name_str, inode, reply);
return;
}
}
if let Some(query) = self.get_query_for_inode(parent) {
trace!(parent, name = %name_str, query = %query, "query_inode matched");
if let Some(ref search_ops) = self.search_ops {
let inode = self.get_or_create_query_inode(&format!("{}:{}", query, name_str));
search_ops.lookup_result(inode, reply);
return;
}
}
let tree = self.tree.read();
if let Some(inode) = tree.lookup(parent, name) {
trace!(parent, name = %name_str, ino = inode, "file found in tree");
if let Some(node) = tree.get(inode) {
let attr = self.node_to_attr(node);
reply.entry(&TTL, &attr, 0);
return;
}
}
trace!(parent, name = %name_str, "file not found");
reply.error(libc::ENOENT);
}
#[instrument(level = "debug", skip(self, reply))]
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
if ino == SearchOps::search_dir_inode() {
trace!(ino, "search_dir_inode matched");
if let Some(ref search_ops) = self.search_ops {
search_ops.getattr_search_dir(reply);
return;
}
}
if SearchOps::is_search_inode(ino) {
trace!(ino, "search_inode matched");
if let Some(ref search_ops) = self.search_ops {
search_ops.getattr_result(ino, reply);
return;
}
}
if self.get_query_for_inode(ino).is_some() {
trace!(ino, "query_inode matched");
if let Some(ref search_ops) = self.search_ops {
search_ops.getattr_search_dir(reply);
return;
}
}
let tree = self.tree.read();
if let Some(node) = tree.get(ino) {
trace!(ino, "inode found in tree");
let attr = self.node_to_attr(node);
reply.attr(&TTL, &attr);
} else {
trace!(ino, "inode not found");
reply.error(libc::ENOENT);
}
}
#[instrument(level = "debug", skip(self, reply))]
fn readdir(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
mut reply: ReplyDirectory,
) {
if ino == SearchOps::search_dir_inode() {
trace!(ino, offset, "search_dir_inode matched");
if let Some(ref search_ops) = self.search_ops {
search_ops.readdir_search_root(offset, reply);
return;
}
}
if let Some(query) = self.get_query_for_inode(ino) {
trace!(ino, offset, query = %query, "query_inode matched");
if let Some(ref search_ops) = self.search_ops {
search_ops.readdir_query(&query, offset, reply);
return;
}
}
let tree = self.tree.read();
if let Some(children) = tree.readdir(ino) {
trace!(
ino,
offset,
children_count = children.len(),
"directory found"
);
let parent_ino = tree.get_parent(ino).unwrap_or(ROOT_INODE);
let entries: Vec<(u64, FileType, &str)> = vec![
(ino, FileType::Directory, "."),
(parent_ino, FileType::Directory, ".."),
];
let child_entries: Vec<(u64, FileType, String)> = children
.iter()
.map(|(name, child_ino, is_dir)| {
let kind = if *is_dir {
FileType::Directory
} else {
FileType::RegularFile
};
(*child_ino, kind, name.to_string_lossy().to_string())
})
.collect();
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
if reply.add(*inode, (i + 1) as i64, *kind, name) {
reply.ok();
return;
}
}
let base_offset = entries.len();
for (i, (inode, kind, name)) in child_entries.iter().enumerate() {
let entry_offset = base_offset + i;
if entry_offset < offset as usize {
continue;
}
if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) {
break;
}
}
reply.ok();
} else {
trace!(ino, offset, "directory not found");
reply.error(libc::ENOENT);
}
}
#[instrument(level = "debug", skip(self, reply))]
fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen) {
let write_flags = libc::O_WRONLY | libc::O_RDWR | libc::O_APPEND | libc::O_TRUNC;
if flags & write_flags != 0 {
trace!(ino, flags, "write flags detected");
reply.error(libc::EROFS);
return;
}
let tree = self.tree.read();
if tree.get(ino).is_some() {
trace!(ino, "inode found");
reply.opened(0, 0);
} else {
trace!(ino, "inode not found");
reply.error(libc::ENOENT);
}
}
#[instrument(level = "debug", skip(self, reply))]
fn read(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
size: u32,
_flags: i32,
_lock_owner: Option<u64>,
reply: ReplyData,
) {
let file_id = {
let tree = self.tree.read();
if let Some(VirtualNode::File(file)) = tree.get(ino) {
trace!(ino, "file found in tree");
file.file_id
} else {
trace!(ino, "file not found");
reply.error(libc::ENOENT);
return;
}
};
let Some(reader) = &self.reader else {
trace!(ino, "no reader available");
reply.data(&[]);
return;
};
let reader = reader.clone();
let handle = self.runtime_handle.clone();
let result = std::thread::scope(|_| {
handle.block_on(async {
tokio::time::timeout(
Duration::from_secs(30),
reader.read(file_id, offset as u64, size),
)
.await
})
});
match result {
Ok(Ok(data)) => {
trace!(
ino,
offset,
size_bytes = size,
bytes_read = data.len(),
"read successful"
);
reply.data(&data);
}
Ok(Err(e)) => {
warn!(ino, offset, size_bytes = size, error = %e, "read failed");
reply.error(libc::EIO);
}
Err(_timeout) => {
warn!(ino, offset, size_bytes = size, "read timed out after 30s");
reply.error(libc::EIO);
}
}
}
#[instrument(level = "debug", skip(self, reply))]
fn release(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
_flags: i32,
_lock_owner: Option<u64>,
_flush: bool,
reply: fuser::ReplyEmpty,
) {
trace!(ino, "releasing file handle");
reply.ok();
}
fn readlink(&mut self, _req: &Request, ino: u64, reply: ReplyData) {
debug!("readlink(ino={})", ino);
if SearchOps::is_search_inode(ino) {
if let Some(ref search_ops) = self.search_ops {
search_ops.readlink(ino, reply);
return;
}
}
reply.error(libc::EINVAL);
}
fn write(
&mut self,
_req: &Request,
_ino: u64,
_fh: u64,
_offset: i64,
_data: &[u8],
_write_flags: u32,
_flags: i32,
_lock_owner: Option<u64>,
reply: fuser::ReplyWrite,
) {
reply.error(libc::EROFS);
}
fn mkdir(
&mut self,
_req: &Request,
_parent: u64,
_name: &OsStr,
_mode: u32,
_umask: u32,
reply: ReplyEntry,
) {
reply.error(libc::EROFS);
}
fn unlink(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS);
}
fn rmdir(&mut self, _req: &Request, _parent: u64, _name: &OsStr, reply: fuser::ReplyEmpty) {
reply.error(libc::EROFS);
}
fn rename(
&mut self,
_req: &Request,
_parent: u64,
_name: &OsStr,
_newparent: u64,
_newname: &OsStr,
_flags: u32,
reply: fuser::ReplyEmpty,
) {
reply.error(libc::EROFS);
}
fn create(
&mut self,
_req: &Request,
_parent: u64,
_name: &OsStr,
_mode: u32,
_umask: u32,
_flags: i32,
reply: fuser::ReplyCreate,
) {
reply.error(libc::EROFS);
}
fn setattr(
&mut self,
_req: &Request,
_ino: u64,
_mode: Option<u32>,
_uid: Option<u32>,
_gid: Option<u32>,
_size: Option<u64>,
_atime: Option<fuser::TimeOrNow>,
_mtime: Option<fuser::TimeOrNow>,
_ctime: Option<SystemTime>,
_fh: Option<u64>,
_crtime: Option<SystemTime>,
_chgtime: Option<SystemTime>,
_bkuptime: Option<SystemTime>,
_flags: Option<u32>,
reply: ReplyAttr,
) {
reply.error(libc::EROFS);
}
fn symlink(
&mut self,
_req: &Request,
_parent: u64,
_name: &OsStr,
_link: &Path,
reply: ReplyEntry,
) {
reply.error(libc::EROFS);
}
fn link(
&mut self,
_req: &Request,
_ino: u64,
_newparent: u64,
_newname: &OsStr,
reply: ReplyEntry,
) {
reply.error(libc::EROFS);
}
fn mknod(
&mut self,
_req: &Request,
_parent: u64,
_name: &OsStr,
_mode: u32,
_umask: u32,
_rdev: u32,
reply: ReplyEntry,
) {
reply.error(libc::EROFS);
}
}
#[cfg(test)]
mod tests {
use super::*;
use musicfs_cache::TreeBuilder;
use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath};
use std::path::PathBuf;
fn make_file_meta(id: i64, vpath: &str, size: u64) -> FileMeta {
FileMeta {
id: FileId(id),
virtual_path: VirtualPath::new(vpath),
real_path: RealPath {
origin_id: OriginId::from("test"),
path: PathBuf::from("/test"),
},
size,
mtime: SystemTime::now(),
content_hash: None,
audio: None,
}
}
#[test]
fn test_tree_integration() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let handle = runtime.handle().clone();
let mut builder = TreeBuilder::new();
builder.add_file(&make_file_meta(1, "/Artist/Album/Track.flac", 30_000_000));
let tree = Arc::new(RwLock::new(builder.build()));
let _fs = MusicFs::new(tree.clone(), handle);
let tree_read = tree.read();
assert!(tree_read.get(ROOT_INODE).is_some());
assert!(tree_read
.get_by_path(&VirtualPath::new("/Artist"))
.is_some());
}
}
+5
View File
@@ -0,0 +1,5 @@
mod filesystem;
pub mod ops;
pub use filesystem::MusicFs;
pub use ops::SearchOps;
+5
View File
@@ -0,0 +1,5 @@
mod prefetch;
mod search;
pub use prefetch::PrefetchOps;
pub use search::SearchOps;
+298
View File
@@ -0,0 +1,298 @@
use fuser::{FileAttr, FileType, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry};
use musicfs_cache::{PatternStore, PrefetchConfig, PrefetchEngine};
use musicfs_cas::ContentFetcher;
use musicfs_core::{EventBus, FileId};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
const PREFETCH_DIR_INODE: u64 = 0xFFFF_FFFF_0000_0002;
const PREFETCH_STATUS_INODE: u64 = 0xFFFF_FFFF_0000_0003;
const PREFETCH_HINTS_BASE: u64 = 0xFFFF_FFFF_2000_0000;
pub struct PrefetchOps {
pattern_store: Arc<PatternStore>,
engine: Option<Arc<PrefetchEngine>>,
uid: u32,
gid: u32,
}
impl PrefetchOps {
pub fn new(pattern_store: Arc<PatternStore>, uid: u32, gid: u32) -> Self {
Self {
pattern_store,
engine: None,
uid,
gid,
}
}
pub fn with_engine(
pattern_store: Arc<PatternStore>,
fetcher: Arc<ContentFetcher>,
config: PrefetchConfig,
uid: u32,
gid: u32,
) -> Self {
let engine = Arc::new(PrefetchEngine::new(config, pattern_store.clone(), fetcher));
Self {
pattern_store,
engine: Some(engine),
uid,
gid,
}
}
pub fn start_engine(&self, event_bus: Arc<EventBus>) -> Option<musicfs_cache::PrefetchHandle> {
self.engine
.as_ref()
.map(|e| e.clone().start(event_bus, self.pattern_store.clone()))
}
pub fn is_prefetch_dir_name(name: &str) -> bool {
name == ".prefetch"
}
pub fn is_prefetch_inode(inode: u64) -> bool {
inode == PREFETCH_DIR_INODE
|| inode == PREFETCH_STATUS_INODE
|| inode >= PREFETCH_HINTS_BASE
}
pub fn prefetch_dir_inode() -> u64 {
PREFETCH_DIR_INODE
}
pub fn lookup_prefetch_dir(&self, reply: ReplyEntry) {
let attr = self.dir_attr(PREFETCH_DIR_INODE);
reply.entry(&Duration::from_secs(60), &attr, 0);
}
pub fn lookup_status(&self, reply: ReplyEntry) {
let status = self.generate_status();
let attr = self.file_attr(PREFETCH_STATUS_INODE, status.len() as u64);
reply.entry(&Duration::from_secs(1), &attr, 0);
}
pub fn lookup_hint(&self, name: &str, reply: ReplyEntry) {
if let Some(inode) = self.hint_name_to_inode(name) {
let attr = self.file_attr(inode, 256);
reply.entry(&Duration::from_secs(1), &attr, 0);
} else {
reply.error(libc::ENOENT);
}
}
pub fn getattr_prefetch_dir(&self, reply: ReplyAttr) {
let attr = self.dir_attr(PREFETCH_DIR_INODE);
reply.attr(&Duration::from_secs(60), &attr);
}
pub fn getattr_status(&self, reply: ReplyAttr) {
let status = self.generate_status();
let attr = self.file_attr(PREFETCH_STATUS_INODE, status.len() as u64);
reply.attr(&Duration::from_secs(1), &attr);
}
pub fn getattr_hint(&self, inode: u64, reply: ReplyAttr) {
let attr = self.file_attr(inode, 256);
reply.attr(&Duration::from_secs(1), &attr);
}
pub fn readdir_prefetch_root(&self, offset: i64, mut reply: ReplyDirectory) {
let entries: Vec<(u64, FileType, &str)> = vec![
(PREFETCH_DIR_INODE, FileType::Directory, "."),
(1, FileType::Directory, ".."),
(PREFETCH_STATUS_INODE, FileType::RegularFile, "status"),
];
let recently_played = self.pattern_store.recently_played(7).unwrap_or_default();
let predictions: Vec<(u64, FileType, String)> = recently_played
.iter()
.take(10)
.enumerate()
.map(|(i, file_id)| {
let inode = PREFETCH_HINTS_BASE + i as u64;
let name = format!("hint_{:04}", file_id.0);
(inode, FileType::RegularFile, name)
})
.collect();
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
if reply.add(*inode, (i + 1) as i64, *kind, *name) {
reply.ok();
return;
}
}
let base_offset = entries.len();
for (i, (inode, kind, name)) in predictions.iter().enumerate() {
let entry_offset = base_offset + i;
if entry_offset < offset as usize {
continue;
}
if reply.add(*inode, (entry_offset + 1) as i64, *kind, name) {
break;
}
}
reply.ok();
}
pub fn read_status(&self, offset: i64, size: u32, reply: ReplyData) {
let status = self.generate_status();
let start = offset as usize;
let end = std::cmp::min(start + size as usize, status.len());
if start >= status.len() {
reply.data(&[]);
} else {
reply.data(&status.as_bytes()[start..end]);
}
}
pub fn read_hint(&self, inode: u64, offset: i64, size: u32, reply: ReplyData) {
let file_id = self.inode_to_file_id(inode);
let predictions = self.pattern_store.predict_next(file_id, 5);
let content = predictions
.iter()
.map(|id| format!("{}", id.0))
.collect::<Vec<_>>()
.join("\n");
let start = offset as usize;
let end = std::cmp::min(start + size as usize, content.len());
if start >= content.len() {
reply.data(&[]);
} else {
reply.data(&content.as_bytes()[start..end]);
}
}
fn generate_status(&self) -> String {
let engine_status = if let Some(engine) = &self.engine {
format!(
"running: {}\nin_flight: {}",
engine.is_running(),
engine.in_flight_count()
)
} else {
"engine: disabled".to_string()
};
let most_played = self
.pattern_store
.most_played(5)
.unwrap_or_default()
.iter()
.map(|id| format!("{}", id.0))
.collect::<Vec<_>>()
.join(", ");
format!(
"MusicFS Prefetch Status\n\
=======================\n\
{}\n\
most_played: [{}]\n",
engine_status, most_played
)
}
fn hint_name_to_inode(&self, name: &str) -> Option<u64> {
if name.starts_with("hint_") {
let id_str = name.strip_prefix("hint_")?;
let id: i64 = id_str.parse().ok()?;
Some(PREFETCH_HINTS_BASE + id as u64)
} else {
None
}
}
fn inode_to_file_id(&self, inode: u64) -> FileId {
FileId((inode - PREFETCH_HINTS_BASE) as i64)
}
fn dir_attr(&self, inode: u64) -> FileAttr {
FileAttr {
ino: inode,
size: 0,
blocks: 0,
atime: SystemTime::UNIX_EPOCH,
mtime: SystemTime::UNIX_EPOCH,
ctime: SystemTime::UNIX_EPOCH,
crtime: SystemTime::UNIX_EPOCH,
kind: FileType::Directory,
perm: 0o555,
nlink: 2,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
fn file_attr(&self, inode: u64, size: u64) -> FileAttr {
FileAttr {
ino: inode,
size,
blocks: (size + 511) / 512,
atime: SystemTime::UNIX_EPOCH,
mtime: SystemTime::UNIX_EPOCH,
ctime: SystemTime::UNIX_EPOCH,
crtime: SystemTime::UNIX_EPOCH,
kind: FileType::RegularFile,
perm: 0o444,
nlink: 1,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_prefetch_ops_new() {
let dir = TempDir::new().unwrap();
let pattern_store =
Arc::new(PatternStore::new(&dir.path().join("patterns.db"), 30).unwrap());
let _ops = PrefetchOps::new(pattern_store, 1000, 1000);
}
#[test]
fn test_is_prefetch_inode() {
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_DIR_INODE));
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_STATUS_INODE));
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_HINTS_BASE));
assert!(PrefetchOps::is_prefetch_inode(PREFETCH_HINTS_BASE + 100));
assert!(!PrefetchOps::is_prefetch_inode(1));
assert!(!PrefetchOps::is_prefetch_inode(1000));
}
#[test]
fn test_hint_name_to_inode() {
let dir = TempDir::new().unwrap();
let pattern_store =
Arc::new(PatternStore::new(&dir.path().join("patterns.db"), 30).unwrap());
let ops = PrefetchOps::new(pattern_store, 1000, 1000);
assert_eq!(
ops.hint_name_to_inode("hint_0001"),
Some(PREFETCH_HINTS_BASE + 1)
);
assert_eq!(
ops.hint_name_to_inode("hint_9999"),
Some(PREFETCH_HINTS_BASE + 9999)
);
assert_eq!(ops.hint_name_to_inode("invalid"), None);
}
}
+273
View File
@@ -0,0 +1,273 @@
use fuser::{FileAttr, FileType, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry};
use moka::sync::Cache;
use musicfs_search::{SearchHit, SearchIndex};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
const SEARCH_DIR_INODE: u64 = 0xFFFF_FFFF_0000_0001;
const SEARCH_RESULT_BASE: u64 = 0xFFFF_FFFF_1000_0000;
const RESULT_CACHE_MAX_ENTRIES: u64 = 1000;
const RESULT_CACHE_TTL_SECS: u64 = 300;
const INODE_CACHE_MAX_ENTRIES: u64 = 10000;
const MAX_QUERY_LENGTH: usize = 256;
pub struct SearchOps {
index: Arc<SearchIndex>,
result_cache: Cache<String, Vec<SearchHit>>,
inode_to_result: Cache<u64, (String, usize)>,
mount_point: String,
uid: u32,
gid: u32,
}
impl SearchOps {
pub fn new(index: Arc<SearchIndex>, mount_point: &str, uid: u32, gid: u32) -> Self {
let result_cache = Cache::builder()
.max_capacity(RESULT_CACHE_MAX_ENTRIES)
.time_to_live(Duration::from_secs(RESULT_CACHE_TTL_SECS))
.build();
let inode_to_result = Cache::builder()
.max_capacity(INODE_CACHE_MAX_ENTRIES)
.time_to_live(Duration::from_secs(RESULT_CACHE_TTL_SECS))
.build();
Self {
index,
result_cache,
inode_to_result,
mount_point: mount_point.to_string(),
uid,
gid,
}
}
pub fn is_search_dir_name(name: &str) -> bool {
name == ".search"
}
pub fn is_search_inode(inode: u64) -> bool {
inode == SEARCH_DIR_INODE || inode >= SEARCH_RESULT_BASE
}
pub fn search_dir_inode() -> u64 {
SEARCH_DIR_INODE
}
pub fn lookup_search_dir(&self, reply: ReplyEntry) {
let attr = self.dir_attr(SEARCH_DIR_INODE);
reply.entry(&Duration::from_secs(60), &attr, 0);
}
pub fn lookup_query_dir(&self, query: &str, inode: u64, reply: ReplyEntry) {
let results = self.execute_query(query);
if results.is_empty() {
reply.error(libc::ENOENT);
return;
}
let attr = self.dir_attr(inode);
reply.entry(&Duration::from_secs(1), &attr, 0);
}
pub fn lookup_result(&self, inode: u64, reply: ReplyEntry) {
if self.inode_to_result.contains_key(&inode) {
let attr = self.symlink_attr(inode, 256);
reply.entry(&Duration::from_secs(1), &attr, 0);
} else {
reply.error(libc::ENOENT);
}
}
pub fn getattr_search_dir(&self, reply: ReplyAttr) {
let attr = self.dir_attr(SEARCH_DIR_INODE);
reply.attr(&Duration::from_secs(60), &attr);
}
pub fn getattr_result(&self, inode: u64, reply: ReplyAttr) {
let attr = self.symlink_attr(inode, 256);
reply.attr(&Duration::from_secs(1), &attr);
}
pub fn readdir_search_root(&self, offset: i64, mut reply: ReplyDirectory) {
let entries = vec![
(SEARCH_DIR_INODE, FileType::Directory, "."),
(1, FileType::Directory, ".."),
];
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
if reply.add(*inode, (i + 1) as i64, *kind, name) {
break;
}
}
reply.ok();
}
pub fn readdir_query(&self, query: &str, offset: i64, mut reply: ReplyDirectory) {
let results = self.execute_query(query);
let entries = vec![
(SEARCH_DIR_INODE + 1, FileType::Directory, ".".to_string()),
(SEARCH_DIR_INODE, FileType::Directory, "..".to_string()),
];
for (i, (inode, kind, name)) in entries.iter().enumerate().skip(offset as usize) {
if reply.add(*inode, (i + 1) as i64, *kind, name) {
reply.ok();
return;
}
}
let base_offset = entries.len();
for (i, hit) in results.iter().enumerate() {
let entry_offset = base_offset + i;
if entry_offset < offset as usize {
continue;
}
let inode = SEARCH_RESULT_BASE + i as u64;
let name = self.result_filename(hit, i);
self.inode_to_result.insert(inode, (query.to_string(), i));
if reply.add(inode, (entry_offset + 1) as i64, FileType::Symlink, &name) {
break;
}
}
reply.ok();
}
pub fn readlink(&self, inode: u64, reply: ReplyData) {
let (query, index) = match self.inode_to_result.get(&inode) {
Some((q, i)) => (q, i),
None => {
reply.error(libc::ENOENT);
return;
}
};
let results = self.execute_query(&query);
if let Some(hit) = results.get(index) {
if let Some(target) = self.safe_symlink_target(hit.virtual_path.as_str()) {
reply.data(target.as_bytes());
} else {
reply.error(libc::EINVAL);
}
} else {
reply.error(libc::ENOENT);
}
}
fn safe_symlink_target(&self, virtual_path: &str) -> Option<String> {
let normalized = Path::new(virtual_path).components().fold(
std::path::PathBuf::new(),
|mut acc, comp| {
match comp {
std::path::Component::Normal(s) => acc.push(s),
std::path::Component::RootDir => acc.push("/"),
_ => {}
}
acc
},
);
let path_str = normalized.to_string_lossy();
if path_str.contains("..") {
return None;
}
Some(format!("{}{}", self.mount_point, path_str))
}
fn execute_query(&self, query: &str) -> Vec<SearchHit> {
let query = if query.len() > MAX_QUERY_LENGTH {
&query[..MAX_QUERY_LENGTH]
} else {
query
};
if let Some(results) = self.result_cache.get(query) {
return results;
}
let results = self.index.search(query, 1000).unwrap_or_default();
self.result_cache.insert(query.to_string(), results.clone());
results
}
fn result_filename(&self, hit: &SearchHit, index: usize) -> String {
let artist = hit.artist.as_deref().unwrap_or("Unknown");
let title = hit.title.as_deref().unwrap_or("Unknown");
let ext = hit
.virtual_path
.as_str()
.rsplit('.')
.next()
.unwrap_or("flac");
format!("{:03}. {} - {}.{}", index + 1, artist, title, ext)
}
fn dir_attr(&self, inode: u64) -> FileAttr {
FileAttr {
ino: inode,
size: 0,
blocks: 0,
atime: SystemTime::UNIX_EPOCH,
mtime: SystemTime::UNIX_EPOCH,
ctime: SystemTime::UNIX_EPOCH,
crtime: SystemTime::UNIX_EPOCH,
kind: FileType::Directory,
perm: 0o555,
nlink: 2,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
fn symlink_attr(&self, inode: u64, target_len: u64) -> FileAttr {
FileAttr {
ino: inode,
size: target_len,
blocks: 0,
atime: SystemTime::UNIX_EPOCH,
mtime: SystemTime::UNIX_EPOCH,
ctime: SystemTime::UNIX_EPOCH,
crtime: SystemTime::UNIX_EPOCH,
kind: FileType::Symlink,
perm: 0o777,
nlink: 1,
uid: self.uid,
gid: self.gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use musicfs_search::SearchIndex;
use tempfile::TempDir;
#[test]
fn test_search_ops_new() {
let dir = TempDir::new().unwrap();
let index = Arc::new(SearchIndex::open(dir.path()).unwrap());
let _ops = SearchOps::new(index, "/mnt/music", 1000, 1000);
}
#[test]
fn test_is_search_inode() {
assert!(SearchOps::is_search_inode(SEARCH_DIR_INODE));
assert!(SearchOps::is_search_inode(SEARCH_RESULT_BASE));
assert!(SearchOps::is_search_inode(SEARCH_RESULT_BASE + 100));
assert!(!SearchOps::is_search_inode(1));
assert!(!SearchOps::is_search_inode(1000));
}
}