From d1d49731eabf34e66e4e7537fa464c3ebbdfee6d Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Thu, 5 Jun 2025 23:24:26 +0100 Subject: [PATCH] Add fuser implementation Signed-off-by: Nikolaos Karaolidis --- Cargo.lock | 2 + Cargo.toml | 2 + flake.nix | 21 ++ src/config.rs | 7 +- src/fuser.rs | 785 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 15 +- support/Containerfile | 2 +- 7 files changed, 820 insertions(+), 14 deletions(-) create mode 100644 src/fuser.rs diff --git a/Cargo.lock b/Cargo.lock index c8aa429..821e902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1250,10 +1250,12 @@ dependencies = [ "axum-extra", "clap", "fuser", + "libc", "log", "log4rs", "non-empty-string", "openidconnect", + "parking_lot", "passwords", "redis 0.31.0", "redis-macros", diff --git a/Cargo.toml b/Cargo.toml index c24b964..3ae2e2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,10 +21,12 @@ axum = { version = "0.8.4", features = ["macros"] } axum-extra = { version = "0.10.1", features = ["typed-header"] } clap = { version = "4.5.39", features = ["derive"] } fuser = "0.15.1" +libc = "0.2.172" log = "0.4.27" log4rs = "1.3.0" non-empty-string = { version = "0.2.6", features = ["serde"] } openidconnect = { version = "4.0.0", features = ["reqwest"] } +parking_lot = "0.12.4" passwords = "3.1.16" redis = { version = "0.31.0", features = ["tokio-comp"] } redis-macros = "0.5.4" diff --git a/flake.nix b/flake.nix index 24dfb2e..32a27ab 100755 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,25 @@ treefmt = inputs.treefmt-nix.lib.evalModule pkgs ./treefmt.nix; in { + packages.default = pkgs.rustPlatform.buildRustPackage { + pname = "glyph"; + version = "0.1.0"; + src = ./.; + cargoLock = { + lockFile = ./Cargo.lock; + }; + + SQLX_OFFLINE = true; + + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + buildInputs = with pkgs; [ + fuse3 + ]; + }; + devShells.default = pkgs.mkShell { packages = with pkgs; [ cargo @@ -42,6 +61,8 @@ cargo-udeps cargo-outdated sqlx-cli + fuse3 + pkg-config ]; }; diff --git a/src/config.rs b/src/config.rs index 7e961d2..acc1096 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,8 +37,9 @@ pub struct OAuthConfig { } #[derive(Clone, Deserialize)] -pub struct AutheliaConfig { - pub user_database: PathBuf, +pub struct FuseConfig { + pub mount_directory: PathBuf, + pub user_database_name: String, } #[derive(Clone, Deserialize)] @@ -62,7 +63,7 @@ pub struct RedisConfig { pub struct Config { pub server: ServerConfig, pub oauth: OAuthConfig, - pub authelia: AutheliaConfig, + pub fuse: FuseConfig, pub postgresql: PostgresqlConfig, pub redis: RedisConfig, } diff --git a/src/fuser.rs b/src/fuser.rs new file mode 100644 index 0000000..09f348d --- /dev/null +++ b/src/fuser.rs @@ -0,0 +1,785 @@ +#![warn(clippy::all, clippy::pedantic, clippy::nursery)] +#![allow(clippy::missing_docs_in_private_items)] + +use std::{ + cmp, + collections::HashMap, + ffi::CString, + mem::MaybeUninit, + ops::Deref, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use fuser::Filesystem; +use libc::{ + EACCES, EINVAL, EISDIR, ENOENT, ENOSYS, EPERM, O_ACCMODE, O_APPEND, O_RDONLY, O_TRUNC, + O_WRONLY, R_OK, W_OK, X_OK, gid_t, uid_t, +}; +use parking_lot::{RwLock, RwLockWriteGuard}; + +use crate::config::FuseConfig; + +struct Handle { + ino: u64, + flags: i32, + cursor: i64, +} + +struct VariableFileState { + contents: String, + atime: SystemTime, + mtime: SystemTime, +} + +struct StaticFileState { + crtime: SystemTime, + uid: u32, + gid: u32, + blksize: u32, +} + +struct Handles { + handles: HashMap, + next_handle: u64, +} + +impl Handles { + const fn next_handle(&mut self) -> u64 { + let handle = self.next_handle; + self.next_handle += 1; + handle + } +} + +type WriteCallback = Box; + +pub struct AutheliaFS { + config: FuseConfig, + write_callback: Option, + variable_file_state: Arc>, + static_file_state: Arc, + handles: Arc>, +} + +const TTL: Duration = Duration::from_secs(1); +const ROOT_INODE: u64 = 1; +const PARENT_INODE: u64 = 2; +const FILE_INODE: u64 = 3; + +const ROOT_MODE: u16 = 0o550; // dr-xr-x--- +const FILE_MODE: u16 = 0o640; // -rw-r----- + +pub fn stat(path: &str) -> std::io::Result { + let c_path = CString::new(path).unwrap(); + let mut stat_buf = MaybeUninit::::uninit(); + let ret = unsafe { libc::stat(c_path.as_ptr(), stat_buf.as_mut_ptr()) }; + + if ret == 0 { + Ok(unsafe { stat_buf.assume_init() }) + } else { + Err(std::io::Error::last_os_error()) + } +} + +pub fn getuid() -> uid_t { + unsafe { libc::getuid() } +} + +pub fn getgid() -> gid_t { + unsafe { libc::getgid() } +} + +impl AutheliaFS { + pub fn new(config: FuseConfig, write_callback: Option) -> Self { + let contents = String::new(); + let time = SystemTime::now(); + let stat = stat(config.mount_directory.to_str().unwrap()).unwrap(); + let uid = getuid(); + let gid = getgid(); + + let variable_file_state = Arc::new(RwLock::new(VariableFileState { + contents, + atime: time, + mtime: time, + })); + + let static_file_state = Arc::new(StaticFileState { + crtime: time, + uid, + gid, + blksize: u32::try_from(stat.st_blksize).unwrap_or(4096), + }); + + let handles = Arc::new(RwLock::new(Handles { + handles: HashMap::new(), + next_handle: 1, + })); + + Self { + config, + write_callback, + variable_file_state, + static_file_state, + handles, + } + } + + pub fn mount(self) -> std::io::Result<()> { + let options = vec![]; + let mountpoint = self.config.mount_directory.clone(); + fuser::mount2(self, mountpoint, &options) + } + + fn get_root_attr(&self) -> fuser::FileAttr { + fuser::FileAttr { + ino: ROOT_INODE, + size: self.static_file_state.blksize.into(), + blocks: 1, + atime: self.static_file_state.crtime, + mtime: self.static_file_state.crtime, + ctime: self.static_file_state.crtime, + crtime: self.static_file_state.crtime, + kind: fuser::FileType::Directory, + perm: ROOT_MODE, + nlink: 2, + uid: self.static_file_state.uid, + gid: self.static_file_state.gid, + rdev: 0, + blksize: self.static_file_state.blksize, + flags: 0, + } + } + + fn get_file_attr(&self, lock: &T) -> fuser::FileAttr + where + T: Deref, + { + let contents = lock.contents.as_bytes(); + + let contents_len = contents.len() as u64; + let blocks = contents_len.div_ceil(u64::from(self.static_file_state.blksize)); + + fuser::FileAttr { + ino: FILE_INODE, + size: contents_len, + blocks, + atime: lock.atime, + mtime: lock.mtime, + ctime: self.static_file_state.crtime, + crtime: self.static_file_state.crtime, + kind: fuser::FileType::RegularFile, + perm: FILE_MODE, + nlink: 1, + uid: self.static_file_state.uid, + gid: self.static_file_state.gid, + rdev: 0, + blksize: self.static_file_state.blksize, + flags: 0, + } + } +} + +impl Filesystem for AutheliaFS { + fn lookup( + &mut self, + _req: &fuser::Request<'_>, + parent: u64, + name: &std::ffi::OsStr, + reply: fuser::ReplyEntry, + ) { + if parent != ROOT_INODE || *name != *self.config.user_database_name { + reply.error(ENOENT); + return; + } + + let attr = self.get_file_attr(&self.variable_file_state.read()); + reply.entry(&TTL, &attr, 0); + } + + fn getattr( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + _fh: Option, + reply: fuser::ReplyAttr, + ) { + if ino != ROOT_INODE && ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + let attr = if ino == ROOT_INODE { + self.get_root_attr() + } else { + let lock = self.variable_file_state.read(); + self.get_file_attr(&lock) + }; + + reply.attr(&TTL, &attr); + } + + #[allow(clippy::similar_names)] + fn setattr( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + mode: Option, + uid: Option, + gid: Option, + size: Option, + atime: Option, + mtime: Option, + ctime: Option, + fh: Option, + crtime: Option, + _chgtime: Option, + bkuptime: Option, + _flags: Option, + reply: fuser::ReplyAttr, + ) { + if let Some(fh) = fh { + let handles = self.handles.read(); + if !handles.handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + } + + if ino == ROOT_INODE { + reply.error(EPERM); + return; + } + + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + if mode.is_some() || uid.is_some() || gid.is_some() { + reply.error(EPERM); + return; + } + + if ctime.is_some() || crtime.is_some() || bkuptime.is_some() { + reply.error(ENOSYS); + return; + } + + if let Some(size) = size { + if size == 0 { + let mut variable_file_state = self.variable_file_state.write(); + variable_file_state.contents.clear(); + } else { + reply.error(ENOENT); + return; + } + } + + if mtime.is_some() || atime.is_some() { + let mut variable_file_state = self.variable_file_state.write(); + + variable_file_state.mtime = match mtime { + Some(fuser::TimeOrNow::Now) => SystemTime::now(), + Some(fuser::TimeOrNow::SpecificTime(time)) => time, + None => variable_file_state.mtime, + }; + + variable_file_state.atime = match atime { + Some(fuser::TimeOrNow::Now) => SystemTime::now(), + Some(fuser::TimeOrNow::SpecificTime(time)) => time, + None => variable_file_state.atime, + }; + } + + let attr = self.get_file_attr(&self.variable_file_state.read()); + + reply.attr(&TTL, &attr); + } + + fn open(&mut self, _req: &fuser::Request<'_>, ino: u64, flags: i32, reply: fuser::ReplyOpen) { + if ino == ROOT_INODE { + reply.error(EISDIR); + return; + } + + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + let mut handles = self.handles.write(); + let handle = handles.next_handle(); + + handles.handles.insert( + handle, + Handle { + ino, + flags, + cursor: 0, + }, + ); + + drop(handles); + + if flags & O_TRUNC != 0 && flags & O_ACCMODE != O_RDONLY { + let mut variable_file_state = self.variable_file_state.write(); + variable_file_state.contents.clear(); + } + + reply.opened(handle, 0); + } + + fn read( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: fuser::ReplyData, + ) { + if ino == ROOT_INODE { + reply.error(EISDIR); + return; + } + + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + let handles = self.handles.read(); + if !handles.handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + let flags = handles.handles.get(&fh).unwrap().flags; + drop(handles); + + if flags & O_ACCMODE == O_WRONLY { + reply.error(EPERM); + return; + } + + #[allow(clippy::significant_drop_tightening)] + let mut variable_file_state = self.variable_file_state.write(); + variable_file_state.atime = SystemTime::now(); + + #[allow(clippy::significant_drop_tightening)] + let variable_file_state = RwLockWriteGuard::downgrade(variable_file_state); + let contents = variable_file_state.contents.as_bytes(); + + let contents_len = i64::try_from(contents.len()).unwrap(); + + if offset < 0 || offset >= contents_len { + reply.data(&[]); + return; + } + + let end = (offset + i64::from(size)).min(contents_len); + let data = &contents[usize::try_from(offset).unwrap()..usize::try_from(end).unwrap()]; + + reply.data(data); + } + + fn write( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + offset: i64, + data: &[u8], + _write_flags: u32, + _flags: i32, + _lock_owner: Option, + reply: fuser::ReplyWrite, + ) { + if ino == ROOT_INODE { + reply.error(EISDIR); + return; + } + + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + let mut handles = self.handles.write(); + + let handle = handles.handles.get_mut(&fh); + if handle.is_none() { + reply.error(ENOENT); + return; + } + + let handle = handle.unwrap(); + if handle.flags & O_ACCMODE == O_RDONLY { + reply.error(EPERM); + return; + } + + let mut variable_file_state = self.variable_file_state.write(); + + let old_end = variable_file_state.contents.len(); + + let offset = if handle.flags & O_APPEND != 0 { + handle.cursor = i64::try_from(old_end).unwrap(); + old_end + } else { + if offset < 0 || offset > i64::try_from(old_end).unwrap() { + reply.error(EINVAL); + return; + } + usize::try_from(offset).unwrap() + }; + + variable_file_state.atime = SystemTime::now(); + variable_file_state.mtime = SystemTime::now(); + + let Ok(new_data) = std::str::from_utf8(data) else { + reply.error(EINVAL); + return; + }; + + let new_end = offset + new_data.len(); + let new_real_end = cmp::max(new_end, old_end); + + let mut new_contents = String::with_capacity(new_real_end); + new_contents.push_str(&variable_file_state.contents[..offset]); + new_contents.push_str(new_data); + if new_end < old_end { + new_contents.push_str(&variable_file_state.contents[new_end..]); + } + variable_file_state.contents = new_contents; + + handle.cursor = i64::try_from(offset + new_data.len()).unwrap(); + drop(handles); + + if let Some(callback) = &self.write_callback { + callback(&variable_file_state.contents); + } + + drop(variable_file_state); + + reply.written(u32::try_from(data.len()).unwrap()); + } + + fn flush( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + _lock_owner: u64, + reply: fuser::ReplyEmpty, + ) { + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + if !self.handles.read().handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + + reply.ok(); + } + + fn release( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + _flags: i32, + _lock_owner: Option, + _flush: bool, + reply: fuser::ReplyEmpty, + ) { + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + let handle = self.handles.write().handles.remove(&fh); + if handle.is_none() { + reply.error(ENOENT); + return; + } + + reply.ok(); + } + + fn fsync( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + _datasync: bool, + reply: fuser::ReplyEmpty, + ) { + if !self.handles.read().handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + + if ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + reply.ok(); + } + + fn opendir( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + _flags: i32, + reply: fuser::ReplyOpen, + ) { + if ino == FILE_INODE { + reply.error(EINVAL); + return; + } + + if ino != ROOT_INODE { + reply.error(ENOENT); + return; + } + + let mut handles = self.handles.write(); + + let handle = handles.next_handle(); + handles.handles.insert( + handle, + Handle { + ino, + flags: O_RDONLY, + cursor: 0, + }, + ); + + drop(handles); + + reply.opened(handle, 0); + } + + fn readdir( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + offset: i64, + mut reply: fuser::ReplyDirectory, + ) { + if !self.handles.read().handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + + if ino == FILE_INODE { + reply.error(EINVAL); + return; + } + + if ino != ROOT_INODE { + reply.error(ENOENT); + return; + } + + if offset < 0 { + reply.error(EINVAL); + return; + } + + let entries = [ + (ROOT_INODE, 1, fuser::FileType::Directory, "."), + (PARENT_INODE, 2, fuser::FileType::Directory, ".."), + ( + FILE_INODE, + 3, + fuser::FileType::RegularFile, + &self.config.user_database_name, + ), + ]; + + let start = usize::try_from(offset).unwrap(); + for entry in entries.iter().skip(start) { + if reply.add(entry.0, entry.1, entry.2, entry.3) { + // Buffer full + break; + } + } + + reply.ok(); + } + + fn readdirplus( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + offset: i64, + mut reply: fuser::ReplyDirectoryPlus, + ) { + if !self.handles.read().handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + + if ino == FILE_INODE { + reply.error(EINVAL); + return; + } + + if ino != ROOT_INODE { + reply.error(ENOENT); + return; + } + + if offset < 0 { + reply.error(EINVAL); + return; + } + + let entries = vec![ + (ROOT_INODE, 1, ".", TTL, self.get_root_attr(), 0), + (PARENT_INODE, 2, "..", TTL, self.get_root_attr(), 0), + ( + FILE_INODE, + 3, + &self.config.user_database_name, + TTL, + self.get_file_attr(&self.variable_file_state.read()), + 0, + ), + ]; + + let start = usize::try_from(offset).unwrap(); + for entry in entries.iter().skip(start) { + if reply.add(entry.0, entry.1, entry.2, &entry.3, &entry.4, entry.5) { + // Buffer full + break; + } + } + } + + fn releasedir( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + _flags: i32, + reply: fuser::ReplyEmpty, + ) { + if ino != ROOT_INODE { + reply.error(ENOENT); + return; + } + + let handle = self.handles.write().handles.remove(&fh); + if handle.is_none() { + reply.error(ENOENT); + return; + } + + reply.ok(); + } + + fn fsyncdir( + &mut self, + _req: &fuser::Request<'_>, + ino: u64, + fh: u64, + _datasync: bool, + reply: fuser::ReplyEmpty, + ) { + let handles = self.handles.read(); + if !handles.handles.contains_key(&fh) { + reply.error(ENOENT); + return; + } + drop(handles); + + if ino != ROOT_INODE { + reply.error(ENOENT); + return; + } + + reply.ok(); + } + + fn statfs(&mut self, _req: &fuser::Request<'_>, ino: u64, reply: fuser::ReplyStatfs) { + if ino != ROOT_INODE && ino != FILE_INODE { + reply.error(ENOENT); + return; + } + + let variable_file_state = self.variable_file_state.read(); + let blocks = (variable_file_state.contents.len() as u64) + .div_ceil(u64::from(self.static_file_state.blksize)); + drop(variable_file_state); + + reply.statfs( + blocks, + 0, + 0, + 2, + 0, + self.static_file_state.blksize, + u8::MAX.into(), + u32::try_from(blocks).unwrap(), + ); + } + + fn access(&mut self, req: &fuser::Request<'_>, ino: u64, mask: i32, reply: fuser::ReplyEmpty) { + if ino != ROOT_INODE && ino != FILE_INODE { + reply.error(libc::ENOENT); + return; + } + + let uid = req.uid(); + let gid = req.gid(); + + #[allow(clippy::similar_names)] + let owner_uid = self.static_file_state.uid; + #[allow(clippy::similar_names)] + let owner_gid = self.static_file_state.gid; + + let mode = if ino == ROOT_INODE { + ROOT_MODE + } else { + FILE_MODE + }; + + let wants_read = (mask & R_OK) != 0; + let wants_write = (mask & W_OK) != 0; + let wants_exec = (mask & X_OK) != 0; + + let perm_bits: u8 = if uid == owner_uid { + ((mode >> 6) & 0b111) as u8 + } else if gid == owner_gid { + ((mode >> 3) & 0b111) as u8 + } else { + (mode & 0b111) as u8 + }; + + if wants_read && (perm_bits & 0b100) == 0 { + reply.error(EACCES); + return; + } + if wants_write && (perm_bits & 0b010) == 0 { + reply.error(EACCES); + return; + } + if wants_exec && (perm_bits & 0b001) == 0 { + reply.error(EACCES); + return; + } + + reply.ok(); + } +} diff --git a/src/main.rs b/src/main.rs index 33460b2..8245542 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ #![allow(clippy::missing_docs_in_private_items)] mod config; +mod fuser; mod models; mod routes; mod state; @@ -25,7 +26,10 @@ async fn main() { let config = Config::try_from(&args.config).unwrap(); let state = State::from_config(config.clone()).await; - init(&state).await.unwrap(); + sqlx::migrate!("./migrations") + .run(&state.pg_pool) + .await + .unwrap(); let routes = routes::routes(state); let app = axum::Router::new().nest(&format!("{}/api", config.server.subpath), routes); @@ -36,12 +40,3 @@ async fn main() { info!("Listening on {}", listener.local_addr().unwrap()); serve(listener, app).await.unwrap(); } - -async fn init(state: &State) -> Result<(), Box> { - sqlx::migrate!("./migrations") - .run(&state.pg_pool) - .await - .expect("Failed to run migrations"); - - Ok(()) -} diff --git a/support/Containerfile b/support/Containerfile index ad9c78d..a884b9b 100644 --- a/support/Containerfile +++ b/support/Containerfile @@ -2,7 +2,7 @@ FROM docker.io/library/rust AS builder ARG BUILD_MODE=debug -RUN apt-get update && apt-get clean +RUN apt-get update && apt-get install -y fuse3 libfuse3-dev && apt-get clean WORKDIR /app