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 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
+ [![CI](https://github.com/Quad4-Software/newmount/actions/workflows/ci.yml/badge.svg)](https://github.com/Quad4-Software/newmount/actions/workflows/ci.yml)
32
+ [![CodeQL](https://github.com/Quad4-Software/newmount/actions/workflows/codeql.yml/badge.svg)](https://github.com/Quad4-Software/newmount/actions/workflows/codeql.yml)
33
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Quad4-Software/newmount/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Quad4-Software/newmount)
34
+ [![PyPI](https://img.shields.io/pypi/v/newmount.svg)](https://pypi.org/project/newmount/)
35
+ [![License: 0BSD](https://img.shields.io/badge/license-0BSD-blue)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.