diff --git a/.github/workflows/bindings-c.yml b/.github/workflows/bindings-c.yml new file mode 100644 index 00000000..dc4b3443 --- /dev/null +++ b/.github/workflows/bindings-c.yml @@ -0,0 +1,48 @@ +# libpathrs: safe path resolution on Linux +# Copyright (C) 2019-2024 Aleksa Sarai +# Copyright (C) 2019-2024 SUSE LLC +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +# PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + release: + types: [ published ] + schedule: + - cron: '0 0 * * *' + +name: bindings-c + +jobs: + smoke-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Build and install libpathrs.so. + - uses: dtolnay/rust-toolchain@stable + - name: build libpathrs + run: make release + - name: install libpathrs + run: sudo ./install.sh --libdir=/usr/lib + # Run smoke-tests. + - run: make -C examples/c smoke-test + + complete: + needs: + - smoke-test + runs-on: ubuntu-latest + steps: + - run: echo "C CI jobs completed successfully." diff --git a/.github/workflows/bindings-go.yml b/.github/workflows/bindings-go.yml new file mode 100644 index 00000000..f90d90e0 --- /dev/null +++ b/.github/workflows/bindings-go.yml @@ -0,0 +1,58 @@ +# libpathrs: safe path resolution on Linux +# Copyright (C) 2019-2024 Aleksa Sarai +# Copyright (C) 2019-2024 SUSE LLC +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +# PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + release: + types: [ published ] + schedule: + - cron: '0 0 * * *' + +name: bindings-go + +jobs: + smoke-test: + strategy: + fail-fast: false + matrix: + go-version: ["1.18.x", "1.21.x", "1.22.x"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Build and install libpathrs.so. + - uses: dtolnay/rust-toolchain@stable + - name: build libpathrs + run: make release + - name: install libpathrs + run: sudo ./install.sh --libdir=/usr/lib + # Setup go. + - name: install go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + # Run smoke-tests. + - run: make -C examples/go smoke-test + + complete: + needs: + - smoke-test + runs-on: ubuntu-latest + steps: + - run: echo "Go CI jobs completed successfully." diff --git a/.github/workflows/bindings.yml b/.github/workflows/bindings-python.yml similarity index 75% rename from .github/workflows/bindings.yml rename to .github/workflows/bindings-python.yml index 7b8e1aa6..148c174c 100644 --- a/.github/workflows/bindings.yml +++ b/.github/workflows/bindings-python.yml @@ -24,46 +24,32 @@ on: schedule: - cron: '0 0 * * *' -name: bindings-ci +name: bindings-python jobs: - c: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # Build and install libpathrs.so. - - uses: dtolnay/rust-toolchain@stable - - name: build libpathrs - run: make release - - name: install libpathrs - run: sudo ./install.sh --libdir=/usr/lib - # Run smoke-tests. - - run: make -C examples/c smoke-test + # TODO: Do some kind of lints? - go: - strategy: - fail-fast: false - matrix: - go-version: ["1.18.x", "1.21.x", "1.22.x"] + mypy: + permissions: + contents: read + pull-requests: read + checks: write # allow the action to annotate code runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # Build and install libpathrs.so. - - uses: dtolnay/rust-toolchain@stable - - name: build libpathrs - run: make release - - name: install libpathrs - run: sudo ./install.sh --libdir=/usr/lib - # Setup go. - - name: install go ${{ matrix.go-version }} - uses: actions/setup-go@v5 + # Set up python venv. + - uses: actions/setup-python@v5 + - name: install mypy + run: >- + python3 -m pip install --user mypy + - uses: tsuyoshicho/action-mypy@v4 with: - go-version: ${{ matrix.go-version }} - check-latest: true - # Run smoke-tests. - - run: make -C examples/go smoke-test + github_token: ${{ secrets.github_token }} + reporter: github-check + workdir: contrib/bindings/python/pathrs + fail_on_error: true - python: + smoke-test: strategy: fail-fast: false matrix: @@ -108,3 +94,11 @@ jobs: path: contrib/bindings/python/dist/ # Run smoke-tests. - run: make -C examples/python smoke-test + + complete: + needs: + - mypy + - smoke-test + runs-on: ubuntu-latest + steps: + - run: echo "Python CI jobs completed successfully." diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index caac28f1..92736d5f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -117,3 +117,16 @@ jobs: with: components: clippy - run: cargo clippy --all-features --all-targets + + complete: + needs: + - check + - check-msrv + - rustdoc + - test + - examples + - fmt + - clippy + runs-on: ubuntu-latest + steps: + - run: echo "Rust CI jobs completed successfully." diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ddbc19..0f54a2bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixes ### - python bindings: add a minimal README for PyPI. - python bindings: actually export `PROC_ROOT`. +- python bindings: add type annotations and `py.typed` to allow for downstream + users to get proper type annotations for the API. ## [0.1.1] - 2024-10-01 ## diff --git a/contrib/bindings/python/Makefile b/contrib/bindings/python/Makefile index e1f72788..eb3364ad 100644 --- a/contrib/bindings/python/Makefile +++ b/contrib/bindings/python/Makefile @@ -19,7 +19,7 @@ PIP ?= pip3 SRC_FILES := $(wildcard *.py pathrs/*.py) -dist: $(SRC_FILES) +dist: $(SRC_FILES) pyproject.toml $(PYTHON) -m build .PHONY: clean diff --git a/contrib/bindings/python/pathrs/.gitignore b/contrib/bindings/python/pathrs/.gitignore index eea96bf6..c97e693e 100644 --- a/contrib/bindings/python/pathrs/.gitignore +++ b/contrib/bindings/python/pathrs/.gitignore @@ -1,2 +1,2 @@ /__pycache__/ -/_libpathrs_cffi* +/_libpathrs_cffi.* diff --git a/contrib/bindings/python/pathrs/__init__.py b/contrib/bindings/python/pathrs/__init__.py index f11e495b..d201a8c8 100644 --- a/contrib/bindings/python/pathrs/__init__.py +++ b/contrib/bindings/python/pathrs/__init__.py @@ -15,7 +15,23 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib +import importlib.metadata + +from . import _pathrs from ._pathrs import * -# TODO: Figure out a way to keep this version up-to-date with Cargo.toml. -__version__ = "0.0.2" +# In order get pydoc to include the documentation for the re-exported code from +# _pathrs, we need to include all of the members in __all__. Rather than +# duplicating the member list here explicitly, just re-export __all__. +__all__ = [] +__all__ += _pathrs.__all__ # pyright doesn't support "=" here. + +try: + # In order to avoid drift between this version and the dist-info/ version + # information, just fill __version__ with the dist-info/ information. + __version__ = importlib.metadata.version("pathrs") +except importlib.metadata.PackageNotFoundError: + # We're being run from a local directory without an installed version of + # pathrs, so just fill in a dummy version. + __version__ = "" diff --git a/contrib/bindings/python/pathrs/_libpathrs_cffi/__init__.pyi b/contrib/bindings/python/pathrs/_libpathrs_cffi/__init__.pyi new file mode 100644 index 00000000..585873f4 --- /dev/null +++ b/contrib/bindings/python/pathrs/_libpathrs_cffi/__init__.pyi @@ -0,0 +1,19 @@ +# libpathrs: safe path resolution on Linux +# Copyright (C) 2019-2024 Aleksa Sarai +# Copyright (C) 2019-2024 SUSE LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cffi + +ffi: cffi.FFI diff --git a/contrib/bindings/python/pathrs/_libpathrs_cffi/lib.pyi b/contrib/bindings/python/pathrs/_libpathrs_cffi/lib.pyi new file mode 100644 index 00000000..e28521be --- /dev/null +++ b/contrib/bindings/python/pathrs/_libpathrs_cffi/lib.pyi @@ -0,0 +1,82 @@ +# libpathrs: safe path resolution on Linux +# Copyright (C) 2019-2024 Aleksa Sarai +# Copyright (C) 2019-2024 SUSE LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import cffi + +from typing import Optional, overload, type_check_only, Union + +# TODO: Remove this once we only support Python >= 3.10. +from typing_extensions import TypeAlias, Literal + +from .._pathrs import CBuffer, CString + +# pathrs_errorinfo_t * +@type_check_only +class CError: + saved_errno: int + description: CString + +ErrorId: TypeAlias = int +RawFd: TypeAlias = int + +# TODO: We actually return Union[CError, cffi.FFI.NULL] but we can't express +# this using the typing stubs for CFFI... +def pathrs_errorinfo(err_id: Union[ErrorId, int]) -> CError: ... +def pathrs_errorinfo_free(err: CError) -> None: ... + +ProcfsBase: TypeAlias = int + +PATHRS_PROC_ROOT: ProcfsBase +PATHRS_PROC_SELF: ProcfsBase +PATHRS_PROC_THREAD_SELF: ProcfsBase + +def pathrs_proc_open( + base: ProcfsBase, path: CString, flags: int +) -> Union[RawFd, ErrorId]: ... +def pathrs_proc_readlink( + base: ProcfsBase, path: CString, linkbuf: CBuffer, linkbuf_size: int +) -> Union[int, ErrorId]: ... +def pathrs_root_open(path: CString) -> Union[RawFd, ErrorId]: ... +def pathrs_reopen(fd: RawFd, flags: int) -> Union[RawFd, ErrorId]: ... +def pathrs_resolve(rootfd: RawFd, path: CString) -> Union[RawFd, ErrorId]: ... +def pathrs_resolve_nofollow(rootfd: RawFd, path: CString) -> Union[RawFd, ErrorId]: ... +def pathrs_creat( + rootfd: RawFd, path: CString, flags: int, filemode: int +) -> Union[RawFd, ErrorId]: ... +def pathrs_rename( + rootfd: RawFd, src: CString, dst: CString, flags: int +) -> Union[Literal[0], ErrorId]: ... +def pathrs_rmdir(rootfd: RawFd, path: CString) -> Union[Literal[0], ErrorId]: ... +def pathrs_unlink(rootfd: RawFd, path: CString) -> Union[Literal[0], ErrorId]: ... +def pathrs_remove_all(rootfd: RawFd, path: CString) -> Union[RawFd, ErrorId]: ... +def pathrs_mkdir( + rootfd: RawFd, path: CString, mode: int +) -> Union[Literal[0], ErrorId]: ... +def pathrs_mkdir_all( + rootfd: RawFd, path: CString, mode: int +) -> Union[Literal[0], ErrorId]: ... +def pathrs_mknod( + rootfd: RawFd, path: CString, mode: int, dev: int +) -> Union[Literal[0], ErrorId]: ... +def pathrs_hardlink( + rootfd: RawFd, path: CString, target: CString +) -> Union[Literal[0], ErrorId]: ... +def pathrs_symlink( + rootfd: RawFd, path: CString, target: CString +) -> Union[Literal[0], ErrorId]: ... +def pathrs_readlink( + rootfd: RawFd, path: CString, linkbuf: CBuffer, linkbuf_size: int +) -> Union[int, ErrorId]: ... diff --git a/contrib/bindings/python/pathrs/_pathrs.py b/contrib/bindings/python/pathrs/_pathrs.py index fedaef83..0eff1f49 100644 --- a/contrib/bindings/python/pathrs/_pathrs.py +++ b/contrib/bindings/python/pathrs/_pathrs.py @@ -20,9 +20,28 @@ import re import sys import copy +import errno import fcntl -from ._libpathrs_cffi import ffi, lib as libpathrs_so +import typing +from typing import Any, IO, Optional, TextIO, Union +# TODO: Remove this once we only support Python >= 3.11. +from typing_extensions import Self, TypeAlias + +if typing.TYPE_CHECKING: + # mypy apparently cannot handle the "ffi: cffi.api.FFI" definition in + # _libpathrs_cffi/__init__.pyi so we need to explicitly reference the type + # from cffi here. + import cffi + ffi: cffi.FFI + CString: TypeAlias = cffi.FFI.CData + CBuffer: TypeAlias = cffi.FFI.CData +else: + from ._libpathrs_cffi import ffi + CString: TypeAlias = ffi.CData + CBuffer: TypeAlias = ffi.CData + +from ._libpathrs_cffi import lib as libpathrs_so __all__ = [ # core api @@ -34,16 +53,15 @@ "Error", ] -def _cstr(pystr): +def _cstr(pystr: str) -> CString: return ffi.new("char[]", pystr.encode("utf8")) -def _pystr(cstr): - return ffi.string(cstr).decode("utf8") +def _pystr(cstr: CString) -> str: + s = ffi.string(cstr) + assert isinstance(s, bytes) # typing + return s.decode("utf8") -def _pyptr(cptr): - return int(ffi.cast("uintptr_t", cptr)) - -def _cbuffer(size): +def _cbuffer(size: int) -> CBuffer: return ffi.new("char[%d]" % (size,)) @@ -55,7 +73,11 @@ class Error(Exception): (Error.errno). """ - def __init__(self, message, *_, errno=None): + message: str + errno: Optional[int] + strerror: Optional[str] + + def __init__(self, message: str, *_, errno: Optional[int] = None): # Construct Exception. super().__init__(message) @@ -65,14 +87,14 @@ def __init__(self, message, *_, errno=None): # Pre-format the errno. self.strerror = None - if self.errno is not None: + if errno is not None: try: self.strerror = os.strerror(errno) except ValueError: self.strerror = str(errno) @classmethod - def _fetch(cls, err_id): + def _fetch(cls, err_id: int) -> Optional[Self]: if err_id >= 0: return None @@ -83,21 +105,22 @@ def _fetch(cls, err_id): description = _pystr(err.description) errno = err.saved_errno or None + # TODO: Should we use ffi.gc()? mypy doesn't seem to like our types... libpathrs_so.pathrs_errorinfo_free(err) del err return cls(description, errno=errno) - def __str__(self): + def __str__(self) -> str: if self.errno is None: return self.message else: return "%s (%s)" % (self.message, self.strerror) - def __repr__(self): + def __repr__(self) -> str: return "Error(%r, errno=%r)" % (self.message, self.errno) - def pprint(self, out=sys.stdout): + def pprint(self, out: TextIO = sys.stdout) -> None: "Pretty-print the error to the given @out file." # Basic error information. if self.errno is None: @@ -109,8 +132,13 @@ def pprint(self, out=sys.stdout): INTERNAL_ERROR = Error("tried to fetch libpathrs error but no error found") +class FilenoFile(typing.Protocol): + def fileno(self) -> int: + ... + +FileLike = Union[FilenoFile, int] -def _fileno(file): +def _fileno(file: FileLike) -> int: if isinstance(file, int): # file is a plain fd return file @@ -118,8 +146,8 @@ def _fileno(file): # Assume there is a fileno method. return file.fileno() -def _clonefile(file): - return fcntl.fcntl(fileno(file), fcntl.F_DUPFD_CLOEXEC) +def _clonefile(file: FileLike) -> int: + return fcntl.fcntl(_fileno(file), fcntl.F_DUPFD_CLOEXEC) class WrappedFd(object): @@ -131,7 +159,9 @@ class WrappedFd(object): pathrs will return WrappedFds for most operations that return an fd. """ - def __init__(self, file): + _fd: Optional[int] + + def __init__(self, file: FileLike): """ Construct a WrappedFd from any file-like object. @@ -152,10 +182,10 @@ def __init__(self, file): # If this is a regular open file, we need to make a copy because # you cannot leak files and so the GC might close it from # underneath us. - fd = clonefd(fd) + fd = _clonefile(fd) self._fd = fd - def fileno(self): + def fileno(self) -> int: """ Return the file descriptor number of this WrappedFd. @@ -170,7 +200,7 @@ def fileno(self): raise OSError(errno.EBADF, "Closed file descriptor") return self._fd - def leak(self): + def leak(self) -> None: """ Clears this WrappedFd without closing the underlying file, to stop GC from closing the file. @@ -182,7 +212,7 @@ def leak(self): """ self._fd = None - def fdopen(self, mode="r"): + def fdopen(self, mode: str = "r") -> IO[Any]: """ Convert this WrappedFd into an os.fileopen() handle. @@ -201,11 +231,11 @@ def fdopen(self, mode="r"): raise @classmethod - def from_raw_fd(cls, fd): + def from_raw_fd(cls, fd: int) -> Self: "Shorthand for WrappedFd(fd)." return cls(fd) - def into_raw_fd(self): + def into_raw_fd(self) -> int: """ Convert this WrappedFd into a raw file descriptor that GC won't touch. @@ -216,14 +246,14 @@ def into_raw_fd(self): self.leak() return fd - def isclosed(self): + def isclosed(self) -> bool: """ Returns whether the underlying file descriptor is closed or the WrappedFd has been leaked. """ return self._fd is None - def close(self): + def close(self) -> None: """ Manually close the underlying file descriptor for this WrappedFd. @@ -231,59 +261,61 @@ def close(self): you really care about the point where a file is closed. """ if not self.isclosed(): + assert self._fd is not None # typing os.close(self._fd) self._fd = None - def clone(self): + def clone(self) -> Self: "Create a clone of this WrappedFd that has a separate lifetime." if self.isclosed(): raise ValueError("cannot clone closed file") - return self.__class__(_clonefile(self)) + assert self._fd is not None # typing + return self.__class__(_clonefile(self._fd)) - def __copy__(self): + def __copy__(self) -> Self: "Identical to WrappedFd.clone()" # A "shallow copy" of a file is the same as a deep copy. return copy.deepcopy(self) - def __deepcopy__(self, memo): + def __deepcopy__(self, memo) -> Self: "Identical to WrappedFd.clone()" return self.clone() - def __del__(self): + def __del__(self) -> None: "Identical to WrappedFd.close()" self.close() - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_value, exc_traceback): + def __exit__(self, exc_type, exc_value, exc_traceback) -> None: self.close() # XXX: This is _super_ ugly but so is the one in CPython. -def _convert_mode(mode): - mode = set(mode) +def _convert_mode(mode: str) -> int: + mode_set = set(mode) flags = os.O_CLOEXEC # We don't support O_CREAT or O_EXCL with libpathrs -- use creat(). - if "x" in mode: + if "x" in mode_set: raise ValueError("pathrs doesn't support mode='x', use Root.creat()") # Basic sanity-check to make sure we don't accept garbage modes. - if len(mode & {"r", "w", "a"}) > 1: + if len(mode_set & {"r", "w", "a"}) > 1: raise ValueError("must have exactly one of read/write/append mode") read = False write = False - if "+" in mode: + if "+" in mode_set: read = True write = True - if "r" in mode: + if "r" in mode_set: read = True - if "w" in mode: + if "w" in mode_set: write = True flags |= os.O_TRUNC - if "a" in mode: + if "a" in mode_set: write = True flags |= os.O_APPEND @@ -298,11 +330,22 @@ def _convert_mode(mode): return flags +#: Resolve proc_* operations relative to the /proc root. Note that this mode +#: may be more expensive because we have to take steps to try to avoid leaking +#: unmasked procfs handles, so you should use PROC_SELF if you can. PROC_ROOT = libpathrs_so.PATHRS_PROC_ROOT + +#: Resolve proc_* operations relative to /proc/self. For most programs, this is +#: the standard choice. PROC_SELF = libpathrs_so.PATHRS_PROC_SELF + +#: Resolve proc_* operations relative to /proc/thread-self. In multi-threaded +#: programs where one thread has a different CLONE_FS, it is possible for +#: /proc/self to point the wrong thread and so /proc/thread-self may be +#: necessary. PROC_THREAD_SELF = libpathrs_so.PATHRS_PROC_THREAD_SELF -def proc_open(base, path, mode="r", extra_flags=0): +def proc_open(base, path: str, mode: str = "r", extra_flags: int = 0): """ Open a procfs file using Pythonic mode strings. @@ -324,7 +367,7 @@ def proc_open(base, path, mode="r", extra_flags=0): with proc_open_raw(base, path, flags) as file: return file.fdopen(mode) -def proc_open_raw(base, path, flags): +def proc_open_raw(base, path: str, flags: int) -> WrappedFd: """ Open a procfs file using Unix open flags. @@ -341,13 +384,12 @@ def proc_open_raw(base, path, flags): let libpathrs know that it can be more strict when opening the path. """ # TODO: Should we default to O_NOFOLLOW or put a separate argument for it? - path = _cstr(path) - fd = libpathrs_so.pathrs_proc_open(base, path, flags) + fd = libpathrs_so.pathrs_proc_open(base, _cstr(path), flags) if fd < 0: raise Error._fetch(fd) or INTERNAL_ERROR return WrappedFd(fd) -def proc_readlink(base, path): +def proc_readlink(base, path: str) -> str: """ Fetch the target of a procfs symlink. @@ -361,11 +403,11 @@ def proc_readlink(base, path): open. """ # TODO: See if we can merge this with Root.readlink. - path = _cstr(path) + cpath = _cstr(path) linkbuf_size = 128 while True: linkbuf = _cbuffer(linkbuf_size) - n = libpathrs_so.pathrs_proc_readlink(base, path, linkbuf, linkbuf_size) + n = libpathrs_so.pathrs_proc_readlink(base, cpath, linkbuf, linkbuf_size) if n < 0: raise Error._fetch(n) or INTERNAL_ERROR elif n <= linkbuf_size: @@ -382,16 +424,16 @@ def proc_readlink(base, path): class Handle(WrappedFd): "A handle to a filesystem object, usually resolved using Root.resolve()." - def __init__(self, file): + def __init__(self, file: FileLike): # XXX: Is this necessary? super().__init__(file) @classmethod - def from_file(cls, file): + def from_file(cls, file: FileLike): "Manually create a Handle from a file-like object." return cls(file) - def reopen(self, mode="r", extra_flags=0): + def reopen(self, mode: str = "r", extra_flags: int = 0) -> IO[Any]: """ Upgrade a Handle to a os.fdopen() file handle. @@ -405,7 +447,7 @@ def reopen(self, mode="r", extra_flags=0): with self.reopen_raw(flags) as file: return file.fdopen(mode) - def reopen_raw(self, flags): + def reopen_raw(self, flags: int) -> WrappedFd: """ Upgrade a Handle to a WrappedFd file handle. @@ -426,7 +468,7 @@ class Root(WrappedFd): relative to. """ - def __init__(self, file_or_path): + def __init__(self, file_or_path: Union[FileLike, str]): """ Create a handle from a file-like object or a path to a directory. @@ -435,27 +477,29 @@ def __init__(self, file_or_path): is a path string, be aware there are no protections against rename race attacks when opening the Root directory handle itself. """ - file = file_or_path if isinstance(file_or_path, str): path = _cstr(file_or_path) fd = libpathrs_so.pathrs_root_open(path) if fd < 0: raise Error._fetch(fd) or INTERNAL_ERROR - file = fd + file: FileLike = fd + else: + file = file_or_path + # XXX: Is this necessary? super().__init__(file) @classmethod - def open(cls, path): + def open(cls, path: str) -> Self: "Identical to Root(path)." return cls(path) @classmethod - def from_file(cls, file): + def from_file(cls, file: FileLike) -> Self: "Identical to Root(file)." return cls(file) - def resolve(self, path, follow_trailing=True): + def resolve(self, path: str, follow_trailing: bool = True) -> Handle: """ Resolve the given path inside the Root and return a Handle. @@ -466,26 +510,25 @@ def resolve(self, path, follow_trailing=True): A pathrs.Error is raised if the path doesn't exist. """ - path = _cstr(path) if follow_trailing: - fd = libpathrs_so.pathrs_resolve(self.fileno(), path) + fd = libpathrs_so.pathrs_resolve(self.fileno(), _cstr(path)) else: - fd = libpathrs_so.pathrs_resolve_nofollow(self.fileno(), path) + fd = libpathrs_so.pathrs_resolve_nofollow(self.fileno(), _cstr(path)) if fd < 0: raise Error._fetch(fd) or INTERNAL_ERROR return Handle(fd) - def readlink(self, path): + def readlink(self, path: str) -> str: """ Fetch the target of a symlink at the given path in the Root. A pathrs.Error is raised if the path is not a symlink or doesn't exist. """ - path = _cstr(path) + cpath = _cstr(path) linkbuf_size = 128 while True: linkbuf = _cbuffer(linkbuf_size) - n = libpathrs_so.pathrs_readlink(self.fileno(), path, linkbuf, linkbuf_size) + n = libpathrs_so.pathrs_readlink(self.fileno(), cpath, linkbuf, linkbuf_size) if n < 0: raise Error._fetch(n) or INTERNAL_ERROR elif n <= linkbuf_size: @@ -498,7 +541,7 @@ def readlink(self, path): # make sure we are a fair bit larger). linkbuf_size += n - def creat(self, path, filemode, mode="r", extra_flags=0): + def creat(self, path: str, filemode: int, mode: str = "r", extra_flags: int = 0) -> IO[Any]: """ Atomically create-and-open a new file at the given path in the Root, a-la O_CREAT. @@ -514,16 +557,15 @@ def creat(self, path, filemode, mode="r", extra_flags=0): to ensure the new file was created *by you* then you may wish to add O_EXCL to extra_flags. """ - path = _cstr(path) flags = _convert_mode(mode) | extra_flags - fd = libpathrs_so.pathrs_creat(self.fileno(), path, flags, filemode) + fd = libpathrs_so.pathrs_creat(self.fileno(), _cstr(path), flags, filemode) if fd < 0: raise Error._fetch(fd) or INTERNAL_ERROR - return Handle(fd) + return os.fdopen(fd, mode) # TODO: creat_raw? - def rename(self, src, dst, flags=0): + def rename(self, src: str, dst: str, flags: int = 0) -> None: """ Rename a path from src to dst within the Root. @@ -532,25 +574,22 @@ def rename(self, src, dst, flags=0): RENAME_EXCHANGE will turn this into an atomic swap operation. """ # TODO: Should we have a separate Root.swap() operation? - src = _cstr(src) - dst = _cstr(dst) - err = libpathrs_so.pathrs_rename(self.fileno(), src, dst, flags) + err = libpathrs_so.pathrs_rename(self.fileno(), _cstr(src), _cstr(dst), flags) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def rmdir(self, path): + def rmdir(self, path: str) -> None: """ Remove an empty directory at the given path within the Root. To remove non-empty directories recursively, you can use Root.remove_all(). """ - path = _cstr(path) - err = libpathrs_so.pathrs_rmdir(self.fileno(), path) + err = libpathrs_so.pathrs_rmdir(self.fileno(), _cstr(path)) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def unlink(self, path): + def unlink(self, path: str) -> None: """ Remove a non-directory inode at the given path within the Root. @@ -558,22 +597,20 @@ def unlink(self, path): files and non-empty directories recursively, you can use Root.remove_all(). """ - path = _cstr(path) - err = libpathrs_so.pathrs_unlink(self.fileno(), path) + err = libpathrs_so.pathrs_unlink(self.fileno(), _cstr(path)) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def remove_all(self, path): + def remove_all(self, path: str) -> None: """ Remove the file or directory (empty or non-empty) at the given path within the Root. """ - path = _cstr(path) - err = libpathrs_so.pathrs_remove_all(self.fileno(), path) + err = libpathrs_so.pathrs_remove_all(self.fileno(), _cstr(path)) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def mkdir(self, path, mode): + def mkdir(self, path: str, mode: int) -> None: """ Create a directory at the given path within the Root. @@ -586,12 +623,11 @@ def mkdir(self, path, mode): directories (or just re-use an existing directory) you can use Root.mkdir_all(). """ - path = _cstr(path) - err = libpathrs_so.pathrs_mkdir(self.fileno(), path, mode) + err = libpathrs_so.pathrs_mkdir(self.fileno(), _cstr(path), mode) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def mkdir_all(self, path, mode): + def mkdir_all(self, path: str, mode: int) -> Handle: """ Recursively create a directory and all of its parents at the given path within the Root (or re-use an existing directory if the path already @@ -605,13 +641,12 @@ def mkdir_all(self, path, mode): full path already exists, this mode is ignored and the existing directory mode is kept. """ - path = _cstr(path) - fd = libpathrs_so.pathrs_mkdir_all(self.fileno(), path, mode) + fd = libpathrs_so.pathrs_mkdir_all(self.fileno(), _cstr(path), mode) if fd < 0: raise Error._fetch(fd) or INTERNAL_ERROR return Handle(fd) - def mknod(self, path, mode, dev=0): + def mknod(self, path: str, mode: int, dev: int = 0) -> None: """ Create a new inode at the given path within the Root. @@ -621,14 +656,17 @@ def mknod(self, path, mode, dev=0): mode of the created file due to a variety of external factors (umask, setgid bits, POSIX ACLs). + dev is the the (major, minor) device number used for the new inode if + the mode contains S_IFCHR or S_IFBLK. You can construct the device + number from a (major, minor) using os.makedev(). + A pathrs.Error is raised if the path already exists. """ - path = _cstr(path) - err = libpathrs_so.pathrs_mknod(self.fileno(), path, mode, dev) + err = libpathrs_so.pathrs_mknod(self.fileno(), _cstr(path), mode, dev) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def hardlink(self, path, target): + def hardlink(self, path: str, target: str) -> None: """ Create a hardlink between two paths inside the Root. @@ -638,13 +676,11 @@ def hardlink(self, path, target): A pathrs.Error is raised if the path for the new hardlink already exists. """ - path = _cstr(path) - target = _cstr(target) - err = libpathrs_so.pathrs_hardlink(self.fileno(), path, target) + err = libpathrs_so.pathrs_hardlink(self.fileno(), _cstr(path), _cstr(target)) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR - def symlink(self, path, target): + def symlink(self, path: str, target: str) -> None: """ Create a symlink at the given path in the Root. @@ -655,8 +691,6 @@ def symlink(self, path, target): A pathrs.Error is raised if the path for the new symlink already exists. """ - path = _cstr(path) - target = _cstr(target) - err = libpathrs_so.pathrs_symlink(self.fileno(), path, target) + err = libpathrs_so.pathrs_symlink(self.fileno(), _cstr(path), _cstr(target)) if err < 0: raise Error._fetch(err) or INTERNAL_ERROR diff --git a/contrib/bindings/python/pathrs/pathrs_build.py b/contrib/bindings/python/pathrs/pathrs_build.py index 65f0a932..526bb981 100755 --- a/contrib/bindings/python/pathrs/pathrs_build.py +++ b/contrib/bindings/python/pathrs/pathrs_build.py @@ -23,9 +23,12 @@ import os import sys +from typing import Optional +from collections.abc import Iterable + import cffi -def load_hdr(ffi, hdr_path): +def load_hdr(ffi: cffi.FFI, hdr_path: str) -> None: with open(hdr_path) as f: hdr = f.read() @@ -41,7 +44,7 @@ def load_hdr(ffi, hdr_path): # Load the header. ffi.cdef(hdr) -def create_ffibuilder(**kwargs): +def create_ffibuilder(**kwargs) -> cffi.FFI: ffibuilder = cffi.FFI() ffibuilder.cdef("typedef uint32_t dev_t;") @@ -58,7 +61,7 @@ def create_ffibuilder(**kwargs): return ffibuilder -def find_rootdir(): +def find_rootdir() -> str: # Figure out where the libpathrs source dir is. root_dir = None candidate = os.path.dirname(sys.path[0] or os.getcwd()) @@ -80,7 +83,7 @@ def find_rootdir(): return root_dir -def srcdir_ffibuilder(root_dir=None): +def srcdir_ffibuilder(root_dir: Optional[str] = None) -> cffi.FFI: """ Build the CFFI bindings using the provided root_dir as the root of a pathrs source tree which has compiled cdylibs ready in target/*. @@ -90,7 +93,7 @@ def srcdir_ffibuilder(root_dir=None): root_dir = find_rootdir() # Figure out which libs are usable. - library_dirs = ( + library_dirs: Iterable[str] = ( os.path.join(root_dir, "target/%s/libpathrs.so" % (mode,)) for mode in ("debug", "release") ) @@ -102,7 +105,7 @@ def srcdir_ffibuilder(root_dir=None): return create_ffibuilder(include_dirs=[os.path.join(root_dir, "include")], library_dirs=library_dirs) -def system_ffibuilder(): +def system_ffibuilder() -> cffi.FFI: """ Build the CFFI bindings using the installed libpathrs system libraries. """ diff --git a/contrib/bindings/python/pathrs/py.typed b/contrib/bindings/python/pathrs/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/contrib/bindings/python/pyproject.toml b/contrib/bindings/python/pyproject.toml index 240a090d..9d1c9fd2 100644 --- a/contrib/bindings/python/pyproject.toml +++ b/contrib/bindings/python/pyproject.toml @@ -53,6 +53,7 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ "cffi>=1.10.0", + "typing_extensions>=4.0.0", # TODO: Remove this once we only support Python >= 3.11. ] [project.urls] diff --git a/go-pathrs/root_linux.go b/go-pathrs/root_linux.go index 72e8134c..876d2062 100644 --- a/go-pathrs/root_linux.go +++ b/go-pathrs/root_linux.go @@ -106,21 +106,17 @@ func (r *Root) ResolveNoFollow(path string) (*Handle, error) { // Create creates a file within the Root's directory tree at the given path, // and returns a handle to the file. The provided mode is used for the new file // (the process's umask applies). -func (r *Root) Create(path string, flags int, mode os.FileMode) (*Handle, error) { +func (r *Root) Create(path string, flags int, mode os.FileMode) (*os.File, error) { unixMode, err := toUnixMode(mode) if err != nil { return nil, err } - return withFileFd(r.inner, func(rootFd uintptr) (*Handle, error) { + return withFileFd(r.inner, func(rootFd uintptr) (*os.File, error) { handleFd, err := pathrsCreat(rootFd, path, flags, unixMode) if err != nil { return nil, err } - handleFile, err := mkFile(uintptr(handleFd)) - if err != nil { - return nil, err - } - return &Handle{inner: handleFile}, nil + return mkFile(uintptr(handleFd)) }) } diff --git a/src/capi/ret.rs b/src/capi/ret.rs index f2a988a4..e219cfc4 100644 --- a/src/capi/ret.rs +++ b/src/capi/ret.rs @@ -19,7 +19,10 @@ use crate::{capi::error as capi_error, error::Error, Handle, Root}; -use std::os::unix::io::{IntoRawFd, OwnedFd}; +use std::{ + fs::File, + os::unix::io::{IntoRawFd, OwnedFd}, +}; use libc::c_int; @@ -64,6 +67,12 @@ impl IntoCReturn for Handle { } } +impl IntoCReturn for File { + fn into_c_return(self) -> CReturn { + OwnedFd::from(self).into_c_return() + } +} + impl IntoCReturn for Result where V: IntoCReturn, diff --git a/src/root.rs b/src/root.rs index 192457b4..d78f6769 100644 --- a/src/root.rs +++ b/src/root.rs @@ -29,7 +29,7 @@ use crate::{ }; use std::{ - fs::Permissions, + fs::{File, Permissions}, io::Error as IOError, os::unix::{ ffi::OsStrExt, @@ -367,7 +367,7 @@ impl Root { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { self.as_ref().create_file(path, flags, perm) } @@ -829,7 +829,7 @@ impl RootRef<'_> { path: P, mut flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { // The path doesn't exist yet, so we need to get a safe reference to the // parent and just operate on the final (slashless) component. let (dir, name) = self @@ -851,7 +851,7 @@ impl RootRef<'_> { } })?; - Ok(Handle::from_fd_unchecked(fd)) + Ok(fd.into()) } /// Within the [`RootRef`]'s tree, create a directory and any of its parent diff --git a/src/tests/capi/root.rs b/src/tests/capi/root.rs index 221ca040..6a4df1b0 100644 --- a/src/tests/capi/root.rs +++ b/src/tests/capi/root.rs @@ -32,7 +32,7 @@ use crate::{ }; use std::{ - fs::Permissions, + fs::{File, Permissions}, os::unix::{ fs::PermissionsExt, io::{AsFd, BorrowedFd, OwnedFd}, @@ -140,14 +140,14 @@ impl CapiRoot { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { let root_fd = self.inner.as_fd(); let path = capi_utils::path_to_cstring(path); capi_utils::call_capi_fd(|| unsafe { capi::core::pathrs_creat(root_fd.into(), path.as_ptr(), flags.bits(), perm.mode()) }) - .map(CapiHandle::from_fd_unchecked) + .map(File::from) } fn mkdir_all>( @@ -269,7 +269,7 @@ impl RootImpl for CapiRoot { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { self.create_file(path, flags, perm) } @@ -348,7 +348,7 @@ impl RootImpl for &CapiRoot { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { CapiRoot::create_file(self, path, flags, perm) } diff --git a/src/tests/traits/root.rs b/src/tests/traits/root.rs index a186f1bb..16e14894 100644 --- a/src/tests/traits/root.rs +++ b/src/tests/traits/root.rs @@ -26,7 +26,7 @@ use crate::{ }; use std::{ - fs::Permissions, + fs::{File, Permissions}, os::unix::io::{AsFd, OwnedFd}, path::{Path, PathBuf}, }; @@ -57,7 +57,7 @@ pub(in crate::tests) trait RootImpl: AsFd + std::fmt::Debug + Sized { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result; + ) -> Result; fn mkdir_all>( &self, @@ -122,7 +122,7 @@ impl RootImpl for Root { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { self.create_file(path, flags, perm) } @@ -199,7 +199,7 @@ impl RootImpl for &Root { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { Root::create_file(self, path, flags, perm) } @@ -276,7 +276,7 @@ impl RootImpl for RootRef<'_> { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { self.create_file(path, flags, perm) } @@ -353,7 +353,7 @@ impl RootImpl for &RootRef<'_> { path: P, flags: OpenFlags, perm: &Permissions, - ) -> Result { + ) -> Result { RootRef::create_file(self, path, flags, perm) }