summaryrefslogtreecommitdiff
path: root/tests/test_atlocation.py
blob: 5d7286a56048ffe5b5db52588bd3dd85cf53aa79 (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
# Copyright 2024 Helmut Grohne <helmut@subdivi.de>
# SPDX-License-Identifier: GPL-3

import contextlib
import functools
import os
import pathlib
import stat
import tempfile
import typing
import unittest

import linuxnamespaces
from linuxnamespaces import AtLocation


class AtLocationTest(unittest.TestCase):
    def setUp(self) -> None:
        self.tempdir = pathlib.Path(
            self.enterContext(tempfile.TemporaryDirectory())
        )
        self.counter = 0

    @contextlib.contextmanager
    def create(
        self,
        loctype: typing.Literal["relative", "absolute", "emptypath", "withfd"],
        filetype: typing.Literal["absent", "file", "directory", "symlink"],
        linktarget: typing.Optional[str],
        follow_symlinks: bool = True,
    ) -> typing.Iterator[AtLocation]:
        """Create an AtLocation object for testing purposes. The created object
        can be "absent", a regular "file", a "directory" or a symbolic link
        ("symlink"). If it is a symlink, a linktarget must be given and
        follow_symlinks may be used to set AT_SYMLINK_NOFOLLOW. The location
        can refer to the object in multiple ways. If expressed as a "relative"
        path, the resulting context manager will temporarily change the working
        directory. It can also be "absolute" or use an O_PATH file descriptor
        with an "emptypath" or with a path relative to the file descriptor
        ("withfd")."""
        sym = str(self.counter)
        self.counter += 1
        abspath = self.tempdir / sym
        if filetype == "file":
            abspath.touch()
        elif filetype == "directory":
            abspath.mkdir()
        elif filetype == "symlink":
            assert linktarget is not None
            abspath.symlink_to(linktarget)
        else:
            self.assertEqual(filetype, "absent")
        if follow_symlinks:
            flags = linuxnamespaces.AtFlags.NONE
        else:
            flags = linuxnamespaces.AtFlags.AT_SYMLINK_NOFOLLOW
        if loctype == "relative":
            origcwd = os.getcwd()
            os.chdir(self.tempdir)
            try:
                yield AtLocation(sym, flags=flags)
            finally:
                os.chdir(origcwd)
        elif loctype == "absolute":
            yield AtLocation(abspath, flags=flags)
        elif loctype == "emptypath":
            if follow_symlinks:
                fd = os.open(abspath, os.O_PATH | os.O_NOFOLLOW)
            else:
                fd = os.open(abspath, os.O_PATH)
            with AtLocation(fd) as loc:
                yield loc
        else:
            self.assertEqual(loctype, "withfd")
            with AtLocation(
                os.open(self.tempdir, os.O_PATH), sym, flags
            ) as loc:
                yield loc

    def create_all(
        self, skip: typing.Container[str] = ()
    ) -> typing.Iterator[tuple[str, typing.ContextManager[AtLocation]]]:
        """Create various AtLocation objects referring to files, directories
        and other things in various ways.
        """
        for loctype in ("relative", "absolute", "emptypath", "withfd"):
            if loctype in skip:
                continue
            for filetype in ("absent", "file", "directory", "symlink"):
                if filetype in skip:
                    continue
                if filetype == "absent" and loctype in ("emptypath", "withfd"):
                    continue
                follow_symlinks_values = [True]
                if filetype == "symlink" and loctype != "emptypath":
                    follow_symlinks_values.append(False)
                for follow_symlinks in follow_symlinks_values:
                    # Mypy fails to see that loctype and filetype really are
                    # literals rather than arbitrary strings.
                    atlocctx = self.create(
                        loctype,  # type: ignore[arg-type]
                        filetype,  # type: ignore[arg-type]
                        "X",
                        follow_symlinks,
                    )
                    yield (filetype, atlocctx)

    @staticmethod
    def atloc_subtest(
        skip: typing.Container[str] = (),
    ) -> typing.Callable[
        [typing.Callable[["AtLocationTest", str, AtLocation], None]],
        typing.Callable[["AtLocationTest"], None],
    ]:
        """Wrap a test function and invoke it with possible AtLocations in a
        subTest managed context.
        """

        def decorator(
            func: typing.Callable[["AtLocationTest", str, AtLocation], None]
        ) -> typing.Callable[["AtLocationTest"], None]:
            @functools.wraps(func)
            def decorated(self: "AtLocationTest") -> None:
                for filetype, atlocctx in self.create_all(skip):
                    with atlocctx as atloc, self.subTest(
                        atlocation=repr(atloc), filetype=filetype
                    ):
                        func(self, filetype, atloc)

            return decorated

        return decorator

    @atloc_subtest()
    def test_access(self, filetype: str, atloc: AtLocation) -> None:
        should_exist = filetype in ("file", "directory") or (
            filetype == "symlink"
            and atloc.flags
            & (
                linuxnamespaces.AtFlags.AT_SYMLINK_NOFOLLOW
                | linuxnamespaces.AtFlags.AT_EMPTY_PATH
            )
            != linuxnamespaces.AtFlags.NONE
        )
        if not atloc.flags & linuxnamespaces.AtFlags.AT_EMPTY_PATH:
            self.assertEqual(atloc.access(os.R_OK), should_exist)
        self.assertEqual(atloc.exists(), should_exist)

    def test_as_emptypath(self) -> None:
        atloc = AtLocation(self.tempdir)
        self.assertFalse(atloc.flags & linuxnamespaces.AtFlags.AT_EMPTY_PATH)
        statres = atloc.stat()
        atloc_ep = self.enterContext(atloc.as_emptypath())
        self.assertTrue(atloc_ep.flags & linuxnamespaces.AtFlags.AT_EMPTY_PATH)
        self.assertGreaterEqual(atloc_ep.fd, 0)
        self.assertEqual(atloc_ep.location, "")
        self.assertEqual(atloc_ep.stat().st_ino, statres.st_ino)
        atloc_dup = self.enterContext(atloc_ep.as_emptypath())
        self.assertTrue(
            atloc_dup.flags & linuxnamespaces.AtFlags.AT_EMPTY_PATH
        )
        self.assertGreaterEqual(atloc_ep.fd, 0)
        self.assertNotEqual(atloc_dup.fd, atloc_ep.fd)
        self.assertEqual(atloc_dup.location, "")
        self.assertEqual(atloc_dup.stat().st_ino, statres.st_ino)

    @atloc_subtest(skip=("absent", "file", "symlink"))
    def test_join_mkdir(self, _: str, atloc: AtLocation) -> None:
        subdir = atloc / "subdir"
        self.assertFalse(subdir.exists())
        subdir.mkdir()
        self.assertTrue(subdir.is_dir())
        subdir.rmdir()
        self.assertFalse(subdir.exists())

    def test_mknod(self) -> None:
        with AtLocation(os.open(self.tempdir, os.O_PATH)) as rootloc:
            for loc in [AtLocation(self.tempdir) / "a", rootloc / "b"]:
                self.assertFalse(loc.exists(), f"{loc} does not exist")
                loc.mknod(device=stat.S_IFREG)
                self.assertTrue(loc.is_file(), f"{loc} is a file")

    @atloc_subtest(skip=("absent", "file", "symlink"))
    def test_walk(self, _: str, atloc: AtLocation) -> None:
        (atloc / "emptydir").mkdir()
        (atloc / "dir").mkdir()
        (atloc / "dir" / "alibi").mknod(device=stat.S_IFREG)
        (atloc / "deadlink").symlink_to("doesnotexist")
        (atloc / "symlink").symlink_to("dir/alibi")
        for dirloc, dirnames, filenames, dirfd in atloc.walk():
            self.assertEqual(dirloc.fd, atloc.fd)
            self.assertTrue(
                dirfd.flags & linuxnamespaces.AtFlags.AT_EMPTY_PATH
            )
            for dentry in dirnames:
                self.assertEqual(dentry.fd, dirfd.fd)
                self.assertTrue(dentry.location)
            for fentry in filenames:
                self.assertEqual(fentry.fd, dirfd.fd)
                self.assertTrue(fentry.location)
                thing = str(fentry.location).rsplit("/", 1)[-1]
                if thing == "alibi":
                    self.assertTrue(fentry.is_file())
                if thing == "deadlink":
                    self.assertTrue(fentry.is_symlink())
                    self.assertEqual(fentry.readlink(), "doesnotexist")
                    self.assertFalse(fentry.symfollow().exists())
                if thing == "symlink":
                    self.assertTrue(fentry.is_symlink())
                    self.assertEqual(fentry.readlink(), "dir/alibi")
                    self.assertTrue(fentry.symfollow().exists())