2026-03-16 22:45:27 -05:00
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-19 22:43:11 -05:00
|
|
|
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)
|
|
|
|
|
|
2026-03-16 22:45:27 -05:00
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 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
|