Add fuser implementation

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2025-06-05 23:24:26 +01:00
parent ed958a8ed0
commit d1d49731ea
7 changed files with 820 additions and 14 deletions

2
Cargo.lock generated
View File

@@ -1250,10 +1250,12 @@ dependencies = [
"axum-extra", "axum-extra",
"clap", "clap",
"fuser", "fuser",
"libc",
"log", "log",
"log4rs", "log4rs",
"non-empty-string", "non-empty-string",
"openidconnect", "openidconnect",
"parking_lot",
"passwords", "passwords",
"redis 0.31.0", "redis 0.31.0",
"redis-macros", "redis-macros",

View File

@@ -21,10 +21,12 @@ axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["typed-header"] } axum-extra = { version = "0.10.1", features = ["typed-header"] }
clap = { version = "4.5.39", features = ["derive"] } clap = { version = "4.5.39", features = ["derive"] }
fuser = "0.15.1" fuser = "0.15.1"
libc = "0.2.172"
log = "0.4.27" log = "0.4.27"
log4rs = "1.3.0" log4rs = "1.3.0"
non-empty-string = { version = "0.2.6", features = ["serde"] } non-empty-string = { version = "0.2.6", features = ["serde"] }
openidconnect = { version = "4.0.0", features = ["reqwest"] } openidconnect = { version = "4.0.0", features = ["reqwest"] }
parking_lot = "0.12.4"
passwords = "3.1.16" passwords = "3.1.16"
redis = { version = "0.31.0", features = ["tokio-comp"] } redis = { version = "0.31.0", features = ["tokio-comp"] }
redis-macros = "0.5.4" redis-macros = "0.5.4"

View File

@@ -33,6 +33,25 @@
treefmt = inputs.treefmt-nix.lib.evalModule pkgs ./treefmt.nix; treefmt = inputs.treefmt-nix.lib.evalModule pkgs ./treefmt.nix;
in 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 { devShells.default = pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
cargo cargo
@@ -42,6 +61,8 @@
cargo-udeps cargo-udeps
cargo-outdated cargo-outdated
sqlx-cli sqlx-cli
fuse3
pkg-config
]; ];
}; };

View File

@@ -37,8 +37,9 @@ pub struct OAuthConfig {
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
pub struct AutheliaConfig { pub struct FuseConfig {
pub user_database: PathBuf, pub mount_directory: PathBuf,
pub user_database_name: String,
} }
#[derive(Clone, Deserialize)] #[derive(Clone, Deserialize)]
@@ -62,7 +63,7 @@ pub struct RedisConfig {
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
pub oauth: OAuthConfig, pub oauth: OAuthConfig,
pub authelia: AutheliaConfig, pub fuse: FuseConfig,
pub postgresql: PostgresqlConfig, pub postgresql: PostgresqlConfig,
pub redis: RedisConfig, pub redis: RedisConfig,
} }

785
src/fuser.rs Normal file
View File

@@ -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<u64, Handle>,
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<dyn Fn(&str) + Send + Sync>;
pub struct AutheliaFS {
config: FuseConfig,
write_callback: Option<WriteCallback>,
variable_file_state: Arc<RwLock<VariableFileState>>,
static_file_state: Arc<StaticFileState>,
handles: Arc<RwLock<Handles>>,
}
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<libc::stat> {
let c_path = CString::new(path).unwrap();
let mut stat_buf = MaybeUninit::<libc::stat>::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<WriteCallback>) -> 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<T>(&self, lock: &T) -> fuser::FileAttr
where
T: Deref<Target = VariableFileState>,
{
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<u64>,
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<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: 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<u64>,
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<u64>,
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<u64>,
_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();
}
}

View File

@@ -2,6 +2,7 @@
#![allow(clippy::missing_docs_in_private_items)] #![allow(clippy::missing_docs_in_private_items)]
mod config; mod config;
mod fuser;
mod models; mod models;
mod routes; mod routes;
mod state; mod state;
@@ -25,7 +26,10 @@ async fn main() {
let config = Config::try_from(&args.config).unwrap(); let config = Config::try_from(&args.config).unwrap();
let state = State::from_config(config.clone()).await; 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 routes = routes::routes(state);
let app = axum::Router::new().nest(&format!("{}/api", config.server.subpath), routes); 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()); info!("Listening on {}", listener.local_addr().unwrap());
serve(listener, app).await.unwrap(); serve(listener, app).await.unwrap();
} }
async fn init(state: &State) -> Result<(), Box<dyn Error + Send + Sync>> {
sqlx::migrate!("./migrations")
.run(&state.pg_pool)
.await
.expect("Failed to run migrations");
Ok(())
}

View File

@@ -2,7 +2,7 @@ FROM docker.io/library/rust AS builder
ARG BUILD_MODE=debug 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 WORKDIR /app