newmount 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- newmount/__init__.py +78 -0
- newmount/_syscall.py +228 -0
- newmount/classic.py +144 -0
- newmount/errors.py +10 -0
- newmount/flags.py +208 -0
- newmount/fsapi.py +428 -0
- newmount/py.typed +0 -0
- newmount-0.1.0.dist-info/METADATA +78 -0
- newmount-0.1.0.dist-info/RECORD +11 -0
- newmount-0.1.0.dist-info/WHEEL +4 -0
- newmount-0.1.0.dist-info/licenses/LICENSE +12 -0
newmount/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Python bindings for the Linux mount API.
|
|
3
|
+
|
|
4
|
+
Two layers: the classic mount(2)/umount2(2) calls with helpers like
|
|
5
|
+
bind() and tmpfs(), and the new mount API (kernel 5.2+) built on
|
|
6
|
+
fsopen/fsconfig/fsmount/move_mount/open_tree/fspick/mount_setattr.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from . import flags as flags
|
|
12
|
+
from .classic import (
|
|
13
|
+
bind,
|
|
14
|
+
make_private,
|
|
15
|
+
make_shared,
|
|
16
|
+
make_slave,
|
|
17
|
+
make_unbindable,
|
|
18
|
+
mount,
|
|
19
|
+
mount_proc,
|
|
20
|
+
tmpfs,
|
|
21
|
+
umount,
|
|
22
|
+
umount2,
|
|
23
|
+
)
|
|
24
|
+
from .errors import MountError, UnsupportedError
|
|
25
|
+
from .flags import * # noqa: F403 - re-export the constant namespace
|
|
26
|
+
from .flags import __all__ as _flags_all
|
|
27
|
+
from .fsapi import (
|
|
28
|
+
FsContext,
|
|
29
|
+
MountAttr,
|
|
30
|
+
MountFd,
|
|
31
|
+
Tree,
|
|
32
|
+
apply_attrs,
|
|
33
|
+
attach,
|
|
34
|
+
fsconfig,
|
|
35
|
+
fsmount,
|
|
36
|
+
fsopen,
|
|
37
|
+
fspick,
|
|
38
|
+
mount_setattr,
|
|
39
|
+
move_mount,
|
|
40
|
+
new_api_supported,
|
|
41
|
+
open_tree,
|
|
42
|
+
open_tree_clone,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"FsContext",
|
|
49
|
+
"MountAttr",
|
|
50
|
+
"MountError",
|
|
51
|
+
"MountFd",
|
|
52
|
+
"Tree",
|
|
53
|
+
"UnsupportedError",
|
|
54
|
+
"__version__",
|
|
55
|
+
"apply_attrs",
|
|
56
|
+
"attach",
|
|
57
|
+
"bind",
|
|
58
|
+
"flags",
|
|
59
|
+
"fsconfig",
|
|
60
|
+
"fsmount",
|
|
61
|
+
"fsopen",
|
|
62
|
+
"fspick",
|
|
63
|
+
"make_private",
|
|
64
|
+
"make_shared",
|
|
65
|
+
"make_slave",
|
|
66
|
+
"make_unbindable",
|
|
67
|
+
"mount",
|
|
68
|
+
"mount_proc",
|
|
69
|
+
"mount_setattr",
|
|
70
|
+
"move_mount",
|
|
71
|
+
"new_api_supported",
|
|
72
|
+
"open_tree",
|
|
73
|
+
"open_tree_clone",
|
|
74
|
+
"tmpfs",
|
|
75
|
+
"umount",
|
|
76
|
+
"umount2",
|
|
77
|
+
*_flags_all,
|
|
78
|
+
]
|
newmount/_syscall.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Raw ctypes bindings for the Linux mount syscalls.
|
|
3
|
+
|
|
4
|
+
mount(2) and umount2(2) go through the libc wrappers. The new mount API
|
|
5
|
+
(open_tree, move_mount, fsopen, fsconfig, fsmount, fspick, mount_setattr,
|
|
6
|
+
all since kernel 5.2) goes through libc syscall(2) so no compiler or
|
|
7
|
+
libmount is needed. Every mainline architecture uses the asm-generic
|
|
8
|
+
numbers for these syscalls, verified against /usr/include/asm/unistd_64.h,
|
|
9
|
+
/usr/include/asm/unistd_32.h and /usr/include/asm-generic/unistd.h. MIPS
|
|
10
|
+
applies its ABI base offset instead.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ctypes
|
|
14
|
+
import ctypes.util
|
|
15
|
+
import errno
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import sys
|
|
19
|
+
from typing import NamedTuple, NoReturn
|
|
20
|
+
|
|
21
|
+
from .errors import MountError, UnsupportedError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MountAttr(ctypes.Structure):
|
|
25
|
+
"""struct mount_attr for mount_setattr(2), see /usr/include/linux/mount.h.
|
|
26
|
+
|
|
27
|
+
All four fields are u64, so the structure is 32 bytes on every ABI,
|
|
28
|
+
matching MOUNT_ATTR_SIZE_VER0.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_fields_ = [
|
|
32
|
+
("attr_set", ctypes.c_uint64),
|
|
33
|
+
("attr_clr", ctypes.c_uint64),
|
|
34
|
+
("propagation", ctypes.c_uint64),
|
|
35
|
+
("userns_fd", ctypes.c_uint64),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Numbers(NamedTuple):
|
|
40
|
+
"""Per-architecture syscall numbers for the new mount API."""
|
|
41
|
+
|
|
42
|
+
open_tree: int
|
|
43
|
+
move_mount: int
|
|
44
|
+
fsopen: int
|
|
45
|
+
fsconfig: int
|
|
46
|
+
fsmount: int
|
|
47
|
+
fspick: int
|
|
48
|
+
mount_setattr: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# asm-generic numbering shared by x86_64, i386, aarch64, riscv and all
|
|
52
|
+
# other modern ports
|
|
53
|
+
_GENERIC = _Numbers(428, 429, 430, 431, 432, 433, 442)
|
|
54
|
+
|
|
55
|
+
_GENERIC_MACHINES = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"x86_64",
|
|
58
|
+
"amd64",
|
|
59
|
+
"i386",
|
|
60
|
+
"i486",
|
|
61
|
+
"i586",
|
|
62
|
+
"i686",
|
|
63
|
+
"x86",
|
|
64
|
+
"aarch64",
|
|
65
|
+
"arm64",
|
|
66
|
+
"riscv64",
|
|
67
|
+
"riscv32",
|
|
68
|
+
"loongarch64",
|
|
69
|
+
"s390x",
|
|
70
|
+
"s390",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_libc: ctypes.CDLL | None = None
|
|
75
|
+
_numbers: _Numbers | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _get_libc() -> ctypes.CDLL:
|
|
79
|
+
global _libc
|
|
80
|
+
if _libc is None:
|
|
81
|
+
if sys.platform != "linux":
|
|
82
|
+
raise UnsupportedError("newmount is only available on Linux")
|
|
83
|
+
name = ctypes.util.find_library("c")
|
|
84
|
+
_libc = ctypes.CDLL(name or None, use_errno=True)
|
|
85
|
+
_libc.syscall.restype = ctypes.c_long
|
|
86
|
+
return _libc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _syscall_numbers() -> _Numbers:
|
|
90
|
+
"""Return the syscall numbers for this architecture."""
|
|
91
|
+
global _numbers
|
|
92
|
+
if _numbers is not None:
|
|
93
|
+
return _numbers
|
|
94
|
+
machine = platform.machine().lower()
|
|
95
|
+
if machine in ("mips64", "mips64el"):
|
|
96
|
+
base = 5000 # n64 ABI
|
|
97
|
+
elif machine in ("mips", "mipsel", "mips32"):
|
|
98
|
+
base = 4000 # o32 ABI
|
|
99
|
+
elif machine in _GENERIC_MACHINES or machine.startswith(("arm", "ppc", "powerpc")):
|
|
100
|
+
base = 0
|
|
101
|
+
else:
|
|
102
|
+
raise UnsupportedError(
|
|
103
|
+
f"no mount API syscall numbers for architecture {machine}"
|
|
104
|
+
)
|
|
105
|
+
_numbers = _Numbers(*(base + n for n in _GENERIC))
|
|
106
|
+
return _numbers
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _raise_errno(err: int) -> NoReturn:
|
|
110
|
+
if err in (errno.ENOSYS, errno.EOPNOTSUPP):
|
|
111
|
+
raise UnsupportedError(err, os.strerror(err))
|
|
112
|
+
raise MountError(err, os.strerror(err))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _call(nr: int, *args: object) -> int:
|
|
116
|
+
ret = int(_get_libc().syscall(nr, *args))
|
|
117
|
+
if ret == -1:
|
|
118
|
+
_raise_errno(ctypes.get_errno())
|
|
119
|
+
return ret
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def mount(
|
|
123
|
+
source: bytes | None,
|
|
124
|
+
target: bytes,
|
|
125
|
+
fstype: bytes | None,
|
|
126
|
+
flags: int,
|
|
127
|
+
data: bytes | None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""mount(2) through the libc wrapper."""
|
|
130
|
+
ret = int(_get_libc().mount(source, target, fstype, ctypes.c_ulong(flags), data))
|
|
131
|
+
if ret == -1:
|
|
132
|
+
_raise_errno(ctypes.get_errno())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def umount2(target: bytes, flags: int) -> None:
|
|
136
|
+
"""umount2(2) through the libc wrapper."""
|
|
137
|
+
ret = int(_get_libc().umount2(target, ctypes.c_int(flags)))
|
|
138
|
+
if ret == -1:
|
|
139
|
+
_raise_errno(ctypes.get_errno())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fsopen(fstype: bytes, flags: int) -> int:
|
|
143
|
+
"""fsopen(2): open a filesystem context, returns an fd."""
|
|
144
|
+
return int(_call(_syscall_numbers().fsopen, fstype, ctypes.c_uint(flags)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fsconfig(
|
|
148
|
+
fd: int,
|
|
149
|
+
cmd: int,
|
|
150
|
+
key: bytes | None,
|
|
151
|
+
value: bytes | None,
|
|
152
|
+
aux: int,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""fsconfig(2): pass one parameter or command to a context fd."""
|
|
155
|
+
_call(
|
|
156
|
+
_syscall_numbers().fsconfig,
|
|
157
|
+
ctypes.c_int(fd),
|
|
158
|
+
ctypes.c_uint(cmd),
|
|
159
|
+
key,
|
|
160
|
+
value,
|
|
161
|
+
ctypes.c_int(aux),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fsmount(fd: int, flags: int, attr_flags: int) -> int:
|
|
166
|
+
"""fsmount(2): create a detached mount from a context, returns an fd."""
|
|
167
|
+
return int(
|
|
168
|
+
_call(
|
|
169
|
+
_syscall_numbers().fsmount,
|
|
170
|
+
ctypes.c_int(fd),
|
|
171
|
+
ctypes.c_uint(flags),
|
|
172
|
+
ctypes.c_uint(attr_flags),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def open_tree(dfd: int, path: bytes, flags: int) -> int:
|
|
178
|
+
"""open_tree(2): open or clone a mount tree, returns an fd."""
|
|
179
|
+
return int(
|
|
180
|
+
_call(
|
|
181
|
+
_syscall_numbers().open_tree,
|
|
182
|
+
ctypes.c_int(dfd),
|
|
183
|
+
path,
|
|
184
|
+
ctypes.c_uint(flags),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def move_mount(
|
|
190
|
+
from_dfd: int,
|
|
191
|
+
from_path: bytes,
|
|
192
|
+
to_dfd: int,
|
|
193
|
+
to_path: bytes,
|
|
194
|
+
flags: int,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""move_mount(2): attach or relocate a mount."""
|
|
197
|
+
_call(
|
|
198
|
+
_syscall_numbers().move_mount,
|
|
199
|
+
ctypes.c_int(from_dfd),
|
|
200
|
+
from_path,
|
|
201
|
+
ctypes.c_int(to_dfd),
|
|
202
|
+
to_path,
|
|
203
|
+
ctypes.c_uint(flags),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def fspick(dfd: int, path: bytes, flags: int) -> int:
|
|
208
|
+
"""fspick(2): reopen an existing mount for reconfiguration."""
|
|
209
|
+
return int(
|
|
210
|
+
_call(
|
|
211
|
+
_syscall_numbers().fspick,
|
|
212
|
+
ctypes.c_int(dfd),
|
|
213
|
+
path,
|
|
214
|
+
ctypes.c_uint(flags),
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def mount_setattr(dfd: int, path: bytes, flags: int, attr: MountAttr) -> None:
|
|
220
|
+
"""mount_setattr(2): change attributes of a mount or mount tree."""
|
|
221
|
+
_call(
|
|
222
|
+
_syscall_numbers().mount_setattr,
|
|
223
|
+
ctypes.c_int(dfd),
|
|
224
|
+
path,
|
|
225
|
+
ctypes.c_uint(flags),
|
|
226
|
+
ctypes.byref(attr),
|
|
227
|
+
ctypes.c_size_t(ctypes.sizeof(attr)),
|
|
228
|
+
)
|
newmount/classic.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Helpers built on the classic mount(2) and umount2(2) calls."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from . import _syscall
|
|
9
|
+
from .flags import (
|
|
10
|
+
MS_BIND,
|
|
11
|
+
MS_NODEV,
|
|
12
|
+
MS_NOEXEC,
|
|
13
|
+
MS_NOSUID,
|
|
14
|
+
MS_PRIVATE,
|
|
15
|
+
MS_RDONLY,
|
|
16
|
+
MS_REC,
|
|
17
|
+
MS_REMOUNT,
|
|
18
|
+
MS_SHARED,
|
|
19
|
+
MS_SLAVE,
|
|
20
|
+
MS_UNBINDABLE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"bind",
|
|
25
|
+
"make_private",
|
|
26
|
+
"make_shared",
|
|
27
|
+
"make_slave",
|
|
28
|
+
"make_unbindable",
|
|
29
|
+
"mount",
|
|
30
|
+
"mount_proc",
|
|
31
|
+
"tmpfs",
|
|
32
|
+
"umount",
|
|
33
|
+
"umount2",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_Path = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mount(
|
|
40
|
+
source: _Path | None,
|
|
41
|
+
target: _Path,
|
|
42
|
+
fstype: str | bytes | None,
|
|
43
|
+
flags: int = 0,
|
|
44
|
+
data: str | bytes | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Mount a filesystem with mount(2).
|
|
47
|
+
|
|
48
|
+
source and fstype may be None for operations that ignore them, such as
|
|
49
|
+
MS_REMOUNT and propagation changes. data is the filesystem specific
|
|
50
|
+
option string. Raises MountError on failure.
|
|
51
|
+
"""
|
|
52
|
+
_syscall.mount(
|
|
53
|
+
None if source is None else os.fsencode(source),
|
|
54
|
+
os.fsencode(target),
|
|
55
|
+
None if fstype is None else os.fsencode(fstype),
|
|
56
|
+
flags,
|
|
57
|
+
None if data is None else os.fsencode(data),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def umount2(target: _Path, flags: int = 0) -> None:
|
|
62
|
+
"""Unmount with umount2(2); flags are MNT_* and UMOUNT_* bits."""
|
|
63
|
+
_syscall.umount2(os.fsencode(target), flags)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def umount(target: _Path) -> None:
|
|
67
|
+
"""Unmount a filesystem, equivalent to umount2(target, 0)."""
|
|
68
|
+
umount2(target, 0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def bind(
|
|
72
|
+
source: _Path,
|
|
73
|
+
target: _Path,
|
|
74
|
+
*,
|
|
75
|
+
recursive: bool = True,
|
|
76
|
+
readonly: bool = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Bind mount source onto target.
|
|
79
|
+
|
|
80
|
+
A bind mount cannot be created read-only in one call: the kernel
|
|
81
|
+
applies per-mount flags only at creation time. readonly first binds
|
|
82
|
+
and then remounts the new mount with MS_REMOUNT|MS_BIND|MS_RDONLY.
|
|
83
|
+
"""
|
|
84
|
+
flags = MS_BIND | (MS_REC if recursive else 0)
|
|
85
|
+
mount(source, target, None, flags)
|
|
86
|
+
if readonly:
|
|
87
|
+
mount(source, target, None, flags | MS_REMOUNT | MS_RDONLY)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _propagation(target: _Path, flag: int, recursive: bool) -> None:
|
|
91
|
+
mount(None, target, None, flag | (MS_REC if recursive else 0))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def make_private(target: _Path = "/", *, recursive: bool = True) -> None:
|
|
95
|
+
"""Mark target (recursively by default) as private propagation."""
|
|
96
|
+
_propagation(target, MS_PRIVATE, recursive)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def make_shared(target: _Path, *, recursive: bool = True) -> None:
|
|
100
|
+
"""Mark target (recursively by default) as shared propagation."""
|
|
101
|
+
_propagation(target, MS_SHARED, recursive)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def make_slave(target: _Path, *, recursive: bool = True) -> None:
|
|
105
|
+
"""Mark target (recursively by default) as slave propagation."""
|
|
106
|
+
_propagation(target, MS_SLAVE, recursive)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def make_unbindable(target: _Path, *, recursive: bool = True) -> None:
|
|
110
|
+
"""Mark target (recursively by default) as unbindable."""
|
|
111
|
+
_propagation(target, MS_UNBINDABLE, recursive)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def tmpfs(
|
|
115
|
+
target: _Path,
|
|
116
|
+
*,
|
|
117
|
+
size: int | str | None = None,
|
|
118
|
+
mode: int | str | None = None,
|
|
119
|
+
uid: int | None = None,
|
|
120
|
+
gid: int | None = None,
|
|
121
|
+
flags: int = 0,
|
|
122
|
+
source: str = "tmpfs",
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Mount a tmpfs at target, building the option string from keywords.
|
|
125
|
+
|
|
126
|
+
size is a byte count or a string like "64m". mode is an int rendered
|
|
127
|
+
as octal or a string passed through unchanged. uid and gid are numeric
|
|
128
|
+
owner ids for the root inode.
|
|
129
|
+
"""
|
|
130
|
+
options = []
|
|
131
|
+
if size is not None:
|
|
132
|
+
options.append(f"size={size}")
|
|
133
|
+
if mode is not None:
|
|
134
|
+
options.append(f"mode={mode:o}" if isinstance(mode, int) else f"mode={mode}")
|
|
135
|
+
if uid is not None:
|
|
136
|
+
options.append(f"uid={uid}")
|
|
137
|
+
if gid is not None:
|
|
138
|
+
options.append(f"gid={gid}")
|
|
139
|
+
mount(source, target, "tmpfs", flags, ",".join(options) or None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def mount_proc(target: _Path = "/proc") -> None:
|
|
143
|
+
"""Mount a fresh procfs, usually inside a new pid+mount namespace."""
|
|
144
|
+
mount("proc", target, "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV)
|
newmount/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Exception types raised by newmount."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MountError(OSError):
|
|
6
|
+
"""A mount related syscall or operation failed."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnsupportedError(MountError):
|
|
10
|
+
"""The running kernel or architecture lacks the requested feature."""
|
newmount/flags.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""Constants for mount(2), umount2(2) and the new mount API.
|
|
3
|
+
|
|
4
|
+
Values verified against /usr/include/sys/mount.h,
|
|
5
|
+
/usr/include/linux/mount.h and the AT_* bits from
|
|
6
|
+
/usr/include/bits/fcntl-linux.h.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AT_EMPTY_PATH",
|
|
15
|
+
"AT_FDCWD",
|
|
16
|
+
"AT_NO_AUTOMOUNT",
|
|
17
|
+
"AT_RECURSIVE",
|
|
18
|
+
"AT_SYMLINK_FOLLOW",
|
|
19
|
+
"AT_SYMLINK_NOFOLLOW",
|
|
20
|
+
"FSCONFIG_CMD_CREATE",
|
|
21
|
+
"FSCONFIG_CMD_CREATE_EXCL",
|
|
22
|
+
"FSCONFIG_CMD_RECONFIGURE",
|
|
23
|
+
"FSCONFIG_SET_BINARY",
|
|
24
|
+
"FSCONFIG_SET_FD",
|
|
25
|
+
"FSCONFIG_SET_FLAG",
|
|
26
|
+
"FSCONFIG_SET_PATH",
|
|
27
|
+
"FSCONFIG_SET_PATH_EMPTY",
|
|
28
|
+
"FSCONFIG_SET_STRING",
|
|
29
|
+
"FSMOUNT_CLOEXEC",
|
|
30
|
+
"FSMOUNT_NAMESPACE",
|
|
31
|
+
"FSOPEN_CLOEXEC",
|
|
32
|
+
"FSPICK_CLOEXEC",
|
|
33
|
+
"FSPICK_EMPTY_PATH",
|
|
34
|
+
"FSPICK_NO_AUTOMOUNT",
|
|
35
|
+
"FSPICK_SYMLINK_NOFOLLOW",
|
|
36
|
+
"MNT_DETACH",
|
|
37
|
+
"MNT_EXPIRE",
|
|
38
|
+
"MNT_FORCE",
|
|
39
|
+
"MOUNT_ATTR_ATIME_SHIFT",
|
|
40
|
+
"MOUNT_ATTR_IDMAP",
|
|
41
|
+
"MOUNT_ATTR_NOATIME",
|
|
42
|
+
"MOUNT_ATTR_NODEV",
|
|
43
|
+
"MOUNT_ATTR_NODIRATIME",
|
|
44
|
+
"MOUNT_ATTR_NOEXEC",
|
|
45
|
+
"MOUNT_ATTR_NOSUID",
|
|
46
|
+
"MOUNT_ATTR_NOSYMFOLLOW",
|
|
47
|
+
"MOUNT_ATTR_RDONLY",
|
|
48
|
+
"MOUNT_ATTR_RELATIME",
|
|
49
|
+
"MOUNT_ATTR_SIZE_VER0",
|
|
50
|
+
"MOUNT_ATTR_STRICTATIME",
|
|
51
|
+
"MOUNT_ATTR__ATIME",
|
|
52
|
+
"MOVE_MOUNT_BENEATH",
|
|
53
|
+
"MOVE_MOUNT_F_AUTOMOUNTS",
|
|
54
|
+
"MOVE_MOUNT_F_EMPTY_PATH",
|
|
55
|
+
"MOVE_MOUNT_F_SYMLINKS",
|
|
56
|
+
"MOVE_MOUNT_SET_GROUP",
|
|
57
|
+
"MOVE_MOUNT_T_AUTOMOUNTS",
|
|
58
|
+
"MOVE_MOUNT_T_EMPTY_PATH",
|
|
59
|
+
"MOVE_MOUNT_T_SYMLINKS",
|
|
60
|
+
"MS_ACTIVE",
|
|
61
|
+
"MS_BIND",
|
|
62
|
+
"MS_BORN",
|
|
63
|
+
"MS_DIRSYNC",
|
|
64
|
+
"MS_I_VERSION",
|
|
65
|
+
"MS_KERNMOUNT",
|
|
66
|
+
"MS_LAZYTIME",
|
|
67
|
+
"MS_MANDLOCK",
|
|
68
|
+
"MS_MGC_MSK",
|
|
69
|
+
"MS_MGC_VAL",
|
|
70
|
+
"MS_MOVE",
|
|
71
|
+
"MS_NOATIME",
|
|
72
|
+
"MS_NODEV",
|
|
73
|
+
"MS_NODIRATIME",
|
|
74
|
+
"MS_NOEXEC",
|
|
75
|
+
"MS_NOSUID",
|
|
76
|
+
"MS_NOSYMFOLLOW",
|
|
77
|
+
"MS_NOUSER",
|
|
78
|
+
"MS_POSIXACL",
|
|
79
|
+
"MS_PRIVATE",
|
|
80
|
+
"MS_RDONLY",
|
|
81
|
+
"MS_REC",
|
|
82
|
+
"MS_RELATIME",
|
|
83
|
+
"MS_REMOUNT",
|
|
84
|
+
"MS_RMT_MASK",
|
|
85
|
+
"MS_SHARED",
|
|
86
|
+
"MS_SILENT",
|
|
87
|
+
"MS_SLAVE",
|
|
88
|
+
"MS_STRICTATIME",
|
|
89
|
+
"MS_SUBMOUNT",
|
|
90
|
+
"MS_SYNCHRONOUS",
|
|
91
|
+
"MS_UNBINDABLE",
|
|
92
|
+
"OPEN_TREE_CLOEXEC",
|
|
93
|
+
"OPEN_TREE_CLONE",
|
|
94
|
+
"OPEN_TREE_NAMESPACE",
|
|
95
|
+
"UMOUNT_NOFOLLOW",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
# mount(2) rwflag bits, /usr/include/linux/mount.h
|
|
99
|
+
MS_RDONLY = 1
|
|
100
|
+
MS_NOSUID = 2
|
|
101
|
+
MS_NODEV = 4
|
|
102
|
+
MS_NOEXEC = 8
|
|
103
|
+
MS_SYNCHRONOUS = 16
|
|
104
|
+
MS_REMOUNT = 32
|
|
105
|
+
MS_MANDLOCK = 64
|
|
106
|
+
MS_DIRSYNC = 128
|
|
107
|
+
MS_NOSYMFOLLOW = 256
|
|
108
|
+
MS_NOATIME = 1024
|
|
109
|
+
MS_NODIRATIME = 2048
|
|
110
|
+
MS_BIND = 4096
|
|
111
|
+
MS_MOVE = 8192
|
|
112
|
+
MS_REC = 16384
|
|
113
|
+
MS_SILENT = 32768 # MS_VERBOSE is deprecated, same bit
|
|
114
|
+
MS_POSIXACL = 1 << 16
|
|
115
|
+
MS_UNBINDABLE = 1 << 17
|
|
116
|
+
MS_PRIVATE = 1 << 18
|
|
117
|
+
MS_SLAVE = 1 << 19
|
|
118
|
+
MS_SHARED = 1 << 20
|
|
119
|
+
MS_RELATIME = 1 << 21
|
|
120
|
+
MS_KERNMOUNT = 1 << 22
|
|
121
|
+
MS_I_VERSION = 1 << 23
|
|
122
|
+
MS_STRICTATIME = 1 << 24
|
|
123
|
+
MS_LAZYTIME = 1 << 25
|
|
124
|
+
|
|
125
|
+
# Internal kernel bits, listed for completeness
|
|
126
|
+
MS_SUBMOUNT = 1 << 26
|
|
127
|
+
MS_BORN = 1 << 29
|
|
128
|
+
MS_ACTIVE = 1 << 30
|
|
129
|
+
MS_NOUSER = 1 << 31
|
|
130
|
+
|
|
131
|
+
# Bits changeable through MS_REMOUNT
|
|
132
|
+
MS_RMT_MASK = MS_RDONLY | MS_SYNCHRONOUS | MS_MANDLOCK | MS_I_VERSION | MS_LAZYTIME
|
|
133
|
+
|
|
134
|
+
# Legacy magic bits
|
|
135
|
+
MS_MGC_VAL = 0xC0ED0000
|
|
136
|
+
MS_MGC_MSK = 0xFFFF0000
|
|
137
|
+
|
|
138
|
+
# umount2(2) flags
|
|
139
|
+
MNT_FORCE = 1
|
|
140
|
+
MNT_DETACH = 2
|
|
141
|
+
MNT_EXPIRE = 4
|
|
142
|
+
UMOUNT_NOFOLLOW = 8
|
|
143
|
+
|
|
144
|
+
# AT_* bits shared by open_tree(2), mount_setattr(2) and friends
|
|
145
|
+
AT_FDCWD = -100
|
|
146
|
+
AT_SYMLINK_NOFOLLOW = 0x100
|
|
147
|
+
AT_SYMLINK_FOLLOW = 0x400
|
|
148
|
+
AT_NO_AUTOMOUNT = 0x800
|
|
149
|
+
AT_EMPTY_PATH = 0x1000
|
|
150
|
+
AT_RECURSIVE = 0x8000
|
|
151
|
+
|
|
152
|
+
# open_tree(2) flags
|
|
153
|
+
OPEN_TREE_CLONE = 1 << 0
|
|
154
|
+
OPEN_TREE_NAMESPACE = 1 << 1
|
|
155
|
+
OPEN_TREE_CLOEXEC = os.O_CLOEXEC
|
|
156
|
+
|
|
157
|
+
# move_mount(2) flags
|
|
158
|
+
MOVE_MOUNT_F_SYMLINKS = 0x00000001
|
|
159
|
+
MOVE_MOUNT_F_AUTOMOUNTS = 0x00000002
|
|
160
|
+
MOVE_MOUNT_F_EMPTY_PATH = 0x00000004
|
|
161
|
+
MOVE_MOUNT_T_SYMLINKS = 0x00000010
|
|
162
|
+
MOVE_MOUNT_T_AUTOMOUNTS = 0x00000020
|
|
163
|
+
MOVE_MOUNT_T_EMPTY_PATH = 0x00000040
|
|
164
|
+
MOVE_MOUNT_SET_GROUP = 0x00000100
|
|
165
|
+
MOVE_MOUNT_BENEATH = 0x00000200
|
|
166
|
+
|
|
167
|
+
# fsopen(2) flags
|
|
168
|
+
FSOPEN_CLOEXEC = 0x00000001
|
|
169
|
+
|
|
170
|
+
# fsmount(2) flags
|
|
171
|
+
FSMOUNT_CLOEXEC = 0x00000001
|
|
172
|
+
FSMOUNT_NAMESPACE = 0x00000002
|
|
173
|
+
|
|
174
|
+
# fspick(2) flags
|
|
175
|
+
FSPICK_CLOEXEC = 0x00000001
|
|
176
|
+
FSPICK_SYMLINK_NOFOLLOW = 0x00000002
|
|
177
|
+
FSPICK_NO_AUTOMOUNT = 0x00000004
|
|
178
|
+
FSPICK_EMPTY_PATH = 0x00000008
|
|
179
|
+
|
|
180
|
+
# fsconfig(2) commands, enum fsconfig_command in /usr/include/linux/mount.h
|
|
181
|
+
FSCONFIG_SET_FLAG = 0
|
|
182
|
+
FSCONFIG_SET_STRING = 1
|
|
183
|
+
FSCONFIG_SET_BINARY = 2
|
|
184
|
+
FSCONFIG_SET_PATH = 3
|
|
185
|
+
FSCONFIG_SET_PATH_EMPTY = 4
|
|
186
|
+
FSCONFIG_SET_FD = 5
|
|
187
|
+
FSCONFIG_CMD_CREATE = 6
|
|
188
|
+
FSCONFIG_CMD_RECONFIGURE = 7
|
|
189
|
+
FSCONFIG_CMD_CREATE_EXCL = 8
|
|
190
|
+
|
|
191
|
+
# MOUNT_ATTR_* bits for fsmount(2) and mount_setattr(2)
|
|
192
|
+
MOUNT_ATTR_RDONLY = 0x00000001
|
|
193
|
+
MOUNT_ATTR_NOSUID = 0x00000002
|
|
194
|
+
MOUNT_ATTR_NODEV = 0x00000004
|
|
195
|
+
MOUNT_ATTR_NOEXEC = 0x00000008
|
|
196
|
+
MOUNT_ATTR__ATIME = 0x00000070 # mask covering the three atime bits
|
|
197
|
+
MOUNT_ATTR_RELATIME = 0x00000000
|
|
198
|
+
MOUNT_ATTR_NOATIME = 0x00000010
|
|
199
|
+
MOUNT_ATTR_STRICTATIME = 0x00000020
|
|
200
|
+
MOUNT_ATTR_NODIRATIME = 0x00000080
|
|
201
|
+
MOUNT_ATTR_IDMAP = 0x00100000
|
|
202
|
+
MOUNT_ATTR_NOSYMFOLLOW = 0x00200000
|
|
203
|
+
|
|
204
|
+
# Bit position of the atime field inside MOUNT_ATTR__ATIME
|
|
205
|
+
MOUNT_ATTR_ATIME_SHIFT = 4
|
|
206
|
+
|
|
207
|
+
# sizeof first published struct mount_attr
|
|
208
|
+
MOUNT_ATTR_SIZE_VER0 = 32
|
newmount/fsapi.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# SPDX-License-Identifier: 0BSD
|
|
2
|
+
"""The new mount API (kernel 5.2+) plus ergonomic wrappers.
|
|
3
|
+
|
|
4
|
+
The syscall layer builds a detached mount with fsopen/fsconfig/fsmount,
|
|
5
|
+
attaches it with move_mount, clones an existing tree with open_tree,
|
|
6
|
+
reopens a mount for reconfiguration with fspick and flips attributes in
|
|
7
|
+
place with mount_setattr. All of it requires CAP_SYS_ADMIN in the user
|
|
8
|
+
namespace owning the mount namespace; unprivileged callers get there
|
|
9
|
+
through a user+mount namespace.
|
|
10
|
+
|
|
11
|
+
Kernel reference:
|
|
12
|
+
https://www.kernel.org/doc/html/latest/filesystems/mount_api.html
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import os
|
|
19
|
+
import types
|
|
20
|
+
from typing import TypeVar
|
|
21
|
+
|
|
22
|
+
from . import _syscall
|
|
23
|
+
from ._syscall import MountAttr
|
|
24
|
+
from .errors import MountError, UnsupportedError
|
|
25
|
+
from .flags import (
|
|
26
|
+
AT_EMPTY_PATH,
|
|
27
|
+
AT_FDCWD,
|
|
28
|
+
AT_RECURSIVE,
|
|
29
|
+
FSCONFIG_CMD_CREATE,
|
|
30
|
+
FSCONFIG_CMD_CREATE_EXCL,
|
|
31
|
+
FSCONFIG_CMD_RECONFIGURE,
|
|
32
|
+
FSCONFIG_SET_BINARY,
|
|
33
|
+
FSCONFIG_SET_FD,
|
|
34
|
+
FSCONFIG_SET_FLAG,
|
|
35
|
+
FSCONFIG_SET_PATH,
|
|
36
|
+
FSCONFIG_SET_PATH_EMPTY,
|
|
37
|
+
FSCONFIG_SET_STRING,
|
|
38
|
+
FSMOUNT_CLOEXEC,
|
|
39
|
+
FSOPEN_CLOEXEC,
|
|
40
|
+
FSPICK_CLOEXEC,
|
|
41
|
+
MOVE_MOUNT_F_EMPTY_PATH,
|
|
42
|
+
OPEN_TREE_CLOEXEC,
|
|
43
|
+
OPEN_TREE_CLONE,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"FsContext",
|
|
48
|
+
"MountAttr",
|
|
49
|
+
"MountFd",
|
|
50
|
+
"Tree",
|
|
51
|
+
"apply_attrs",
|
|
52
|
+
"attach",
|
|
53
|
+
"fsconfig",
|
|
54
|
+
"fsmount",
|
|
55
|
+
"fsopen",
|
|
56
|
+
"fspick",
|
|
57
|
+
"mount_setattr",
|
|
58
|
+
"move_mount",
|
|
59
|
+
"new_api_supported",
|
|
60
|
+
"open_tree",
|
|
61
|
+
"open_tree_clone",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
_Path = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
|
65
|
+
|
|
66
|
+
_OwnedFdT = TypeVar("_OwnedFdT", bound="_OwnedFd")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fsopen(fstype: str | bytes, flags: int = FSOPEN_CLOEXEC) -> int:
|
|
70
|
+
"""Open a filesystem configuration context, returns an fd."""
|
|
71
|
+
return _syscall.fsopen(os.fsencode(fstype), flags)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fsconfig(
|
|
75
|
+
fd: int,
|
|
76
|
+
cmd: int,
|
|
77
|
+
key: str | bytes | None = None,
|
|
78
|
+
value: str | bytes | None = None,
|
|
79
|
+
aux: int = 0,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Issue one fsconfig(2) command on a context fd.
|
|
82
|
+
|
|
83
|
+
cmd is an FSCONFIG_* constant. For SET_STRING and the SET_PATH family,
|
|
84
|
+
value is the option or path string and aux the dirfd (AT_FDCWD when
|
|
85
|
+
unset). For SET_BINARY, value is the blob and its length is passed as
|
|
86
|
+
aux. For SET_FD, pass the file descriptor as aux. The CMD_* actions
|
|
87
|
+
take neither key nor value.
|
|
88
|
+
"""
|
|
89
|
+
k = None if key is None else os.fsencode(key)
|
|
90
|
+
if cmd == FSCONFIG_SET_BINARY:
|
|
91
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
92
|
+
raise TypeError("FSCONFIG_SET_BINARY requires a bytes value")
|
|
93
|
+
blob = bytes(value)
|
|
94
|
+
_syscall.fsconfig(fd, cmd, k, blob, len(blob))
|
|
95
|
+
return
|
|
96
|
+
v = None if value is None else os.fsencode(value)
|
|
97
|
+
_syscall.fsconfig(fd, cmd, k, v, aux)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fsmount(fd: int, flags: int = FSMOUNT_CLOEXEC, attr_flags: int = 0) -> int:
|
|
101
|
+
"""Create a detached mount from a created context, returns an fd.
|
|
102
|
+
|
|
103
|
+
attr_flags are MOUNT_ATTR_* bits applied to the new mount.
|
|
104
|
+
"""
|
|
105
|
+
return _syscall.fsmount(fd, flags, attr_flags)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def open_tree(dfd: int, path: _Path, flags: int) -> int:
|
|
109
|
+
"""open_tree(2): duplicate a mount or clone a tree, returns an fd."""
|
|
110
|
+
return _syscall.open_tree(dfd, os.fsencode(path), flags)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def move_mount(
|
|
114
|
+
from_dfd: int,
|
|
115
|
+
from_path: _Path,
|
|
116
|
+
to_dfd: int,
|
|
117
|
+
to_path: _Path,
|
|
118
|
+
flags: int = 0,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""move_mount(2): attach a detached mount or relocate an existing one.
|
|
121
|
+
|
|
122
|
+
Pass the mount fd as from_dfd with an empty from_path and
|
|
123
|
+
MOVE_MOUNT_F_EMPTY_PATH to attach it; see attach() for the shortcut.
|
|
124
|
+
"""
|
|
125
|
+
_syscall.move_mount(
|
|
126
|
+
from_dfd,
|
|
127
|
+
os.fsencode(from_path),
|
|
128
|
+
to_dfd,
|
|
129
|
+
os.fsencode(to_path),
|
|
130
|
+
flags,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fspick(dfd: int, path: _Path, flags: int = FSPICK_CLOEXEC) -> int:
|
|
135
|
+
"""fspick(2): reopen an existing mount as a configuration context."""
|
|
136
|
+
return _syscall.fspick(dfd, os.fsencode(path), flags)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def mount_setattr(dfd: int, path: _Path, flags: int, attr: MountAttr) -> None:
|
|
140
|
+
"""mount_setattr(2): change attributes of a mount or mount tree."""
|
|
141
|
+
_syscall.mount_setattr(dfd, os.fsencode(path), flags, attr)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def attach(
|
|
145
|
+
fd: int,
|
|
146
|
+
target: _Path,
|
|
147
|
+
*,
|
|
148
|
+
dfd: int = AT_FDCWD,
|
|
149
|
+
flags: int = 0,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Attach a detached mount fd (fsmount/open_tree result) at target."""
|
|
152
|
+
move_mount(fd, "", dfd, target, MOVE_MOUNT_F_EMPTY_PATH | flags)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def apply_attrs(
|
|
156
|
+
path: _Path,
|
|
157
|
+
*,
|
|
158
|
+
set: int = 0, # noqa: A002 - matches the mount_attr field name
|
|
159
|
+
clear: int = 0,
|
|
160
|
+
propagation: int = 0,
|
|
161
|
+
userns_fd: int = 0,
|
|
162
|
+
recursive: bool = False,
|
|
163
|
+
dfd: int = AT_FDCWD,
|
|
164
|
+
flags: int = 0,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Change mount attributes in place via mount_setattr(2).
|
|
167
|
+
|
|
168
|
+
set and clear take MOUNT_ATTR_* bits; only one atime value may appear
|
|
169
|
+
in set. propagation takes MS_PRIVATE, MS_SHARED, MS_SLAVE or
|
|
170
|
+
MS_UNBINDABLE, or 0 to leave it unchanged. recursive applies the
|
|
171
|
+
change to the whole subtree beneath path.
|
|
172
|
+
"""
|
|
173
|
+
attr = MountAttr(
|
|
174
|
+
attr_set=set,
|
|
175
|
+
attr_clr=clear,
|
|
176
|
+
propagation=propagation,
|
|
177
|
+
userns_fd=userns_fd,
|
|
178
|
+
)
|
|
179
|
+
if recursive:
|
|
180
|
+
flags |= AT_RECURSIVE
|
|
181
|
+
p = os.fsencode(path)
|
|
182
|
+
if not p:
|
|
183
|
+
flags |= AT_EMPTY_PATH
|
|
184
|
+
_syscall.mount_setattr(dfd, p, flags, attr)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def new_api_supported() -> bool:
|
|
188
|
+
"""Probe whether the kernel provides the new mount API.
|
|
189
|
+
|
|
190
|
+
fsopen(2) fails with EPERM without CAP_SYS_ADMIN and with ENODEV for
|
|
191
|
+
an unknown filesystem; both mean the API exists. ENOSYS maps to
|
|
192
|
+
UnsupportedError and means the kernel predates 5.2.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
fd = fsopen("proc", 0)
|
|
196
|
+
except UnsupportedError:
|
|
197
|
+
return False
|
|
198
|
+
except MountError:
|
|
199
|
+
return True
|
|
200
|
+
os.close(fd)
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def open_tree_clone(
|
|
205
|
+
path: _Path,
|
|
206
|
+
*,
|
|
207
|
+
recursive: bool = True,
|
|
208
|
+
cloexec: bool = True,
|
|
209
|
+
dfd: int = AT_FDCWD,
|
|
210
|
+
flags: int = 0,
|
|
211
|
+
) -> Tree:
|
|
212
|
+
"""Clone the mount tree rooted at path into a detached Tree.
|
|
213
|
+
|
|
214
|
+
recursive passes AT_RECURSIVE so the whole subtree is cloned; with
|
|
215
|
+
recursive=False only the topmost mount is cloned. The clone is not
|
|
216
|
+
attached anywhere until Tree.attach() is called.
|
|
217
|
+
"""
|
|
218
|
+
fl = OPEN_TREE_CLONE | flags
|
|
219
|
+
if cloexec:
|
|
220
|
+
fl |= OPEN_TREE_CLOEXEC
|
|
221
|
+
if recursive:
|
|
222
|
+
fl |= AT_RECURSIVE
|
|
223
|
+
return Tree(open_tree(dfd, path, fl))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _OwnedFd:
|
|
227
|
+
"""Base for objects that own a kernel file descriptor."""
|
|
228
|
+
|
|
229
|
+
__slots__ = ("_fd",)
|
|
230
|
+
|
|
231
|
+
def __init__(self, fd: int) -> None:
|
|
232
|
+
self._fd = fd
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def closed(self) -> bool:
|
|
236
|
+
"""Whether the file descriptor has been closed."""
|
|
237
|
+
return self._fd < 0
|
|
238
|
+
|
|
239
|
+
def fileno(self) -> int:
|
|
240
|
+
"""Return the underlying file descriptor."""
|
|
241
|
+
if self._fd < 0:
|
|
242
|
+
raise ValueError("file descriptor is closed")
|
|
243
|
+
return self._fd
|
|
244
|
+
|
|
245
|
+
def close(self) -> None:
|
|
246
|
+
"""Close the file descriptor. Safe to call twice."""
|
|
247
|
+
if self._fd >= 0:
|
|
248
|
+
os.close(self._fd)
|
|
249
|
+
self._fd = -1
|
|
250
|
+
|
|
251
|
+
def __copy__(self) -> _OwnedFd:
|
|
252
|
+
raise TypeError(
|
|
253
|
+
f"{type(self).__name__} cannot be copied; it owns a kernel file descriptor"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def __deepcopy__(self, memo: dict[int, object]) -> _OwnedFd:
|
|
257
|
+
raise TypeError(
|
|
258
|
+
f"{type(self).__name__} cannot be copied; it owns a kernel file descriptor"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def __repr__(self) -> str:
|
|
262
|
+
state = "closed" if self.closed else "open"
|
|
263
|
+
return f"{type(self).__name__}(fd={self._fd}, {state})"
|
|
264
|
+
|
|
265
|
+
def __enter__(self: _OwnedFdT) -> _OwnedFdT:
|
|
266
|
+
if self.closed:
|
|
267
|
+
raise RuntimeError(f"{type(self).__name__} is closed")
|
|
268
|
+
return self
|
|
269
|
+
|
|
270
|
+
def __exit__(
|
|
271
|
+
self,
|
|
272
|
+
exc_type: type[BaseException] | None,
|
|
273
|
+
exc: BaseException | None,
|
|
274
|
+
tb: types.TracebackType | None,
|
|
275
|
+
) -> None:
|
|
276
|
+
self.close()
|
|
277
|
+
|
|
278
|
+
def __del__(self) -> None:
|
|
279
|
+
# __del__ must never raise
|
|
280
|
+
with contextlib.suppress(Exception):
|
|
281
|
+
self.close()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class MountFd(_OwnedFd):
|
|
285
|
+
"""A detached mount fd, the result of fsmount() or open_tree().
|
|
286
|
+
|
|
287
|
+
The mount is not part of the file hierarchy until attach() moves it
|
|
288
|
+
under a mountpoint. Use as a context manager or call close() to
|
|
289
|
+
release the fd.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
__slots__ = ()
|
|
293
|
+
|
|
294
|
+
def attach(
|
|
295
|
+
self,
|
|
296
|
+
target: _Path,
|
|
297
|
+
*,
|
|
298
|
+
dfd: int = AT_FDCWD,
|
|
299
|
+
flags: int = 0,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Attach this detached mount at target via move_mount(2)."""
|
|
302
|
+
attach(self.fileno(), target, dfd=dfd, flags=flags)
|
|
303
|
+
|
|
304
|
+
def apply_attrs(
|
|
305
|
+
self,
|
|
306
|
+
*,
|
|
307
|
+
set: int = 0, # noqa: A002 - matches the mount_attr field name
|
|
308
|
+
clear: int = 0,
|
|
309
|
+
propagation: int = 0,
|
|
310
|
+
userns_fd: int = 0,
|
|
311
|
+
recursive: bool = False,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Change attributes on this mount via mount_setattr(2)."""
|
|
314
|
+
apply_attrs(
|
|
315
|
+
"",
|
|
316
|
+
set=set,
|
|
317
|
+
clear=clear,
|
|
318
|
+
propagation=propagation,
|
|
319
|
+
userns_fd=userns_fd,
|
|
320
|
+
recursive=recursive,
|
|
321
|
+
dfd=self.fileno(),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class Tree(MountFd):
|
|
326
|
+
"""A detached clone of an existing mount tree (OPEN_TREE_CLONE)."""
|
|
327
|
+
|
|
328
|
+
__slots__ = ()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class FsContext(_OwnedFd):
|
|
332
|
+
"""Filesystem configuration context from fsopen(2) or fspick(2).
|
|
333
|
+
|
|
334
|
+
Configure parameters with set()/set_flag()/set_path(), then create()
|
|
335
|
+
to instantiate the superblock and mount() to obtain a detached
|
|
336
|
+
MountFd:
|
|
337
|
+
|
|
338
|
+
with FsContext("tmpfs") as ctx:
|
|
339
|
+
ctx.set("size", "16m")
|
|
340
|
+
ctx.create()
|
|
341
|
+
with ctx.mount() as mnt:
|
|
342
|
+
mnt.attach("/mnt/scratch")
|
|
343
|
+
|
|
344
|
+
FsContext.pick() reopens an existing mount so reconfigure() can change
|
|
345
|
+
its parameters.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
__slots__ = ()
|
|
349
|
+
|
|
350
|
+
def __init__(self, fstype: str | bytes, flags: int = FSOPEN_CLOEXEC) -> None:
|
|
351
|
+
super().__init__(fsopen(fstype, flags))
|
|
352
|
+
|
|
353
|
+
@classmethod
|
|
354
|
+
def _adopt(cls, fd: int) -> FsContext:
|
|
355
|
+
self = cls.__new__(cls)
|
|
356
|
+
_OwnedFd.__init__(self, fd)
|
|
357
|
+
return self
|
|
358
|
+
|
|
359
|
+
@classmethod
|
|
360
|
+
def pick(
|
|
361
|
+
cls,
|
|
362
|
+
path: _Path,
|
|
363
|
+
*,
|
|
364
|
+
dfd: int = AT_FDCWD,
|
|
365
|
+
flags: int = FSPICK_CLOEXEC,
|
|
366
|
+
) -> FsContext:
|
|
367
|
+
"""Reopen the mount at path for reconfiguration via fspick(2)."""
|
|
368
|
+
return cls._adopt(fspick(dfd, path, flags))
|
|
369
|
+
|
|
370
|
+
def _config(
|
|
371
|
+
self,
|
|
372
|
+
cmd: int,
|
|
373
|
+
key: str | bytes | None = None,
|
|
374
|
+
value: str | bytes | None = None,
|
|
375
|
+
aux: int = 0,
|
|
376
|
+
) -> None:
|
|
377
|
+
fsconfig(self.fileno(), cmd, key, value, aux)
|
|
378
|
+
|
|
379
|
+
def set_flag(self, key: str | bytes) -> None:
|
|
380
|
+
"""Set a parameter that takes no value (FSCONFIG_SET_FLAG)."""
|
|
381
|
+
self._config(FSCONFIG_SET_FLAG, key)
|
|
382
|
+
|
|
383
|
+
def set(self, key: str | bytes, value: str | bytes | int) -> None:
|
|
384
|
+
"""Set one parameter, dispatching on the value type.
|
|
385
|
+
|
|
386
|
+
str sends FSCONFIG_SET_STRING, bytes sends FSCONFIG_SET_BINARY and
|
|
387
|
+
int sends FSCONFIG_SET_FD with the value as the file descriptor.
|
|
388
|
+
Numeric filesystem options are passed as strings.
|
|
389
|
+
"""
|
|
390
|
+
if isinstance(value, str):
|
|
391
|
+
self._config(FSCONFIG_SET_STRING, key, value)
|
|
392
|
+
elif isinstance(value, (bytes, bytearray, memoryview)):
|
|
393
|
+
self._config(FSCONFIG_SET_BINARY, key, bytes(value))
|
|
394
|
+
elif isinstance(value, int):
|
|
395
|
+
self._config(FSCONFIG_SET_FD, key, None, value)
|
|
396
|
+
else:
|
|
397
|
+
raise TypeError(f"unsupported fsconfig value type: {type(value)!r}")
|
|
398
|
+
|
|
399
|
+
def set_path(
|
|
400
|
+
self,
|
|
401
|
+
key: str | bytes,
|
|
402
|
+
path: _Path,
|
|
403
|
+
*,
|
|
404
|
+
dfd: int = AT_FDCWD,
|
|
405
|
+
allow_empty: bool = False,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Set a parameter that takes a path (FSCONFIG_SET_PATH)."""
|
|
408
|
+
cmd = FSCONFIG_SET_PATH_EMPTY if allow_empty else FSCONFIG_SET_PATH
|
|
409
|
+
self._config(cmd, key, os.fsencode(path), dfd)
|
|
410
|
+
|
|
411
|
+
def create(self, *, exclusive: bool = False) -> None:
|
|
412
|
+
"""Instantiate the superblock (FSCONFIG_CMD_CREATE).
|
|
413
|
+
|
|
414
|
+
exclusive uses FSCONFIG_CMD_CREATE_EXCL to fail rather than reuse
|
|
415
|
+
an existing matching superblock.
|
|
416
|
+
"""
|
|
417
|
+
self._config(FSCONFIG_CMD_CREATE_EXCL if exclusive else FSCONFIG_CMD_CREATE)
|
|
418
|
+
|
|
419
|
+
def reconfigure(self) -> None:
|
|
420
|
+
"""Apply queued parameters to an existing superblock."""
|
|
421
|
+
self._config(FSCONFIG_CMD_RECONFIGURE)
|
|
422
|
+
|
|
423
|
+
def mount(self, attr_flags: int = 0, *, flags: int = FSMOUNT_CLOEXEC) -> MountFd:
|
|
424
|
+
"""Create a detached mount from this context via fsmount(2).
|
|
425
|
+
|
|
426
|
+
attr_flags are MOUNT_ATTR_* bits applied to the new mount.
|
|
427
|
+
"""
|
|
428
|
+
return MountFd(fsmount(self.fileno(), flags, attr_flags))
|
newmount/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: newmount
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python bindings for the Linux mount API
|
|
5
|
+
Project-URL: Homepage, https://quad4.io
|
|
6
|
+
Project-URL: Repository, https://github.com/Quad4-Software/newmount
|
|
7
|
+
Project-URL: Issues, https://github.com/Quad4-Software/newmount/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Quad4-Software/newmount/blob/master/CHANGELOG.md
|
|
9
|
+
Author: Quad4
|
|
10
|
+
License-Expression: 0BSD
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: filesystems,fsopen,linux,mount,namespaces
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Topic :: System :: Operating System Kernels :: Linux
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# newmount
|
|
30
|
+
|
|
31
|
+
[](https://github.com/Quad4-Software/newmount/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/Quad4-Software/newmount/actions/workflows/codeql.yml)
|
|
33
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/newmount)
|
|
34
|
+
[](https://pypi.org/project/newmount/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
Dependency-free ctypes bindings for the Linux mount API: the classic
|
|
38
|
+
mount(2)/umount2(2) calls, and the new mount API (kernel 5.2+) built on
|
|
39
|
+
fsopen, fsconfig, fsmount, open_tree, move_mount, fspick and
|
|
40
|
+
mount_setattr.
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10+ and Linux. Mounting needs CAP_SYS_ADMIN; an
|
|
43
|
+
unprivileged process gets there inside a user+mount namespace
|
|
44
|
+
(`unshare -Urm`).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
pip install newmount
|
|
49
|
+
|
|
50
|
+
## Example: classic bind mount
|
|
51
|
+
|
|
52
|
+
import newmount
|
|
53
|
+
|
|
54
|
+
# bind recursively, then remount read-only
|
|
55
|
+
newmount.bind("/srv/data", "/mnt/data", readonly=True)
|
|
56
|
+
|
|
57
|
+
## Example: clone, reconfigure and attach with the new API
|
|
58
|
+
|
|
59
|
+
import newmount
|
|
60
|
+
|
|
61
|
+
# clone the tree, flip the clone read-only, attach it
|
|
62
|
+
with newmount.open_tree_clone("/srv/data") as tree:
|
|
63
|
+
tree.apply_attrs(set=newmount.MOUNT_ATTR_RDONLY)
|
|
64
|
+
tree.attach("/mnt/data")
|
|
65
|
+
|
|
66
|
+
# or build a fresh filesystem from scratch
|
|
67
|
+
with newmount.FsContext("tmpfs") as ctx:
|
|
68
|
+
ctx.set("size", "16m")
|
|
69
|
+
ctx.create()
|
|
70
|
+
with ctx.mount() as mnt:
|
|
71
|
+
mnt.attach("/mnt/scratch")
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
uv sync --group dev
|
|
76
|
+
make check
|
|
77
|
+
|
|
78
|
+
License: 0BSD. Quad4 Software, https://quad4.io
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
newmount/__init__.py,sha256=a8D7d0RNHXcT_Sj9BPkdRIwV4p3CXK6pmPcEc-uzbjw,1478
|
|
2
|
+
newmount/_syscall.py,sha256=toPaWfDsxEqBYcumZWOzAOc9iJ0a_YHnsPe9CBbTKqQ,5969
|
|
3
|
+
newmount/classic.py,sha256=1KvlxiBWmVY699ImNnyN_dovM7Llyp4ZHrA0I9pxbSU,4047
|
|
4
|
+
newmount/errors.py,sha256=ZM9_2PTG5mRbpYHzpyeqnsCMKFVEK_jvttKaNZmtkuA,270
|
|
5
|
+
newmount/flags.py,sha256=tLIV6bNcWCdwR9LaWiahnQ6eqlqKM_RRck5gPiWj6mc,4930
|
|
6
|
+
newmount/fsapi.py,sha256=vDw971UCmwf-wRhthu9gspcUZk5qoja0dXyeA4pna9w,12625
|
|
7
|
+
newmount/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
newmount-0.1.0.dist-info/METADATA,sha256=4tr-hgWaFDq2jYuL6bba2iaVVNj3vKQBPiz3n2MQGu0,3022
|
|
9
|
+
newmount-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
newmount-0.1.0.dist-info/licenses/LICENSE,sha256=3Hnwsz5EXuTC9iTlGjzA171dKQtIVsgjFoF5DEMRl48,633
|
|
11
|
+
newmount-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2026 Quad4
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
4
|
+
with or without fee is hereby granted.
|
|
5
|
+
|
|
6
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
7
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
8
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
9
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
10
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
11
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
12
|
+
PERFORMANCE OF THIS SOFTWARE.
|