""" fuse.py — FUSE filesystem layer for Trove, backed by pyfuse3 + trio. Blob objects are exposed as regular files. Tree objects are exposed as directories. Inode numbers map 1:1 to Trove object IDs (NODE_ROOT_ID == pyfuse3 root inode == 1). Entry point: serve(trove, mountpoint) """ import errno import os import stat import time import pyfuse3 import trio from trovedb.trove import Trove, Note, Tree as TroveTree, TreeNote, Blob as TroveBlob class TroveFuseOps(pyfuse3.Operations): enable_writeback_cache = False def __init__(self, trove: Trove): super().__init__() self._trove = trove # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _note_or_error(self, inode: int): note = self._trove.get_raw_note(inode) if note is None: raise pyfuse3.FUSEError(errno.ENOENT) return note def _make_attr(self, inode: int, is_tree: bool, size: int = 0) -> pyfuse3.EntryAttributes: attr = pyfuse3.EntryAttributes() attr.st_ino = pyfuse3.InodeT(inode) attr.st_nlink = 1 attr.st_uid = os.getuid() attr.st_gid = os.getgid() now_ns = int(time.time() * 1e9) attr.st_atime_ns = now_ns attr.st_mtime_ns = now_ns attr.st_ctime_ns = now_ns attr.generation = 0 attr.entry_timeout = 5.0 attr.attr_timeout = 5.0 if is_tree: attr.st_mode = stat.S_IFDIR | 0o755 attr.st_size = 0 attr.st_blksize = 512 attr.st_blocks = 0 else: attr.st_mode = stat.S_IFREG | 0o644 attr.st_size = size attr.st_blksize = 512 attr.st_blocks = (size + 511) // 512 return attr def _attr_for_note(self, note: Note) -> pyfuse3.EntryAttributes: size = 0 is_tree = True if isinstance(note, TroveBlob): size = len(note.read()) is_tree = False return self._make_attr(note.object_id, is_tree, size) # ------------------------------------------------------------------ # Stat / lookup # ------------------------------------------------------------------ async def getattr(self, inode: int, ctx=None) -> pyfuse3.EntryAttributes: note = self._note_or_error(inode) return self._attr_for_note(note) async def lookup(self, parent_inode: int, name: bytes, ctx=None) -> pyfuse3.EntryAttributes: parent = self._note_or_error(parent_inode) if not isinstance(parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) entries = parent.list() name_str = name.decode() if name_str not in entries: raise pyfuse3.FUSEError(errno.ENOENT) child = self._trove.get_raw_note(entries[name_str]) if child is None: raise pyfuse3.FUSEError(errno.ENOENT) return self._attr_for_note(child) async def setattr(self, inode: int, attr, fields, fh, ctx) -> pyfuse3.EntryAttributes: note = self._note_or_error(inode) if fields.update_size and not isinstance(note, TroveTree): current = note.read() new_size = attr.st_size if new_size < len(current): note.write(current[:new_size]) elif new_size > len(current): note.write(current + b"\x00" * (new_size - len(current))) return self._attr_for_note(note) async def forget(self, inode_list) -> None: pass # ------------------------------------------------------------------ # Directory ops # ------------------------------------------------------------------ async def opendir(self, inode: int, ctx) -> pyfuse3.FileHandleT: note = self._note_or_error(inode) if not isinstance(note, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) return pyfuse3.FileHandleT(inode) async def readdir(self, fh: int, start_id: int, token) -> None: note = self._note_or_error(fh) if not isinstance(note, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) entries = list(note.list().items()) # [(name, object_id), ...] for idx, (name, child_id) in enumerate(entries): if idx < start_id: continue child = self._trove.get_raw_note(child_id) if child is None: continue attr = self._attr_for_note(child) if not pyfuse3.readdir_reply(token, name.encode(), attr, idx + 1): break async def releasedir(self, fh: int) -> None: pass async def mkdir(self, parent_inode: int, name: bytes, mode: int, ctx) -> pyfuse3.EntryAttributes: parent = self._note_or_error(parent_inode) if not isinstance(parent, TreeNote): raise pyfuse3.FUSEError(errno.ENOTDIR) name_str = name.decode() if name_str in parent.list(): raise pyfuse3.FUSEError(errno.EEXIST) new_tree: TreeNote = parent.mkdir(name_str) return self._make_attr(new_tree.object_id, True, 0) async def rmdir(self, parent_inode: int, name: bytes, ctx) -> None: parent = self._note_or_error(parent_inode) if not isinstance(parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) name_str = name.decode() entries = parent.list() if name_str not in entries: raise pyfuse3.FUSEError(errno.ENOENT) child = self._trove.get_raw_note(entries[name_str]) if child is None: raise pyfuse3.FUSEError(errno.ENOENT) if not isinstance(child, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) if child.list(): raise pyfuse3.FUSEError(errno.ENOTEMPTY) parent.unlink(name_str) # ------------------------------------------------------------------ # File ops # ------------------------------------------------------------------ async def open(self, inode: int, flags, ctx) -> pyfuse3.FileInfo: note = self._note_or_error(inode) if isinstance(note, TroveTree): raise pyfuse3.FUSEError(errno.EISDIR) return pyfuse3.FileInfo(fh=pyfuse3.FileHandleT(inode)) async def create(self, parent_inode: int, name: bytes, mode: int, flags, ctx) -> tuple: parent = self._note_or_error(parent_inode) if not isinstance(parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) name_str = name.decode() if name_str in parent.list(): raise pyfuse3.FUSEError(errno.EEXIST) blob = self._trove.create_blob(b"") parent.link(name_str, blob) attr = self._make_attr(blob.object_id, False, 0) return pyfuse3.FileInfo(fh=pyfuse3.FileHandleT(blob.object_id)), attr async def read(self, fh: int, offset: int, length: int) -> bytes: note = self._note_or_error(fh) return note.read()[offset:offset + length] async def write(self, fh: int, offset: int, data: bytes) -> int: note = self._note_or_error(fh) existing = note.read() if offset > len(existing): existing = existing + b"\x00" * (offset - len(existing)) note.write(existing[:offset] + data + existing[offset + len(data):]) return len(data) async def release(self, fh: int) -> None: pass async def unlink(self, parent_inode: int, name: bytes, ctx) -> None: parent = self._note_or_error(parent_inode) if not isinstance(parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) name_str = name.decode() entries = parent.list() if name_str not in entries: raise pyfuse3.FUSEError(errno.ENOENT) child = self._trove.get_raw_note(entries[name_str]) if child is None: raise pyfuse3.FUSEError(errno.ENOENT) if isinstance(child, TroveTree): raise pyfuse3.FUSEError(errno.EISDIR) parent.unlink(name_str) async def rename(self, parent_inode_old, name_old, parent_inode_new, name_new, flags, ctx): old_parent = self._note_or_error(parent_inode_old) new_parent = self._note_or_error(parent_inode_new) if not isinstance(old_parent, TroveTree) or not isinstance(new_parent, TroveTree): raise pyfuse3.FUSEError(errno.ENOTDIR) name_old_str = name_old.decode() name_new_str = name_new.decode() old_entries = old_parent.list() if name_old_str not in old_entries: raise pyfuse3.FUSEError(errno.ENOENT) child_id = old_entries[name_old_str] child = self._trove.get_raw_note(child_id) if child is None: raise pyfuse3.FUSEError(errno.ENOENT) # Remove existing target if present new_entries = new_parent.list() if name_new_str in new_entries: target = self._trove.get_raw_note(new_entries[name_new_str]) if target is not None and isinstance(target, TroveTree): if target.list(): raise pyfuse3.FUSEError(errno.ENOTEMPTY) new_parent.unlink(name_new_str) new_parent.link(name_new_str, child) old_parent.unlink(name_old_str) # ------------------------------------------------------------------ # Serve # ------------------------------------------------------------------ async def _run(ops: TroveFuseOps, mountpoint: str) -> None: options = set(pyfuse3.default_options) options.add("fsname=trove") pyfuse3.init(ops, mountpoint, options) try: await pyfuse3.main() finally: pyfuse3.close() def serve(trove: Trove, mountpoint: str) -> None: """ Mount a Trove store at mountpoint and serve until KeyboardInterrupt. Runs a trio event loop internally. """ ops = TroveFuseOps(trove) try: trio.run(_run, ops, mountpoint) except KeyboardInterrupt: pass