summaryrefslogtreecommitdiff
path: root/linuxnamespaces/atlocation.py
blob: 2c827a20d75c2535a60a49b00d38949b49e88aaa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# Copyright 2024 Helmut Grohne <helmut@subdivi.de>
# SPDX-License-Identifier: GPL-3

"""Describe a location in the filesystem by a combination of a file descriptor
and a file name each of which can be optional. Many Linux system calls are able
to work with a location described in this way and this module provides support
code for doing so.
"""

import enum
import os
import os.path
import pathlib
import typing


AT_FDCWD = -100


PathConvertible = typing.Union[bytes, str, os.PathLike]


class AtFlags(enum.IntFlag):
    """Linux AT_* flags used with many different syscalls."""

    NONE = 0
    AT_SYMLINK_NOFOLLOW = 0x100
    AT_NO_AUTOMOUNT = 0x800
    AT_EMPTY_PATH = 0x1000


class AtLocation:
    """Represent a location in the filesystem suitable for use with the
    at-family of syscalls. If flags has the AT_EMPTY_PATH bit set, the
    location string must be empty and the file descriptor specifies the
    filesystem object. Otherwise, the location specifies the filesystem object.
    If it is relative, it the anchor is the file descriptor or the current
    working directory if the file descriptor is AT_FDCWD.
    """

    fd: int
    location: PathConvertible
    flags: AtFlags

    def __new__(
        cls,
        thing: typing.Union["AtLocation", int, PathConvertible],
        location: PathConvertible | None = None,
        flags: AtFlags = AtFlags.NONE,
    ) -> "AtLocation":
        """The argument thing can be many different thing. If it is an
        AtLocation, it is copied and all other arguments must be unset. If it
        is an integer, it is considered to be a file descriptor and the
        location must be unset if flags contains AT_EMPTY_PATH. flags are used
        as is except that AT_EMPTY_PATH is automatically added when given a
        file descriptor and no location.
        """
        if isinstance(thing, AtLocation):
            if location is not None or flags != AtFlags.NONE:
                raise ValueError(
                    "cannot override location or flags for an AtLocation"
                )
            return thing  # Don't copy.
        obj = super(AtLocation, cls).__new__(cls)
        if isinstance(thing, int):
            if thing < 0 and thing != AT_FDCWD:
                raise ValueError("fd cannot be negative")
            obj.fd = thing
            if location is None:
                obj.location = ""
                obj.flags = flags | AtFlags.AT_EMPTY_PATH
            elif flags & AtFlags.AT_EMPTY_PATH:
                raise ValueError(
                    "cannot set AT_EMPTY_PATH with a non-empty location"
                )
            else:
                obj.location = location
                obj.flags = flags
        elif location is not None:
            raise ValueError("location specified twice")
        else:
            obj.fd = AT_FDCWD
            obj.location = thing
            obj.flags = flags
        return obj

    def close(self) -> None:
        """Close the underlying file descriptor."""
        if self.fd >= 0:
            os.close(self.fd)
            self.fd = AT_FDCWD

    def nosymfollow(self) -> "AtLocation":
        """Return a copy with the AT_SYMLINK_NOFOLLOW set."""
        return AtLocation(
            self.fd, self.location, self.flags | AtFlags.AT_SYMLINK_NOFOLLOW
        )

    def symfollow(self) -> "AtLocation":
        """Return a copy with AT_SYMLINK_NOFOLLOW cleared."""
        return AtLocation(
            self.fd, self.location, self.flags & ~AtFlags.AT_SYMLINK_NOFOLLOW
        )

    def noautomount(self) -> "AtLocation":
        """Return a copy with AT_NO_AUTOMOUNT set."""
        return AtLocation(
            self.fd, self.location, self.flags | AtFlags.AT_NO_AUTOMOUNT
        )

    def automount(self) -> "AtLocation":
        """Return a copy with AT_NO_AUTOMOUNT cleared."""
        return AtLocation(
            self.fd, self.location, self.flags & ~AtFlags.AT_NO_AUTOMOUNT
        )

    def joinpath(self, name: PathConvertible) -> "AtLocation":
        """Combine an AtLocation and a path by doing the equivalent of joining
        them with a slash as separator.
        """
        if self.flags & AtFlags.AT_EMPTY_PATH:
            return AtLocation(
                self.fd, name, self.flags & ~AtFlags.AT_EMPTY_PATH
            )
        if not self.location:
            return AtLocation(self.fd, name, self.flags)
        if isinstance(self.location, bytes) or isinstance(name, bytes):
            return AtLocation(
                self.fd,
                os.path.join(os.fsencode(self.location), os.fsencode(name)),
                self.flags,
            )
        return AtLocation(
            self.fd, pathlib.Path(self.location).joinpath(name), self.flags
        )

    def __truediv__(self, name: PathConvertible) -> "AtLocation":
        return self.joinpath(name)

    def fileno(self) -> int:
        """Return the underlying file descriptor if this is an AT_EMPTY_PATH
        location and raise a ValueError otherwise.
        """
        if self.flags != AtFlags.AT_EMPTY_PATH:
            raise ValueError("AtLocation is not simply a file descriptor")
        assert self.fd >= 0
        assert not self.location
        return self.fd

    @property
    def fd_or_none(self) -> int | None:
        """A variant of the fd attribute that replaces AT_FDCWD with None."""
        return None if self.fd == AT_FDCWD else self.fd

    def access(self, mode: int, *, effective_ids: bool = False) -> bool:
        """Wrapper for os.access supplying path, dir_fd and follow_symlinks."""
        if self.flags == AtFlags.AT_SYMLINK_NOFOLLOW:
            follow_symlinks = False
        elif self.flags == AtFlags.NONE:
            follow_symlinks = True
        else:
            raise NotImplementedError(
                "access on AtLocation only supports flag AT_SYMLINK_NOFOLLOW"
            )
        assert self.location
        return os.access(
            self.location,
            mode,
            dir_fd=self.fd_or_none,
            effective_ids=effective_ids,
            follow_symlinks=follow_symlinks,
        )

    def chdir(self) -> None:
        """Wrapper for os.chdir or os.fchdir."""
        if self.flags == AtFlags.AT_EMPTY_PATH:
            return os.fchdir(self.fd)
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "chdir on AtLocation only supports flag AT_EMPTY_PATH"
            )
        assert self.location
        return os.chdir(self.location)

    def chmod(self, mode: int) -> None:
        """Wrapper for os.chmod or os.fchmod."""
        if self.flags == AtFlags.AT_EMPTY_PATH:
            return os.fchmod(self.fd, mode)
        if self.flags == AtFlags.AT_SYMLINK_NOFOLLOW:
            follow_symlinks = False
        elif self.flags == AtFlags.NONE:
            follow_symlinks = True
        else:
            raise NotImplementedError(
                "chmod on AtLocation with unsupported flags"
            )
        assert self.location
        return os.chmod(
            self.location,
            mode,
            dir_fd=self.fd_or_none,
            follow_symlinks=follow_symlinks,
        )

    def chown(self, uid: int, gid: int) -> None:
        """Wrapper for os.chown or os.chown."""
        if self.flags == AtFlags.AT_EMPTY_PATH:
            return os.fchown(self.fd, uid, gid)
        if self.flags == AtFlags.AT_SYMLINK_NOFOLLOW:
            follow_symlinks = False
        elif self.flags == AtFlags.NONE:
            follow_symlinks = True
        else:
            raise NotImplementedError(
                "chmod on AtLocation with unsupported flags"
            )
        assert self.location
        return os.chown(
            self.location,
            uid,
            gid,
            dir_fd=self.fd_or_none,
            follow_symlinks=follow_symlinks,
        )

    def mkdir(self, mode: int = 0o777) -> None:
        """Wrapper for os.mkdir supplying path and dir_fd."""
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "mkdir is not supported for an AtLocation with flags"
            )
        assert self.location
        os.mkdir(self.location, mode, dir_fd=self.fd_or_none)

    def mknod(self, mode: int = 0o600, device: int = 0) -> None:
        """Wrapper for os.mknod supplying path and dir_fd."""
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "mknod is not supported for an AtLocation with flags"
            )
        assert self.location
        os.mknod(self.location, mode, device, dir_fd=self.fd_or_none)

    def open(self, flags: int, mode: int = 0o777) -> int:
        """Wrapper for os.open supplying path and dir_fd."""
        if self.flags == AtFlags.AT_SYMLINK_NOFOLLOW:
            flags |= os.O_NOFOLLOW
        elif self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "opening an AtLocation only supports flag AT_SYMLINK_NOFOLLOW"
            )
        assert self.location
        return os.open(self.location, flags, mode, dir_fd=self.fd_or_none)

    def readlink(self) -> str:
        """Wrapper for os.readlink supplying path and dir_fd."""
        if self.flags & ~AtFlags.AT_EMPTY_PATH != AtFlags.NONE:
            raise NotImplementedError(
                "readlink on AtLocation only support flag AT_EMPTY_PATH"
            )
        return os.fsdecode(
            os.readlink(os.fspath(self.location), dir_fd=self.fd_or_none)
        )

    def rmdir(self) -> None:
        """Wrapper for os.rmdir suppling path and dir_fd."""
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "rmdir is not supported for an AtLocation with flags"
            )
        assert self.location
        return os.rmdir(self.location, dir_fd=self.fd_or_none)

    def symlink(self, linktarget: PathConvertible) -> None:
        """Create a symlink at self pointing to linktarget. Note that this
        method has its arguments reversed compared to the usual os.symlink,
        because the dir_fd is applicable to the second argument there.
        """
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "symlink is not supported for an AtLocation with flags"
            )
        assert self.location
        os.symlink(linktarget, self.location, dir_fd=self.fd_or_none)

    def unlink(self) -> None:
        """Wrapper for os.unlink suppling path and dir_fd."""
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "unlink is not supported for an AtLocation with flags"
            )
        assert self.location
        return os.unlink(self.location, dir_fd=self.fd_or_none)

    def walk(
        self,
        topdown: bool = True,
        onerror: typing.Callable[[OSError], typing.Any] | None = None,
        follow_symlinks: bool = False,
    ) -> typing.Iterator[
        tuple[
            "AtLocation", list["AtLocation"], list["AtLocation"], "AtLocation",
        ]
    ]:
        """Resemble os.fwalk with a few differences. The returned iterator
        yields the dirpath as an AtLocation that borrows the fd from self. The
        dirnames and filenames become AtLocations whose location is the entry
        name and whose fd is temporary. Finally, the dirfd also becomes an
        AtLocations referencing the same object as the dirpath though as an
        AT_EMPTY_PATH with temporary fd.
        """
        if self.flags != AtFlags.NONE:
            raise NotImplementedError(
                "walk is not supported for an AtLocation with flags"
            )
        for dirpath, dirnames, filenames, dirfd in os.fwalk(
            self.location,
            topdown=topdown,
            onerror=onerror,
            follow_symlinks=follow_symlinks,
            dir_fd=self.fd_or_none,
        ):
            yield (
                AtLocation(self.fd, dirpath),
                [AtLocation(dirfd, dirname) for dirname in dirnames],
                [AtLocation(dirfd, filename) for filename in filenames],
                AtLocation(dirfd),
            )

    def __enter__(self) -> "AtLocation":
        """When used as a context manager, the associated fd will be closed on
        scope exit.
        """
        return self

    def __exit__(
        self,
        exc_type: typing.Any,
        exc_value: typing.Any,
        traceback: typing.Any,
    ) -> None:
        """When used as a context manager, the associated fd will be closed on
        scope exit.
        """
        self.close()

    def __fspath__(self) -> str | bytes:
        """Return the underlying location if it uniquely defines this object.
        Otherwise raise a ValueError.
        """
        if self.fd != AT_FDCWD:
            raise ValueError(
                "AtLocation with fd is not convertible to plain path"
            )
        if self.flags != AtFlags.NONE:
            raise ValueError(
                "AtLocation with flags is not convertible to plain path"
            )
        return os.fspath(self.location)


AtLocationLike = typing.Union[AtLocation, int, PathConvertible]