├── tests ├── .mypy.ini ├── pytest.ini ├── 3dsfetch.3dsx ├── requirements-tests.txt ├── conftest.py ├── test_enoattr.py ├── .flake8 ├── clean.sh ├── run-style-checkers.sh ├── .pylintrc ├── .ruff.toml ├── test_struct_layout.py ├── single-file.tar └── test_examples.py ├── .gitignore ├── LICENSE ├── .github └── workflows │ ├── python-publish.yml │ └── tests.yml ├── pyproject.toml ├── examples ├── context.py ├── readdir_returning_offsets.py ├── readdir_with_offset.py ├── sftp.py ├── loopback.py ├── memory.py └── memory_nullpath.py ├── CHANGELOG.md ├── TODO.md ├── README.md └── mfusepy.py /tests/.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | enable_assertion_pass_hook=true 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.whl 2 | *.tar.gz 3 | *.egg-info 4 | .* 5 | build 6 | dist 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /tests/3dsfetch.3dsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mxmlnkn/mfusepy/HEAD/tests/3dsfetch.3dsx -------------------------------------------------------------------------------- /tests/requirements-tests.txt: -------------------------------------------------------------------------------- 1 | black 2 | codespell 3 | flake8 4 | ioctl-opt 5 | paramiko 6 | types-paramiko 7 | ruff 8 | pylint 9 | pytest 10 | pytype 11 | mypy 12 | pytest-order 13 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | assertion_count = 0 4 | 5 | 6 | def pytest_assertion_pass(item, lineno, orig, expl): 7 | global assertion_count 8 | assertion_count += 1 9 | 10 | 11 | def pytest_terminal_summary(terminalreporter, exitstatus, config): 12 | print(f'{assertion_count} assertions tested.') 13 | -------------------------------------------------------------------------------- /tests/test_enoattr.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wrong-import-position 2 | 3 | import os 4 | import sys 5 | 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 7 | 8 | import mfusepy # noqa: E402 9 | 10 | 11 | def test_enoattr_is_not_none(): 12 | assert mfusepy.ENOATTR is not None, "neither errno.ENOATTR nor errno.ENODATA is defined" 13 | -------------------------------------------------------------------------------- /tests/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # W503 "line break before binary operator" is directly in opposition to how black breaks lines! 3 | extend-ignore = C901, E201, E202, E203, E211, E221, E251, E266, E501, W503 4 | max-line-length = 120 5 | max-complexity = 18 6 | select = B,C,E,F,W,T4,B9 7 | # We need to configure the mypy.ini because the flake8-mypy's default 8 | # options don't properly override it, so if we don't specify it we get 9 | # half of the config from mypy.ini and half from flake8-mypy. 10 | mypy_config = .mypy.ini 11 | -------------------------------------------------------------------------------- /tests/clean.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Will print warnings for each folder because find tries to descend into it after finding those. 4 | # It may be confusing but it is a nice verbose output to list deleted folders. 5 | find . -type d '(' \ 6 | -name '*.egg-info' -or -name '*.mypy_cache' -or -name '__pycache__' -or -name '.ruff_cache' -or \ 7 | -name '*.pytest_cache' -or -name '*.pytype' -or -name 'dist' -or -name 'build' \ 8 | ')' -exec rm -rf {} ';' 9 | 'rm' -f httpd-ruby-webrick.log ratarmount.stderr.log.tmp ratarmount.stdout.log.tmp 10 | 11 | for service in httpd ipfs pyftpdlib wsgidav; do pkill -f "$service"; done 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012 Giorgos Verigakis 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-slim 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.12' 18 | 19 | - name: Install Dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install setuptools wheel twine build 23 | 24 | - name: Build and Publish 25 | env: 26 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 27 | run: | 28 | python3 -m build 29 | twine check dist/* 30 | twine upload --skip-existing -u __token__ dist/* 31 | -------------------------------------------------------------------------------- /tests/run-style-checkers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | allTextFiles=() 4 | while read -r file; do 5 | allTextFiles+=( "$file" ) 6 | done < <( git ls-tree -r --name-only HEAD | 'grep' -E '[.](py|md|txt|sh|yml)' ) 7 | 8 | codespell "${allTextFiles[@]}" 9 | 10 | allPythonFiles=() 11 | while read -r file; do 12 | allPythonFiles+=( "$file" ) 13 | done < <( git ls-tree -r --name-only HEAD | 'grep' '[.]py$' ) 14 | 15 | ruff check --fix --config tests/.ruff.toml -- "${allPythonFiles[@]}" 16 | black -q --line-length 120 --skip-string-normalization "${allPythonFiles[@]}" 17 | flake8 --config tests/.flake8 "${allPythonFiles[@]}" 18 | pylint --rcfile tests/.pylintrc "${allPythonFiles[@]}" | tee pylint.log 19 | ! 'egrep' ': E[0-9]{4}: ' pylint.log 20 | pytype -d import-error "${allPythonFiles[@]}" 21 | mypy --config-file tests/.mypy.ini "${allPythonFiles[@]}" 22 | -------------------------------------------------------------------------------- /tests/.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # Maximum number of characters on a single line. 3 | max-line-length=120 4 | 5 | [MESSAGES CONTROL] 6 | 7 | # Disable the message, report, category or checker with the given id(s). You 8 | # can either give multiple identifiers separated by comma (,) or put this 9 | # option multiple times (only on the command line, not in the configuration 10 | # file where it should appear only once). You can also use "--disable=all" to 11 | # disable everything first and then reenable specific checks. For example, if 12 | # you want to run only the similarities checker, you can use "--disable=all 13 | # --enable=similarities". If you want to run only the classes checker, but have 14 | # no Warning level messages displayed, use "--disable=all --enable=classes 15 | # --disable=W". 16 | disable=broad-except, 17 | invalid-name, 18 | too-many-arguments, 19 | too-many-branches, 20 | too-many-instance-attributes, 21 | too-many-locals, 22 | too-many-lines, 23 | too-many-positional-arguments, 24 | too-many-public-methods, 25 | too-many-statements, 26 | too-few-public-methods, 27 | # I don't need the style checker to bother me with missing docstrings and todos. 28 | missing-class-docstring, 29 | missing-function-docstring, 30 | missing-module-docstring, 31 | fixme, 32 | unused-argument, 33 | comparison-with-callable, 34 | R0801, 35 | global-statement, 36 | chained-comparison, # Only Since Python 3.8 37 | -------------------------------------------------------------------------------- /tests/.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 120 2 | target-version = "py39" 3 | 4 | [lint] 5 | select = [ 6 | "A", "B", "E", "F", "G", "I", "W", "N", "ASYNC", "C4", "COM", "FLY", "FURB", "ICN", "INT", "ISC", "LOG", 7 | "PERF", "PIE", "PLW", "PT", "PYI", "RET", "RSE", "RUF", "SIM", "TID", "TC", "UP", "YTT" 8 | ] 9 | ignore = [ 10 | # Preview. Complaining about spaces (aligned arguments) should be a formatter option, not a linter one! 11 | # https://github.com/astral-sh/ruff/issues/2402 12 | "E201", "E202", "E203", "E211", "E221", "E226", "E251", "E265", "E266", 13 | "E501", # A linter should lint, not check for line lengths! 14 | "F401", # Wants to from .version import __version__ as __version__ which clashes with pylint errors! 15 | "B904", 16 | "N801", # Some class names are snake_case to match ctypes 17 | "COM812", # Do not force trailing commas where it makes no sense, e.g., function calls for which I'll 18 | # never add more arguments. 19 | "PLW0603", # Cannot live without global statements, especially for subprocessing. 20 | "PT017", 21 | "RET504", # https://github.com/astral-sh/ruff/issues/17292#issuecomment-3039232890 22 | "RUF100", # BUG: removes necessary noqa: E402 in tests! 23 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`, but _fields_ is not mutable. 24 | 25 | "SIM102", # Sometimes, nested if-statements are more readable. 26 | "SIM105", 27 | 28 | # Bug: SIM118 removes the keys() from row.keys(), which is an sqlite3.Row not a Dict! 29 | ] 30 | # Allow fix for all enabled rules (when `--fix`) is provided. 31 | fixable = ["ALL"] 32 | unfixable = [] 33 | 34 | [format] 35 | line-ending = "lf" 36 | quote-style = "preserve" 37 | 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # See https://setuptools.readthedocs.io/en/latest/build_meta.html 2 | 3 | [build-system] 4 | # Use setuptools >= 43 because it automatically includes pyproject.toml in source distribution 5 | # Use setuptools >= 46.5 to use attr: package.__version__ 6 | # Use setuptools >= 61 for pyproject.toml support 7 | # https://github.com/pypa/setuptools/issues/4903 8 | # Use setuptools >= 77 for project.license-files support 9 | # https://setuptools.readthedocs.io/en/latest/history.html#id284 10 | # Use setuptools <= 82 because the license specification changes backward-incompatible in 2026-02. 11 | # https://github.com/pypa/setuptools/issues/4903#issuecomment-2923109576 12 | requires = ["setuptools >= 61, <= 82"] 13 | build-backend = "setuptools.build_meta" 14 | 15 | [project] 16 | name = "mfusepy" 17 | version = "3.1.0" 18 | description = "Ctypes bindings for the high-level API in libfuse 2 and 3" 19 | authors = [{name = "Maximilian Knespel", email = "mxmlnknp@gmail.com"}] 20 | license = {text = "ISC"} 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "Operating System :: MacOS", 25 | "Operating System :: POSIX", 26 | "Operating System :: Unix", 27 | "Operating System :: POSIX :: BSD :: FreeBSD", 28 | "Operating System :: POSIX :: BSD :: OpenBSD", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Programming Language :: Python :: 3.14", 38 | "Topic :: System :: Filesystems", 39 | ] 40 | urls = {Homepage = "https://github.com/mxmlnkn/mfusepy"} 41 | requires-python = ">= 3.9" 42 | 43 | [project.readme] 44 | file = "README.md" 45 | content-type = "text/markdown" 46 | 47 | [tool.setuptools] 48 | py-modules = ["mfusepy"] 49 | license-files = ["LICENSE"] 50 | -------------------------------------------------------------------------------- /examples/context.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import errno 5 | import logging 6 | import stat 7 | import time 8 | from typing import Any, Optional 9 | 10 | import mfusepy as fuse 11 | 12 | 13 | class Context(fuse.Operations): 14 | 'Example filesystem to demonstrate fuse_get_context()' 15 | 16 | @fuse.overrides(fuse.Operations) 17 | def getattr(self, path: str, fh: Optional[int] = None) -> dict[str, Any]: 18 | uid, gid, pid = fuse.fuse_get_context() 19 | if path == '/': 20 | st: dict[str, Any] = {'st_mode': (stat.S_IFDIR | 0o755), 'st_nlink': 2} 21 | elif path == '/uid': 22 | size = len(f'{uid}\n') 23 | st = {'st_mode': (stat.S_IFREG | 0o444), 'st_size': size} 24 | elif path == '/gid': 25 | size = len(f'{gid}\n') 26 | st = {'st_mode': (stat.S_IFREG | 0o444), 'st_size': size} 27 | elif path == '/pid': 28 | size = len(f'{pid}\n') 29 | st = {'st_mode': (stat.S_IFREG | 0o444), 'st_size': size} 30 | else: 31 | raise fuse.FuseOSError(errno.ENOENT) 32 | st['st_ctime'] = st['st_mtime'] = st['st_atime'] = time.time() 33 | return st 34 | 35 | @fuse.overrides(fuse.Operations) 36 | def read(self, path: str, size: int, offset: int, fh: int) -> bytes: 37 | uid, gid, pid = fuse.fuse_get_context() 38 | 39 | def encoded(x): 40 | return (f'{x}\n').encode() 41 | 42 | if path == '/uid': 43 | return encoded(uid) 44 | if path == '/gid': 45 | return encoded(gid) 46 | if path == '/pid': 47 | return encoded(pid) 48 | 49 | raise RuntimeError(f'unexpected path: {path!r}') 50 | 51 | @fuse.overrides(fuse.Operations) 52 | def readdir(self, path: str, fh: int) -> fuse.ReadDirResult: 53 | return ['.', '..', 'uid', 'gid', 'pid'] 54 | 55 | 56 | def cli(args=None): 57 | parser = argparse.ArgumentParser() 58 | parser.add_argument('mount') 59 | args = parser.parse_args(args) 60 | 61 | logging.basicConfig(level=logging.DEBUG) 62 | fuse.FUSE(Context(), args.mount, foreground=True, ro=True) 63 | 64 | 65 | if __name__ == '__main__': 66 | cli() 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Version 3.1.0 built on 2025-12-23 3 | 4 | Most of the fixes and test/CI improvements were contributed by Thomas Waldmann. Many thanks! 5 | NetBSD is still work-in-progress. 6 | 7 | ## API 8 | 9 | - Add many more missing type hints. 10 | 11 | ## Features 12 | 13 | - Add `mfusepy.ENOATTR` to be used as the correct value for `getxattr` and `removexattr`. 14 | - Make path decode/encode error policy configurable and switch from `strict` to `surrogateescape`. 15 | - Add `readdir_with_offset` implementable interface method, which takes an additional `offset` argument. 16 | Does not work correctly on OpenBSD. Help would be welcome. 17 | - Translate some of the libfuse2-only options (`use_ino`, `direct_io`, `nopath`, ...) to the libfuse3 config struct. 18 | - Allow to set fuse library name via `FUSE_LIBRARY_NAME` environment variable. 19 | - Automatically test and therefore support Ubuntu 22.04/24.04 aarch64/x86_64, macOS 14/15 Intel/ARM, 20 | FreeBSD 14.3, OpenBSD 7.7, NetBSD 10.1. 21 | 22 | ## Fixes 23 | 24 | - Fix type definitions and examples on OpenBSD, FreeBSD, NetBSD, macOS, and others. 25 | - `readdir`: Also forward `st_ino` in case `use_ino` is set to True. 26 | - Avoid `readdir` infinite loop by ignoring the offset and returning offset 0, to trigger non-offset mode. 27 | Overwrite `readdir_with_offset` to actually make use of offsets. 28 | - `getattr_fuse_3`: pass argument `fip` to `fgetattr`. 29 | - Avoid null pointer dereferences for `fip` in `ftruncate`, `fgetattr`, and `lock`. 30 | 31 | 32 | # Version 3.0.0 built on 2025-08-01 33 | 34 | Version 2 was skipped because the git tags would have clashed with the original fusepy tags. 35 | 36 | ## API 37 | 38 | - `Operation.__call__` is not used anymore. Use decorators instead, or check `LoggingMixIn` as to how to overwrite 39 | `__getattribute__`. This is the biggest change requiring an API break and was motivated by the type support. 40 | - Add type-hints. Do not check types at runtime! They may change in a major version, 41 | .e.g., `List` -> `list` -> `Sequence` -> `Iterable`. 42 | - `readdir` may now also return only a triple of (name, mode, offset). 43 | - `init_with_config` arguments are now always structs. Prior, they were ctypes pointers to the struct. 44 | - As the old warning stated, `use_ns` will be removed in version 4 and all timestamps will use nanoseconds then. 45 | Set `FUSE.use_ns = True` and then return only times as integers representing nanoseconds and expect returned 46 | times as such. 47 | 48 | ## Features 49 | 50 | - Add `overrides` decorator. 51 | - Add `log_callback` decorator. 52 | 53 | 54 | # Version 1.1.1 built on 2025-07-07 55 | 56 | ## Fixes 57 | 58 | - Forward return code for `utimens` and `rename`. 59 | - Restore compatibility with old fusepy implementations that implement `create` with only 2 arguments. 60 | - If FUSE 2 flag_nullpath_ok and flag_nopath members exist, then enable fuse_config.nullpath_ok in FUSE 3. 61 | - Handle possible null path in all methods mentioned in nullpath_ok documentation. 62 | 63 | 64 | # Version 1.1.0 built on 2025-05-08 65 | 66 | ## Features 67 | 68 | - Support libfuse 3.17+. 69 | 70 | 71 | # Version 1.0.0 built on 2024-10-27 72 | 73 | - First version with libfuse3 support. 74 | -------------------------------------------------------------------------------- /examples/readdir_returning_offsets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import errno 5 | import logging 6 | import stat as stat_module 7 | import sys 8 | from typing import Optional 9 | 10 | import mfusepy as fuse 11 | 12 | log = logging.getLogger("readdir with offset") 13 | 14 | 15 | class ReaddirWithOffset(fuse.Operations): 16 | """ 17 | Minimal filesystem showing usage of readdir with offsets. 18 | 19 | We create a directory with many files to ensure the buffer fills up, 20 | triggering multiple readdir calls with different offsets. 21 | """ 22 | 23 | use_ns = True 24 | 25 | def __init__(self, readdir_call_limit=10): 26 | self._readdir_calls = 0 27 | # Create enough files to fill the readdir buffer. (In my tests the readdir buffer was ~10) 28 | self._file_count = 1000 29 | self._readdir_call_limit = readdir_call_limit 30 | 31 | def getattr(self, path: str, fh: Optional[int] = None): 32 | self._readdir_calls = 0 33 | if path == '/': 34 | # Root directory 35 | st = { 36 | 'st_mode': stat_module.S_IFDIR | 0o755, 37 | 'st_nlink': 2, 38 | 'st_size': 4096, 39 | 'st_ctime': 0, 40 | 'st_mtime': 0, 41 | 'st_atime': 0, 42 | } 43 | elif path.strip('/').isdigit(): 44 | # Regular file 45 | st = { 46 | 'st_mode': stat_module.S_IFREG | 0o644, 47 | 'st_nlink': 1, 48 | 'st_size': 0, 49 | 'st_ctime': 0, 50 | 'st_mtime': 0, 51 | 'st_atime': 0, 52 | } 53 | else: 54 | raise fuse.FuseOSError(errno.ENOENT) 55 | 56 | return st 57 | 58 | def readdir(self, path: str, fh: int): 59 | """ 60 | Yield directory entries with incrementing offsets starting at 1. 61 | An offset of 0 should only be returned when the offsets should be ignored. 62 | """ 63 | 64 | # After ~10 entries, warn about the old bug. 65 | self._readdir_calls += 1 66 | if self._readdir_calls >= self._readdir_call_limit * self._file_count: 67 | log.warning("If you see this message repeating, the FUSE wrapper bug is triggered!") 68 | sys.exit(1) 69 | 70 | log.debug("readdir called %s times for path=%s", self._readdir_calls, path) 71 | 72 | if path != '/': 73 | raise fuse.FuseOSError(errno.ENOENT) 74 | 75 | # Yield . and .. 76 | offset = 1 77 | yield ('.', {'st_mode': stat_module.S_IFDIR | 0o755}, offset) 78 | offset += 1 79 | yield ('..', {'st_mode': stat_module.S_IFDIR | 0o755}, offset) 80 | offset += 1 81 | 82 | # Yield many files to ensure buffer fills up 83 | for i in range(self._file_count): 84 | yield f'{i:04d}', {'st_mode': stat_module.S_IFREG | 0o644}, offset 85 | offset += 1 86 | 87 | 88 | def cli(args=None): 89 | parser = argparse.ArgumentParser() 90 | parser.add_argument('mount') 91 | args = parser.parse_args(args) 92 | 93 | logging.basicConfig(level=logging.DEBUG) 94 | fuse.FUSE(ReaddirWithOffset(), args.mount, foreground=True) 95 | 96 | 97 | if __name__ == '__main__': 98 | cli() 99 | -------------------------------------------------------------------------------- /examples/readdir_with_offset.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import errno 5 | import logging 6 | import stat as stat_module 7 | import sys 8 | from typing import Optional 9 | 10 | import mfusepy as fuse 11 | 12 | log = logging.getLogger("readdir with offset") 13 | 14 | 15 | class ReaddirWithOffset(fuse.Operations): 16 | """ 17 | Minimal filesystem showing usage of readdir with offsets. 18 | 19 | We create a directory with many files to ensure the buffer fills up, 20 | triggering multiple readdir calls with different offsets. 21 | """ 22 | 23 | use_ns = True 24 | 25 | def __init__(self, readdir_call_limit=10): 26 | self._readdir_calls = 0 27 | # Create enough files to fill the readdir buffer. (In my tests the readdir buffer was ~10) 28 | self._file_count = 1000 29 | self._readdir_call_limit = readdir_call_limit 30 | 31 | def getattr(self, path: str, fh: Optional[int] = None): 32 | self._readdir_calls = 0 33 | if path == '/': 34 | # Root directory 35 | st = { 36 | 'st_mode': stat_module.S_IFDIR | 0o755, 37 | 'st_nlink': 2, 38 | 'st_size': 4096, 39 | 'st_ctime': 0, 40 | 'st_mtime': 0, 41 | 'st_atime': 0, 42 | } 43 | elif path.strip('/').isdigit(): 44 | # Regular file 45 | st = { 46 | 'st_mode': stat_module.S_IFREG | 0o644, 47 | 'st_nlink': 1, 48 | 'st_size': 0, 49 | 'st_ctime': 0, 50 | 'st_mtime': 0, 51 | 'st_atime': 0, 52 | } 53 | else: 54 | raise fuse.FuseOSError(errno.ENOENT) 55 | 56 | return st 57 | 58 | def readdir_with_offset(self, path: str, offset: int, fh: int): 59 | """ 60 | Yield directory entries with incrementing offsets starting at 1. 61 | An offset of 0 should only be returned when the offsets should be ignored. 62 | """ 63 | 64 | # After ~10 entries, warn about the old bug. 65 | self._readdir_calls += 1 66 | if self._readdir_calls >= self._readdir_call_limit * self._file_count: 67 | log.warning("If you see this message repeating, the FUSE wrapper bug is triggered!") 68 | sys.exit(1) 69 | 70 | log.debug("readdir called %s times, path=%s, offset=%d", self._readdir_calls, path, offset) 71 | 72 | if path != '/': 73 | raise fuse.FuseOSError(errno.ENOENT) 74 | 75 | # Yield . and .. 76 | entry_id = 1 77 | if offset == 0 or entry_id >= offset: 78 | yield ('.', {'st_mode': stat_module.S_IFDIR | 0o755}, entry_id) 79 | entry_id += 1 80 | if offset == 0 or entry_id >= offset: 81 | yield ('..', {'st_mode': stat_module.S_IFDIR | 0o755}, entry_id) 82 | entry_id += 1 83 | 84 | # Yield many files to ensure buffer fills up 85 | for i in range(max(0, offset - 2), self._file_count): 86 | yield f'{i:04d}', {'st_mode': stat_module.S_IFREG | 0o644}, entry_id + i 87 | 88 | 89 | def cli(args=None): 90 | parser = argparse.ArgumentParser() 91 | parser.add_argument('mount') 92 | args = parser.parse_args(args) 93 | 94 | logging.basicConfig(level=logging.DEBUG) 95 | fuse.FUSE(ReaddirWithOffset(), args.mount, foreground=True) 96 | 97 | 98 | if __name__ == '__main__': 99 | cli() 100 | -------------------------------------------------------------------------------- /examples/sftp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import errno 5 | import logging 6 | from typing import Optional 7 | 8 | import paramiko 9 | 10 | import mfusepy as fuse 11 | 12 | 13 | class SFTP(fuse.Operations): 14 | ''' 15 | A simple SFTP filesystem. Requires paramiko: http://www.lag.net/paramiko/ 16 | 17 | You need to be able to login to remote host without entering a password. 18 | ''' 19 | 20 | def __init__(self, host, username=None, port=22): 21 | self.client = paramiko.SSHClient() 22 | self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 23 | self.client.load_system_host_keys() 24 | self.client.connect(host, port=port, username=username) 25 | self.sftp = self.client.open_sftp() 26 | 27 | @fuse.overrides(fuse.Operations) 28 | def chmod(self, path: str, mode: int) -> int: 29 | return self.sftp.chmod(path, mode) 30 | 31 | @fuse.overrides(fuse.Operations) 32 | def chown(self, path: str, uid: int, gid: int) -> int: 33 | return self.sftp.chown(path, uid, gid) 34 | 35 | @fuse.overrides(fuse.Operations) 36 | def create(self, path: str, mode, fi=None) -> int: 37 | f = self.sftp.open(path, 'w') 38 | f.chmod(mode) 39 | f.close() 40 | return 0 41 | 42 | @fuse.overrides(fuse.Operations) 43 | def destroy(self, path: str) -> None: 44 | self.sftp.close() 45 | self.client.close() 46 | 47 | @fuse.overrides(fuse.Operations) 48 | def getattr(self, path: str, fh: Optional[int] = None): 49 | try: 50 | st = self.sftp.lstat(path) 51 | except OSError: 52 | raise fuse.FuseOSError(errno.ENOENT) 53 | 54 | return {key: getattr(st, key) for key in ('st_atime', 'st_gid', 'st_mode', 'st_mtime', 'st_size', 'st_uid')} 55 | 56 | @fuse.overrides(fuse.Operations) 57 | def mkdir(self, path: str, mode: int) -> int: 58 | return self.sftp.mkdir(path, mode) 59 | 60 | @fuse.overrides(fuse.Operations) 61 | def read(self, path: str, size: int, offset: int, fh: int) -> bytes: 62 | f = self.sftp.open(path) 63 | f.seek(offset, 0) 64 | buf = f.read(size) 65 | f.close() 66 | return buf 67 | 68 | @fuse.overrides(fuse.Operations) 69 | def readdir(self, path: str, fh: int) -> fuse.ReadDirResult: 70 | return ['.', '..'] + [name.encode('utf-8') for name in self.sftp.listdir(path)] 71 | 72 | @fuse.overrides(fuse.Operations) 73 | def readlink(self, path: str) -> str: 74 | return self.sftp.readlink(path) 75 | 76 | @fuse.overrides(fuse.Operations) 77 | def rename(self, old: str, new: str) -> int: 78 | return self.sftp.rename(old, new) 79 | 80 | @fuse.overrides(fuse.Operations) 81 | def rmdir(self, path: str) -> int: 82 | return self.sftp.rmdir(path) 83 | 84 | @fuse.overrides(fuse.Operations) 85 | def symlink(self, target: str, source: str) -> int: 86 | return self.sftp.symlink(source, target) 87 | 88 | @fuse.overrides(fuse.Operations) 89 | def truncate(self, path: str, length: int, fh: Optional[int] = None) -> int: 90 | return self.sftp.truncate(path, length) 91 | 92 | @fuse.overrides(fuse.Operations) 93 | def unlink(self, path: str) -> int: 94 | return self.sftp.unlink(path) 95 | 96 | @fuse.overrides(fuse.Operations) 97 | def utimens(self, path: str, times: Optional[tuple[int, int]] = None) -> int: 98 | return self.sftp.utime(path, times) 99 | 100 | @fuse.overrides(fuse.Operations) 101 | def write(self, path: str, data: bytes, offset: int, fh: int) -> int: 102 | f = self.sftp.open(path, 'r+') 103 | f.seek(offset, 0) 104 | f.write(data) 105 | f.close() 106 | return len(data) 107 | 108 | 109 | def cli(args=None): 110 | parser = argparse.ArgumentParser() 111 | parser.add_argument('-l', dest='login') 112 | parser.add_argument('host') 113 | parser.add_argument('mount') 114 | args = parser.parse_args(args) 115 | 116 | logging.basicConfig(level=logging.DEBUG) 117 | 118 | if not args.login: 119 | if '@' in args.host: 120 | args.login, _, args.host = args.host.partition('@') 121 | 122 | fuse.FUSE(SFTP(args.host, username=args.login), args.mount, foreground=True, nothreads=True) 123 | 124 | 125 | if __name__ == '__main__': 126 | cli() 127 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Open issues and PRs upstream 2 | 3 | ## Possibly valid bugs 4 | 5 | - [x] `#146 Bug: NameError: name 'self' is not defined` 6 | - [ ] `#144 How to enable O_DIRECT` 7 | - [x] `#142 FUSE::_wrapper() is a static method, so it shouldn't refer to 'self'` 8 | - [x] `#130 fixing TypeError: an integer is required when val is None` 9 | - [x] `#129 setattr(st, key, val): TypeError: an integer is required` 10 | - [x] `#124 "NameError: global name 'self' is not defined" in static FUSE._wrapper()` 11 | - [x] `#120 lock operation is passed a pointer to the flock strut` 12 | - [ ] `#116 Segfault when calling fuse_exit` 13 | - [ ] `#97 broken exception handling bug` 14 | - [x] `#81 Irritating default behavior of Operations class - raising FuseOSError(EROFS) where it really should not bug` 15 | 16 | ## Features 17 | 18 | - [x] `#147 Implement support for poll in the high-level API` 19 | - [x] `#145 Added fuse-t for Darwin search. See https://www.fuse-t.org/` 20 | - [x] `#127 Pass flags to create in non raw_fi mode.` 21 | - [ ] `#104 fix POSIX support for UTIME_OMIT and UTIME_NOW` 22 | - [x] `#101 Support init options and parameters.` 23 | - [x] `#100 libfuse versions` 24 | - [ ] `#70 time precision inside utimens causes rsync misses` 25 | - [x] `#66 Support init options and parameters` 26 | - [ ] `#61 performance with large numbers of files` 27 | - [x] `#28 Implement read_buf() and write_buf()` 28 | - [ ] `#7 fusepy speed needs` 29 | - [ ] `#2 Unable to deal with non-UTF-8 filenames` 30 | 31 | ## Cross-platform issues and feature requests that are out of scope 32 | 33 | - `#143 Expose a file system as case-insensitive` 34 | - `#141 Windows version?` 35 | - `#136 RHEL8 RPM package` 36 | - This is the task of Linux distribution package maintainers. 37 | - `#133 Slashes in filenames appear to cause "Input/output error"` 38 | - `#128 fusepy doesn't work when using 32bit personality` 39 | - `#117 Module name clash with python-fuse` 40 | - `#57 Does this support using the dokany fuse wrapper for use on Windows?` 41 | - [x] `#40 [openbsd] fuse_main_real not existing, there's fuse_main` 42 | 43 | ## Questions 44 | 45 | Most of these are non-actionable. 46 | 47 | - `#138 “nothreads” argument explanation` 48 | - [x] `#134 Project status?` 49 | - `#132 fusepy doesn't work when in background mode` 50 | - `#123 Create/Copy file with content` 51 | - `#119 Documentation` 52 | - [x] `#118 Publish a new release` 53 | - Not relevant to mfusepy. Also, mfusepy already did publish a new release. 54 | - `#115 read not returning 0 to client` 55 | - `#112 truncate vs ftruncate using python std library` 56 | - `#105 fuse_get_context() returns 0-filled tuple during release bug needs example` 57 | - `#98 Next steps/road map for the near future` 58 | - `#26 ls: ./mnt: Input/output error` 59 | 60 | ## FUSE-ll out of scope for me personally 61 | 62 | - `#114 [fusell] Allow userdata to be passed to constructor` 63 | - `#111 [fusell] Allow userdata to be set` 64 | - `#102 Extensions to fusell.` 65 | - `#85 bring system support in fusell.py to match fuse.py` 66 | 67 | ## Tests and documentation 68 | 69 | - [x] `#139 Memory example empty files and ENOATTR` 70 | - Fixed with mfusepy#33. 71 | - [x] `#126 package the LICENSE file in distributions` 72 | - The sdist and the wheel, both include the LICENSE. 73 | - `#109 Add test cases for fuse_exit implementation needs tests` 74 | - [x] `#99 Python versions` 75 | - Tests for all supported Python versions >= 3.9 exist. There is no point in supporting 2.x anymore. 76 | - `#82 Create CONTRIBUTING.md` 77 | - [x] `#80 Test infrastructure and suite` 78 | - `#78 update memory.py with mem.py from kungfuse?` 79 | - [x] `#59 Include license text in its own file` 80 | - `#27 link to wiki from readme` 81 | 82 | ## Performance Improvement Ideas 83 | 84 | - Reduce wrappers: 85 | - [ ] Always forward path as bytes. This avoids the `_decode_optional_path` call completely. 86 | 87 | ## Changes for some real major version break 88 | 89 | - [ ] Enable `raw_fi` by default. 90 | - [ ] Remove file path encoding/decoding by default. 91 | - [ ] Return ENOSYS by default for almost all `Operations` implementation. 92 | - [ ] Simply expose `c_stat` to the fusepy user instead of expecting a badly documented dictionary. 93 | It is platform-dependent, but thanks to POSIX the core members are named identically. 94 | The order is unspecified by POSIX. What the current approach with `set_st_attrs` adds is silent 95 | ignoring of unknown keys. This may or may not be what one wants and the same can be achieved by 96 | testing `c_stat` with `hasattr` before setting values. This style guide should be documented. 97 | -------------------------------------------------------------------------------- /examples/loopback.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import errno 5 | import logging 6 | import os 7 | import stat 8 | import threading 9 | import time 10 | from typing import Any, Optional 11 | 12 | import mfusepy as fuse 13 | 14 | 15 | def with_root_path(func): 16 | def wrapper(self, path, *args, **kwargs): 17 | if path is not None: 18 | path = self.root + path 19 | return func(self, path, *args, **kwargs) 20 | 21 | return wrapper 22 | 23 | 24 | def static_with_root_path(func): 25 | def wrapper(self, path, *args, **kwargs): 26 | if path is not None: 27 | path = self.root + path 28 | return func(path, *args, **kwargs) 29 | 30 | return wrapper 31 | 32 | 33 | class Loopback(fuse.Operations): 34 | use_ns = True 35 | 36 | def __init__(self, root): 37 | self.root = os.path.realpath(root) 38 | self.rwlock = threading.Lock() 39 | 40 | @with_root_path 41 | @fuse.overrides(fuse.Operations) 42 | def access(self, path: str, amode: int) -> int: 43 | if not os.access(path, amode): 44 | raise fuse.FuseOSError(errno.EACCES) 45 | return 0 46 | 47 | chmod = static_with_root_path(os.chmod) 48 | chown = static_with_root_path(os.chown) 49 | 50 | @with_root_path 51 | @fuse.overrides(fuse.Operations) 52 | def create(self, path: str, mode: int, fi=None) -> int: 53 | return os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) 54 | 55 | @with_root_path 56 | @fuse.overrides(fuse.Operations) 57 | def flush(self, path: str, fh: int) -> int: 58 | os.fsync(fh) 59 | return 0 60 | 61 | @with_root_path 62 | @fuse.overrides(fuse.Operations) 63 | def fsync(self, path: str, datasync: int, fh: int) -> int: 64 | if datasync != 0: 65 | os.fdatasync(fh) 66 | else: 67 | os.fsync(fh) 68 | return 0 69 | 70 | @fuse.log_callback 71 | @with_root_path 72 | @fuse.overrides(fuse.Operations) 73 | def getattr(self, path: str, fh: Optional[int] = None) -> dict[str, Any]: 74 | if fh is not None: 75 | st = os.fstat(fh) 76 | elif path is not None: 77 | st = os.lstat(path) 78 | else: 79 | raise fuse.FuseOSError(errno.ENOENT) 80 | return { 81 | key.removesuffix('_ns'): getattr(st, key) 82 | for key in ( 83 | 'st_atime_ns', 84 | 'st_ctime_ns', 85 | 'st_gid', 86 | 'st_mode', 87 | 'st_mtime_ns', 88 | 'st_nlink', 89 | 'st_size', 90 | 'st_uid', 91 | ) 92 | } 93 | 94 | @with_root_path 95 | @fuse.overrides(fuse.Operations) 96 | def link(self, target: str, source: str): 97 | return os.link(self.root + source, target) 98 | 99 | mkdir = static_with_root_path(os.mkdir) 100 | open = static_with_root_path(os.open) 101 | 102 | @with_root_path 103 | @fuse.overrides(fuse.Operations) 104 | def mknod(self, path: str, mode: int, dev: int): 105 | # OpenBSD calls mknod + open instead of create. 106 | if stat.S_ISREG(mode): 107 | # OpenBSD does not allow using os.mknod to create regular files. 108 | fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_EXCL, mode & 0o7777) 109 | os.close(fd) 110 | else: 111 | os.mknod(path, mode, dev) 112 | return 0 113 | 114 | @with_root_path 115 | @fuse.overrides(fuse.Operations) 116 | def read(self, path: str, size: int, offset: int, fh: int) -> bytes: 117 | with self.rwlock: 118 | os.lseek(fh, offset, 0) 119 | return os.read(fh, size) 120 | 121 | @with_root_path 122 | @fuse.overrides(fuse.Operations) 123 | def readdir(self, path: str, fh: int) -> fuse.ReadDirResult: 124 | return ['.', '..', *os.listdir(path)] 125 | 126 | readlink = static_with_root_path(os.readlink) 127 | 128 | @with_root_path 129 | @fuse.overrides(fuse.Operations) 130 | def release(self, path: str, fh: int) -> int: 131 | os.close(fh) 132 | return 0 133 | 134 | @with_root_path 135 | @fuse.overrides(fuse.Operations) 136 | def rename(self, old: str, new: str): 137 | return os.rename(old, self.root + new) 138 | 139 | rmdir = static_with_root_path(os.rmdir) 140 | 141 | @with_root_path 142 | @fuse.overrides(fuse.Operations) 143 | def statfs(self, path: str) -> dict[str, int]: 144 | stv = os.statvfs(path) 145 | return { 146 | key: getattr(stv, key) 147 | for key in ( 148 | 'f_bavail', 149 | 'f_bfree', 150 | 'f_blocks', 151 | 'f_bsize', 152 | 'f_favail', 153 | 'f_ffree', 154 | 'f_files', 155 | 'f_flag', 156 | 'f_frsize', 157 | 'f_namemax', 158 | ) 159 | } 160 | 161 | @with_root_path 162 | @fuse.overrides(fuse.Operations) 163 | def symlink(self, target: str, source: str): 164 | return os.symlink(source, target) 165 | 166 | @with_root_path 167 | @fuse.overrides(fuse.Operations) 168 | def truncate(self, path: str, length: int, fh: Optional[int] = None) -> int: 169 | with open(path, 'rb+') as f: 170 | f.truncate(length) 171 | return 0 172 | 173 | unlink = static_with_root_path(os.unlink) 174 | 175 | @with_root_path 176 | @fuse.overrides(fuse.Operations) 177 | def utimens(self, path: str, times: Optional[tuple[int, int]] = None) -> int: 178 | now = int(time.time() * 1e9) 179 | os.utime(path, ns=times or (now, now)) 180 | return 0 181 | 182 | @fuse.log_callback 183 | @with_root_path 184 | @fuse.overrides(fuse.Operations) 185 | def write(self, path: str, data, offset: int, fh: int) -> int: 186 | with self.rwlock: 187 | os.lseek(fh, offset, 0) 188 | return os.write(fh, data) 189 | 190 | 191 | def cli(args=None): 192 | parser = argparse.ArgumentParser() 193 | parser.add_argument('root') 194 | parser.add_argument('mount') 195 | args = parser.parse_args(args) 196 | 197 | logging.basicConfig(level=logging.DEBUG) 198 | fuse.FUSE(Loopback(args.root), args.mount, foreground=True) 199 | 200 | 201 | if __name__ == '__main__': 202 | cli() 203 | -------------------------------------------------------------------------------- /tests/test_struct_layout.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import os 3 | import platform 4 | import pprint 5 | import shutil 6 | import subprocess 7 | import tempfile 8 | from pathlib import Path 9 | 10 | import pytest 11 | 12 | import mfusepy 13 | 14 | pytestmark = pytest.mark.order(0) 15 | 16 | 17 | # Only check the struct members that are present on all supported platforms. 18 | STRUCT_NAMES = { 19 | 'stat': [ 20 | 'st_atimespec', 21 | 'st_blksize', 22 | 'st_blocks', 23 | 'st_ctimespec', 24 | 'st_dev', 25 | 'st_gid', 26 | 'st_ino', 27 | 'st_mode', 28 | 'st_mtimespec', 29 | 'st_nlink', 30 | 'st_rdev', 31 | 'st_size', 32 | 'st_uid', 33 | ], 34 | 'statvfs': [ 35 | 'f_bavail', 36 | 'f_bfree', 37 | 'f_blocks', 38 | 'f_bsize', 39 | 'f_favail', 40 | 'f_ffree', 41 | 'f_files', 42 | 'f_flag', 43 | 'f_frsize', 44 | 'f_fsid', 45 | 'f_namemax', 46 | ], 47 | 'fuse_context': ['fuse', 'uid', 'gid', 'pid', 'umask'], 48 | 'fuse_conn_info': [ 49 | 'proto_major', 50 | 'proto_minor', 51 | 'max_write', 52 | 'max_readahead', 53 | 'capable', 54 | 'want', 55 | 'max_background', 56 | 'congestion_threshold', 57 | ], 58 | 'fuse_operations': [ 59 | 'getattr', 60 | 'readlink', 61 | 'mknod', 62 | 'mkdir', 63 | 'unlink', 64 | 'rmdir', 65 | 'symlink', 66 | 'rename', 67 | 'link', 68 | 'chmod', 69 | 'chown', 70 | 'truncate', 71 | 'open', 72 | 'read', 73 | 'write', 74 | 'statfs', 75 | 'flush', 76 | 'release', 77 | 'fsync', 78 | 'setxattr', 79 | 'getxattr', 80 | 'listxattr', 81 | 'removexattr', 82 | ], 83 | } 84 | 85 | if platform.system() != 'NetBSD': 86 | STRUCT_NAMES['fuse_file_info'] = ['flags', 'fh', 'lock_owner'] 87 | 88 | if mfusepy.fuse_version_major == 3: 89 | STRUCT_NAMES['fuse_config'] = [ 90 | 'set_gid', 91 | 'gid', 92 | 'set_uid', 93 | 'uid', 94 | 'set_mode', 95 | 'umask', 96 | 'entry_timeout', 97 | 'negative_timeout', 98 | 'attr_timeout', 99 | 'intr', 100 | 'intr_signal', 101 | 'remember', 102 | 'hard_remove', 103 | 'use_ino', 104 | 'readdir_ino', 105 | 'direct_io', 106 | 'kernel_cache', 107 | 'auto_cache', 108 | ] 109 | 110 | 111 | C_CHECKER = r''' 112 | #include 113 | #include 114 | #include 115 | #include 116 | #include 117 | 118 | #define PRINT_STAT_MEMBER_OFFSET(NAME) \ 119 | printf("stat.%s offset:%zu\n", #NAME, offsetof(struct stat, NAME)); 120 | 121 | #define PRINT_STATVFS_MEMBER_OFFSET(NAME) \ 122 | printf("statvfs.%s offset:%zu\n", #NAME, offsetof(struct statvfs, NAME)); 123 | 124 | #define PRINT_FUSE_FILE_INFO_MEMBER_OFFSET(NAME) \ 125 | printf("fuse_file_info.%s offset:%zu\n", #NAME, offsetof(struct fuse_file_info, NAME)); 126 | 127 | int main() 128 | { 129 | ''' 130 | 131 | PY_INFOS = {} 132 | for struct_name, member_names in STRUCT_NAMES.items(): 133 | fusepy_struct = getattr(mfusepy, struct_name, getattr(mfusepy, 'c_' + struct_name, None)) 134 | assert fusepy_struct is not None 135 | 136 | PY_INFOS[struct_name + " size"] = ctypes.sizeof(fusepy_struct) 137 | C_CHECKER += f"""\n printf("{struct_name} size:%zu\\n", sizeof(struct {struct_name}));\n""" 138 | 139 | for name in member_names: 140 | PY_INFOS[f'{struct_name}.{name} offset'] = getattr(fusepy_struct, name).offset 141 | # This naming discrepancy is not good but would be an API change, I think. 142 | c_name = name.replace('timespec', 'time') if name.endswith('timespec') else name 143 | C_CHECKER += f' printf("{struct_name}.{name} offset:%zu\\n", offsetof(struct {struct_name}, {c_name}));\n' 144 | 145 | C_CHECKER += """ 146 | return 0; 147 | } 148 | """ 149 | 150 | print(C_CHECKER) 151 | 152 | 153 | def get_compiler(): 154 | compiler = os.environ.get('CC') 155 | if not compiler: 156 | for cc in ['cc', 'gcc', 'clang']: 157 | if shutil.which(cc): 158 | compiler = cc 159 | break 160 | else: 161 | compiler = 'cc' 162 | return compiler 163 | 164 | 165 | def c_run(name: str, source: str) -> str: 166 | with tempfile.TemporaryDirectory() as tmpdir: 167 | c_file = os.path.join(tmpdir, name + '.c') 168 | exe_file = os.path.join(tmpdir, name) 169 | preprocessed_file = os.path.join(tmpdir, name + '.preprocessed.c') 170 | 171 | with open(c_file, 'w', encoding='utf-8') as f: 172 | f.write(source) 173 | 174 | print(f"FUSE version: {mfusepy.fuse_version_major}.{mfusepy.fuse_version_minor}") 175 | 176 | # Common include locations for different OSes 177 | include_paths = [ 178 | '/usr/local/include/osxfuse/fuse', 179 | '/usr/local/include/macfuse/fuse', 180 | '/usr/include/libfuse', 181 | ] 182 | if mfusepy.fuse_version_major == 3: 183 | include_paths += ['/usr/local/include/fuse3', '/usr/include/fuse3'] 184 | else: 185 | include_paths += ['/usr/local/include/fuse', '/usr/include/fuse'] 186 | 187 | cflags = [ 188 | f'-DFUSE_USE_VERSION={mfusepy.fuse_version_major}{mfusepy.fuse_version_minor}', 189 | '-D_FILE_OFFSET_BITS=64', 190 | ] 191 | cflags += [f'-I{path}' for path in include_paths if os.path.exists(path)] 192 | 193 | # Add possible pkg-config flags if available 194 | for fuse_lib in ("fuse", "fuse3"): 195 | try: 196 | pkg_config_flags = subprocess.check_output(['pkg-config', '--cflags', fuse_lib], text=True).split() 197 | cflags.extend(pkg_config_flags) 198 | break 199 | except (subprocess.CalledProcessError, FileNotFoundError): 200 | pass 201 | 202 | cmd = [get_compiler(), *cflags, c_file, '-o', exe_file] 203 | print(f"Compiling with: {' '.join(cmd)}") 204 | try: 205 | subprocess.run(cmd, capture_output=True, text=True, check=True) 206 | except subprocess.CalledProcessError as e: 207 | print(f"Compiler return code: {e.returncode}") 208 | print(f"Compiler stdout:\n{e.stdout}") 209 | print(f"Compiler stderr:\n{e.stderr}") 210 | assert e.returncode == 0, "Could not compile C program to verify sizes." 211 | 212 | cmd = [get_compiler(), '-E', *cflags, c_file, '-o', preprocessed_file] 213 | print(f"Preprocessing with: {' '.join(cmd)}") 214 | try: 215 | subprocess.run(cmd, capture_output=True, text=True, check=True) 216 | except subprocess.CalledProcessError as e: 217 | print(f"Compiler return code: {e.returncode}") 218 | print(f"Compiler stdout:\n{e.stdout}") 219 | print(f"Compiler stderr:\n{e.stderr}") 220 | assert e.returncode == 0, "Could not compile C program to verify sizes." 221 | 222 | for line in Path(preprocessed_file).read_text().split('\n'): 223 | if not line.startswith('#') and line: 224 | print(line) 225 | print(preprocessed_file) 226 | 227 | output = subprocess.check_output([exe_file], text=True) 228 | return output 229 | 230 | 231 | @pytest.mark.skipif(os.name == 'nt', reason="C compiler check not implemented for Windows") 232 | def test_struct_layout(): 233 | output = c_run("verify_structs", C_CHECKER) 234 | c_infos = {line.split(':', 1)[0]: int(line.split(':', 1)[1]) for line in output.strip().split('\n')} 235 | pprint.pprint(c_infos) 236 | 237 | fail = False 238 | for struct_name, member_names in STRUCT_NAMES.items(): 239 | key = f"{struct_name} size" 240 | if c_infos[key] == PY_INFOS[key]: 241 | print(f"OK: {key} = {c_infos[key]}") 242 | else: 243 | print(f"Mismatch for {key}: C={c_infos[key]}, Python={PY_INFOS[key]}") 244 | fail = True 245 | 246 | for name in member_names: 247 | key = f"{struct_name}.{name} offset" 248 | if c_infos[key] == PY_INFOS[key]: 249 | print(f"OK: {key} = {c_infos[key]}") 250 | else: 251 | print(f"Mismatch for {key}: C={c_infos[key]}, Python={PY_INFOS[key]}") 252 | fail = True 253 | 254 | assert not fail, "Struct layout mismatch, see stdout output for details!" 255 | -------------------------------------------------------------------------------- /examples/memory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import collections 5 | import ctypes 6 | import errno 7 | import logging 8 | import os 9 | import stat 10 | import struct 11 | import time 12 | from collections.abc import Iterable 13 | from typing import Any, Optional 14 | 15 | import mfusepy as fuse 16 | 17 | 18 | # Use log_callback instead of LoggingMixIn! This is only here for downwards compatibility tests. 19 | class Memory(fuse.LoggingMixIn, fuse.Operations): 20 | 'Example memory filesystem. Supports only one level of files.' 21 | 22 | use_ns = True 23 | 24 | def __init__(self) -> None: 25 | self.data: dict[str, bytes] = collections.defaultdict(bytes) 26 | now = int(time.time() * 1e9) 27 | self.files: dict[str, dict[str, Any]] = { 28 | '/': { 29 | # writable by owner only 30 | 'st_mode': (stat.S_IFDIR | 0o755), 31 | 'st_ctime': now, 32 | 'st_mtime': now, 33 | 'st_atime': now, 34 | 'st_nlink': 2, 35 | # ensure the mount root is owned by the current user 36 | 'st_uid': os.getuid() if hasattr(os, 'getuid') else 0, 37 | 'st_gid': os.getgid() if hasattr(os, 'getgid') else 0, 38 | } 39 | } 40 | 41 | @fuse.overrides(fuse.Operations) 42 | def chmod(self, path: str, mode: int) -> int: 43 | self.files[path]['st_mode'] &= 0o770000 44 | self.files[path]['st_mode'] |= mode 45 | return 0 46 | 47 | @fuse.overrides(fuse.Operations) 48 | def chown(self, path: str, uid: int, gid: int) -> int: 49 | self.files[path]['st_uid'] = uid 50 | self.files[path]['st_gid'] = gid 51 | return 0 52 | 53 | @fuse.overrides(fuse.Operations) 54 | def create(self, path: str, mode: int, fi=None) -> int: 55 | now = int(time.time() * 1e9) 56 | uid, gid, _pid = fuse.fuse_get_context() 57 | self.files[path] = { 58 | 'st_mode': (stat.S_IFREG | mode), 59 | 'st_nlink': 1, 60 | 'st_size': 0, 61 | 'st_ctime': now, 62 | 'st_mtime': now, 63 | 'st_atime': now, 64 | # ensure the file is owned by the current user 65 | 'st_uid': uid, 66 | 'st_gid': gid, 67 | } 68 | return 0 69 | 70 | @fuse.overrides(fuse.Operations) 71 | def getattr(self, path: str, fh=None) -> dict[str, Any]: 72 | if path not in self.files: 73 | raise fuse.FuseOSError(errno.ENOENT) 74 | return self.files[path] 75 | 76 | @fuse.overrides(fuse.Operations) 77 | def getxattr(self, path: str, name: str, position=0) -> bytes: 78 | attrs: dict[str, bytes] = self.files[path].get('attrs', {}) 79 | 80 | try: 81 | return attrs[name] 82 | except KeyError: 83 | raise fuse.FuseOSError(fuse.ENOATTR) 84 | 85 | @fuse.overrides(fuse.Operations) 86 | def listxattr(self, path: str) -> Iterable[str]: 87 | attrs = self.files[path].get('attrs', {}) 88 | return attrs.keys() 89 | 90 | @fuse.overrides(fuse.Operations) 91 | def mkdir(self, path: str, mode: int) -> int: 92 | now = int(time.time() * 1e9) 93 | uid, gid, _pid = fuse.fuse_get_context() 94 | self.files[path] = { 95 | 'st_mode': (stat.S_IFDIR | mode), 96 | 'st_nlink': 2, 97 | 'st_size': 0, 98 | 'st_ctime': now, 99 | 'st_mtime': now, 100 | 'st_atime': now, 101 | # ensure the directory is owned by the current user 102 | 'st_uid': uid, 103 | 'st_gid': gid, 104 | } 105 | 106 | self.files['/']['st_nlink'] += 1 107 | return 0 108 | 109 | @fuse.overrides(fuse.Operations) 110 | def read(self, path: str, size: int, offset: int, fh: int) -> bytes: 111 | return self.data[path][offset : offset + size] 112 | 113 | @fuse.overrides(fuse.Operations) 114 | def readdir(self, path: str, fh) -> fuse.ReadDirResult: 115 | yield '.' 116 | yield '..' 117 | for x in self.files: 118 | if x.startswith(path) and len(x) > len(path): 119 | yield x[1:] 120 | 121 | @fuse.overrides(fuse.Operations) 122 | def readlink(self, path: str) -> str: 123 | return self.data[path].decode() 124 | 125 | @fuse.overrides(fuse.Operations) 126 | def removexattr(self, path: str, name: str) -> int: 127 | attrs: dict[str, bytes] = self.files[path].get('attrs', {}) 128 | 129 | try: 130 | del attrs[name] 131 | except KeyError: 132 | raise fuse.FuseOSError(fuse.ENOATTR) 133 | 134 | return 0 135 | 136 | @fuse.overrides(fuse.Operations) 137 | def rename(self, old: str, new: str) -> int: 138 | if old in self.data: # Directories have no data. 139 | self.data[new] = self.data.pop(old) 140 | if old not in self.files: 141 | raise fuse.FuseOSError(errno.ENOENT) 142 | self.files[new] = self.files.pop(old) 143 | return 0 144 | 145 | @fuse.overrides(fuse.Operations) 146 | def rmdir(self, path: str) -> int: 147 | # with multiple level support, need to raise ENOTEMPTY if contains any files 148 | self.files.pop(path) 149 | self.files['/']['st_nlink'] -= 1 150 | return 0 151 | 152 | @fuse.overrides(fuse.Operations) 153 | def setxattr(self, path: str, name: str, value, options: int, position: int = 0) -> int: 154 | # Ignore options 155 | attrs: dict[str, bytes] = self.files[path].setdefault('attrs', {}) 156 | attrs[name] = value 157 | return 0 158 | 159 | @fuse.overrides(fuse.Operations) 160 | def statfs(self, path: str) -> dict[str, int]: 161 | return {'f_bsize': 512, 'f_blocks': 4096, 'f_bavail': 2048} 162 | 163 | @fuse.overrides(fuse.Operations) 164 | def symlink(self, target: str, source: str) -> int: 165 | self.files[target] = {'st_mode': (stat.S_IFLNK | 0o777), 'st_nlink': 1, 'st_size': len(source)} 166 | self.data[target] = source.encode() 167 | return 0 168 | 169 | @fuse.overrides(fuse.Operations) 170 | def truncate(self, path: str, length: int, fh=None) -> int: 171 | # make sure extending the file fills in zero bytes 172 | self.data[path] = self.data[path][:length].ljust(length, '\x00'.encode('ascii')) 173 | self.files[path]['st_size'] = length 174 | return 0 175 | 176 | @fuse.overrides(fuse.Operations) 177 | def unlink(self, path: str) -> int: 178 | self.data.pop(path, None) 179 | self.files.pop(path) 180 | return 0 181 | 182 | @fuse.overrides(fuse.Operations) 183 | def utimens(self, path: str, times: Optional[tuple[int, int]] = None) -> int: 184 | now = int(time.time() * 1e9) 185 | atime, mtime = times or (now, now) 186 | self.files[path]['st_atime'] = atime 187 | self.files[path]['st_mtime'] = mtime 188 | return 0 189 | 190 | @fuse.overrides(fuse.Operations) 191 | def open(self, path: str, flags: int) -> int: 192 | # OpenBSD calls mknod + open instead of create. 193 | return 0 194 | 195 | @fuse.overrides(fuse.Operations) 196 | def release(self, path: str, fh: int) -> int: 197 | return 0 198 | 199 | @fuse.overrides(fuse.Operations) 200 | def mknod(self, path: str, mode: int, dev: int) -> int: 201 | # OpenBSD calls mknod + open instead of create. 202 | now = int(time.time() * 1e9) 203 | uid, gid, _pid = fuse.fuse_get_context() 204 | self.files[path] = { 205 | 'st_mode': mode, 206 | 'st_nlink': 1, 207 | 'st_size': 0, 208 | 'st_ctime': now, 209 | 'st_mtime': now, 210 | 'st_atime': now, 211 | # ensure the file is owned by the current user 212 | 'st_uid': uid, 213 | 'st_gid': gid, 214 | } 215 | return 0 216 | 217 | @fuse.overrides(fuse.Operations) 218 | def write(self, path: str, data, offset: int, fh: int) -> int: 219 | self.data[path] = ( 220 | # make sure the data gets inserted at the right offset 221 | self.data[path][:offset].ljust(offset, '\x00'.encode('ascii')) 222 | + data 223 | # and only overwrites the bytes that data is replacing 224 | + self.data[path][offset + len(data) :] 225 | ) 226 | self.files[path]['st_size'] = len(self.data[path]) 227 | return len(data) 228 | 229 | @fuse.overrides(fuse.Operations) 230 | def ioctl(self, path: str, cmd: int, arg: ctypes.c_void_p, fh: int, flags: int, data: ctypes.c_void_p) -> int: 231 | """ 232 | An example ioctl implementation that defines a command with integer code corresponding to 'M' in ASCII, 233 | which returns the 32-bit integer argument incremented by 1. 234 | """ 235 | from ioctl_opt import IOWR 236 | 237 | iowr_m = IOWR(ord('M'), 1, ctypes.c_uint32) 238 | if cmd == iowr_m: 239 | inbuf = ctypes.create_string_buffer(4) 240 | ctypes.memmove(inbuf, data, 4) 241 | data_in = struct.unpack('= 1.1, < 3.0",] 41 | ``` 42 | 43 | 44 | # About this fork 45 | 46 | This is a fork of [fusepy](https://github.com/fusepy/fusepy) because it did not see any development for over 6 years. 47 | [Refuse](https://github.com/pleiszenburg/refuse/) was an attempt to fork fusepy, but it has not seen any development for over 4 years. Among many metadata changes, it contains two bugfixes to the high-level API, which I have redone in this fork. 48 | See also the discussion in [this issue](https://github.com/mxmlnkn/ratarmount/issues/101). 49 | I intend to maintain this fork as long as I maintain [ratarmount](https://github.com/mxmlnkn/ratarmount), which is now over 5 years old. 50 | 51 | The main motivations for the fork are: 52 | 53 | - [x] FUSE 3 support. Based on the [libfuse changelog](https://github.com/libfuse/libfuse/blob/master/ChangeLog.rst#libfuse-300-2016-12-08), the amount of breaking changes should be fairly small. It should be possible to simply update these ten or so changed structs and functions in the existing fusepy. 54 | - [x] Translation layer performance. In benchmarks for a simple `find` call that lists all files, some callbacks such as `readdir` turned out to be significantly limited by converting Python dictionaries to `ctype` structs. The idea would be to expose the `ctype` structs to the fusepy caller. 55 | - Much of the performance was lost trying to populate the stat struct even though only the mode and inode member are used by the kernel FUSE API. 56 | 57 | The prefix `m` in the name stands for anything you want it to: "multi" because multiple libfuse versions are supported, "modded", "modern", or "Maximilian". 58 | 59 | 60 | # Comparison with other libraries 61 | 62 | ## High-level interface support (path-based) 63 | 64 | | Project | License | Dependants | Notes 65 | |-------------------------------------------------------|------|-----|------------------------| 66 | | [fusepy](https://github.com/fusepy/fusepy) | ISC | [63](https://www.wheelodex.org/projects/fusepy/rdepends/) | The most popular Python-bindings, but unfortunately unmaintained for 6+ years. | 67 | | [python-fuse](https://github.com/libfuse/python-fuse) | LGPL | [12](https://www.wheelodex.org/projects/fuse-python/rdepends/) | Written directly in C interfacing with `fuse.h` and exposing it via `Python.h`. Only supports libfuse2, not libfuse3. | 68 | | [refuse](https://github.com/pleiszenburg/refuse) | ISC | [3](https://www.wheelodex.org/projects/refuse/rdepends/) | Dead fork of fusepy with many other dead forks: [[1]](https://github.com/yarikoptic/refuse) [[2]](https://github.com/YoilyL/refuse) | 69 | | [fusepyng](https://pypi.org/project/fusepyng/) | ISC | [0](https://www.wheelodex.org/projects/fusepyng/rdepends/) | Dead fork of fusepy. GitHub repo has been force-pushed as a statement. Fork [here](https://github.com/djsutherland/fusepyng). | 70 | | [userspacefs](https://pypi.org/project/userspacefs/) | GPL3 (why not ISC?) | [1](https://www.wheelodex.org/projects/userspacefs/rdepends/) | Fork of fusepyng/fusepy. Gated behind a self-hosted solution with no possibility to open issues or pull requests. | 71 | | [fusepy3](https://github.com/fox-it/fusepy3) | ISC | Not on PyPI | Fork of fusepy for the [fox-it/dissect](https://github.com/fox-it/dissect) ecosystem to add libfuse3 support. Seems to drop libfuse2 support and does not appear to work around the ABI [incompatibilities](https://github.com/libfuse/libfuse/issues/1029) between libfuse3 minor versions. Last updated 1.5 years ago. Looks like publish-and-forget, or it may simply have no bugs. | 72 | 73 | 74 | ## Low-level interface support (inode/int-based) 75 | 76 | All these libraries only wrap the low-level libfuse interface, which works with inodes instead of paths, and therefore are not (easily) usable for my use case. 77 | In the end, there is mostly only some path-to-hash table in the high-level libfuse API, but it is cumbersome to implement and performance-critical. 78 | 79 | | Project | License | Dependants | Notes 80 | |------------------------------------------------------------------|------|-----|------------------------| 81 | | [pyfuse3](https://github.com/libfuse/pyfuse3) | LGPL | [9](https://www.wheelodex.org/projects/pyfuse3/rdepends/) | ReadMe contains: "Warning - no longer developed!". | 82 | | [llfuse](https://github.com/python-llfuse/python-llfuse/) | LGPL | [2](https://www.wheelodex.org/projects/llfuse/rdepends/) | ReadMe contains: ["Warning - no longer developed!"](https://github.com/python-llfuse/python-llfuse/issues/67). | 83 | | [arvados-llfuse](https://github.com/arvados/python-llfuse/) | LGPL | [1](https://www.wheelodex.org/projects/arvados-llfuse/rdepends/) | Fork of llfuse, but less up to date? | 84 | | [aliyundrive-fuse](https://github.com/messense/aliyundrive-fuse) | MIT | [0](https://www.wheelodex.org/projects/aliyundrive-fuse/rdepends/) | Alibaba Cloud Disk FUSE disk mounting. "This repository has been archived by the owner on Mar 28, 2023". Documentation is only in Chinese. Read-only support. Multiple fizzled-out forks: [pikpak-fuse](https://github.com/ykxVK8yL5L/pikpak-fuse/), [alist-fuse](https://github.com/ykxVK8yL5L/alist-fuse) | 85 | 86 | 87 | # Examples 88 | 89 | See some examples of how you can use mfusepy: 90 | 91 | | Example | Description | 92 | |----------------------------------|------------------------------------------------| 93 | | [memory](examples/memory.py) | A simple memory filesystem | 94 | | [loopback](examples/loopback.py) | A loopback filesystem | 95 | | [context](examples/context.py) | Sample usage of `fuse_get_context()` | 96 | | [sftp](examples/sftp.py) | A simple SFTP filesystem (requires paramiko) | 97 | 98 | 99 | # Platforms 100 | 101 | mfusepy requires FUSE 2.6 (or later) and runs on: 102 | 103 | - Linux (i386, x86_64, PPC, arm64, MIPS) 104 | - macOS (Apple Silicon, Intel) 105 | - FreeBSD (i386, amd64) 106 | - NetBSD (amd64) 107 | - OpenBSD (amd64) 108 | 109 | While FUSE is (at least in the Unix world) a [Linux kernel feature](https://man7.org/linux/man-pages/man4/fuse.4.html), several user space libraries exist for easy access. 110 | `libfuse` acts as the reference implementation. 111 | 112 | - [libfuse](https://github.com/libfuse/libfuse) (Linux, FreeBSD) (fuse.h [2](https://github.com/libfuse/libfuse/blob/fuse-2_9_bugfix/include/fuse.h) [3](https://github.com/libfuse/libfuse/blob/master/include/fuse.h)) 113 | - [libfuse](https://github.com/openbsd/src/tree/master/lib/libfuse) (OpenBSD) (fuse.h [2](https://github.com/openbsd/src/blob/master/lib/libfuse/fuse.h)) 114 | - [librefuse](https://github.com/NetBSD/src/tree/netbsd-8/lib/librefuse) (NetBSD) through [PUFFS](https://en.wikipedia.org/wiki/PUFFS_(NetBSD)) (fuse.h [2](https://github.com/NetBSD/src/blob/netbsd-8/lib/librefuse/fuse.h)) 115 | - [macFUSE](https://github.com/macfuse/macfuse) (macOS), previously called [osxfuse](https://osxfuse.github.io/), (fuse.h [2](https://github.com/osxfuse/fuse/blob/master/include/fuse.h) [3](https://github.com/macfuse/library/blob/6a8b90a0ab2685af918d5788cfdb5186f07351ce/include/fuse.h)) 116 | - [MacFUSE](https://code.google.com/archive/p/macfuse/) (macOS), no longer maintained 117 | - [FUSE-T](https://www.fuse-t.org/) (macOS), [GitHub](https://github.com/macos-fuse-t/fuse-t) 118 | - [WinFsp](https://github.com/billziss-gh/winfsp) (Windows) (fuse.h [2](https://github.com/winfsp/winfsp/blob/master/inc/fuse/fuse.h) [3](https://github.com/winfsp/winfsp/blob/master/inc/fuse3/fuse.h)) 119 | - [Dokany](https://github.com/dokan-dev/dokany) (Windows) (fuse.h [2](https://github.com/dokan-dev/dokany/blob/master/dokan_fuse/include/fuse.h)) 120 | - [Dokan](https://code.google.com/archive/p/dokan/) (Windows), no longer maintained 121 | 122 | 123 | # Known Dependents 124 | 125 | - [Megatron-Energon](https://github.com/NVIDIA/Megatron-Energon) 126 | - [ninfs](https://github.com/ihaveamac/ninfs) 127 | - [ratarmount](https://github.com/mxmlnkn/ratarmount) 128 | -------------------------------------------------------------------------------- /examples/memory_nullpath.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import collections 5 | import errno 6 | import os 7 | import stat 8 | import time 9 | from collections.abc import Iterable 10 | from typing import Any, Optional 11 | 12 | import mfusepy as fuse 13 | 14 | 15 | class Memory(fuse.Operations): 16 | 'Example memory filesystem. Supports only one level of files.' 17 | 18 | flag_nullpath_ok = True 19 | flag_nopath = True 20 | use_ns = True 21 | 22 | def __init__(self) -> None: 23 | self.data: dict[str, bytes] = collections.defaultdict(bytes) 24 | self.fd = 0 25 | now = int(time.time() * 1e9) 26 | self.files: dict[str, dict[str, Any]] = { 27 | '/': { 28 | # writable by owner only 29 | 'st_mode': (stat.S_IFDIR | 0o755), 30 | 'st_ctime': now, 31 | 'st_mtime': now, 32 | 'st_atime': now, 33 | 'st_nlink': 2, 34 | 'st_ino': 31, 35 | # ensure the mount root is owned by the current user 36 | 'st_uid': os.getuid() if hasattr(os, 'getuid') else 0, 37 | 'st_gid': os.getgid() if hasattr(os, 'getgid') else 0, 38 | } 39 | } 40 | self._inode = 100 41 | self._opened: dict[int, str] = {} 42 | 43 | @fuse.overrides(fuse.Operations) 44 | def init_with_config(self, conn_info: Optional[fuse.fuse_conn_info], config_3: Optional[fuse.fuse_config]) -> None: 45 | # This only works for FUSE 3 while the flag_nullpath_ok and flag_nopath class members work for FUSE 2 and 3! 46 | if config_3: 47 | config_3.nullpath_ok = True 48 | config_3.use_ino = True 49 | 50 | @fuse.overrides(fuse.Operations) 51 | def chmod(self, path: str, mode: int) -> int: 52 | self.files[path]['st_mode'] &= 0o770000 53 | self.files[path]['st_mode'] |= mode 54 | return 0 55 | 56 | @fuse.overrides(fuse.Operations) 57 | def chown(self, path: str, uid: int, gid: int) -> int: 58 | self.files[path]['st_uid'] = uid 59 | self.files[path]['st_gid'] = gid 60 | return 0 61 | 62 | @fuse.overrides(fuse.Operations) 63 | def create(self, path: str, mode: int, fi=None) -> int: 64 | now = int(time.time() * 1e9) 65 | uid, gid, _pid = fuse.fuse_get_context() 66 | self.files[path] = { 67 | 'st_mode': (stat.S_IFREG | mode), 68 | 'st_nlink': 1, 69 | 'st_size': 0, 70 | 'st_ctime': now, 71 | 'st_mtime': now, 72 | 'st_atime': now, 73 | 'st_ino': self._inode, 74 | # ensure the file is owned by the current user 75 | 'st_uid': uid, 76 | 'st_gid': gid, 77 | } 78 | self._inode += 1 79 | 80 | self.fd += 1 81 | self._opened[self.fd] = path 82 | return self.fd 83 | 84 | @fuse.overrides(fuse.Operations) 85 | def getattr(self, path: str, fh=None) -> dict[str, Any]: 86 | if fh is not None and fh in self._opened: 87 | path = self._opened[fh] 88 | if path not in self.files: 89 | raise fuse.FuseOSError(errno.ENOENT) 90 | return self.files[path] 91 | 92 | @fuse.overrides(fuse.Operations) 93 | def getxattr(self, path: str, name: str, position: int = 0) -> bytes: 94 | attrs: dict[str, bytes] = self.files[path].get('attrs', {}) 95 | 96 | try: 97 | return attrs[name] 98 | except KeyError: 99 | raise fuse.FuseOSError(fuse.ENOATTR) 100 | 101 | @fuse.overrides(fuse.Operations) 102 | def listxattr(self, path: str) -> Iterable[str]: 103 | attrs = self.files[path].get('attrs', {}) 104 | return attrs.keys() 105 | 106 | @fuse.overrides(fuse.Operations) 107 | def mkdir(self, path: str, mode: int) -> int: 108 | now = int(time.time() * 1e9) 109 | uid, gid, _pid = fuse.fuse_get_context() 110 | self.files[path] = { 111 | 'st_mode': (stat.S_IFDIR | mode), 112 | 'st_nlink': 2, 113 | 'st_size': 0, 114 | 'st_ctime': now, 115 | 'st_mtime': now, 116 | 'st_atime': now, 117 | 'st_ino': self._inode, 118 | # ensure the directory is owned by the current user 119 | 'st_uid': uid, 120 | 'st_gid': gid, 121 | } 122 | self._inode += 1 123 | 124 | self.files['/']['st_nlink'] += 1 125 | return 0 126 | 127 | @fuse.overrides(fuse.Operations) 128 | def open(self, path: str, flags: int) -> int: 129 | self.fd += 1 130 | self._opened[self.fd] = path 131 | return self.fd 132 | 133 | @fuse.overrides(fuse.Operations) 134 | def read(self, path: str, size, offset: int, fh: int) -> bytes: 135 | return self.data[self._opened[fh]][offset : offset + size] 136 | 137 | @fuse.overrides(fuse.Operations) 138 | def release(self, path: str, fh: int) -> int: 139 | del self._opened[fh] 140 | return 0 141 | 142 | @fuse.overrides(fuse.Operations) 143 | def opendir(self, path: str) -> int: 144 | self.fd += 1 145 | self._opened[self.fd] = path 146 | return self.fd 147 | 148 | @fuse.overrides(fuse.Operations) 149 | def readdir(self, path: str, fh: int) -> fuse.ReadDirResult: 150 | path = self._opened[fh] 151 | return [('.', self.files['/'], 0), ('..', self.files['/'], 0)] + [ 152 | (x[1:], info, 0) for x, info in self.files.items() if x.startswith(path) and len(x) > len(path) 153 | ] 154 | 155 | @fuse.overrides(fuse.Operations) 156 | def releasedir(self, path: str, fh: int) -> int: 157 | del self._opened[fh] 158 | return 0 159 | 160 | @fuse.overrides(fuse.Operations) 161 | def readlink(self, path: str) -> str: 162 | return self.data[path].decode() 163 | 164 | @fuse.overrides(fuse.Operations) 165 | def removexattr(self, path: str, name: str) -> int: 166 | attrs: dict[str, bytes] = self.files[path].get('attrs', {}) 167 | 168 | try: 169 | del attrs[name] 170 | except KeyError: 171 | raise fuse.FuseOSError(fuse.ENOATTR) 172 | 173 | return 0 174 | 175 | @fuse.overrides(fuse.Operations) 176 | def rename(self, old: str, new: str) -> int: 177 | if old in self.data: # Directories have no data. 178 | self.data[new] = self.data.pop(old) 179 | if old not in self.files: 180 | raise fuse.FuseOSError(errno.ENOENT) 181 | self.files[new] = self.files.pop(old) 182 | return 0 183 | 184 | @fuse.overrides(fuse.Operations) 185 | def rmdir(self, path: str) -> int: 186 | # with multiple level support, need to raise ENOTEMPTY if contains any files 187 | self.files.pop(path) 188 | self.files['/']['st_nlink'] -= 1 189 | return 0 190 | 191 | @fuse.overrides(fuse.Operations) 192 | def setxattr(self, path: str, name: str, value: bytes, options, position: int = 0) -> int: 193 | # Ignore options 194 | attrs: dict[str, bytes] = self.files[path].setdefault('attrs', {}) 195 | attrs[name] = value 196 | return 0 197 | 198 | @fuse.overrides(fuse.Operations) 199 | def statfs(self, path: str) -> dict[str, int]: 200 | return {'f_bsize': 512, 'f_blocks': 4096, 'f_bavail': 2048} 201 | 202 | @fuse.overrides(fuse.Operations) 203 | def symlink(self, target: str, source: str) -> int: 204 | self.files[target] = {'st_mode': (stat.S_IFLNK | 0o777), 'st_nlink': 1, 'st_size': len(source)} 205 | self.data[target] = source.encode() 206 | return 0 207 | 208 | @fuse.overrides(fuse.Operations) 209 | def truncate(self, path: str, length: int, fh=None) -> int: 210 | if fh is not None and fh in self._opened: 211 | path = self._opened[fh] 212 | # make sure extending the file fills in zero bytes 213 | self.data[path] = self.data[path][:length].ljust(length, '\x00'.encode('ascii')) 214 | self.files[path]['st_size'] = length 215 | return 0 216 | 217 | @fuse.overrides(fuse.Operations) 218 | def unlink(self, path: str) -> int: 219 | self.data.pop(path, None) 220 | self.files.pop(path) 221 | return 0 222 | 223 | @fuse.overrides(fuse.Operations) 224 | def utimens(self, path: str, times: Optional[tuple[int, int]] = None) -> int: 225 | now = int(time.time() * 1e9) 226 | atime, mtime = times or (now, now) 227 | self.files[path]['st_atime'] = atime 228 | self.files[path]['st_mtime'] = mtime 229 | return 0 230 | 231 | @fuse.overrides(fuse.Operations) 232 | def mknod(self, path: str, mode: int, dev: int) -> int: 233 | # OpenBSD calls mknod + open instead of create. 234 | now = int(time.time() * 1e9) 235 | uid, gid, _pid = fuse.fuse_get_context() 236 | self.files[path] = { 237 | 'st_mode': mode, 238 | 'st_nlink': 1, 239 | 'st_size': 0, 240 | 'st_ctime': now, 241 | 'st_mtime': now, 242 | 'st_atime': now, 243 | 'st_ino': self._inode, 244 | # ensure the file is owned by the current user 245 | 'st_uid': uid, 246 | 'st_gid': gid, 247 | } 248 | self._inode += 1 249 | return 0 250 | 251 | @fuse.overrides(fuse.Operations) 252 | def write(self, path: str, data, offset: int, fh: int) -> int: 253 | path = self._opened[fh] 254 | self.data[path] = ( 255 | # make sure the data gets inserted at the right offset 256 | self.data[path][:offset].ljust(offset, '\x00'.encode('ascii')) 257 | + data 258 | # and only overwrites the bytes that data is replacing 259 | + self.data[path][offset + len(data) :] 260 | ) 261 | self.files[path]['st_size'] = len(self.data[path]) 262 | return len(data) 263 | 264 | 265 | def cli(args=None): 266 | parser = argparse.ArgumentParser() 267 | parser.add_argument('mount') 268 | args = parser.parse_args(args) 269 | 270 | fuse.FUSE(Memory(), args.mount, foreground=True, use_ino=True) 271 | 272 | 273 | if __name__ == '__main__': 274 | cli() 275 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: '**' 6 | tags-ignore: '**' 7 | pull_request: 8 | 9 | jobs: 10 | Static-Code-Checks: 11 | runs-on: ubuntu-slim 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.10' 20 | 21 | - name: Install Dependencies 22 | run: | 23 | python3 -m pip install ioctl-opt paramiko types-paramiko pytest 24 | 25 | - name: Style Check With Ruff 26 | run: | 27 | python3 -m pip install ruff 28 | ruff check --config tests/.ruff.toml -- $( git ls-tree -r --name-only HEAD | 'grep' -E '[.]py$' ) 29 | 30 | - name: Style Check With Black 31 | run: | 32 | python3 -m pip install black 33 | black -q --diff --line-length 120 --skip-string-normalization $( git ls-tree -r --name-only HEAD | 'grep' -E '[.]py$' ) > black.diff 34 | if [ -s black.diff ]; then 35 | cat black.diff 36 | exit 123 37 | fi 38 | 39 | - name: Lint With Codespell 40 | run: | 41 | python3 -m pip install codespell 42 | codespell $( git ls-tree -r --name-only HEAD | 'grep' -E '[.](py|md|txt|sh|yml)$' ) 43 | 44 | - name: Lint With Flake8 45 | run: | 46 | python3 -m pip install flake8 47 | flake8 --config tests/.flake8 $( git ls-tree -r --name-only HEAD | 'grep' -E '[.]py$' ) 48 | 49 | - name: Lint With Pylint 50 | run: | 51 | python3 -m pip install pylint 52 | pylint --rcfile tests/.pylintrc $( git ls-tree -r --name-only HEAD | 'grep' -E '[.]py$' ) | tee pylint.log 53 | ! 'egrep' ': E[0-9]{4}: ' pylint.log 54 | 55 | - name: Lint With Pytype 56 | run: | 57 | python3 -m pip install pytype 58 | pytype -d import-error $( git ls-tree -r --name-only HEAD | 'grep' -E '[.]py$' ) 59 | 60 | - name: Lint With Mypy 61 | run: | 62 | yes | python3 -m pip install --upgrade-strategy eager --upgrade types-dataclasses mypy 63 | mypy --config-file tests/.mypy.ini $( git ls-tree -r --name-only HEAD | 'grep' -E '[.]py$' ) 64 | 65 | Tests: 66 | runs-on: ${{ matrix.os }} 67 | 68 | strategy: 69 | matrix: 70 | # https://endoflife.date/python 71 | include: 72 | - os: ubuntu-22.04 73 | python-version: '3.9' 74 | - os: ubuntu-22.04-arm 75 | python-version: '3.10' 76 | - os: ubuntu-22.04 77 | python-version: '3.11' 78 | - os: ubuntu-24.04-arm 79 | python-version: '3.12' 80 | - os: ubuntu-24.04 81 | python-version: '3.13' 82 | - os: ubuntu-24.04 83 | python-version: '3.14' 84 | - os: macos-15 85 | python-version: '3.9' 86 | - os: macos-14 87 | python-version: '3.10' 88 | - os: macos-15-intel 89 | python-version: '3.11' 90 | - os: macos-14 91 | python-version: '3.12' 92 | - os: macos-15-intel 93 | python-version: '3.13' 94 | - os: macos-15 95 | python-version: '3.14' 96 | 97 | env: 98 | MFUSEPY_CHECK_OVERRIDES: 1 99 | defaults: 100 | run: 101 | # This is especially important for windows because it seems to default to powershell 102 | shell: bash 103 | 104 | steps: 105 | - uses: actions/checkout@v4 106 | with: 107 | # We need one tag for testing the git mount. 108 | # This is BROKEN! God damn it. Is anything working at all... 109 | # https://github.com/actions/checkout/issues/1781 110 | fetch-tags: true 111 | 112 | - name: Set up Python ${{ matrix.python-version }} 113 | uses: actions/setup-python@v5 114 | with: 115 | python-version: ${{ matrix.python-version }} 116 | 117 | - name: Install Dependencies (Linux) 118 | if: startsWith( matrix.os, 'ubuntu' ) 119 | run: | 120 | sudo apt-get -y install libfuse2 libfuse-dev libfuse3-dev fuse3 wget 121 | 122 | - name: Install Dependencies (MacOS) 123 | if: startsWith( matrix.os, 'macos' ) 124 | run: | 125 | brew install -q macfuse 126 | 127 | - name: Install pip Dependencies 128 | run: | 129 | python3 -m pip install --upgrade pip 130 | python3 -m pip install --upgrade wheel 131 | python3 -m pip install --upgrade setuptools 132 | # https://github.com/pyca/pynacl/issues/839 133 | # https://github.com/pyca/pynacl/pull/848 134 | python3 -m pip install --upgrade git+https://github.com/pyca/pynacl.git 135 | python3 -m pip install --upgrade-strategy eager --upgrade twine build pytest pytest-order ioctl-opt paramiko types-paramiko 136 | 137 | - name: Test Installation From Tarball 138 | run: | 139 | python3 -m build 140 | twine check dist/* 141 | python3 -m pip install "$( find dist -name '*.tar.gz' | head -1 )"[full] 142 | 143 | - name: Test Installation From Source 144 | run: | 145 | python3 -m pip install . 146 | 147 | - name: Test Import 148 | run: | 149 | python3 -c 'import mfusepy' 150 | 151 | - name: Examples 152 | run: | 153 | # Simply parsing the source runs the @overrides check! 154 | for file in examples/*.py; do 155 | python3 "$file" -h 156 | done 157 | 158 | - name: Only test struct layout (MacOS) 159 | if: startsWith( matrix.os, 'macos' ) 160 | run: | 161 | python3 -m pytest -s -v -rs tests/test_struct_layout.py 162 | 163 | - name: Unit Tests (FUSE 2) 164 | if: startsWith( matrix.os, 'ubuntu' ) 165 | run: | 166 | python3 -c 'import mfusepy; assert mfusepy.fuse_version_major == 2' 167 | python3 -m pytest -v -rs tests 168 | 169 | - name: Unit Tests (FUSE 3) 170 | if: startsWith( matrix.os, 'ubuntu' ) 171 | run: | 172 | export FUSE_LIBRARY_PATH=$( dpkg -L libfuse3-3 | 'grep' -F .so | head -1 ) 173 | python3 -c 'import mfusepy; assert mfusepy.fuse_version_major == 3' 174 | python3 -m pytest -v -rs tests 175 | 176 | - name: Test Compatibility (Ratarmount) 177 | if: startsWith( matrix.os, 'ubuntu' ) 178 | run: | 179 | python3 -m pip install ratarmount 180 | python3 -m pip install --force-reinstall . 181 | mkdir -p mounted-tar 182 | ratarmount tests/single-file.tar mounted-tar 183 | [[ "$( cat mounted-tar/bar )" == "foo" ]] 184 | fusermount -u mounted-tar 185 | 186 | - name: Test Compatibility (ninfs) 187 | if: ${{ startsWith( matrix.os, 'ubuntu' ) && matrix.python-version != '3.7' }} 188 | run: | 189 | python3 -m pip install git+https://github.com/ihaveamac/ninfs.git@main 190 | python3 -m pip install --force-reinstall . 191 | mkdir -p mounted-3dsx 192 | ninfs threedsx tests/3dsfetch.3dsx mounted-3dsx 193 | [[ "$( md5sum mounted-3dsx/icon.smdh )" =~ "a3d784b6f20182ebdf964589cbf427c2" ]] 194 | fusermount -u mounted-3dsx 195 | 196 | VM-Tests: 197 | # Run tests inside a VM using cross-platform-actions 198 | runs-on: ubuntu-latest 199 | timeout-minutes: 10 200 | 201 | strategy: 202 | matrix: 203 | include: 204 | - os: freebsd 205 | version: '14.3' 206 | display_name: FreeBSD 207 | - os: freebsd 208 | version: '13.5' 209 | display_name: FreeBSD 210 | - os: openbsd 211 | version: '7.7' 212 | display_name: OpenBSD 213 | # OpenBSD 6.9 is not supported because it only has Python 3.8, which is not supported anymore. 214 | # NetBSD 9.4 is not supported because its interface seems to mirror libFUSE 2.6 and therefore is HIGHLY 215 | # incompatible. Even fuse_context.umask is missing! 216 | - os: netbsd 217 | version: '10.1' 218 | display_name: NetBSD 219 | 220 | 221 | steps: 222 | - name: Check out repository 223 | uses: actions/checkout@v4 224 | with: 225 | fetch-tags: true 226 | 227 | - name: Test on ${{ matrix.display_name }} 228 | uses: cross-platform-actions/action@v0.29.0 229 | with: 230 | operating_system: ${{ matrix.os }} 231 | version: ${{ matrix.version }} 232 | shell: bash 233 | run: | 234 | set -euxo pipefail 235 | case "${{ matrix.os }}" in 236 | freebsd) 237 | # Ensure a proper hostname/FQDN is set (VMs may not have one by default) 238 | sudo -E /bin/sh -c 'grep -q "freebsd\.local" /etc/hosts || echo "127.0.0.1 freebsd.local freebsd" >> /etc/hosts' 239 | sudo -E hostname freebsd.local 240 | hostname 241 | 242 | # Keep pkg from aborting on minor version drift 243 | export IGNORE_OSVERSION=yes 244 | 245 | # Packages 246 | sudo -E pkg update -f 247 | sudo -E pkg install -y git fusefs-libs python3 python311 py311-pip 248 | 249 | # Make FUSE work 250 | sudo -E kldload fusefs 251 | sudo -E sysctl vfs.usermount=1 252 | sudo -E chmod 666 /dev/fuse 253 | 254 | # Install pip Dependencies 255 | python3 -m pip install pytest pytest-order ioctl-opt 256 | 257 | # Test Installation From Source 258 | python3 -m pip install . 259 | 260 | # Unit Tests (FUSE 2) 261 | python3 -c 'import mfusepy; assert mfusepy.fuse_version_major == 2' 262 | python3 -m pytest -s -v -rs tests 263 | ;; 264 | openbsd) 265 | # Ensure a proper hostname/FQDN is set (VMs may not have one by default) 266 | sudo -E /bin/sh -c 'grep -q "openbsd\.local" /etc/hosts || echo "127.0.0.1 openbsd.local openbsd" >> /etc/hosts' 267 | sudo -E hostname openbsd.local 268 | hostname 269 | 270 | # Packages 271 | sudo -E pkg_add git py3-pip py3-virtualenv py3-tox 272 | sudo -E pkg_add sshfs-fuse # needed for sideeffect of having fuse 2.6? 273 | 274 | # Install pip Dependencies 275 | python3 -m venv .venv 276 | source .venv/bin/activate 277 | python3 -m pip install pytest pytest-order ioctl-opt 278 | 279 | # Test Installation From Source 280 | python3 -m pip install . 281 | 282 | # Unit Tests (FUSE 2) 283 | python3 -c 'import mfusepy; assert mfusepy.fuse_version_major == 2' 284 | 285 | # FUSE mounting only works as root on OpenBSD 286 | sudo -E .venv/bin/python3 -m pytest -s -v -rs tests 287 | ;; 288 | netbsd) 289 | # Ensure a proper hostname/FQDN is set (VMs may not have one by default) 290 | sudo -E /bin/sh -c 'grep -q "netbsd\.local" /etc/hosts || echo "127.0.0.1 netbsd.local netbsd" >> /etc/hosts' 291 | sudo -E hostname netbsd.local 292 | hostname 293 | 294 | # Packages 295 | sudo -E pkgin -y update 296 | sudo -E pkgin -y install git fuse perfuse python311 py311-pip 297 | sudo -E ln -sf /usr/pkg/bin/python3.11 /usr/pkg/bin/python3 298 | 299 | # Fix PATH 300 | export PATH="/usr/pkg/bin:/usr/pkg/sbin:/sbin:/usr/sbin:$PATH" 301 | 302 | # Make FUSE work (NetBSD) 303 | sudo sysctl -w vfs.generic.usermount=1 304 | sudo sysctl -w kern.sbmax=4194304 305 | sudo chmod 666 /dev/puffs 306 | 307 | # avoid permissions issue for perfused trace file: 308 | sudo chmod 777 /var/run 309 | 310 | # Install pip Dependencies 311 | python3 -m pip install pytest pytest-order ioctl-opt 312 | 313 | # Test Installation From Source 314 | python3 -m pip install . 315 | 316 | # preserve pytest exit status, always show logs 317 | rc=0 318 | python3 -m pytest -s -v -rs tests/test_struct_layout.py 319 | # for now, run the tests as root, too many PermissionErrors: 320 | sudo -E python3 -m pytest -s -v -rs tests || rc=$? 321 | exit "$rc" 322 | ;; 323 | esac 324 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=wrong-import-position 2 | # pylint: disable=protected-access 3 | 4 | import ctypes 5 | import errno 6 | import fcntl 7 | import io 8 | import os 9 | import stat 10 | import struct 11 | import subprocess 12 | import sys 13 | import tempfile 14 | import threading 15 | import time 16 | from datetime import datetime 17 | from pathlib import Path 18 | from types import ModuleType 19 | from typing import Optional 20 | 21 | import pytest 22 | from ioctl_opt import IOWR 23 | 24 | pwd: Optional[ModuleType] 25 | try: 26 | import pwd as _pwd 27 | 28 | pwd = _pwd 29 | except ImportError: 30 | pwd = None 31 | 32 | grp: Optional[ModuleType] 33 | try: 34 | import grp as _grp 35 | 36 | grp = _grp 37 | except ImportError: 38 | grp = None 39 | 40 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 41 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../examples'))) 42 | 43 | from loopback import cli as cli_loopback # noqa: E402 44 | from memory import cli as cli_memory # noqa: E402 45 | from memory_nullpath import cli as cli_memory_nullpath # noqa: E402 46 | from readdir_returning_offsets import cli as cli_readdir_returning_offsets # noqa: E402 47 | from readdir_with_offset import cli as cli_readdir_with_offset # noqa: E402 48 | 49 | # Some Python interpreters, e.g. the macOS Python may lack os.*xattr APIs. 50 | os_has_xattr_funcs = all(hasattr(os, f) for f in ("listxattr", "setxattr", "getxattr", "removexattr")) 51 | 52 | 53 | def filter_platform_files(files): 54 | # macOS uses files starting with ._ (known as AppleDouble files) to store file metadata and 55 | # extended attributes when the underlying filesystem does not natively support macOS-specific features. 56 | # 57 | # Legacy macOS filesystems (HFS/HFS+) used a "dual-fork" structure: 58 | # 59 | # Data Fork: The actual content of the file (what most OSs see). 60 | # Resource Fork: Metadata like icons, window positions, and application-specific resources. 61 | # When you copy files to a filesystem that only supports a single data stream (like FAT32, SMB/network shares, 62 | # or some FUSE implementations), macOS cannot "attach" the metadata to the file. Instead, it creates a second, 63 | # hidden file with the ._ prefix to store that metadata. 64 | # 65 | # Common types of data found in these files include: 66 | # 67 | # Finder Info: Labels, tags, and whether the file should be hidden. 68 | # Extended Attributes (xattrs): Custom metadata used by applications (e.g., "Where from" URL for downloads). 69 | # Resource Forks: Legacy data used by older Mac apps. 70 | # ACLs: Access Control Lists for permissions. 71 | files = [f for f in files if not f.startswith("._")] 72 | return files 73 | 74 | 75 | def get_mount_output() -> str: 76 | """Return the output of the system's 'mount' command as a string.""" 77 | try: 78 | completed = subprocess.run(["mount"], capture_output=True, check=True, text=True) 79 | return completed.stdout 80 | except Exception as exc: 81 | return f"\n{exc}" 82 | 83 | 84 | def stat_readable(st, path=None): 85 | """Return a single human-readable line from an os.stat_result.""" 86 | user_name = None 87 | if pwd: 88 | try: 89 | user_name = pwd.getpwuid(st.st_uid).pw_name 90 | except Exception: 91 | pass 92 | group_name = None 93 | if grp: 94 | try: 95 | group_name = grp.getgrgid(st.st_gid).gr_name 96 | except Exception: 97 | pass 98 | 99 | mode = stat.filemode(st.st_mode) 100 | size = str(st.st_size) 101 | user = f"{user_name or st.st_uid}" 102 | group = f"{group_name or st.st_gid}" 103 | atime = datetime.fromtimestamp(st.st_atime).isoformat() 104 | ctime = datetime.fromtimestamp(st.st_ctime).isoformat() 105 | mtime = datetime.fromtimestamp(st.st_mtime).isoformat() 106 | dev = str(getattr(st, "st_dev", "")) 107 | inode = str(getattr(st, "st_ino", "")) 108 | nlink = str(getattr(st, "st_nlink", "")) 109 | 110 | return f"{mode} {nlink} {user} {group} {size} a:{atime} c:{ctime} m:{mtime} {dev} {inode} {path or ''}" 111 | 112 | 113 | class RunCLI: 114 | def __init__(self, cli, mount_point, arguments): 115 | self.timeout = 4 116 | self.mount_point = str(mount_point) 117 | self.args = [*arguments, self.mount_point] 118 | self.thread = threading.Thread(target=cli, args=(self.args,)) 119 | 120 | self._stdout = None 121 | self._stderr = None 122 | 123 | def __enter__(self): 124 | self._stdout = sys.stdout 125 | self._stderr = sys.stderr 126 | sys.stdout = io.StringIO() 127 | sys.stderr = io.StringIO() 128 | 129 | self.thread.start() 130 | self.wait_for_mount_point() 131 | 132 | return self 133 | 134 | def __exit__(self, exception_type, exception_value, exception_traceback): 135 | try: 136 | stdout = sys.stdout 137 | stderr = sys.stderr 138 | sys.stdout = self._stdout 139 | sys.stderr = self._stderr 140 | stdout.seek(0) 141 | stderr.seek(0) 142 | output = stdout.read() 143 | errors = stderr.read() 144 | 145 | problematic_words = ['[Warning]', '[Error]'] 146 | if any(word in output or word in errors for word in problematic_words): 147 | print("===== stdout =====\n", output) 148 | print("===== stderr =====\n", errors) 149 | raise AssertionError("There were warnings or errors!") 150 | 151 | finally: 152 | self.unmount() 153 | self.thread.join(self.timeout) 154 | 155 | def get_stdout(self): 156 | old_position = sys.stdout.tell() 157 | try: 158 | sys.stdout.seek(0) 159 | return sys.stdout.read() 160 | finally: 161 | sys.stdout.seek(old_position) 162 | 163 | def get_stderr(self): 164 | old_position = sys.stderr.tell() 165 | try: 166 | sys.stderr.seek(0) 167 | return sys.stderr.read() 168 | finally: 169 | sys.stderr.seek(old_position) 170 | 171 | def wait_for_mount_point(self): 172 | t0 = time.time() 173 | while True: 174 | st = os.stat(self.mount_point) # helps diagnose getattr issues 175 | if os.path.ismount(self.mount_point): 176 | break 177 | if time.time() - t0 > self.timeout: 178 | mount_list = "" 179 | try: 180 | mount_list = subprocess.run("mount", capture_output=True, check=True).stdout.decode() 181 | except Exception as exception: 182 | mount_list += f"\n{exception}" 183 | raise RuntimeError( 184 | "Expected mount point but it isn't one!" 185 | "\n===== stderr =====\n" 186 | + self.get_stderr() 187 | + "\n===== stdout =====\n" 188 | + self.get_stdout() 189 | + "\n===== mount =====\n" 190 | + mount_list 191 | + "\n===== stat(self.mount_point) =====\n" 192 | + f"{stat_readable(st)}\n" 193 | ) 194 | time.sleep(0.1) 195 | 196 | def unmount(self): 197 | self.wait_for_mount_point() 198 | 199 | # Linux: fusermount -u, macOS: umount, FreeBSD: umount 200 | cmd = ["fusermount", "-u", self.mount_point] if sys.platform == 'linux' else ["umount", self.mount_point] 201 | subprocess.run(cmd, check=True, capture_output=True) 202 | 203 | t0 = time.time() 204 | while True: 205 | if not os.path.ismount(self.mount_point): 206 | break 207 | if time.time() - t0 > self.timeout: 208 | raise RuntimeError("Unmounting did not finish in time!") 209 | time.sleep(0.1) 210 | 211 | 212 | @pytest.mark.parametrize('cli', [cli_loopback, cli_memory, cli_memory_nullpath]) 213 | def test_read_write_file_system(cli, tmp_path): 214 | if cli == cli_loopback: 215 | mount_source = tmp_path / "folder" 216 | mount_point = tmp_path / "mounted" 217 | mount_source.mkdir() 218 | mount_point.mkdir() 219 | arguments = [str(mount_source)] 220 | else: 221 | mount_point = tmp_path 222 | arguments = [] 223 | with RunCLI(cli, mount_point, arguments): 224 | st = os.stat(mount_point) 225 | assert os.path.isdir(mount_point), f"{mount_point} is not a directory, st={stat_readable(st)}!" 226 | 227 | path = mount_point / "foo" 228 | assert not path.is_dir() 229 | 230 | try: 231 | n = path.write_bytes(b"bar") 232 | except PermissionError: 233 | mtab = get_mount_output() 234 | pytest.fail(reason=f"PermissionError, mount_point: st={stat_readable(st)}, mtab:\n{mtab}") 235 | else: 236 | assert n == 3 237 | 238 | assert path.exists() 239 | assert path.is_file() 240 | assert not path.is_dir() 241 | 242 | assert path.read_bytes() == b"bar" 243 | 244 | # ioctl does not work for regular files on macOS / *BSD. 245 | # IOCTL(2) for BSDs and macOS: 246 | # [ENOTTY] The fd argument is not associated with a character special device. 247 | if sys.platform == 'linux' and cli == cli_memory: 248 | with open(path, 'rb') as file: 249 | # Test a simple ioctl command that returns the argument incremented by one. 250 | argument = 123 251 | iowr_m = IOWR(ord('M'), 1, ctypes.c_uint32) 252 | result = fcntl.ioctl(file, iowr_m, struct.pack('I', argument)) 253 | assert struct.unpack('I', result)[0] == argument + 1 254 | 255 | os.truncate(path, 2) 256 | assert path.read_bytes() == b"ba" 257 | 258 | os.chmod(path, 0) 259 | assert os.stat(path).st_mode & 0o777 == 0 260 | os.chmod(path, 0o777) 261 | assert os.stat(path).st_mode & 0o777 == 0o777 262 | 263 | try: 264 | # Only works for memory file systems on Linux, but not on macOS. 265 | os.chown(path, 12345, 23456) 266 | assert os.stat(path).st_uid == 12345 267 | assert os.stat(path).st_gid == 23456 268 | except PermissionError: 269 | if sys.platform != 'darwin': 270 | assert cli == cli_loopback 271 | 272 | os.chown(path, os.getuid(), os.getgid()) 273 | assert os.stat(path).st_uid == os.getuid() 274 | assert os.stat(path).st_gid == os.getgid() 275 | 276 | if os_has_xattr_funcs: 277 | try: 278 | assert not os.listxattr(path) 279 | os.setxattr(path, b"user.tag-test", b"FOO-RESULT") 280 | assert os.listxattr(path) 281 | assert os.getxattr(path, b"user.tag-test") == b"FOO-RESULT" 282 | os.removexattr(path, b"user.tag-test") 283 | assert not os.listxattr(path) 284 | except OSError as exception: 285 | assert cli == cli_loopback 286 | assert exception.errno == errno.ENOTSUP 287 | 288 | os.utime(path, (1.5, 12.5)) 289 | assert os.stat(path).st_atime == 1.5 290 | assert os.stat(path).st_mtime == 12.5 291 | 292 | os.utime(path, ns=(int(1.5e9), int(12.5e9))) 293 | assert os.stat(path).st_atime == 1.5 294 | assert os.stat(path).st_mtime == 12.5 295 | 296 | assert filter_platform_files(os.listdir(mount_point)) == ["foo"] 297 | os.unlink(path) 298 | assert not path.exists() 299 | 300 | os.mkdir(path) 301 | assert path.exists() 302 | assert not path.is_file() 303 | assert path.is_dir() 304 | 305 | assert filter_platform_files(os.listdir(mount_point)) == ["foo"] 306 | assert os.listdir(path) == [] 307 | 308 | os.rename(mount_point / "foo", mount_point / "bar") 309 | assert not os.path.exists(mount_point / "foo") 310 | assert os.path.exists(mount_point / "bar") 311 | 312 | os.symlink(mount_point / "bar", path) 313 | assert path.exists() 314 | # assert path.is_file() # Does not have a follow_symlink argument but it seems to be True, see below. 315 | assert path.is_dir() 316 | assert os.path.islink(path) 317 | assert os.readlink(path) == str(mount_point / "bar") 318 | 319 | os.rmdir(mount_point / "bar") 320 | assert not os.path.exists(mount_point / "bar") 321 | 322 | if cli != cli_loopback: 323 | # Looks like macOS always returns the memory page size here (16K Apple Silicon, 4K Intel) 324 | # and not the value provided by the fuse fs implementation (here: 512). 325 | # FreeBSD returns 65536 (why?). 326 | if sys.platform == 'darwin': 327 | expected_bsize = os.sysconf('SC_PAGE_SIZE') 328 | elif sys.platform.startswith('freebsd'): 329 | expected_bsize = 65536 330 | else: 331 | expected_bsize = 512 332 | assert os.statvfs(mount_point).f_bsize == expected_bsize 333 | assert os.statvfs(mount_point).f_bavail == 2048 334 | 335 | for i in range(200): 336 | path = mount_point / str(i) 337 | assert not path.exists() 338 | assert path.write_bytes(b"bar") == 3 339 | assert len(filter_platform_files(os.listdir(mount_point))) == i + 2 340 | 341 | for i in range(200): 342 | path = mount_point / str(i) 343 | path.unlink() 344 | 345 | 346 | @pytest.mark.parametrize('cli', [cli_memory_nullpath]) 347 | def test_use_inode(cli, tmp_path): 348 | mount_point = tmp_path 349 | arguments = [] 350 | with RunCLI(cli, mount_point, arguments): 351 | st = os.stat(mount_point) 352 | assert os.path.isdir(mount_point), f"{mount_point} is not a directory, st={stat_readable(st)}!" 353 | 354 | assert os.stat(mount_point).st_ino == 31 355 | 356 | path = mount_point / "foo" 357 | assert not path.is_dir() 358 | 359 | try: 360 | n = path.write_bytes(b"bar") 361 | except PermissionError: 362 | mtab = get_mount_output() 363 | pytest.fail(reason=f"PermissionError, mount_point: st={stat_readable(st)}, mtab:\n{mtab}") 364 | else: 365 | assert n == 3 366 | 367 | assert path.exists() 368 | assert path.is_file() 369 | assert not path.is_dir() 370 | 371 | assert os.stat(path).st_ino == 100 372 | 373 | 374 | @pytest.mark.parametrize('cli', [cli_readdir_with_offset, cli_readdir_returning_offsets]) 375 | def test_readdir_with_offset(cli, tmp_path): 376 | if sys.platform.startswith('openbsd') and cli == cli_readdir_with_offset: 377 | pytest.skip("OpenBSD FUSE implementation uses byte offsets, incompatible with this example's logic") 378 | mount_point = tmp_path 379 | arguments = [] 380 | with RunCLI(cli, mount_point, arguments): 381 | assert os.path.isdir(mount_point) 382 | 383 | path = mount_point / "foo" 384 | assert not path.is_dir() 385 | 386 | assert len(set(filter_platform_files(os.listdir(mount_point)))) == 1000 387 | 388 | 389 | if __name__ == '__main__': 390 | with tempfile.TemporaryDirectory() as directory: 391 | # Directory argument must not be something in the current directory, 392 | # or else it might lead to recursive calls into FUSE. 393 | test_read_write_file_system(cli_memory_nullpath, Path(directory)) 394 | -------------------------------------------------------------------------------- /mfusepy.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012 Terence Honles (maintainer) 2 | # Copyright (c) 2008 Giorgos Verigakis (author) 3 | # 4 | # Permission to use, copy, modify, and distribute this software for any 5 | # purpose with or without fee is hereby granted, provided that the above 6 | # copyright notice and this permission notice appear in all copies. 7 | # 8 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | # Note that for ABI forward compatibility and other issues, most C-types should be initialized 17 | # to 0 and the ctypes module does that for us out of the box! 18 | # https://github.com/python/cpython/blob/f8a736b8e14ab839e1193cb1d3955b61c316d048/Lib/test/test_ctypes/test_numbers.py#L95 19 | 20 | import contextlib 21 | import ctypes 22 | import errno 23 | import functools 24 | import inspect 25 | import logging 26 | import os 27 | import platform 28 | import warnings 29 | from collections.abc import Iterable, Sequence 30 | from ctypes import CFUNCTYPE, POINTER, c_char_p, c_int, c_size_t, c_ssize_t, c_uint, c_void_p 31 | from ctypes.util import find_library 32 | from signal import SIG_DFL, SIGINT, SIGTERM, signal 33 | from stat import S_IFDIR 34 | from typing import TYPE_CHECKING, Any, Optional, Union, get_type_hints 35 | 36 | FieldsEntry = Union[tuple[str, type], tuple[str, type, int]] 37 | BitFieldsEntry = tuple[str, type, int] 38 | ReadDirResult = Iterable[Union[str, tuple[str, dict[str, int], int], tuple[str, int, int]]] 39 | 40 | if TYPE_CHECKING: 41 | c_byte_p = ctypes._Pointer[ctypes.c_byte] # noqa: W212 42 | c_uint64_p = ctypes._Pointer[ctypes.c_uint64] # noqa: W212 43 | else: 44 | c_byte_p = ctypes.POINTER(ctypes.c_byte) 45 | c_uint64_p = ctypes.POINTER(ctypes.c_uint64) 46 | 47 | log = logging.getLogger("fuse") 48 | _system = platform.system() 49 | _machine = platform.machine() 50 | 51 | if _system == 'Windows' or _system.startswith('CYGWIN'): 52 | # NOTE: 53 | # 54 | # sizeof(long)==4 on Windows 32-bit and 64-bit 55 | # sizeof(long)==4 on Cygwin 32-bit and ==8 on Cygwin 64-bit 56 | # 57 | # We have to fix up c_long and c_ulong so that it matches the 58 | # Cygwin (and UNIX) sizes when run on Windows. 59 | import sys 60 | 61 | c_win_long = ctypes.c_int64 if sys.maxsize > 0xFFFFFFFF else ctypes.c_int32 62 | c_win_ulong = ctypes.c_uint64 if sys.maxsize > 0xFFFFFFFF else ctypes.c_uint32 63 | 64 | 65 | class c_timespec(ctypes.Structure): 66 | if _system == 'Windows' or _system.startswith('CYGWIN'): 67 | _fields_ = [('tv_sec', c_win_long), ('tv_nsec', c_win_long)] 68 | elif _system in ('OpenBSD', 'FreeBSD', 'NetBSD'): 69 | # https://github.com/NetBSD/src/blob/netbsd-10/sys/sys/timespec.h#L47 70 | # https://github.com/NetBSD/src/blob/netbsd-10/sys/arch/hpc/stand/include/machine/types.h#L40 71 | _fields_ = [('tv_sec', ctypes.c_int64), ('tv_nsec', ctypes.c_long)] 72 | else: 73 | _fields_ = [('tv_sec', ctypes.c_long), ('tv_nsec', ctypes.c_long)] 74 | 75 | 76 | class c_utimbuf(ctypes.Structure): 77 | _fields_ = [('actime', c_timespec), ('modtime', c_timespec)] 78 | 79 | 80 | # Beware that FUSE_LIBRARY_PATH path was unchecked! If it is set to libfuse3.so.3.14.0, 81 | # then it will mount without error, but when trying to access the mount point, will give: 82 | # Uncaught exception from FUSE operation setxattr, returning errno.EINVAL: 83 | # 'utf-8' codec can't decode byte 0xe8 in position 1: invalid continuation byte 84 | # Traceback (most recent call last): 85 | # File "fuse.py", line 820, in _wrapper 86 | # return func(*args, **kwargs) or 0 87 | # ^^^^^^^^^^^^^^^^^^^^^ 88 | # File "fuse.py", line 991, in setxattr 89 | # name.decode(self.encoding), 90 | # ^^^^^^^^^^^^^^^^^^^^^^^^^^ 91 | # UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe8 in position 1: 92 | # invalid continuation byte 93 | _libfuse_path = os.environ.get('FUSE_LIBRARY_PATH') 94 | if not _libfuse_path: 95 | if _system == 'Darwin': 96 | # libfuse dependency 97 | _libiconv = ctypes.CDLL(find_library('iconv'), ctypes.RTLD_GLOBAL) 98 | 99 | _libfuse_path = ( 100 | find_library('fuse4x') or find_library('osxfuse') or find_library('fuse') or find_library('fuse-t') 101 | ) 102 | elif _system == 'Windows': 103 | # pytype: disable=module-attr 104 | try: 105 | import _winreg as reg # pytype: disable=import-error 106 | except ImportError: 107 | import winreg as reg # pytype: disable=import-error 108 | 109 | def reg32_get_value(rootkey, keyname, valname): 110 | key, val = None, None 111 | try: 112 | key = reg.OpenKey( 113 | rootkey, keyname, 0, reg.KEY_READ | reg.KEY_WOW64_32KEY 114 | ) # pytype: disable=import-error 115 | val = str(reg.QueryValueEx(key, valname)[0]) 116 | except OSError: # pylint: disable=undefined-variable # pytype: disable=name-error 117 | pass 118 | finally: 119 | if key is not None: 120 | reg.CloseKey(key) 121 | return val 122 | 123 | _libfuse_path = reg32_get_value(reg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WinFsp", r"InstallDir") 124 | if _libfuse_path: 125 | arch = "x64" if sys.maxsize > 0xFFFFFFFF else "x86" 126 | _libfuse_path += f"bin\\winfsp-{arch}.dll" 127 | # pytype: enable=module-attr 128 | elif _libfuse_name := os.environ.get('FUSE_LIBRARY_NAME'): 129 | _libfuse_path = find_library(_libfuse_name) 130 | else: 131 | _libfuse_path = find_library('fuse') 132 | if not _libfuse_path: 133 | _libfuse_path = find_library('fuse3') 134 | 135 | if not _libfuse_path: 136 | raise OSError('Unable to find libfuse') 137 | _libfuse = ctypes.CDLL(_libfuse_path) 138 | 139 | if _system == 'Darwin' and hasattr(_libfuse, 'macfuse_version'): 140 | _system = 'Darwin-MacFuse' 141 | 142 | 143 | def get_fuse_version(libfuse): 144 | version = libfuse.fuse_version() 145 | if version < 100: 146 | return version // 10, version % 10 147 | if version < 1000: 148 | return version // 100, version % 100 149 | raise AttributeError(f"Version {version} of found library {_libfuse._name} cannot be parsed!") 150 | 151 | 152 | fuse_version_major, fuse_version_minor = get_fuse_version(_libfuse) 153 | if fuse_version_major == 2 and fuse_version_minor < 6: 154 | raise AttributeError( 155 | f"Found library {_libfuse_path} is too old: {fuse_version_major}.{fuse_version_minor}. " 156 | "There have been several ABI breaks in each version. Libfuse < 2.6 is not supported!" 157 | ) 158 | if fuse_version_major != 2 and not (fuse_version_major == 3 and _system == 'Linux'): 159 | raise AttributeError( 160 | f"Found library {_libfuse_path} has wrong major version: {fuse_version_major}. Expected FUSE 2!" 161 | ) 162 | 163 | # Some platforms, like macOS 15, define ENOATTR and ENODATA with different values. 164 | # For missing xattrs, errno is set to the ENOATTR value (e.g. in getxattr and removexattr). 165 | # But, on some other platforms, ENOATTR is missing; use the same value as ENODATA there. 166 | # We have a test that makes sure this is not None for all platforms we test on. 167 | ENOATTR = getattr(errno, 'ENOATTR', getattr(errno, 'ENODATA', None)) 168 | 169 | # Check FUSE major version changes by cloning https://github.com/libfuse/libfuse.git 170 | # and check the diff with: 171 | # git diff -w fuse-2.9.9 fuse-3.0.0 include/fuse.h 172 | # or with comments stripped and diffed: 173 | # colordiff <( gcc -fpreprocessed -dD -E -P -Wno-all -x c \ 174 | # <( git show fuse-2.9.9:include/fuse.h ) ) \ 175 | # <( gcc -fpreprocessed -dD -E -P -Wno-all -x c \ 176 | # <( git show fuse-3.0.2:include/fuse.h ) ) 177 | # and repeat for fuse_common.h and possibly over included headers, or check 178 | # the official changelog: 179 | # https://github.com/libfuse/libfuse/blob/master/ChangeLog.rst#libfuse-300-2016-12-08 180 | # 181 | # Header changes summarized: 182 | # - Added enum fuse_readdir_flags, which is added as last argument to readdir. 183 | # - Added enum fuse_fill_dir_flags, which is added to the fuse_fill_dir_t 184 | # function callback argument to readdir. 185 | # - Removed fuse_operations.getdir and related types fuse_dirh_t. 186 | # Was already deprecated in favor of readdir. 187 | # - Added fuse_fill_dir_flags to fuse_dirfil_t callback function pointer that 188 | # is used for readdir. 189 | # - Added fuse_config struct, which is the new second parameter of 190 | # fuse_operations.init. 191 | # - Added new fuse_file_info struct (fuse_common.h), which is added as 192 | # additional arguments to: 193 | # - Added as last argument to: getattr, chmod, chown, truncate, utimens. 194 | # - This argument already existed for: open, read, write, flush, release, fsync, 195 | # opendir, readdir, releasedir, fsyncdir, create, ftruncate, fgetattr, lock, 196 | # ioctl, read_buf, fallocate, poll, write_buf, flock. 197 | # - Added unsigned int flags to rename. 198 | # - Removed utime in favor of utimens, which has been added in libFUSE 2.6. 199 | # - Removed deprecated functions: fuse_fs_fgetattr, fuse_fs_ftruncate, 200 | # fuse_invalidate, and fuse_is_lib_option. 201 | # - Removed flags from fuse_operations: flag_nullpath_ok, flag_nopath, 202 | # flag_utime_omit_ok, flag_reserved. These are now in the new fuse_config struct. 203 | # - Added version argument to fuse_main_real, but this should not be called directly 204 | # anyway and instead the fuse_main wrapper, which is unchanged should be called. 205 | # - Removed first argument struct fuse_chan* from fuse_new. 206 | # - Removed fuse_chan* return value from fuse_mount and fuse_chan* argument from 207 | # fuse_unmount (and moved both methods from fuse_common.h into fuse.h). 208 | # - Added int clone_fd argument to fuse_loop_mt. 209 | # - Added macro definitions for constants FUSE_CAP_* to fuse_common.h. 210 | # - Added fuse_apply_conn_info_opts to fuse_common.h, see the official changelog 211 | # for reasoning. 212 | 213 | # Set non-FUSE-specific kernel type definitions. 214 | if _system in ('Darwin', 'Darwin-MacFuse', 'FreeBSD'): 215 | ENOTSUP = 45 216 | 217 | c_dev_t: type = ctypes.c_int32 218 | c_fsblkcnt_t: type = ctypes.c_ulong 219 | c_fsfilcnt_t: type = ctypes.c_ulong 220 | c_gid_t: type = ctypes.c_uint32 221 | c_mode_t: type = ctypes.c_uint16 222 | c_off_t: type = ctypes.c_int64 223 | c_pid_t: type = ctypes.c_int32 224 | c_uid_t: type = ctypes.c_uint32 225 | setxattr_t = ctypes.CFUNCTYPE( 226 | ctypes.c_int, 227 | ctypes.c_char_p, 228 | ctypes.c_char_p, 229 | c_byte_p, 230 | ctypes.c_size_t, 231 | ctypes.c_int, 232 | ctypes.c_uint32, 233 | ) 234 | getxattr_t = ctypes.CFUNCTYPE( 235 | ctypes.c_int, 236 | ctypes.c_char_p, 237 | ctypes.c_char_p, 238 | c_byte_p, 239 | ctypes.c_size_t, 240 | ctypes.c_uint32, 241 | ) 242 | if _system == 'Darwin': 243 | c_fsblkcnt_t: type = ctypes.c_uint # type: ignore[no-redef] 244 | c_fsfilcnt_t: type = ctypes.c_uint # type: ignore[no-redef] 245 | # https://github.com/apple-oss-distributions/xnu/blob/xnu-11215.1.10/bsd/sys/stat.h 246 | _c_stat__fields_: Sequence[FieldsEntry] = [ 247 | ('st_dev', c_dev_t), 248 | ('st_mode', c_mode_t), 249 | ('st_nlink', ctypes.c_uint16), 250 | ('st_ino', ctypes.c_uint64), 251 | ('st_uid', c_uid_t), 252 | ('st_gid', c_gid_t), 253 | ('st_rdev', c_dev_t), 254 | ('st_atimespec', c_timespec), 255 | ('st_mtimespec', c_timespec), 256 | ('st_ctimespec', c_timespec), 257 | ('st_birthtimespec', c_timespec), 258 | ('st_size', c_off_t), 259 | ('st_blocks', ctypes.c_int64), 260 | ('st_blksize', ctypes.c_int32), 261 | ('st_flags', ctypes.c_int32), 262 | ('st_gen', ctypes.c_int32), 263 | ('st_lspare', ctypes.c_int32), 264 | ('st_qspare', ctypes.c_int64 * 2), 265 | ] 266 | elif _system == 'FreeBSD': 267 | # FreeBSD amd64 struct stat layout 268 | # https://github.com/freebsd/freebsd-src/blob/releng/14.3/sys/sys/stat.h#L159 269 | # Use explicit 64-bit integers for dev and ino to avoid changing global typedefs. 270 | _c_stat__fields_ = [ 271 | ('st_dev', ctypes.c_uint64), 272 | ('st_ino', ctypes.c_uint64), 273 | ('st_nlink', ctypes.c_uint64), 274 | ('st_mode', c_mode_t), 275 | ('st_bsdflags', ctypes.c_int16), 276 | ('st_uid', c_uid_t), 277 | ('st_gid', c_gid_t), 278 | ('st_padding1', ctypes.c_uint32), 279 | ('st_rdev', ctypes.c_uint64), 280 | ('st_atimespec', c_timespec), 281 | ('st_mtimespec', c_timespec), 282 | ('st_ctimespec', c_timespec), 283 | ('st_birthtimespec', c_timespec), 284 | ('st_size', c_off_t), 285 | ('st_blocks', ctypes.c_int64), 286 | ('st_blksize', ctypes.c_uint32), 287 | ('st_flags', ctypes.c_uint32), 288 | ('st_gen', ctypes.c_uint32), 289 | ('st_filerev', ctypes.c_uint64), 290 | ('st_spare', ctypes.c_int64 * 9), 291 | ] 292 | else: 293 | # Darwin-MacFuse fallback (legacy) 294 | _c_stat__fields_ = [ 295 | ('st_dev', c_dev_t), 296 | ('st_ino', ctypes.c_uint32), 297 | ('st_mode', c_mode_t), 298 | ('st_nlink', ctypes.c_uint16), 299 | ('st_uid', c_uid_t), 300 | ('st_gid', c_gid_t), 301 | ('st_rdev', c_dev_t), 302 | ('st_atimespec', c_timespec), 303 | ('st_mtimespec', c_timespec), 304 | ('st_ctimespec', c_timespec), 305 | ('st_size', c_off_t), 306 | ('st_blocks', ctypes.c_int64), 307 | ('st_blksize', ctypes.c_int32), 308 | ] 309 | elif _system == 'Linux': 310 | ENOTSUP = 95 311 | 312 | # sys/statvfs.h 313 | c_fsblkcnt_t = ctypes.c_ulonglong 314 | c_fsfilcnt_t = ctypes.c_ulonglong 315 | 316 | # https://man7.org/linux/man-pages/man0/sys_types.h.0p.html 317 | c_dev_t = ctypes.c_ulonglong 318 | c_gid_t = ctypes.c_uint 319 | c_mode_t = ctypes.c_uint 320 | c_off_t = ctypes.c_longlong 321 | c_pid_t = ctypes.c_int 322 | c_uid_t = ctypes.c_uint 323 | 324 | # sys/xattr.h 325 | setxattr_t = ctypes.CFUNCTYPE( 326 | ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p, c_byte_p, ctypes.c_size_t, ctypes.c_int 327 | ) 328 | getxattr_t = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p, c_byte_p, ctypes.c_size_t) 329 | 330 | # https://github.com/torvalds/linux/blob/v6.18/arch/x86/include/uapi/asm/stat.h#L83-L104 331 | # -> See /arch/ subfolders. Unfortunately, arch=arm64 does not have stat.h for some reason. 332 | if _machine == 'x86_64': 333 | _c_stat__fields_ = [ 334 | ('st_dev', c_dev_t), 335 | ('st_ino', ctypes.c_ulong), 336 | ('st_nlink', ctypes.c_ulong), 337 | ('st_mode', c_mode_t), 338 | ('st_uid', c_uid_t), 339 | ('st_gid', c_gid_t), 340 | ('__pad0', ctypes.c_int), 341 | ('st_rdev', c_dev_t), 342 | ('st_size', c_off_t), 343 | ('st_blksize', ctypes.c_long), 344 | ('st_blocks', ctypes.c_long), 345 | ('st_atimespec', c_timespec), 346 | ('st_mtimespec', c_timespec), 347 | ('st_ctimespec', c_timespec), 348 | ('reserved', ctypes.c_long * 3), 349 | ] 350 | elif _machine == 'mips': 351 | _c_stat__fields_ = [ 352 | ('st_dev', c_dev_t), 353 | ('__pad1_1', ctypes.c_ulong), 354 | ('__pad1_2', ctypes.c_ulong), 355 | ('__pad1_3', ctypes.c_ulong), 356 | ('st_ino', ctypes.c_ulong), 357 | ('st_mode', c_mode_t), 358 | ('st_nlink', ctypes.c_ulong), 359 | ('st_uid', c_uid_t), 360 | ('st_gid', c_gid_t), 361 | ('st_rdev', c_dev_t), 362 | ('__pad2_1', ctypes.c_ulong), 363 | ('__pad2_2', ctypes.c_ulong), 364 | ('st_size', c_off_t), 365 | ('__pad3', ctypes.c_ulong), 366 | ('st_atimespec', c_timespec), 367 | ('__pad4', ctypes.c_ulong), 368 | ('st_mtimespec', c_timespec), 369 | ('__pad5', ctypes.c_ulong), 370 | ('st_ctimespec', c_timespec), 371 | ('__pad6', ctypes.c_ulong), 372 | ('st_blksize', ctypes.c_long), 373 | ('st_blocks', ctypes.c_long), 374 | ('__pad7_1', ctypes.c_ulong), 375 | ('__pad7_2', ctypes.c_ulong), 376 | ('__pad7_3', ctypes.c_ulong), 377 | ('__pad7_4', ctypes.c_ulong), 378 | ('__pad7_5', ctypes.c_ulong), 379 | ('__pad7_6', ctypes.c_ulong), 380 | ('__pad7_7', ctypes.c_ulong), 381 | ('__pad7_8', ctypes.c_ulong), 382 | ('__pad7_9', ctypes.c_ulong), 383 | ('__pad7_10', ctypes.c_ulong), 384 | ('__pad7_11', ctypes.c_ulong), 385 | ('__pad7_12', ctypes.c_ulong), 386 | ('__pad7_13', ctypes.c_ulong), 387 | ('__pad7_14', ctypes.c_ulong), 388 | ] 389 | elif _machine == 'ppc': 390 | _c_stat__fields_ = [ 391 | ('st_dev', c_dev_t), 392 | ('st_ino', ctypes.c_ulonglong), 393 | ('st_mode', c_mode_t), 394 | ('st_nlink', ctypes.c_uint), 395 | ('st_uid', c_uid_t), 396 | ('st_gid', c_gid_t), 397 | ('st_rdev', c_dev_t), 398 | ('__pad2', ctypes.c_ushort), 399 | ('st_size', c_off_t), 400 | ('st_blksize', ctypes.c_long), 401 | ('st_blocks', ctypes.c_longlong), 402 | ('st_atimespec', c_timespec), 403 | ('st_mtimespec', c_timespec), 404 | ('st_ctimespec', c_timespec), 405 | ] 406 | elif _machine in ('ppc64', 'ppc64le'): 407 | _c_stat__fields_ = [ 408 | ('st_dev', c_dev_t), 409 | ('st_ino', ctypes.c_ulong), 410 | ('st_nlink', ctypes.c_ulong), 411 | ('st_mode', c_mode_t), 412 | ('st_uid', c_uid_t), 413 | ('st_gid', c_gid_t), 414 | ('__pad', ctypes.c_uint), 415 | ('st_rdev', c_dev_t), 416 | ('st_size', c_off_t), 417 | ('st_blksize', ctypes.c_long), 418 | ('st_blocks', ctypes.c_long), 419 | ('st_atimespec', c_timespec), 420 | ('st_mtimespec', c_timespec), 421 | ('st_ctimespec', c_timespec), 422 | ] 423 | elif _machine == 'aarch64': 424 | _c_stat__fields_ = [ 425 | ('st_dev', c_dev_t), 426 | ('st_ino', ctypes.c_ulong), 427 | ('st_mode', c_mode_t), 428 | ('st_nlink', ctypes.c_uint), 429 | ('st_uid', c_uid_t), 430 | ('st_gid', c_gid_t), 431 | ('st_rdev', c_dev_t), 432 | ('__pad1', ctypes.c_ulong), 433 | ('st_size', c_off_t), 434 | ('st_blksize', ctypes.c_int), 435 | ('__pad2', ctypes.c_int), 436 | ('st_blocks', ctypes.c_long), 437 | ('st_atimespec', c_timespec), 438 | ('st_mtimespec', c_timespec), 439 | ('st_ctimespec', c_timespec), 440 | ('__reserved', ctypes.c_ulong), # unclear what this is 441 | ] 442 | else: 443 | # i686, use as fallback for everything else 444 | _c_stat__fields_ = [ 445 | ('st_dev', c_dev_t), 446 | ('__pad1', ctypes.c_ushort), 447 | ('__st_ino', ctypes.c_ulong), 448 | ('st_mode', c_mode_t), 449 | ('st_nlink', ctypes.c_uint), 450 | ('st_uid', c_uid_t), 451 | ('st_gid', c_gid_t), 452 | ('st_rdev', c_dev_t), 453 | ('__pad2', ctypes.c_ushort), 454 | ('st_size', c_off_t), 455 | ('st_blksize', ctypes.c_long), 456 | ('st_blocks', ctypes.c_longlong), 457 | ('st_atimespec', c_timespec), 458 | ('st_mtimespec', c_timespec), 459 | ('st_ctimespec', c_timespec), 460 | ('st_ino', ctypes.c_ulonglong), 461 | ] 462 | elif _system == 'Windows' or _system.startswith('CYGWIN'): 463 | ENOTSUP = 129 if _system == 'Windows' else 134 464 | c_dev_t = ctypes.c_uint 465 | c_fsblkcnt_t = c_win_ulong 466 | c_fsfilcnt_t = c_win_ulong 467 | c_gid_t = ctypes.c_uint 468 | c_mode_t = ctypes.c_uint 469 | c_off_t = ctypes.c_longlong 470 | c_pid_t = ctypes.c_int 471 | c_uid_t = ctypes.c_uint 472 | setxattr_t = ctypes.CFUNCTYPE( 473 | ctypes.c_int, 474 | ctypes.c_char_p, 475 | ctypes.c_char_p, 476 | c_byte_p, 477 | ctypes.c_size_t, 478 | ctypes.c_int, 479 | ) 480 | getxattr_t = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p, c_byte_p, ctypes.c_size_t) 481 | _c_stat__fields_ = [ 482 | ('st_dev', c_dev_t), 483 | ('st_ino', ctypes.c_ulonglong), 484 | ('st_mode', c_mode_t), 485 | ('st_nlink', ctypes.c_ushort), 486 | ('st_uid', c_uid_t), 487 | ('st_gid', c_gid_t), 488 | ('st_rdev', c_dev_t), 489 | ('st_size', c_off_t), 490 | ('st_atimespec', c_timespec), 491 | ('st_mtimespec', c_timespec), 492 | ('st_ctimespec', c_timespec), 493 | ('st_blksize', ctypes.c_int), 494 | ('st_blocks', ctypes.c_longlong), 495 | ('st_birthtimespec', c_timespec), 496 | ] 497 | elif _system == 'OpenBSD': 498 | ENOTSUP = 91 499 | c_dev_t = ctypes.c_int32 500 | c_uid_t = ctypes.c_uint32 501 | c_gid_t = ctypes.c_uint32 502 | c_mode_t = ctypes.c_uint32 503 | c_off_t = ctypes.c_int64 504 | c_pid_t = ctypes.c_int32 505 | c_ino_t = ctypes.c_uint64 506 | c_nlink_t = ctypes.c_uint32 507 | c_blkcnt_t = ctypes.c_int64 508 | c_blksize_t = ctypes.c_int32 509 | setxattr_t = ctypes.CFUNCTYPE( 510 | ctypes.c_int, 511 | ctypes.c_char_p, 512 | ctypes.c_char_p, 513 | c_byte_p, 514 | ctypes.c_size_t, 515 | ctypes.c_int, 516 | ) 517 | getxattr_t = ctypes.CFUNCTYPE( 518 | ctypes.c_int, 519 | ctypes.c_char_p, 520 | ctypes.c_char_p, 521 | c_byte_p, 522 | ctypes.c_size_t, 523 | ) 524 | c_fsblkcnt_t = ctypes.c_uint64 525 | c_fsfilcnt_t = ctypes.c_uint64 526 | _c_stat__fields_ = [ 527 | ('st_mode', c_mode_t), 528 | ('st_dev', c_dev_t), 529 | ('st_ino', c_ino_t), 530 | ('st_nlink', c_nlink_t), 531 | ('st_uid', c_uid_t), 532 | ('st_gid', c_gid_t), 533 | ('st_rdev', c_dev_t), 534 | ('st_atimespec', c_timespec), 535 | ('st_mtimespec', c_timespec), 536 | ('st_ctimespec', c_timespec), 537 | ('st_size', c_off_t), 538 | ('st_blocks', c_blkcnt_t), 539 | ('st_blksize', c_blksize_t), 540 | ('st_flags', ctypes.c_uint32), 541 | ('st_gen', ctypes.c_uint32), 542 | ('st_birthtimespec', c_timespec), 543 | ] 544 | elif _system == 'NetBSD': 545 | ENOTSUP = 45 546 | c_dev_t = ctypes.c_uint64 547 | c_uid_t = ctypes.c_uint32 548 | c_gid_t = ctypes.c_uint32 549 | c_mode_t = ctypes.c_uint32 550 | c_off_t = ctypes.c_int64 551 | c_pid_t = ctypes.c_int32 552 | setxattr_t = ctypes.CFUNCTYPE( 553 | ctypes.c_int, 554 | ctypes.c_char_p, 555 | ctypes.c_char_p, 556 | ctypes.POINTER(ctypes.c_byte), 557 | ctypes.c_size_t, 558 | ctypes.c_int, 559 | ) 560 | getxattr_t = ctypes.CFUNCTYPE( 561 | ctypes.c_int64, 562 | ctypes.c_char_p, 563 | ctypes.c_char_p, 564 | ctypes.POINTER(ctypes.c_byte), 565 | ctypes.c_size_t, 566 | ) 567 | c_fsblkcnt_t = ctypes.c_uint64 568 | c_fsfilcnt_t = ctypes.c_uint64 569 | # https://github.com/NetBSD/src/blob/2a172fadee81450ba400e49c8ed98857bedec65c/sys/sys/stat.h#L59-L89 570 | _c_stat__fields_ = [ 571 | ('st_dev', c_dev_t), 572 | ('st_mode', c_mode_t), 573 | ('_padding0', ctypes.c_uint32), # alignment padding implied by the next member 574 | ('st_ino', ctypes.c_uint64), 575 | ('st_nlink', ctypes.c_uint32), 576 | ('st_uid', c_uid_t), 577 | ('st_gid', c_gid_t), 578 | ('_padding1', ctypes.c_uint32), # alignment padding implied by the next member 579 | ('st_rdev', c_dev_t), 580 | ('st_atimespec', c_timespec), 581 | ('st_mtimespec', c_timespec), 582 | ('st_ctimespec', c_timespec), 583 | ('st_birthtimespec', c_timespec), 584 | ('st_size', c_off_t), 585 | ('st_blocks', ctypes.c_int64), 586 | ('st_blksize', ctypes.c_uint32), 587 | ('st_flags', ctypes.c_uint32), 588 | ('st_gen', ctypes.c_uint32), 589 | ('st_spare', ctypes.c_uint32 * 2), 590 | ] 591 | else: 592 | raise NotImplementedError(_system + ' is not supported.') 593 | 594 | 595 | class c_stat(ctypes.Structure): 596 | _fields_ = _c_stat__fields_ 597 | 598 | 599 | # https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/sys_statvfs.h.html 600 | if _system == 'FreeBSD': 601 | c_fsblkcnt_t = ctypes.c_uint64 602 | c_fsfilcnt_t = ctypes.c_uint64 603 | setxattr_t = ctypes.CFUNCTYPE( 604 | ctypes.c_int, 605 | ctypes.c_char_p, 606 | ctypes.c_char_p, 607 | c_byte_p, 608 | ctypes.c_size_t, 609 | ctypes.c_int, 610 | ) 611 | 612 | getxattr_t = ctypes.CFUNCTYPE( 613 | ctypes.c_int, 614 | ctypes.c_char_p, 615 | ctypes.c_char_p, 616 | c_byte_p, 617 | ctypes.c_size_t, 618 | ) 619 | 620 | 621 | class c_statvfs(ctypes.Structure): 622 | if _system == 'FreeBSD': 623 | # https://github.com/freebsd/freebsd-src/blob/b1c3a4d75f4ff74218434a11cdd4e56632e13711/sys/sys/statvfs.h#L57-L68 624 | _fields_ = [ 625 | ('f_bavail', c_fsblkcnt_t), 626 | ('f_bfree', c_fsblkcnt_t), 627 | ('f_blocks', c_fsblkcnt_t), 628 | ('f_favail', c_fsfilcnt_t), 629 | ('f_ffree', c_fsfilcnt_t), 630 | ('f_files', c_fsfilcnt_t), 631 | ('f_bsize', ctypes.c_ulong), 632 | ('f_flag', ctypes.c_ulong), 633 | ('f_frsize', ctypes.c_ulong), 634 | ('f_fsid', ctypes.c_ulong), 635 | ('f_namemax', ctypes.c_ulong), 636 | ] 637 | elif _system == 'Windows' or _system.startswith('CYGWIN'): 638 | _fields_ = [ 639 | ('f_bsize', c_win_ulong), 640 | ('f_frsize', c_win_ulong), 641 | ('f_blocks', c_fsblkcnt_t), 642 | ('f_bfree', c_fsblkcnt_t), 643 | ('f_bavail', c_fsblkcnt_t), 644 | ('f_files', c_fsfilcnt_t), 645 | ('f_ffree', c_fsfilcnt_t), 646 | ('f_favail', c_fsfilcnt_t), 647 | ('f_fsid', c_win_ulong), 648 | ('f_flag', c_win_ulong), 649 | ('f_namemax', c_win_ulong), 650 | ] 651 | elif _system == 'NetBSD': 652 | # https://github.com/NetBSD/src/blob/2a172fadee81450ba400e49c8ed98857bedec65c/sys/sys/statvfs.h#L66-L100 653 | _fields_ = [ 654 | ('f_flag', ctypes.c_ulong), 655 | ('f_bsize', ctypes.c_ulong), 656 | ('f_frsize', ctypes.c_ulong), 657 | ('f_iosize', ctypes.c_ulong), 658 | ('f_blocks', c_fsblkcnt_t), 659 | ('f_bfree', c_fsblkcnt_t), 660 | ('f_bavail', c_fsblkcnt_t), 661 | ('f_bresvd', c_fsblkcnt_t), 662 | ('f_files', c_fsfilcnt_t), 663 | ('f_ffree', c_fsfilcnt_t), 664 | ('f_favail', c_fsfilcnt_t), 665 | ('f_fresvd', c_fsfilcnt_t), 666 | ('f_syncreads', ctypes.c_uint64), 667 | ('f_syncwrites', ctypes.c_uint64), 668 | ('f_asyncreads', ctypes.c_uint64), 669 | ('f_asyncwrites', ctypes.c_uint64), 670 | ('f_fsidx', ctypes.c_int32 * 2), # NetBSD compatible fsid 671 | ('f_fsid', ctypes.c_ulong), # POSIX compatible fsid 672 | ('f_namemax', ctypes.c_ulong), 673 | ('f_owner', c_uid_t), 674 | ('f_spare', ctypes.c_uint64 * 4), 675 | ('f_fstypename', ctypes.c_char * 32), 676 | ('f_mntonname', ctypes.c_char * 1024), 677 | ('f_mntfromname', ctypes.c_char * 1024), 678 | ('f_mntfromlabel', ctypes.c_char * 1024), 679 | ] 680 | else: 681 | # https://sourceware.org/git?p=glibc.git;a=blob;f=bits/statvfs.h;h=ea89d9004d834c81874de00b5e3f5617d3096ccc;hb=HEAD#l33 682 | _fields_ = [ 683 | ('f_bsize', ctypes.c_ulong), 684 | ('f_frsize', ctypes.c_ulong), 685 | ('f_blocks', c_fsblkcnt_t), 686 | ('f_bfree', c_fsblkcnt_t), 687 | ('f_bavail', c_fsblkcnt_t), 688 | ('f_files', c_fsfilcnt_t), 689 | ('f_ffree', c_fsfilcnt_t), 690 | ('f_favail', c_fsfilcnt_t), 691 | ('f_fsid', ctypes.c_ulong), 692 | ('f_flag', ctypes.c_ulong), 693 | ('f_namemax', ctypes.c_ulong), 694 | ] 695 | if _system == 'Linux': # Linux x86_64 and aarch64 696 | _fields_ += [ 697 | ('f_type', ctypes.c_uint), 698 | ('__f_spare', ctypes.c_uint * 5), 699 | ] 700 | 701 | 702 | if _system == 'Linux': 703 | # https://github.com/torvalds/linux/blob/20371ba120635d9ab7fc7670497105af8f33eb08/include/uapi/asm-generic/fcntl.h#L195 704 | class c_flock_t(ctypes.Structure): # type: ignore 705 | _fields_ = [ 706 | ('l_type', ctypes.c_short), 707 | ('l_whence', ctypes.c_short), 708 | ('l_start', c_off_t), 709 | ('l_len', c_off_t), 710 | ('l_pid', c_pid_t), 711 | ('l_sysid', ctypes.c_long), # not always present 712 | ] 713 | 714 | elif _system == 'OpenBSD': 715 | # https://github.com/openbsd/src/blob/a465f6177bcfdb2ffa9f98c7ca0780392688fc0d/sys/sys/fcntl.h#L180 716 | class c_flock_t(ctypes.Structure): # type: ignore 717 | _fields_ = [ 718 | ('l_start', c_off_t), # starting offset 719 | ('l_len', c_off_t), # len = 0 means until end of file 720 | ('l_pid', c_pid_t), # lock owner 721 | ('l_type', ctypes.c_short), # lock type: read/write, etc. 722 | ('l_whence', ctypes.c_short), # type of l_start 723 | ] 724 | 725 | else: 726 | c_flock_t = ctypes.c_void_p # type: ignore 727 | 728 | 729 | # fuse_file_info as defined in fuse_common.h. Changes in FUSE 3: 730 | # - fh_old was removed 731 | # - poll_events was added 732 | # - writepage was added to the bitfield, but the padding was not decreased, 733 | # so now there are 33 bits in total, which probably lead to some unwanted 734 | # padding in libfuse 3.0.2. This has been made explicit since libfuse 3.7.0: 735 | # https://github.com/libfuse/libfuse/commit/1d8e8ca94a3faa635afd1a3bd8d7d26472063a3f 736 | # - 3.0.0 -> 3.4.2: no change (flags and padding did add up to 33 bits from the beginning) 737 | # - 3.4.2 -> 3.5.0: cache_readdir added correctly 738 | # - 3.5.0 -> 3.6.2: no change 739 | # - 3.6.2 -> 3.7.0: padding 2 was explicitly added but did exist because of alignment 740 | # - 3.7.0 -> 3.10.5: no change 741 | # - 3.10.5 -> 3.11.0: noflush added correctly 742 | # - 3.11.0 -> 3.13.1: no change 743 | # - 3.13.1 -> 3.14.1: parallel_direct_writes was added in the middle. 744 | # Padding was correctly decreased by 1. 745 | # - 3.14.1 -> 3.16.2: no change 746 | _fuse_int32 = ctypes.c_int32 if (fuse_version_major, fuse_version_minor) >= (3, 17) else ctypes.c_int 747 | _fuse_uint32 = ctypes.c_uint32 if (fuse_version_major, fuse_version_minor) >= (3, 17) else ctypes.c_uint 748 | _fuse_file_info_fields_: list[FieldsEntry] = [] 749 | _fuse_file_info_fields_bitfield: list[BitFieldsEntry] = [] 750 | # Bogus check. It fixes the struct for NetBSD, but it makes the examples not run anymore! 751 | if _system == 'NetBSD_False': 752 | # NetBSD has its own FUSE library reimplementation with mismatching struct layouts! 753 | # writepage is a bitfield (as in libFUSE 3.x), but the fh_old member still exists and the reported version is 2.9! 754 | # https://www.netbsd.org/docs/puffs/ 755 | # https://github.com/NetBSD/src/blob/netbsd-11/lib/librefuse/fuse.h#L100-L129 756 | # https://github.com/NetBSD/src/blob/netbsd-10/lib/librefuse/fuse.h#L100-L129 757 | # - fuse_file_info is unchanged between 10 and 11 758 | # - FUSE_USE_VERSION is not set, but is set to _REFUSE_VERSION_ (3.10) with a warning if not set! 759 | # - However, the CI prints FUSE version 2.9?! 760 | # - Seems there is no sane way to get the correct compiled version! This is again an absolute shit show! 761 | # https://github.com/NetBSD/src/blob/netbsd-9/lib/librefuse/fuse.h#L51-L61 762 | # - fuse_file_info looks quite different and version is specified as 2.6! 763 | # - #define FUSE_USE_VERSION 26 764 | fuse_version = (fuse_version_major, fuse_version_minor) 765 | _fuse_file_info_fields_ = [ 766 | ('flags', ctypes.c_int32), 767 | ('fh_old', ctypes.c_uint32), 768 | ] 769 | 770 | if fuse_version >= (2, 9): 771 | _fuse_file_info_fields_bitfield += [('writepage', ctypes.c_int32, 1)] 772 | else: 773 | _fuse_file_info_fields_ += [('writepage', ctypes.c_int32)] 774 | 775 | _fuse_file_info_fields_bitfield += [ 776 | ('direct_io', ctypes.c_uint32, 1), # Introduced in FUSE 2.4 777 | ('keep_cache', ctypes.c_uint32, 1), # Introduced in FUSE 2.4 778 | ('flush', ctypes.c_uint32, 1), # Introduced in FUSE 2.6 779 | ] 780 | if fuse_version >= (2, 9): 781 | _fuse_file_info_fields_bitfield += [ 782 | ('nonseekable', ctypes.c_uint, 1), # Introduced in FUSE 2.8 783 | ('flock_release', ctypes.c_uint, 1), # Introduced in FUSE 2.9 784 | ('cache_readdir', ctypes.c_uint, 1), # Introduced in FUSE 3.5 785 | ] 786 | 787 | _fuse_file_info_flag_count = sum(x[2] for x in _fuse_file_info_fields_bitfield) 788 | assert _fuse_file_info_flag_count < ctypes.sizeof(_fuse_uint32) * 8 789 | 790 | _fuse_file_info_fields_ += _fuse_file_info_fields_bitfield 791 | _fuse_file_info_fields_ += [ 792 | ('padding', _fuse_uint32, ctypes.sizeof(_fuse_uint32) * 8 - _fuse_file_info_flag_count), 793 | ('fh', ctypes.c_uint64), 794 | ('lock_owner', ctypes.c_uint64), 795 | ] 796 | 797 | if fuse_version >= (2, 9): 798 | _fuse_file_info_fields_ += [('poll_events', ctypes.c_uint32)] 799 | 800 | elif fuse_version_major == 2: 801 | _fh_old_type = ctypes.c_uint if _system == 'OpenBSD' else ctypes.c_ulong 802 | _fuse_file_info_fields_ = [ 803 | ('flags', ctypes.c_int), 804 | ('fh_old', _fh_old_type), 805 | ('writepage', ctypes.c_int), 806 | ('direct_io', ctypes.c_uint, 1), # Introduced in libfuse 2.4 807 | ('keep_cache', ctypes.c_uint, 1), # Introduced in libfuse 2.4 808 | ('flush', ctypes.c_uint, 1), # Introduced in libfuse 2.6 809 | ('nonseekable', ctypes.c_uint, 1), # Introduced in libfuse 2.8 810 | ('flock_release', ctypes.c_uint, 1), # Introduced in libfuse 2.9 811 | ('padding', ctypes.c_uint, 27), 812 | ('fh', ctypes.c_uint64), 813 | ('lock_owner', ctypes.c_uint64), 814 | ] 815 | elif fuse_version_major == 3: 816 | _fuse_file_info_fields_ = [('flags', _fuse_int32)] 817 | 818 | # Bit flag types were changed from unsigned int to uint32_t in libfuse 3.17, 819 | # but as far as I understand this change does not matter because it is only 1 bit. 820 | 821 | _fuse_file_info_fields_bitfield = [ 822 | ('writepage', _fuse_uint32, 1), 823 | ('direct_io', _fuse_uint32, 1), 824 | ('keep_cache', _fuse_uint32, 1), 825 | ] 826 | # Introduced in 3.15.0 and its placement is an API-incompatible change / bug! 827 | # https://github.com/libfuse/libfuse/issues/1029 828 | if fuse_version_minor >= 15 and fuse_version_minor < 17: 829 | _fuse_file_info_fields_bitfield += [ 830 | ('parallel_direct_writes', _fuse_uint32, 1), 831 | ] 832 | _fuse_file_info_fields_bitfield += [ 833 | ('flush', _fuse_uint32, 1), 834 | ('nonseekable', _fuse_uint32, 1), 835 | ('flock_release', _fuse_uint32, 1), 836 | ] 837 | if fuse_version_minor >= 5: 838 | _fuse_file_info_fields_bitfield += [('cache_readdir', ctypes.c_uint, 1)] 839 | if fuse_version_minor >= 11: 840 | _fuse_file_info_fields_bitfield += [('noflush', ctypes.c_uint, 1)] 841 | if fuse_version_minor >= 17: 842 | _fuse_file_info_fields_bitfield += [ 843 | ('parallel_direct_writes', _fuse_uint32, 1), 844 | ] 845 | 846 | _fuse_file_info_flag_count = sum(x[2] for x in _fuse_file_info_fields_bitfield) 847 | assert _fuse_file_info_flag_count < ctypes.sizeof(_fuse_uint32) * 8 848 | 849 | _fuse_file_info_fields_ += _fuse_file_info_fields_bitfield 850 | _fuse_file_info_fields_ += [ 851 | ('padding', _fuse_uint32, ctypes.sizeof(_fuse_uint32) * 8 - _fuse_file_info_flag_count), 852 | ('padding2', _fuse_uint32), 853 | ] 854 | # https://github.com/libfuse/libfuse/pull/1038#discussion_r1775112524 855 | # https://github.com/libfuse/libfuse/pull/1081 856 | # This padding did always exist because fh was aligned to an offset modulo 8 B, 857 | # but libfuse 3.17 made this explicit and I'm not fully sure how ctypes behaves. 858 | if fuse_version_minor >= 17: 859 | _fuse_file_info_fields_ += [('padding3', _fuse_uint32)] 860 | 861 | _fuse_file_info_fields_ += [ 862 | ('fh', ctypes.c_uint64), 863 | ('lock_owner', ctypes.c_uint64), 864 | ('poll_events', ctypes.c_uint64), 865 | ] 866 | 867 | 868 | class fuse_file_info(ctypes.Structure): 869 | _fields_ = _fuse_file_info_fields_ 870 | 871 | 872 | if ctypes.sizeof(ctypes.c_int) == 4 and (fuse_version_major, fuse_version_minor) >= (3, 17): 873 | assert ctypes.sizeof(fuse_file_info) == 40 874 | 875 | 876 | class fuse_context(ctypes.Structure): 877 | _fields_ = [ 878 | ('fuse', ctypes.c_void_p), 879 | ('uid', c_uid_t), 880 | ('gid', c_gid_t), 881 | ('pid', c_pid_t), 882 | ('private_data', ctypes.c_void_p), 883 | # Added in 2.8. Note that this is an ABI break because programs compiled against 2.7 884 | # will allocate a smaller struct leading to out-of-bound accesses when used for a 2.8 885 | # shared library! It shouldn't hurt the other way around to have a larger struct than 886 | # the shared library expects. The newer members will simply be ignored. 887 | ('umask', c_mode_t), 888 | ] 889 | 890 | 891 | _libfuse.fuse_get_context.restype = ctypes.POINTER(fuse_context) 892 | 893 | 894 | # FUSE_BUF_IS_FD = (1 << 1), 895 | # FUSE_BUF_FD_SEEK = (1 << 2), 896 | # FUSE_BUF_FD_RETRY = (1 << 3), 897 | fuse_buf_flags = ctypes.c_int 898 | 899 | 900 | class fuse_buf(ctypes.Structure): 901 | _fields_ = [ 902 | ('size', ctypes.c_size_t), 903 | ('flags', fuse_buf_flags), 904 | ('mem', ctypes.c_void_p), 905 | ('fd', ctypes.c_int), 906 | ('pos', c_off_t), 907 | ] 908 | 909 | 910 | class fuse_bufvec(ctypes.Structure): 911 | _fields_ = [ 912 | ('count', ctypes.c_size_t), 913 | ('idx', ctypes.c_size_t), 914 | ('off', ctypes.c_size_t), 915 | ('buf', ctypes.POINTER(fuse_buf)), 916 | ] 917 | 918 | 919 | if TYPE_CHECKING: 920 | fuse_fi_p = ctypes._Pointer[fuse_file_info] # noqa: W212 921 | c_stat_p = ctypes._Pointer[c_stat] # noqa: W212 922 | c_statvfs_p = ctypes._Pointer[c_statvfs] # noqa: W212 923 | c_utimbuf_p = ctypes._Pointer[c_utimbuf] # noqa: W212 924 | fuse_bufvec_p = ctypes._Pointer[fuse_bufvec] # noqa: W212 925 | fuse_bufvec_pp = ctypes._Pointer[fuse_bufvec_p] # noqa: W212 926 | else: 927 | fuse_fi_p = ctypes.POINTER(fuse_file_info) 928 | c_stat_p = ctypes.POINTER(c_stat) 929 | c_statvfs_p = ctypes.POINTER(c_statvfs) 930 | c_utimbuf_p = ctypes.POINTER(c_utimbuf) 931 | fuse_bufvec_p = ctypes.POINTER(fuse_bufvec) 932 | fuse_bufvec_pp = ctypes.POINTER(fuse_bufvec_p) 933 | 934 | 935 | # fuse_conn_info struct as defined and documented in fuse_common.h 936 | _fuse_conn_info_fields: list[FieldsEntry] = [ 937 | ('proto_major', ctypes.c_uint), 938 | ('proto_minor', ctypes.c_uint), 939 | ] 940 | # For some reason, NetBSD return 2.9 even though the API is 3.10! 941 | # The correct version is important for the struct layout! 942 | # https://github.com/NetBSD/src/blob/netbsd-10/lib/librefuse/fuse.h#L58-L59 943 | # However, the fuse_operations layout probably fits the advertised version because I had segfaults from utimens! 944 | if fuse_version_major == 2 or _system == 'NetBSD': # No idea why NetBSD did not remove it -.- 945 | _fuse_conn_info_fields += [('async_read', _fuse_uint32)] 946 | _fuse_conn_info_fields += [('max_write', _fuse_uint32)] 947 | if fuse_version_major == 3 or _system == 'NetBSD': 948 | _fuse_conn_info_fields += [('max_read', _fuse_uint32)] 949 | _fuse_conn_info_fields += [('max_readahead', _fuse_uint32)] 950 | if _system == 'Darwin': 951 | _fuse_conn_info_fields += [('enable', _fuse_uint32)] # TODO: is a bitfield 952 | _fuse_conn_info_fields += [ 953 | ('capable', _fuse_uint32), # Added in 2.8 954 | ('want', _fuse_uint32), # Added in 2.8 955 | ('max_background', _fuse_uint32), # Added in 2.9 956 | ('congestion_threshold', _fuse_uint32), # Added in 2.9 957 | ] 958 | if fuse_version_major == 2 and _system != 'NetBSD': 959 | _fuse_conn_info_fields += [('reserved', _fuse_uint32 * (22 if _system == 'Darwin' else 23))] 960 | elif fuse_version_major == 3 or _system == 'NetBSD': 961 | _fuse_conn_info_fields += [('time_gran', _fuse_uint32)] 962 | if fuse_version_minor < 17 or _system == 'NetBSD': 963 | _fuse_conn_info_fields += [('reserved', _fuse_uint32 * 22)] 964 | else: 965 | _fuse_conn_info_fields += [ 966 | ('max_backing_stack_depth', ctypes.c_uint32), 967 | ('no_interrupt', ctypes.c_uint32, 1), 968 | ('padding', ctypes.c_uint32, 31), 969 | ('capable_ext', ctypes.c_uint64), 970 | ('want_ext', ctypes.c_uint64), 971 | ('request_timeout', ctypes.c_uint16), 972 | ('reserved', ctypes.c_uint16 * 31), 973 | ] 974 | 975 | 976 | # https://github.com/libfuse/libfuse/pull/1081/commits/24f5b129c4e1b03ebbd05ac0c7673f306facea1ak 977 | class fuse_conn_info(ctypes.Structure): # Added in 2.6 (ABI break of "init" from 2.5->2.6) 978 | _fields_ = _fuse_conn_info_fields 979 | 980 | 981 | if (fuse_version_major, fuse_version_minor) >= (3, 17): 982 | assert ctypes.sizeof(fuse_conn_info) == 128 983 | 984 | # FUSE 3-only struct for second init argument defined in fuse.h. 985 | # If a FUSE 2 method is loaded but 'init_with_config' overridden, 986 | # then this argument will only be zero-initialized and should be ignored. 987 | # 3.0.0 -> 3.10.5: no change 988 | # 3.10.5 -> 3.11.0: no_rofd_flush was added in the middle causing an ABI break 989 | # 3.11.0 -> 3.13.1: no change 990 | # 3.13.1 -> 3.14.1: parallel_direct_writes was added in the middle causing an ABI break 991 | # 3.14.1 -> 3.16.2: no change 992 | # 3.16.2 -> 3.17.0: Changed all types to uint32_t, added reserved bytes, reverted order to 3.10. 993 | # https://github.com/libfuse/libfuse/pull/1081 994 | _fuse_config_fields_: list[FieldsEntry] = [ 995 | ('set_gid', _fuse_int32), 996 | ('gid', _fuse_uint32), 997 | ('set_uid', _fuse_int32), 998 | ('uid', _fuse_uint32), 999 | ('set_mode', _fuse_int32), 1000 | ('umask', _fuse_uint32), 1001 | ('entry_timeout', ctypes.c_double), 1002 | ('negative_timeout', ctypes.c_double), 1003 | ('attr_timeout', ctypes.c_double), 1004 | ('intr', _fuse_int32), 1005 | ('intr_signal', _fuse_int32), 1006 | ('remember', _fuse_int32), 1007 | ('hard_remove', _fuse_int32), 1008 | ('use_ino', _fuse_int32), 1009 | ('readdir_ino', _fuse_int32), 1010 | ('direct_io', _fuse_int32), 1011 | ('kernel_cache', _fuse_int32), 1012 | ('auto_cache', _fuse_int32), 1013 | ] 1014 | if fuse_version_major == 3: 1015 | # Adding this member in the middle of the struct was an ABI-incompatible change! 1016 | if fuse_version_minor >= 11 and fuse_version_minor < 17: 1017 | _fuse_config_fields_ += [('no_rofd_flush', ctypes.c_int)] 1018 | 1019 | _fuse_config_fields_ += [ 1020 | ('ac_attr_timeout_set', _fuse_int32), 1021 | ('ac_attr_timeout', ctypes.c_double), 1022 | # If this option is given the file-system handlers for the 1023 | # following operations will not receive path information: 1024 | # read, write, flush, release, fallocate, fsync, readdir, 1025 | # releasedir, fsyncdir, lock, ioctl and poll. 1026 | # 1027 | # For the truncate, getattr, chmod, chown and utimens 1028 | # operations the path will be provided only if the struct 1029 | # fuse_file_info argument is NULL. 1030 | ('nullpath_ok', _fuse_int32), 1031 | ] 1032 | 1033 | # Another ABI break as discussed here: https://lists.debian.org/debian-devel/2024/03/msg00278.html 1034 | # The break was in 3.14.1 NOT in 3.14.0, but I cannot query the bugfix version. 1035 | # I'd hope that all 3.14.0 installations have been replaced by updates to 3.14.1. 1036 | # ... they have not. My own system, Ubuntu 24.04 uses fuse 3.14.0. Check for >= 3.15. 1037 | if fuse_version_minor >= 15 and fuse_version_minor < 17: 1038 | _fuse_config_fields_ += [('parallel_direct_writes', ctypes.c_int)] 1039 | 1040 | if _system != 'NetBSD': 1041 | _fuse_config_fields_ += [ 1042 | ('show_help', _fuse_int32), 1043 | ('modules', ctypes.c_char_p), 1044 | ('debug', _fuse_int32), 1045 | ] 1046 | 1047 | if fuse_version_minor >= 17: 1048 | _fuse_config_fields_ += [ 1049 | ('fmask', ctypes.c_uint32), 1050 | ('dmask', ctypes.c_uint32), 1051 | ('no_rofd_flush', ctypes.c_int32), 1052 | ('parallel_direct_writes', ctypes.c_int32), 1053 | ('flags', ctypes.c_int32), 1054 | ('reserved', ctypes.c_uint64 * 48), 1055 | ] 1056 | 1057 | 1058 | class fuse_config(ctypes.Structure): 1059 | _fields_ = _fuse_config_fields_ 1060 | 1061 | 1062 | if TYPE_CHECKING: 1063 | FuseConfigPointer = ctypes._Pointer[fuse_config] 1064 | FuseConnInfoPointer = ctypes._Pointer[fuse_conn_info] 1065 | else: 1066 | FuseConfigPointer = ctypes.POINTER(fuse_config) 1067 | FuseConnInfoPointer = ctypes.POINTER(fuse_conn_info) 1068 | 1069 | 1070 | fuse_pollhandle_p = ctypes.c_void_p # Not exposed to API 1071 | 1072 | 1073 | # These are unchanged in FUSE 3 and therefore nice to have separate to reduce duplication. 1074 | _fuse_operations_fields_mknod_to_symlink = [ 1075 | ('mknod', CFUNCTYPE(c_int, c_char_p, c_mode_t, c_dev_t)), 1076 | ('mkdir', CFUNCTYPE(c_int, c_char_p, c_mode_t)), 1077 | ('unlink', CFUNCTYPE(c_int, c_char_p)), 1078 | ('rmdir', CFUNCTYPE(c_int, c_char_p)), 1079 | ('symlink', CFUNCTYPE(c_int, c_char_p, c_char_p)), 1080 | ] 1081 | _fuse_operations_fields_open_to_removexattr = [ 1082 | ('open', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), 1083 | ('read', CFUNCTYPE(c_int, c_char_p, c_byte_p, c_size_t, c_off_t, fuse_fi_p)), 1084 | ('write', CFUNCTYPE(c_int, c_char_p, c_byte_p, c_size_t, c_off_t, fuse_fi_p)), 1085 | ('statfs', CFUNCTYPE(c_int, c_char_p, c_statvfs_p)), 1086 | ('flush', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), 1087 | ('release', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), 1088 | ('fsync', CFUNCTYPE(c_int, c_char_p, c_int, fuse_fi_p)), 1089 | ('setxattr', setxattr_t), 1090 | ('getxattr', getxattr_t), 1091 | ('listxattr', CFUNCTYPE(c_int, c_char_p, c_byte_p, c_size_t)), 1092 | ('removexattr', CFUNCTYPE(c_int, c_char_p, c_char_p)), 1093 | ] 1094 | _fuse_operations_fields_2_9 = [ 1095 | ('poll', CFUNCTYPE(c_int, c_char_p, fuse_fi_p, fuse_pollhandle_p, POINTER(c_uint))), 1096 | ('write_buf', CFUNCTYPE(c_int, c_char_p, fuse_bufvec_p, c_off_t, fuse_fi_p)), 1097 | ('read_buf', CFUNCTYPE(c_int, c_char_p, fuse_bufvec_pp, c_size_t, c_off_t, fuse_fi_p)), 1098 | ('flock', CFUNCTYPE(c_int, c_char_p, fuse_fi_p, c_int)), 1099 | ('fallocate', CFUNCTYPE(c_int, c_char_p, c_int, c_off_t, c_off_t, fuse_fi_p)), 1100 | ] 1101 | 1102 | if fuse_version_major == 2: 1103 | _fuse_operations_fields: list[FieldsEntry] = [ 1104 | ('getattr', CFUNCTYPE(c_int, c_char_p, c_stat_p)), 1105 | ('readlink', CFUNCTYPE(c_int, c_char_p, c_byte_p, c_size_t)), 1106 | ('getdir', c_void_p), # Deprecated, use readdir 1107 | *_fuse_operations_fields_mknod_to_symlink, 1108 | ('rename', CFUNCTYPE(c_int, c_char_p, c_char_p)), 1109 | ('link', CFUNCTYPE(c_int, c_char_p, c_char_p)), 1110 | ('chmod', CFUNCTYPE(c_int, c_char_p, c_mode_t)), 1111 | ('chown', CFUNCTYPE(c_int, c_char_p, c_uid_t, c_gid_t)), 1112 | ('truncate', CFUNCTYPE(c_int, c_char_p, c_off_t)), 1113 | ('utime', c_void_p), # Deprecated, use utimens 1114 | *_fuse_operations_fields_open_to_removexattr, 1115 | ] 1116 | if fuse_version_minor >= 3: 1117 | _fuse_operations_fields += [ 1118 | ('opendir', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), 1119 | ( 1120 | 'readdir', 1121 | CFUNCTYPE( 1122 | c_int, 1123 | c_char_p, 1124 | c_void_p, 1125 | CFUNCTYPE(c_int, c_void_p, c_char_p, c_stat_p, c_off_t), 1126 | c_off_t, 1127 | fuse_fi_p, 1128 | ), 1129 | ), 1130 | ('releasedir', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), 1131 | ('fsyncdir', CFUNCTYPE(c_int, c_char_p, c_int, fuse_fi_p)), 1132 | ('init', CFUNCTYPE(c_void_p, POINTER(fuse_conn_info))), 1133 | ('destroy', CFUNCTYPE(c_void_p, c_void_p)), 1134 | ] 1135 | if fuse_version_minor >= 5: 1136 | _fuse_operations_fields += [ 1137 | ('access', CFUNCTYPE(c_int, c_char_p, c_int)), 1138 | ('create', CFUNCTYPE(c_int, c_char_p, c_mode_t, fuse_fi_p)), 1139 | ('ftruncate', CFUNCTYPE(c_int, c_char_p, c_off_t, fuse_fi_p)), 1140 | ('fgetattr', CFUNCTYPE(c_int, c_char_p, c_stat_p, fuse_fi_p)), 1141 | ] 1142 | if fuse_version_minor >= 6: 1143 | _fuse_operations_fields += [ 1144 | ('lock', CFUNCTYPE(c_int, c_char_p, fuse_fi_p, c_int, POINTER(c_flock_t))), 1145 | ('utimens', CFUNCTYPE(c_int, c_char_p, c_utimbuf_p)), 1146 | ('bmap', CFUNCTYPE(c_int, c_char_p, c_size_t, c_uint64_p)), 1147 | ] 1148 | if fuse_version_minor >= 8: 1149 | _fuse_operations_fields += [ 1150 | ('flag_nullpath_ok', c_uint, 1), 1151 | ('flag_nopath', c_uint, 1), 1152 | ('flag_utime_omit_ok', c_uint, 1), 1153 | ('flag_reserved', c_uint, 29), 1154 | ('ioctl', CFUNCTYPE(c_int, c_char_p, c_uint, c_void_p, fuse_fi_p, c_uint, c_void_p)), 1155 | ] 1156 | if fuse_version_minor >= 9: 1157 | _fuse_operations_fields += _fuse_operations_fields_2_9 1158 | if _system == 'Darwin': 1159 | _fuse_operations_fields += [ 1160 | ('reserved00', c_void_p), 1161 | ('reserved01', c_void_p), 1162 | ('__todo__', c_void_p * 11), # TODO: misc. addtl. functions 1163 | ] 1164 | elif fuse_version_major == 3: 1165 | fuse_fill_dir_flags = ctypes.c_int # The only flag in libfuse 3.16 is USE_FILL_DIR_PLUS = (1 << 1). 1166 | fuse_fill_dir_t = CFUNCTYPE(c_int, c_void_p, c_char_p, c_stat_p, c_off_t, fuse_fill_dir_flags) 1167 | 1168 | fuse_readdir_flags = ctypes.c_int # The only flag in libfuse 3.16 is FUSE_READDIR_PLUS = (1 << 0). 1169 | 1170 | # Generated bindings with: 1171 | # gcc -fpreprocessed -dD -E -P -Wno-all -x c <( git show fuse-3.16.2:include/fuse.h ) 2>/dev/null | 1172 | # sed -nr '/struct fuse_operations/,$p' | sed -nr '0,/};/p' | sed -z 's|,\n *|, |g' | 1173 | # sed -r ' 1174 | # s|const char [*]( ?[a-z_]+)?|ctypes.c_char_p|g; 1175 | # s|char [*]( ?[a-z_]+)?|c_byte_p|g; 1176 | # s|([( ])([a-z]+)_t( ?[a-z_]+)?|\1c_\2_t|g; 1177 | # s|([( ])unsigned int( ?[a-z_]+)?|\1ctypes.c_uint|g; 1178 | # s|([( ])int( ?[a-z_]+)?|\1ctypes.c_int|g; 1179 | # s|struct (fuse_[a-z_]+) [*][*]( ?[a-z_]+)?|ctypes.POINTER(ctypes.POINTER(\1))|g; 1180 | # s|struct (fuse_[a-z_]+) [*]( ?[a-z_]+)?|ctypes.POINTER(\1)|g; 1181 | # s|struct (stat(vfs)?) [*]( ?[a-z_]+)?|ctypes.POINTER(c_\1)|g; 1182 | # s|struct (flock) [*]( ?[a-z_]+)?|ctypes.POINTER(c_\1)|g; 1183 | # s|(u?int64)_t [*]( ?[a-z_]+)?|ctypes.POINTER(ctypes.c_\1)|g; 1184 | # s|void [*]( ?[a-z_]+)?|ctypes.c_void_p|g; 1185 | # s|const struct timespec tv[[]2[]]|c_utimbuf_p|g; 1186 | # s|enum ([a-z_]+)|\1|g; 1187 | # s|^ *||; 1188 | # ' | 1189 | # sed -r "s|(^[a-z_.]+) [(][*]([a-z_]+)[)] [(](.*);|('\2', ctypes.CFUNCTYPE(\1, \3),|; s|ctypes[.]||g;" 1190 | # Then fix the remaining problems by using pylint and by comparing with the FUSE 2 version. 1191 | # 1192 | # Removed members: getdir, utime, ftruncate, fgetattr, flag_nullpath_ok, flag_nopath, flag_utime_omit_ok 1193 | # - The methods were not used by fusepy anyway. 1194 | # - The flags were not exposed to fusepy callers because the fuse_operations struct is created, 1195 | # given to fuse_main_real, and then forgotten about in FUSE.__init__. 1196 | # Methods with changed arguments: 1197 | # - getattr, rename, chmod, chown, truncate, readdir, init, utimens, ioctl 1198 | # fmt: off 1199 | _fuse_operations_fields = [ 1200 | ('getattr', CFUNCTYPE(c_int, c_char_p, c_stat_p, fuse_fi_p)), # Added file info 1201 | ('readlink', CFUNCTYPE(c_int, c_char_p, c_byte_p, c_size_t)), # Same as v2.9 1202 | *_fuse_operations_fields_mknod_to_symlink, 1203 | ('rename', CFUNCTYPE(c_int, c_char_p, c_char_p, c_uint)), # Added flags 1204 | ('link', CFUNCTYPE(c_int, c_char_p, c_char_p)), # Same as v2.9 1205 | ('chmod', CFUNCTYPE(c_int, c_char_p, c_mode_t, fuse_fi_p)), # Added file info 1206 | ('chown', CFUNCTYPE(c_int, c_char_p, c_uid_t, c_gid_t, fuse_fi_p)), # Added file info 1207 | ('truncate', CFUNCTYPE(c_int, c_char_p, c_off_t, fuse_fi_p)), # Added file info 1208 | *_fuse_operations_fields_open_to_removexattr, 1209 | ('opendir', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), # Same as v2.9 1210 | ('readdir', CFUNCTYPE( 1211 | c_int, c_char_p, c_void_p, fuse_fill_dir_t, c_off_t, fuse_fi_p, fuse_readdir_flags)), # Added flags 1212 | ('releasedir', CFUNCTYPE(c_int, c_char_p, fuse_fi_p)), # Same as v2.9 1213 | ('fsyncdir', CFUNCTYPE(c_int, c_char_p, c_int, fuse_fi_p)), # Same as v2.9 1214 | ('init', CFUNCTYPE(c_void_p, POINTER(fuse_conn_info), POINTER(fuse_config))), # Added config 1215 | ('destroy', CFUNCTYPE(c_void_p, c_void_p)), # Same as v2.9 1216 | ('access', CFUNCTYPE(c_int, c_char_p, c_int)), # Same as v2.9 1217 | ('create', CFUNCTYPE(c_int, c_char_p, c_mode_t, fuse_fi_p)), # Same as v2.9 1218 | ('lock', CFUNCTYPE(c_int, c_char_p, fuse_fi_p, c_int, POINTER(c_flock_t))), # Same as v2.9 1219 | ('utimens', CFUNCTYPE(c_int, c_char_p, c_utimbuf_p, fuse_fi_p)), # Added file info 1220 | ('bmap', CFUNCTYPE(c_int, c_char_p, c_size_t, c_uint64_p)), # Same as v2.9 1221 | ('ioctl', CFUNCTYPE( # Argument type 1222 | c_int, c_char_p, c_int if fuse_version_minor < 5 else c_uint, c_void_p, 1223 | fuse_fi_p, c_uint, c_void_p)), 1224 | *_fuse_operations_fields_2_9, 1225 | ( 1226 | 'copy_file_range', # New 1227 | CFUNCTYPE( 1228 | c_ssize_t, c_char_p, fuse_fi_p, c_off_t, c_char_p, 1229 | fuse_fi_p, c_off_t, c_size_t, c_int, 1230 | ), 1231 | ), 1232 | ('lseek', CFUNCTYPE(c_off_t, c_char_p, c_off_t, c_int, fuse_fi_p)), # New 1233 | ] 1234 | # fmt: on 1235 | 1236 | 1237 | class fuse_operations(ctypes.Structure): 1238 | _fields_ = _fuse_operations_fields 1239 | 1240 | 1241 | if _system == "OpenBSD": 1242 | 1243 | def fuse_main_real(argc, argv, fuse_ops_v, sizeof_fuse_ops, ctx_p): 1244 | return _libfuse.fuse_main(argc, argv, fuse_ops_v, ctx_p) 1245 | 1246 | else: 1247 | fuse_main_real = _libfuse.fuse_main_real 1248 | 1249 | 1250 | def time_of_timespec(ts, use_ns: bool = False) -> float: 1251 | if use_ns: 1252 | return ts.tv_sec * 10**9 + ts.tv_nsec 1253 | return ts.tv_sec + ts.tv_nsec / 1e9 1254 | 1255 | 1256 | def set_st_attrs(st, attrs: dict[str, Any], use_ns: bool = False) -> None: 1257 | for key, val in attrs.items(): 1258 | if key in ('st_atime', 'st_mtime', 'st_ctime', 'st_birthtime'): 1259 | timespec = getattr(st, key + 'spec', None) 1260 | if timespec is None: 1261 | continue 1262 | 1263 | if use_ns: 1264 | timespec.tv_sec, timespec.tv_nsec = divmod(int(val), 10**9) 1265 | else: 1266 | timespec.tv_sec = int(val) 1267 | timespec.tv_nsec = int((val - timespec.tv_sec) * 1e9) 1268 | elif getattr(st, key, None) is not None: 1269 | setattr(st, key, val) 1270 | 1271 | 1272 | def fuse_get_context() -> tuple[int, int, int]: 1273 | 'Returns a (uid, gid, pid) tuple' 1274 | 1275 | ctxp = _libfuse.fuse_get_context() 1276 | ctx = ctxp.contents 1277 | return ctx.uid, ctx.gid, ctx.pid 1278 | 1279 | 1280 | def fuse_exit() -> None: 1281 | ''' 1282 | This will shutdown the FUSE mount and cause the call to FUSE(...) to 1283 | return, similar to sending SIGINT to the process. 1284 | 1285 | Flags the native FUSE session as terminated and will cause any running FUSE 1286 | event loops to exit on the next opportunity. (see fuse.c::fuse_exit) 1287 | ''' 1288 | # OpenBSD doesn't have fuse_exit 1289 | # instead fuse_loop() gracefully catches SIGTERM 1290 | if _system == "OpenBSD": 1291 | os.kill(os.getpid(), SIGTERM) 1292 | return 1293 | 1294 | fuse_ptr = ctypes.c_void_p(_libfuse.fuse_get_context().contents.fuse) 1295 | _libfuse.fuse_exit(fuse_ptr) 1296 | 1297 | 1298 | class FuseOSError(OSError): 1299 | def __init__(self, errno): 1300 | super().__init__(errno, os.strerror(errno)) 1301 | 1302 | 1303 | # See fuse_lib_opts in fuse.c 1304 | _LIBFUSE_2_OPTIONS_REMOVED_IN_FUSE_3 = {"-h", "--help"} 1305 | _LIBFUSE_2_OPTIONS_MOVED_INTO_FUSE_3_CONFIG = { 1306 | "hard_remove", 1307 | "use_ino", 1308 | "readdir_ino", 1309 | "direct_io", 1310 | "nopath", 1311 | "intr", 1312 | "intr_signal", 1313 | } 1314 | _LIBFUSE_3_ONLY_OPTIONS = {"no_rofd_flush", "fmask", "dmask", "parallel_direct_write"} 1315 | 1316 | 1317 | class FUSE: 1318 | ''' 1319 | This class is the lower level interface and should not be subclassed under 1320 | normal use. Its methods are called by fuse. 1321 | 1322 | Assumes API version 2.6 or later. 1323 | ''' 1324 | 1325 | OPTIONS = ( 1326 | ('foreground', '-f'), 1327 | ('debug', '-d'), 1328 | ('nothreads', '-s'), 1329 | ) 1330 | 1331 | def __init__( 1332 | self, 1333 | operations, 1334 | mountpoint: str, 1335 | raw_fi: bool = False, 1336 | encoding: str = 'utf-8', 1337 | errors: str = 'surrogateescape', 1338 | **kwargs, 1339 | ) -> None: 1340 | ''' 1341 | Setting raw_fi to True will cause FUSE to pass the fuse_file_info 1342 | class as is to Operations, instead of just the fh field. 1343 | 1344 | This gives you access to direct_io, keep_cache, etc. 1345 | ''' 1346 | 1347 | self.operations = operations 1348 | self.raw_fi = raw_fi 1349 | self.encoding = encoding 1350 | self.errors = errors 1351 | self.__critical_exception = None 1352 | 1353 | self.use_ns = getattr(self.operations, 'use_ns', False) 1354 | if not self.use_ns: 1355 | warnings.warn( 1356 | 'Time as floating point seconds for utimens is deprecated!\n' 1357 | 'To enable time as nanoseconds set the property "use_ns" to ' 1358 | 'True in your operations class or set your fusepy requirements to <4.', 1359 | DeprecationWarning, 1360 | stacklevel=2, 1361 | ) 1362 | 1363 | if callable(self.operations): 1364 | warnings.warn( 1365 | "The call operator on the Operations object is ignored since mfusepy 3.0!" 1366 | "Use decorators to wrap methods or if really necessary overwrite __getattribute__ instead.", 1367 | DeprecationWarning, 1368 | stacklevel=2, 1369 | ) 1370 | 1371 | args = ['fuse'] 1372 | 1373 | args.extend(flag for arg, flag in self.OPTIONS if kwargs.pop(arg, False)) 1374 | 1375 | kwargs.setdefault('fsname', self.operations.__class__.__name__) 1376 | args.extend(('-o', ','.join(self._normalize_fuse_options(**kwargs)), mountpoint)) 1377 | self._libfuse2_options_moved_into_libfuse3_config = { 1378 | key: value for key, value in kwargs.items() if key in _LIBFUSE_2_OPTIONS_MOVED_INTO_FUSE_3_CONFIG 1379 | } 1380 | 1381 | argsb = [arg.encode(encoding, self.errors) for arg in args] 1382 | argv = (ctypes.c_char_p * len(argsb))(*argsb) 1383 | 1384 | alternative_callbacks = { 1385 | "readdir": ["readdir_with_offset"], 1386 | } 1387 | 1388 | # Iterate over all libfuse operations struct methods and check for user-implemented ones in self.operations. 1389 | fuse_ops = fuse_operations() 1390 | callbacks_to_always_add = {'init'} 1391 | for field in fuse_operations._fields_: 1392 | name, prototype = field[:2] 1393 | is_function = hasattr(prototype, 'argtypes') 1394 | 1395 | check_name = name 1396 | 1397 | # ftruncate()/fgetattr() are implemented in terms of their 1398 | # non-f-prefixed versions in the operations object 1399 | if check_name in ["ftruncate", "fgetattr"]: 1400 | check_name = check_name[1:] 1401 | 1402 | value = getattr(self.operations, check_name, None) 1403 | if value is None or getattr(value, 'libfuse_ignore', False): 1404 | skip = check_name not in callbacks_to_always_add 1405 | if skip and check_name in alternative_callbacks: 1406 | for alternative_name in alternative_callbacks[check_name]: 1407 | value = getattr(self.operations, alternative_name, None) 1408 | skip = value is None or getattr(value, 'libfuse_ignore', False) 1409 | if not skip: 1410 | break 1411 | if skip: 1412 | log.debug("Leave libFUSE %s for '%s' uninitialized.", 'callback' if is_function else 'value', name) 1413 | continue 1414 | 1415 | # Wrap functions into try-except statements. 1416 | if is_function: 1417 | method: Optional[Any] = None 1418 | if fuse_version_major == 2: 1419 | method = getattr(self, name + '_fuse_2', None) 1420 | elif fuse_version_major == 3: 1421 | method = getattr(self, name + '_fuse_3', None) 1422 | 1423 | if method is not None and hasattr(self, name): 1424 | raise RuntimeError( 1425 | "Internal Error: Only either suffixed or non-suffixed methods must exist!" 1426 | f"Found both for '{name}'." 1427 | ) 1428 | 1429 | if method is None: 1430 | method = getattr(self, name, None) 1431 | if method is None: 1432 | raise RuntimeError(f"Internal Error: Method wrapper for FUSE callback '{name}' is missing!") 1433 | 1434 | log.debug("Set libFUSE callback for '%s' to wrapped %s wrapping %s", name, method, value) 1435 | value = prototype(functools.partial(self._wrapper, method)) 1436 | else: 1437 | log.debug("Set libFUSE value for '%s' to %s", name, value) 1438 | 1439 | setattr(fuse_ops, name, value) 1440 | 1441 | try: 1442 | old_handler = signal(SIGINT, SIG_DFL) 1443 | except ValueError: 1444 | old_handler = SIG_DFL 1445 | 1446 | err = fuse_main_real(len(argsb), argv, ctypes.pointer(fuse_ops), ctypes.sizeof(fuse_ops), None) 1447 | 1448 | try: 1449 | signal(SIGINT, old_handler) 1450 | except ValueError: 1451 | pass 1452 | 1453 | del self.operations # Invoke the destructor 1454 | if self.__critical_exception: 1455 | raise self.__critical_exception 1456 | if err: 1457 | raise RuntimeError(err) 1458 | 1459 | @staticmethod 1460 | def _normalize_fuse_options(**kargs): 1461 | for key, value in kargs.items(): 1462 | if fuse_version_major == 2: 1463 | if key in _LIBFUSE_3_ONLY_OPTIONS: 1464 | log.warning("Ignore libfuse3-only option: %s (%s)", key, value) 1465 | continue 1466 | elif fuse_version_major == 3: 1467 | if key in _LIBFUSE_2_OPTIONS_REMOVED_IN_FUSE_3: 1468 | log.warning("Ignore libfuse2-only option: %s (%s)", key, value) 1469 | continue 1470 | if key in _LIBFUSE_2_OPTIONS_MOVED_INTO_FUSE_3_CONFIG: 1471 | continue 1472 | 1473 | if isinstance(value, bool): 1474 | if value is True: 1475 | yield key 1476 | else: 1477 | yield f'{key}={value}' 1478 | 1479 | def _wrapper(self, func, *args, **kwargs): 1480 | 'Decorator for the methods that follow' 1481 | 1482 | # Catch exceptions generically so that the whole filesystem does not crash on each fusepy user 1483 | # error. 'init' must not fail because its return code is just stored as private_data field of 1484 | # struct fuse_contex. 1485 | try: 1486 | try: 1487 | return func(*args, **kwargs) or 0 1488 | 1489 | except OSError as e: 1490 | if func.__name__ == "init": 1491 | raise e 1492 | if isinstance(e.errno, int) and e.errno > 0: 1493 | is_valid_exception = (func.__name__.startswith("getattr") and e.errno == errno.ENOENT) or ( 1494 | func.__name__ == "getxattr" and e.errno == ENOATTR 1495 | ) 1496 | 1497 | error_string = "" 1498 | with contextlib.suppress(ValueError): 1499 | error_string = os.strerror(e.errno) 1500 | 1501 | log.debug( 1502 | "FUSE operation %s (%s) raised a %s, returning errno %s (%s).", 1503 | func.__name__, 1504 | args, 1505 | type(e), 1506 | e.errno, 1507 | error_string, 1508 | exc_info=not is_valid_exception, 1509 | ) 1510 | return -e.errno 1511 | log.exception( 1512 | "FUSE operation %s raised an OSError with negative errno %s, returning errno.EINVAL.", 1513 | func.__name__, 1514 | e.errno, 1515 | ) 1516 | return -errno.EINVAL 1517 | 1518 | except Exception as e: 1519 | if func.__name__ == "init": 1520 | raise e 1521 | log.exception("Uncaught exception from FUSE operation %s, returning errno.EINVAL.", func.__name__) 1522 | return -errno.EINVAL 1523 | 1524 | except BaseException as e: 1525 | self.__critical_exception = e 1526 | log.critical( 1527 | "Uncaught critical exception from FUSE operation %s, aborting.", 1528 | func.__name__, 1529 | exc_info=True, 1530 | ) 1531 | # the raised exception (even SystemExit) will be caught by FUSE 1532 | # potentially causing SIGSEGV, so tell system to stop/interrupt FUSE 1533 | fuse_exit() 1534 | return -errno.EFAULT 1535 | 1536 | def getattr_fuse_2(self, path: bytes, buf: c_stat_p): 1537 | return self.fgetattr(path, buf, None) 1538 | 1539 | def getattr_fuse_3(self, path: bytes, buf: c_stat_p, fip: fuse_fi_p): 1540 | return self.fgetattr(path, buf, fip) 1541 | 1542 | def readlink(self, path: bytes, buf: c_byte_p, bufsize: int) -> int: 1543 | ret = self.operations.readlink(path.decode(self.encoding, self.errors)).encode(self.encoding, self.errors) 1544 | 1545 | # copies a string into the given buffer 1546 | # (null terminated and truncated if necessary) 1547 | data = ctypes.create_string_buffer(ret[: bufsize - 1]) 1548 | ctypes.memmove(buf, data, len(data)) 1549 | return 0 1550 | 1551 | def mknod(self, path: bytes, mode: int, dev: int) -> int: 1552 | return self.operations.mknod(path.decode(self.encoding, self.errors), mode, dev) 1553 | 1554 | def mkdir(self, path: bytes, mode: int) -> int: 1555 | return self.operations.mkdir(path.decode(self.encoding, self.errors), mode) 1556 | 1557 | def unlink(self, path: bytes) -> int: 1558 | return self.operations.unlink(path.decode(self.encoding, self.errors)) 1559 | 1560 | def rmdir(self, path: bytes) -> int: 1561 | return self.operations.rmdir(path.decode(self.encoding, self.errors)) 1562 | 1563 | def symlink(self, source: bytes, target: bytes) -> int: 1564 | 'creates a symlink `target -> source` (e.g. ln -s source target)' 1565 | 1566 | return self.operations.symlink( 1567 | target.decode(self.encoding, self.errors), source.decode(self.encoding, self.errors) 1568 | ) 1569 | 1570 | def rename_fuse_2(self, old: bytes, new: bytes) -> int: 1571 | return self.operations.rename(old.decode(self.encoding, self.errors), new.decode(self.encoding, self.errors)) 1572 | 1573 | def rename_fuse_3(self, old: bytes, new: bytes, flags: int) -> int: 1574 | return self.rename_fuse_2(old, new) 1575 | 1576 | def link(self, source: bytes, target: bytes): 1577 | 'creates a hard link `target -> source` (e.g. ln source target)' 1578 | 1579 | return self.operations.link( 1580 | target.decode(self.encoding, self.errors), source.decode(self.encoding, self.errors) 1581 | ) 1582 | 1583 | def chmod_fuse_2(self, path: Optional[bytes], mode: int) -> int: 1584 | return self.operations.chmod(None if path is None else path.decode(self.encoding, self.errors), mode) 1585 | 1586 | def chmod_fuse_3(self, path: Optional[bytes], mode: int, fip: fuse_fi_p) -> int: 1587 | return self.operations.chmod(None if path is None else path.decode(self.encoding, self.errors), mode) 1588 | 1589 | def _chown(self, path: Optional[bytes], uid: int, gid: int) -> int: 1590 | # Check if any of the arguments is a -1 that has overflowed 1591 | if c_uid_t(uid + 1).value == 0: 1592 | uid = -1 1593 | if c_gid_t(gid + 1).value == 0: 1594 | gid = -1 1595 | 1596 | return self.operations.chown(None if path is None else path.decode(self.encoding, self.errors), uid, gid) 1597 | 1598 | def chown_fuse_2(self, path: Optional[bytes], uid: int, gid: int) -> int: 1599 | return self._chown(path, uid, gid) 1600 | 1601 | def chown_fuse_3(self, path: Optional[bytes], uid: int, gid: int, fip: fuse_fi_p) -> int: 1602 | return self._chown(path, uid, gid) 1603 | 1604 | def truncate_fuse_2(self, path: Optional[bytes], length: int) -> int: 1605 | return self.operations.truncate(None if path is None else path.decode(self.encoding, self.errors), length) 1606 | 1607 | def truncate_fuse_3(self, path: Optional[bytes], length: int, fip: fuse_fi_p) -> int: 1608 | return self.operations.truncate(None if path is None else path.decode(self.encoding, self.errors), length) 1609 | 1610 | def open(self, path: bytes, fip) -> int: 1611 | fi = fip.contents 1612 | if self.raw_fi: 1613 | return self.operations.open(path.decode(self.encoding, self.errors), fi) 1614 | fi.fh = self.operations.open(path.decode(self.encoding, self.errors), fi.flags) 1615 | return 0 1616 | 1617 | def read(self, path: Optional[bytes], buf, size: int, offset: int, fip: fuse_fi_p) -> int: 1618 | fh = fip.contents if self.raw_fi else fip.contents.fh 1619 | ret = self.operations.read(None if path is None else path.decode(self.encoding, self.errors), size, offset, fh) 1620 | 1621 | if not ret: 1622 | return 0 1623 | 1624 | retsize = len(ret) 1625 | assert retsize <= size, f'actual amount read {retsize} greater than expected {size}' 1626 | 1627 | ctypes.memmove(buf, ret, retsize) 1628 | return retsize 1629 | 1630 | def write(self, path: Optional[bytes], buf: c_byte_p, size: int, offset: int, fip: fuse_fi_p) -> int: 1631 | data = ctypes.string_at(buf, size) 1632 | fh = fip.contents if self.raw_fi else fip.contents.fh 1633 | return self.operations.write( 1634 | None if path is None else path.decode(self.encoding, self.errors), data, offset, fh 1635 | ) 1636 | 1637 | def statfs(self, path: bytes, buf: c_statvfs_p) -> int: 1638 | stv = buf.contents 1639 | attrs = self.operations.statfs(path.decode(self.encoding, self.errors)) 1640 | for key, val in attrs.items(): 1641 | if hasattr(stv, key): 1642 | setattr(stv, key, val) 1643 | 1644 | return 0 1645 | 1646 | def flush(self, path: Optional[bytes], fip: fuse_fi_p) -> int: 1647 | fh = fip.contents if self.raw_fi else fip.contents.fh 1648 | return self.operations.flush(None if path is None else path.decode(self.encoding, self.errors), fh) 1649 | 1650 | def release(self, path: Optional[bytes], fip: fuse_fi_p) -> int: 1651 | fh = fip.contents if self.raw_fi else fip.contents.fh 1652 | return self.operations.release(None if path is None else path.decode(self.encoding, self.errors), fh) 1653 | 1654 | def fsync(self, path: Optional[bytes], datasync: int, fip: fuse_fi_p) -> int: 1655 | fh = fip.contents if self.raw_fi else fip.contents.fh 1656 | return self.operations.fsync(None if path is None else path.decode(self.encoding, self.errors), datasync, fh) 1657 | 1658 | def setxattr(self, path: bytes, name: bytes, value: c_byte_p, size: int, options: int, *args) -> int: 1659 | return self.operations.setxattr( 1660 | path.decode(self.encoding, self.errors), 1661 | name.decode(self.encoding, self.errors), 1662 | ctypes.string_at(value, size), 1663 | options, 1664 | *args, 1665 | ) 1666 | 1667 | def getxattr(self, path: bytes, name: bytes, value: c_byte_p, size: int, *args) -> int: 1668 | ret = self.operations.getxattr( 1669 | path.decode(self.encoding, self.errors), name.decode(self.encoding, self.errors), *args 1670 | ) 1671 | 1672 | retsize = len(ret) 1673 | # allow size queries 1674 | if not value: 1675 | return retsize 1676 | 1677 | # do not truncate 1678 | if retsize > size: 1679 | return -errno.ERANGE 1680 | 1681 | # Does not add trailing 0 1682 | buf = ctypes.create_string_buffer(ret, retsize) 1683 | ctypes.memmove(value, buf, retsize) 1684 | 1685 | return retsize 1686 | 1687 | def listxattr(self, path: bytes, namebuf: c_byte_p, size: int) -> int: 1688 | attrs = self.operations.listxattr(path.decode(self.encoding, self.errors)) or '' 1689 | ret = '\x00'.join(attrs).encode(self.encoding, self.errors) 1690 | if len(ret) > 0: 1691 | ret += '\x00'.encode(self.encoding, self.errors) 1692 | 1693 | retsize = len(ret) 1694 | # allow size queries 1695 | if not namebuf: 1696 | return retsize 1697 | 1698 | # do not truncate 1699 | if retsize > size: 1700 | return -errno.ERANGE 1701 | 1702 | buf = ctypes.create_string_buffer(ret, retsize) 1703 | ctypes.memmove(namebuf, buf, retsize) 1704 | 1705 | return retsize 1706 | 1707 | def removexattr(self, path: bytes, name: bytes) -> int: 1708 | return self.operations.removexattr( 1709 | path.decode(self.encoding, self.errors), name.decode(self.encoding, self.errors) 1710 | ) 1711 | 1712 | def opendir(self, path: bytes, fip: fuse_fi_p) -> int: 1713 | # Ignore raw_fi 1714 | fip.contents.fh = self.operations.opendir(path.decode(self.encoding, self.errors)) 1715 | return 0 1716 | 1717 | # == About readdir and what should be returned == 1718 | # 1719 | # Study the implementation in 2.9.9 in fuse.c 1720 | # https://github.com/libfuse/libfuse/blob/fuse_2_9_bugfix/lib/fuse.c 1721 | # Libfuse is split in a high-level and low-level API. We use the former, which calls the latter. 1722 | # The call chain: 1723 | # 1724 | # 1. The fuse_lowlevel_ops.readdir callback is initialized with fuse_lib_readdir. 1725 | # This still gets an inode as argument from the low-level interface. 1726 | # 2. readdir_fill: If the high-level API callback fuse_operations.readdir is set, then the inode is 1727 | # converted to a path via get_path -> get_path_common -> try_get_path -> get_node -> get_node_nocheck, 1728 | # which looks up f->id_table.array[hashid_hash(f, nodeid)], so yeah, basically a std::unorderd_map, 1729 | # and calls fuse_fs_readdir with the path. 1730 | # 3. fuse_fs_readdir: calls the readdir callback if set, or the getdir callback with a "filler" callback. 1731 | # 4. The filler callback is specified in readdir_fill and is simply fill_dir 1732 | # 5. fill_dir copies the full struct stat argument if given and calls fuse_add_direntry_to_dh with it. 1733 | # 6. fuse_lowlevel.c:fuse_add_direntry_to_dh mallocs a new fuse_direntry, strdups the name and copies the 1734 | # full stat object and appends it to the fuse_dh linked list! 1735 | # 1736 | # If offset != 0: 1737 | # 6. fuse.c:fill_dir checks whether the fuse_dh is filled and returns 1 if so. 1738 | # May increase its size in extend_contents. 1739 | # 7. Calls fuse_lowlevel.c:fuse_add_direntry with offset argument 1740 | # 8. fuse_lowlevel.c:fuse_add_direntry -> fuse_add_dirent 1741 | # 9. fuse_add_dirent basically only copies the inode and the mode!!! NOTHING ELSE: 1742 | # struct fuse_dirent *dirent = (struct fuse_dirent *) buf; 1743 | # dirent->ino = stbuf->st_ino; 1744 | # dirent->off = off; 1745 | # dirent->namelen = namelen; 1746 | # dirent->type = (stbuf->st_mode & 0170000) >> 12; 1747 | # strncpy(dirent->name, name, namelen); 1748 | # Everything we do to fill the whole stat struct is for naught! 1749 | # ONLY "stbuf->st_mode & 0170000" IS USED. ONLY 4 BITS. 1750 | # https://github.com/torvalds/linux/blob/1934261d897467a924e2afd1181a74c1cbfa2c1d/include/uapi/linux/stat.h#L9 1751 | # #define S_IFMT 00170000 1752 | # #define S_IFSOCK 0140000 1753 | # #define S_IFLNK 0120000 1754 | # #define S_IFREG 0100000 1755 | # #define S_IFBLK 0060000 1756 | # #define S_IFDIR 0040000 1757 | # #define S_IFCHR 0020000 1758 | # #define S_IFIFO 0010000 1759 | # #define S_ISUID 0004000 1760 | # #define S_ISGID 0002000 1761 | # #define S_ISVTX 0001000 1762 | # -> I'm not sure whether all of these are actually required. It may also be that file and directory 1763 | # would suffice. 1764 | # 1765 | # The important thing to know here is that fuse_dirent, the struct defined by the Linux Kernel FUSE API: 1766 | # https://github.com/torvalds/linux/blob/1934261d897467a924e2afd1181a74c1cbfa2c1d/include/uapi/linux/ 1767 | # fuse.h#L1005-L1010 1768 | # https://man7.org/linux/man-pages/man4/fuse.4.html 1769 | # Only has members for ino, off, nameln, type, and name. Everything else is cruft added by the libfuse 1770 | # abstraction layer. 1771 | # 1772 | # This changes a bit with FUSE 3, which also adds support for readdir_plus. However, when talking about 1773 | # FUSE 3, we are only talking about a major version change in libfuse, not the Kernel FUSE API, I think. 1774 | # 1775 | # Steps 1-4 are the same as in FUSE 2.9 1776 | # Step 4: The filler callback can be chosen via the new flags argument to readdir: 1777 | # fuse_fill_dir_t filler = (flags & FUSE_READDIR_PLUS) ? fill_dir_plus : fill_dir; 1778 | # Steps 5-7 are still the same when the FUSE_READDIR_PLUS flag is not set, which is the default! 1779 | # If it is set, then the fill_dir_plus filler callback calls fuse_add_direntry_plus instead of 1780 | # fuse_add_direntry. 1781 | # fuse_add_direntry_plus converts the stat struct in the fuse_attr attr member of the 1782 | # fuse_entry_out entry_out in the fuse_direntplus struct. fuse_attr has 16 members. 1783 | # https://github.com/torvalds/linux/blob/1934261d897467a924e2afd1181a74c1cbfa2c1d/include/uapi/linux/ 1784 | # fuse.h#L263C1-L280C3 1785 | def _readdir(self, path: Optional[bytes], buf, filler, offset: int, fip: fuse_fi_p) -> int: 1786 | # Ignore raw_fi 1787 | st = c_stat() 1788 | 1789 | decoded_path = None if path is None else path.decode(self.encoding, self.errors) 1790 | use_readdir_with_offset = hasattr(self.operations, "readdir_with_offset") and not getattr( 1791 | self.operations.readdir_with_offset, "libfuse_ignore", False 1792 | ) 1793 | if _system == 'OpenBSD' and getattr(getattr(self.operations, "readdir", None), "libfuse_ignore", False): 1794 | # OpenBSD (FUSE 2.6) does not support readdir_with_offset with arbitrary offsets. 1795 | # It seems to call readdir_with_offset with offsets like 0, 4096, etc., which is 1796 | # not compatible with our example fs implementations. 1797 | use_readdir_with_offset = False 1798 | items = ( 1799 | self.operations.readdir_with_offset(decoded_path, offset, fip.contents.fh) 1800 | if use_readdir_with_offset 1801 | else self.operations.readdir(decoded_path, fip.contents.fh) 1802 | ) 1803 | 1804 | encountered_non_zero_offset = False 1805 | for item in items: 1806 | has_stat = False 1807 | if isinstance(item, str): 1808 | has_stat = True 1809 | name = item 1810 | offset = 0 1811 | else: 1812 | name, attrs, offset = item 1813 | if not use_readdir_with_offset and offset != 0: 1814 | encountered_non_zero_offset = True 1815 | offset = 0 1816 | 1817 | if isinstance(attrs, int): 1818 | st.st_mode = attrs 1819 | has_stat = True 1820 | elif isinstance(attrs, dict): 1821 | # Only the mode and ino (if use_ino is True) are used! The caller may skip everything else. 1822 | # See the members in the fuse_dirent Linux kernel struct. Only those can be used, I think. 1823 | # https://github.com/torvalds/linux/blob/1934261d897467a924e2afd1181a74c1cbfa2c1d/include/uapi/linux/ 1824 | # fuse.h#L1005-L1010 1825 | for key in ['st_mode', 'st_ino']: 1826 | if key in attrs: 1827 | setattr(st, key, attrs[key]) 1828 | has_stat = True 1829 | 1830 | if fuse_version_major == 2: 1831 | if filler(buf, name.encode(self.encoding, self.errors), st if has_stat else None, offset) != 0: # type: ignore 1832 | break 1833 | elif fuse_version_major == 3: 1834 | if filler(buf, name.encode(self.encoding, self.errors), st if has_stat else None, offset, 0) != 0: 1835 | break 1836 | 1837 | if encountered_non_zero_offset and not use_readdir_with_offset: 1838 | log.warning("When returning non-zero offsets from readdir, you should use readdir_with_offset instead.") 1839 | 1840 | return 0 1841 | 1842 | def readdir_fuse_2(self, path: Optional[bytes], buf, filler, offset: int, fip: fuse_fi_p) -> int: 1843 | return self._readdir(path, buf, filler, offset, fip) 1844 | 1845 | def readdir_fuse_3(self, path: Optional[bytes], buf, filler, offset: int, fip: fuse_fi_p, flags: int) -> int: 1846 | # TODO if bit 0 (FUSE_READDIR_PLUS) is set in flags, then we might want to gather more metadata 1847 | # and return it in "filler" with bit 1 (FUSE_FILL_DIR_PLUS) being set. 1848 | # Ignore raw_fi 1849 | return self._readdir(path, buf, filler, offset, fip) 1850 | 1851 | def releasedir(self, path: Optional[bytes], fip: fuse_fi_p) -> int: 1852 | # Ignore raw_fi 1853 | return self.operations.releasedir( 1854 | None if path is None else path.decode(self.encoding, self.errors), fip.contents.fh 1855 | ) 1856 | 1857 | def fsyncdir(self, path: Optional[bytes], datasync: int, fip: fuse_fi_p) -> int: 1858 | # Ignore raw_fi 1859 | return self.operations.fsyncdir( 1860 | None if path is None else path.decode(self.encoding, self.errors), datasync, fip.contents.fh 1861 | ) 1862 | 1863 | def _init(self, conn: FuseConnInfoPointer, config: Optional[FuseConfigPointer]) -> None: 1864 | if hasattr(self.operations, "init_with_config") and not getattr( 1865 | self.operations.init_with_config, "libfuse_ignore", False 1866 | ): 1867 | self.operations.init_with_config( 1868 | None if conn is None else conn.contents, None if config is None else config.contents 1869 | ) 1870 | elif hasattr(self.operations, "init") and not getattr(self.operations.init, "libfuse_ignore", False): 1871 | self.operations.init("/") 1872 | 1873 | def init_fuse_2(self, conn: FuseConnInfoPointer) -> None: 1874 | self._init(conn, None) 1875 | 1876 | def init_fuse_3(self, conn: FuseConnInfoPointer, config: FuseConfigPointer) -> None: 1877 | if getattr(self.operations, 'flag_nopath', False) and getattr(self.operations, 'flag_nullpath_ok', False): 1878 | config.contents.nullpath_ok = True 1879 | if config: 1880 | for key, value in self._libfuse2_options_moved_into_libfuse3_config.items(): 1881 | setattr(config.contents, key, value) 1882 | self._init(conn, config) 1883 | 1884 | def destroy(self, private_data: c_void_p) -> None: 1885 | return self.operations.destroy('/') 1886 | 1887 | def access(self, path: bytes, amode: int) -> int: 1888 | return self.operations.access(path.decode(self.encoding, self.errors), amode) 1889 | 1890 | def create(self, path: bytes, mode: int, fip: fuse_fi_p) -> int: 1891 | fi = fip.contents 1892 | decoded_path = path.decode(self.encoding, self.errors) 1893 | 1894 | if self.raw_fi: 1895 | return self.operations.create(decoded_path, mode, fi) 1896 | if len(inspect.signature(self.operations.create).parameters) == 2: 1897 | fi.fh = self.operations.create(decoded_path, mode) 1898 | else: 1899 | fi.fh = self.operations.create(decoded_path, mode, fi.flags) 1900 | return 0 1901 | 1902 | def ftruncate(self, path: Optional[bytes], length: int, fip: fuse_fi_p) -> int: 1903 | fh = (fip.contents if self.raw_fi else fip.contents.fh) if fip else None 1904 | return self.operations.truncate(None if path is None else path.decode(self.encoding, self.errors), length, fh) 1905 | 1906 | def fgetattr(self, path: Optional[bytes], buf: c_stat_p, fip: Optional[fuse_fi_p]) -> int: 1907 | ctypes.memset(buf, 0, ctypes.sizeof(c_stat)) 1908 | 1909 | st = buf.contents 1910 | fh = (fip.contents if self.raw_fi else fip.contents.fh) if fip else None 1911 | 1912 | attrs = self.operations.getattr(None if path is None else path.decode(self.encoding, self.errors), fh) 1913 | set_st_attrs(st, attrs, use_ns=self.use_ns) 1914 | return 0 1915 | 1916 | def lock(self, path: Optional[bytes], fip: fuse_fi_p, cmd: int, lock) -> int: 1917 | fh = (fip.contents if self.raw_fi else fip.contents.fh) if fip else None 1918 | return self.operations.lock(None if path is None else path.decode(self.encoding, self.errors), fh, cmd, lock) 1919 | 1920 | def utimens_fuse_2(self, path: Optional[bytes], buf: c_utimbuf_p) -> int: 1921 | if buf: 1922 | atime = time_of_timespec(buf.contents.actime, use_ns=self.use_ns) 1923 | mtime = time_of_timespec(buf.contents.modtime, use_ns=self.use_ns) 1924 | times = (atime, mtime) 1925 | else: 1926 | times = None 1927 | 1928 | return self.operations.utimens(None if path is None else path.decode(self.encoding, self.errors), times) 1929 | 1930 | def utimens_fuse_3(self, path: Optional[bytes], buf: c_utimbuf_p, fip: fuse_fi_p) -> int: 1931 | return self.utimens_fuse_2(path, buf) 1932 | 1933 | def bmap(self, path: bytes, blocksize: int, idx: c_uint64_p) -> int: 1934 | return self.operations.bmap(path.decode(self.encoding, self.errors), blocksize, idx) 1935 | 1936 | def ioctl(self, path: Optional[bytes], cmd: int, arg: c_void_p, fip: fuse_fi_p, flags: int, data: c_void_p) -> int: 1937 | fh = fip.contents if self.raw_fi else fip.contents.fh 1938 | return self.operations.ioctl( 1939 | None if path is None else path.decode(self.encoding, self.errors), cmd, arg, fh, flags, data 1940 | ) 1941 | 1942 | def poll(self, path: Optional[bytes], fip: fuse_fi_p, ph, reventsp) -> int: 1943 | fh = fip.contents if self.raw_fi else fip.contents.fh 1944 | return self.operations.poll(None if path is None else path.decode(self.encoding, self.errors), fh, ph, reventsp) 1945 | 1946 | def write_buf(self, path: bytes, buf: fuse_bufvec_p, offset: int, fip: fuse_fi_p) -> int: 1947 | fh = fip.contents if self.raw_fi else fip.contents.fh 1948 | return self.operations.write_buf(path.decode(self.encoding, self.errors), buf, offset, fh) 1949 | 1950 | def read_buf(self, path: bytes, bufpp: fuse_bufvec_pp, size: int, offset: int, fip: fuse_fi_p) -> int: 1951 | fh = fip.contents if self.raw_fi else fip.contents.fh 1952 | return self.operations.read_buf(path.decode(self.encoding, self.errors), bufpp, size, offset, fh) 1953 | 1954 | def flock(self, path: bytes, fip: fuse_fi_p, op: int) -> int: 1955 | fh = fip.contents if self.raw_fi else fip.contents.fh 1956 | return self.operations.flock(path.decode(self.encoding, self.errors), fh, op) 1957 | 1958 | def fallocate(self, path: Optional[bytes], mode: int, offset: int, size: int, fip: fuse_fi_p) -> int: 1959 | fh = fip.contents if self.raw_fi else fip.contents.fh 1960 | return self.operations.fallocate( 1961 | None if path is None else path.decode(self.encoding, self.errors), mode, offset, size, fh 1962 | ) 1963 | 1964 | 1965 | def _nullable_dummy_function(method): 1966 | ''' 1967 | Marks the given method as to be ignored by the 'FUSE' class. 1968 | This makes it possible to add methods with the self-documenting function signatures 1969 | while still not giving any actual callbacks to libfuse as long as these methods are 1970 | not overwritten by a method in a subclassed 'Operations' class. 1971 | ''' 1972 | method.libfuse_ignore = True 1973 | return method 1974 | 1975 | 1976 | class Operations: 1977 | ''' 1978 | This class should be subclassed and passed as an argument to FUSE on 1979 | initialization. All operations should raise a FuseOSError exception on 1980 | error. 1981 | 1982 | When in doubt of what an operation should do, check the FUSE header file 1983 | or the corresponding system call man page. 1984 | 1985 | Any method that is not overwritten will not be set up for libfuse. 1986 | This has the side effect that libfuse can implement fallbacks in case 1987 | callbacks are not implemented. For example 'read' will be used when 'read_buf' 1988 | is not implemented. 1989 | 1990 | This has the side effect that trace debug output, enabled with -o debug, 1991 | for these FUSE function will not be printed. To enable the debug output, 1992 | it should be overwritten with a method simply raising FuseOSError(errno.ENOSYS). 1993 | 1994 | Most function should return 0 on success and -errno. on error and, 1995 | if documented, positive numbers as values. Raising OSError(errno.) 1996 | also works and has to be used for those methods returning something other 1997 | than int. 1998 | ''' 1999 | 2000 | @_nullable_dummy_function 2001 | def access(self, path: str, amode: int) -> int: 2002 | return 0 2003 | 2004 | @_nullable_dummy_function 2005 | def bmap(self, path: str, blocksize: int, idx: c_uint64_p) -> int: 2006 | return 0 2007 | 2008 | @_nullable_dummy_function 2009 | def chmod(self, path: str, mode: int) -> int: 2010 | raise FuseOSError(errno.EROFS) 2011 | 2012 | @_nullable_dummy_function 2013 | def chown(self, path: str, uid: int, gid: int) -> int: 2014 | raise FuseOSError(errno.EROFS) 2015 | 2016 | @_nullable_dummy_function 2017 | def create(self, path: str, mode: int, fi: Optional[Union[fuse_file_info, int]] = None) -> int: 2018 | ''' 2019 | When raw_fi is False (default case), create should return a 2020 | numerical file handle and the signature of create becomes: 2021 | create(self, path, mode, flags) 2022 | 2023 | When raw_fi is True the file handle should be set directly by create 2024 | and return 0. 2025 | ''' 2026 | 2027 | raise FuseOSError(errno.EROFS) 2028 | 2029 | @_nullable_dummy_function 2030 | def destroy(self, path: str) -> None: 2031 | 'Called on filesystem destruction. Path is always /' 2032 | 2033 | @_nullable_dummy_function 2034 | def flush(self, path: str, fh: int) -> int: 2035 | return 0 2036 | 2037 | @_nullable_dummy_function 2038 | def fsync(self, path: str, datasync: int, fh: int) -> int: 2039 | return 0 2040 | 2041 | @_nullable_dummy_function 2042 | def fsyncdir(self, path: str, datasync: int, fh: int) -> int: 2043 | return 0 2044 | 2045 | # Either fgetattr or getattr must be non-null or else libfuse 2.6 will segfault 2046 | # with auto-cache enabled. In FUSE 2.9.9, 3.16, setting this to nullptr, should work fine. 2047 | # https://github.com/libfuse/libfuse/blob/0a0db26bd269562676b6251e8347f4b89907ace3/lib/fuse.c#L1483-L1486 2048 | # That particular location seems to have been fixed in 2.8.0 and 2.7.0, but not in 2.6.5. 2049 | # It seems to have been fixed only by accident in feature commit: 2050 | # https://github.com/libfuse/libfuse/commit/3a7c00ec0c156123c47b53ec1cd7ead001fa4dfb 2051 | def getattr(self, path: str, fh: Optional[int] = None) -> dict[str, Any]: 2052 | ''' 2053 | Returns a dictionary with keys identical to the stat C structure of 2054 | stat(2). 2055 | 2056 | st_atime, st_mtime and st_ctime should be floats. 2057 | 2058 | NOTE: There is an incompatibility between Linux and Mac OS X 2059 | concerning st_nlink of directories. Mac OS X counts all files inside 2060 | the directory, while Linux counts only the subdirectories. 2061 | ''' 2062 | 2063 | if path != '/': 2064 | raise FuseOSError(errno.ENOENT) 2065 | return {'st_mode': (S_IFDIR | 0o755), 'st_nlink': 2} 2066 | 2067 | @_nullable_dummy_function 2068 | def init(self, path: str) -> None: 2069 | ''' 2070 | Called on filesystem initialization. (Path is always /) 2071 | 2072 | Use it instead of __init__ if you start threads on initialization. 2073 | ''' 2074 | 2075 | @_nullable_dummy_function 2076 | def init_with_config(self, conn_info: Optional[fuse_conn_info], config_3: Optional[fuse_config]) -> None: 2077 | ''' 2078 | Called on filesystem initialization. Same function as 'init' but with more parameters. 2079 | Only either 'init' or 'init_with_config' should be overridden. 2080 | Use it instead of __init__ if you start threads on initialization. 2081 | Argument config_3 should be ignored when a FUSE 2 library is loaded. 2082 | ''' 2083 | 2084 | @_nullable_dummy_function 2085 | def ioctl(self, path: str, cmd: int, arg: c_void_p, fh: int, flags: int, data: c_void_p) -> int: 2086 | raise FuseOSError(errno.ENOTTY) 2087 | 2088 | @_nullable_dummy_function 2089 | def link(self, target: str, source: str) -> int: 2090 | 'creates a hard link `target -> source` (e.g. ln source target)' 2091 | 2092 | raise FuseOSError(errno.EROFS) 2093 | 2094 | @_nullable_dummy_function 2095 | def listxattr(self, path: str) -> Iterable[str]: 2096 | ''' 2097 | Return all extended file attribute keys for the specified path. 2098 | Should return an iterable of text strings. 2099 | ''' 2100 | return [] 2101 | 2102 | @_nullable_dummy_function 2103 | def getxattr(self, path: str, name: str, position: int = 0) -> bytes: 2104 | ''' 2105 | Return the extended file attribute value to the specified (key) name and path. 2106 | Should return a bytes object. 2107 | ''' 2108 | # I have no idea what 'position' does. It is a compatibility placeholder specifically for 2109 | # "if _system in ('Darwin', 'Darwin-MacFuse', 'FreeBSD'):", for which getxattr_t supposedly has 2110 | # an additional uint32_t argument for some reason. I think that including FreeBSD here might be a bug, 2111 | # because it also only uses libfuse. TODO: Somehow need to test this! 2112 | # MacFuse does indeed have that extra argument but also only in some overload, not in "Vanilla": 2113 | # https://github.com/macfuse/library/blob/6c26f28394c1cbda2428498c03e1f898c775404e/include/fuse.h#L1465-L1471 2114 | # It seems to be some kind of position, maybe to query very long values in a chunked manner with an offset? 2115 | raise FuseOSError(ENOTSUP) 2116 | 2117 | @_nullable_dummy_function 2118 | def lock(self, path: str, fh: int, cmd: int, lock) -> int: 2119 | raise FuseOSError(errno.ENOSYS) 2120 | 2121 | @_nullable_dummy_function 2122 | def mkdir(self, path: str, mode: int) -> int: 2123 | raise FuseOSError(errno.EROFS) 2124 | 2125 | @_nullable_dummy_function 2126 | def mknod(self, path: str, mode: int, dev: int) -> int: 2127 | raise FuseOSError(errno.EROFS) 2128 | 2129 | @_nullable_dummy_function 2130 | def open(self, path: str, flags: int) -> int: 2131 | ''' 2132 | When raw_fi is False (default case), open should return a numerical 2133 | file handle. 2134 | 2135 | When raw_fi is True the signature of open becomes: 2136 | open(self, path, fi) 2137 | 2138 | and the file handle should be set directly. 2139 | ''' 2140 | 2141 | return 0 2142 | 2143 | @_nullable_dummy_function 2144 | def opendir(self, path: str) -> int: 2145 | 'Returns a numerical file handle.' 2146 | 2147 | return 0 2148 | 2149 | @_nullable_dummy_function 2150 | def read(self, path: str, size: int, offset: int, fh: int) -> bytes: 2151 | 'Returns bytes containing the requested data.' 2152 | 2153 | raise FuseOSError(errno.EIO) 2154 | 2155 | @_nullable_dummy_function 2156 | def readdir(self, path: str, fh: int) -> ReadDirResult: 2157 | ''' 2158 | Can return either a list of names, or a list of (name, attrs, offset) 2159 | tuples. attrs is a dict as in getattr. 2160 | Only st_mode in attrs is used! In the future it may be possible to simply return the mode. 2161 | The 'offset' argument should almost always be 0. If you want to support non-zero offsets 2162 | to avoid memory issues for very large directories, implement readdir_with_offset instead! 2163 | ''' 2164 | 2165 | return ['.', '..'] 2166 | 2167 | @_nullable_dummy_function 2168 | def readdir_with_offset(self, path: str, offset: int, fh: int) -> ReadDirResult: 2169 | ''' 2170 | Similar to readdir but takes an additional 'offset' argument, which is inaptly named in FUSE 2171 | because it also is known to contain inodes, hashes, pointers to B-Trees and whatever. 2172 | https://unix.stackexchange.com/questions/625899/correctly-implementing-seeking-in-fuse-readdir-operation 2173 | The user implementation should use this information to resume yieldings results for readdir 2174 | at the given offset. Not all of the values yielded by the generator might be used. If some 2175 | are not used, this function will be called again with a different offset. 2176 | ''' 2177 | 2178 | return ['.', '..'] 2179 | 2180 | @_nullable_dummy_function 2181 | def readlink(self, path: str) -> str: 2182 | raise FuseOSError(errno.ENOENT) 2183 | 2184 | @_nullable_dummy_function 2185 | def release(self, path: str, fh: int) -> int: 2186 | return 0 2187 | 2188 | @_nullable_dummy_function 2189 | def releasedir(self, path: str, fh: int) -> int: 2190 | return 0 2191 | 2192 | @_nullable_dummy_function 2193 | def removexattr(self, path: str, name: str) -> int: 2194 | raise FuseOSError(ENOTSUP) 2195 | 2196 | @_nullable_dummy_function 2197 | def rename(self, old: str, new: str) -> int: 2198 | raise FuseOSError(errno.EROFS) 2199 | 2200 | @_nullable_dummy_function 2201 | def rmdir(self, path: str) -> int: 2202 | raise FuseOSError(errno.EROFS) 2203 | 2204 | @_nullable_dummy_function 2205 | def setxattr(self, path: str, name: str, value: bytes, options: int, position: int = 0) -> int: 2206 | raise FuseOSError(ENOTSUP) 2207 | 2208 | @_nullable_dummy_function 2209 | def statfs(self, path: str) -> dict[str, int]: 2210 | ''' 2211 | Returns a dictionary with keys identical to the statvfs C structure of 2212 | statvfs(3). 2213 | 2214 | On Mac OS X f_bsize and f_frsize must be a power of 2 2215 | (minimum 512). 2216 | ''' 2217 | 2218 | return {} 2219 | 2220 | @_nullable_dummy_function 2221 | def symlink(self, target: str, source: str) -> int: 2222 | 'creates a symlink `target -> source` (e.g. ln -s source target)' 2223 | 2224 | raise FuseOSError(errno.EROFS) 2225 | 2226 | @_nullable_dummy_function 2227 | def truncate(self, path: str, length: int, fh: Optional[int] = None) -> int: 2228 | raise FuseOSError(errno.EROFS) 2229 | 2230 | @_nullable_dummy_function 2231 | def unlink(self, path: str) -> int: 2232 | raise FuseOSError(errno.EROFS) 2233 | 2234 | @_nullable_dummy_function 2235 | def utimens(self, path: str, times: Optional[tuple[int, int]] = None) -> int: 2236 | 'Times is a (atime, mtime) tuple. If None use current time.' 2237 | 2238 | return 0 2239 | 2240 | @_nullable_dummy_function 2241 | def write(self, path: str, data: bytes, offset: int, fh: int) -> int: 2242 | raise FuseOSError(errno.EROFS) 2243 | 2244 | @_nullable_dummy_function 2245 | def poll(self, path: str, fh: int, ph, reventsp) -> int: 2246 | raise FuseOSError(errno.ENOSYS) 2247 | 2248 | @_nullable_dummy_function 2249 | def write_buf(self, path: str, buf: fuse_bufvec_p, offset: int, fh: int) -> int: 2250 | raise FuseOSError(errno.ENOSYS) 2251 | 2252 | @_nullable_dummy_function 2253 | def read_buf(self, path: str, bufpp: fuse_bufvec_pp, size: int, offset: int, fh: int) -> int: 2254 | raise FuseOSError(errno.ENOSYS) 2255 | 2256 | @_nullable_dummy_function 2257 | def flock(self, path: str, fh: int, op: int) -> int: 2258 | raise FuseOSError(errno.ENOSYS) 2259 | 2260 | @_nullable_dummy_function 2261 | def fallocate(self, path: str, mode: int, offset: int, size: int, fh: int) -> int: 2262 | raise FuseOSError(errno.ENOSYS) 2263 | 2264 | 2265 | callback_logger = logging.getLogger('fuse.log-mixin') 2266 | 2267 | 2268 | def _log_method_call(method, *args): 2269 | # For methods, 'args' will start with 'self'! 2270 | callback_logger.debug('-> %s %s', method.__name__, repr(args)) 2271 | ret = '[Unhandled Exception]' 2272 | try: 2273 | ret = method(*args) 2274 | return ret 2275 | except OSError as e: 2276 | ret = str(e) 2277 | raise 2278 | finally: 2279 | callback_logger.debug('<- %s %s', method.__name__, repr(ret)) 2280 | 2281 | 2282 | class LoggingMixIn: 2283 | """ 2284 | This class can be inherited from in addition to Operations to enable logging for all Operation callbacks. 2285 | Using the decorator is to be preferred! 2286 | """ 2287 | 2288 | def __getattribute__(self, name): 2289 | value = super().__getattribute__(name) 2290 | if ( 2291 | not name.startswith('_') 2292 | and callable(value) 2293 | and hasattr(Operations, name) 2294 | and not getattr(value, 'libfuse_ignore', False) 2295 | ): 2296 | return functools.partial(_log_method_call, value) 2297 | return value 2298 | 2299 | 2300 | def log_callback(method): 2301 | """Simple decorator that adds log output for the decorated method.""" 2302 | 2303 | # For some weird reason functools.partial(_wrap_method_call, method) does not work?! 2304 | def wrap_method_call(*args): 2305 | return _log_method_call(method, *args) 2306 | 2307 | return wrap_method_call 2308 | 2309 | 2310 | def overrides(parent_class): 2311 | """Simple decorator that checks that a method with the same name exists in the parent class""" 2312 | # I tried typing.override (Python 3.12+), but support for it does not seem to be ideal (yet) 2313 | # and portability also is an issue. https://github.com/google/pytype/issues/1915 Maybe in 3 years. 2314 | 2315 | def overrider(method): 2316 | if platform.python_implementation() == 'PyPy': 2317 | return method 2318 | 2319 | assert method.__name__ in dir(parent_class) 2320 | parent_method = getattr(parent_class, method.__name__) 2321 | assert callable(parent_method) 2322 | 2323 | if os.getenv('MFUSEPY_CHECK_OVERRIDES', '').lower() not in ('1', 'yes', 'on', 'enable', 'enabled'): 2324 | return method 2325 | 2326 | # Example return of get_type_hints: 2327 | # {'path': , 2328 | # 'return': typing.Union[typing.Iterable[str], typing.Dict[str, bytes, NoneType]} 2329 | parent_types = get_type_hints(parent_method) 2330 | # If the parent is not typed, e.g., fusepy, then do not show errors for the typed derived class. 2331 | for argument, argument_type in get_type_hints(method).items(): 2332 | if argument in parent_types: 2333 | parent_type = parent_types[argument] 2334 | assert argument_type == parent_type, f"{method.__name__}: {argument}: {argument_type} != {parent_type}" 2335 | 2336 | return method 2337 | 2338 | return overrider 2339 | --------------------------------------------------------------------------------