aboutsummaryrefslogtreecommitdiff
blob: 638c150ffe77fab12c4fec1171f0c09455f27b86 (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
# Copyright 2018-2020 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

__all__ = ["install_mask_dir", "InstallMask"]

import collections
import errno
import fnmatch
import operator

from portage import os, _unicode_decode
from portage.exception import (
    FileNotFound,
    IsADirectory,
    OperationNotPermitted,
    PermissionDenied,
    ReadOnlyFileSystem,
)
from portage.util import normalize_path


def _defaultdict_tree():
    return collections.defaultdict(_defaultdict_tree)


_pattern = collections.namedtuple(
    "_pattern",
    (
        "orig_index",
        "is_inclusive",
        "pattern",
        "leading_slash",
    ),
)


class InstallMask:
    def __init__(self, install_mask):
        """
        @param install_mask: INSTALL_MASK value
        @type install_mask: str
        """
        # Patterns not anchored with leading slash
        self._unanchored = []

        # Patterns anchored with leading slash are indexed by leading
        # non-glob components, making it possible to minimize the
        # number of fnmatch calls. For example:
        # /foo*/bar -> {'.': ['/foo*/bar']}
        # /foo/bar* -> {'foo': {'.': ['/foo/bar*']}}
        # /foo/bar/ -> {'foo': {'bar': {'.': ['/foo/bar/']}}}
        self._anchored = _defaultdict_tree()
        for orig_index, pattern in enumerate(install_mask.split()):
            # if pattern starts with -, possibly exclude this path
            is_inclusive = not pattern.startswith("-")
            if not is_inclusive:
                pattern = pattern[1:]
            pattern_obj = _pattern(
                orig_index, is_inclusive, pattern, pattern.startswith("/")
            )
            # absolute path pattern
            if pattern_obj.leading_slash:
                current_dir = self._anchored
                for component in list(filter(None, pattern.split("/"))):
                    if "*" in component:
                        break
                    else:
                        current_dir = current_dir[component]
                current_dir.setdefault(".", []).append(pattern_obj)

            # filename
            else:
                self._unanchored.append(pattern_obj)

    def _iter_relevant_patterns(self, path):
        """
        Iterate over patterns that may be relevant for the given path.

        Patterns anchored with leading / are indexed by leading
        non-glob components, making it possible to minimize the
        number of fnmatch calls.
        """
        current_dir = self._anchored
        components = list(filter(None, path.split("/")))
        patterns = []
        patterns.extend(current_dir.get(".", []))
        for component in components:
            next_dir = current_dir.get(component, None)
            if next_dir is None:
                break
            current_dir = next_dir
            patterns.extend(current_dir.get(".", []))

        if patterns:
            # Sort by original pattern index, since order matters for
            # non-inclusive patterns.
            patterns.extend(self._unanchored)
            if any(not pattern.is_inclusive for pattern in patterns):
                patterns.sort(key=operator.attrgetter("orig_index"))
            return iter(patterns)

        return iter(self._unanchored)

    def match(self, path):
        """
        @param path: file path relative to ${ED}
        @type path: str
        @rtype: bool
        @return: True if path matches INSTALL_MASK, False otherwise
        """
        ret = False

        for pattern_obj in self._iter_relevant_patterns(path):
            is_inclusive, pattern = pattern_obj.is_inclusive, pattern_obj.pattern
            # absolute path pattern
            if pattern_obj.leading_slash:
                # handle trailing slash for explicit directory match
                if path.endswith("/"):
                    pattern = pattern.rstrip("/") + "/"
                # match either exact path or one of parent dirs
                # the latter is done via matching pattern/*
                if fnmatch.fnmatch(path, pattern[1:]) or fnmatch.fnmatch(
                    path, pattern[1:].rstrip("/") + "/*"
                ):
                    ret = is_inclusive
            # filename
            else:
                if fnmatch.fnmatch(os.path.basename(path), pattern):
                    ret = is_inclusive
        return ret


_exc_map = {
    errno.EISDIR: IsADirectory,
    errno.ENOENT: FileNotFound,
    errno.EPERM: OperationNotPermitted,
    errno.EACCES: PermissionDenied,
    errno.EROFS: ReadOnlyFileSystem,
}


def _raise_exc(e):
    """
    Wrap OSError with portage.exception wrapper exceptions, with
    __cause__ chaining when python supports it.

    @param e: os exception
    @type e: OSError
    @raise PortageException: portage.exception wrapper exception
    """
    wrapper_cls = _exc_map.get(e.errno)
    if wrapper_cls is None:
        raise
    wrapper = wrapper_cls(str(e))
    wrapper.__cause__ = e
    raise wrapper


def install_mask_dir(base_dir, install_mask, onerror=None):
    """
    Remove files and directories matched by INSTALL_MASK.

    @param base_dir: directory path corresponding to ${ED}
    @type base_dir: str
    @param install_mask: INSTALL_MASK configuration
    @type install_mask: InstallMask
    """
    onerror = onerror or _raise_exc
    base_dir = normalize_path(base_dir)
    base_dir_len = len(base_dir) + 1
    dir_stack = []

    # Remove masked files.
    todo = [base_dir]
    while todo:
        parent = todo.pop()
        try:
            parent = _unicode_decode(parent, errors="strict")
        except UnicodeDecodeError:
            continue

        dir_stack.append(parent)
        for entry in os.scandir(parent):
            try:
                abs_path = _unicode_decode(entry.path, errors="strict")
            except UnicodeDecodeError:
                continue

            if entry.is_dir(follow_symlinks=False):
                todo.append(entry.path)
            elif install_mask.match(abs_path[base_dir_len:]):
                try:
                    os.unlink(entry.path)
                except OSError as e:
                    onerror(e)

    # Remove masked dirs (unless non-empty due to exclusions).
    while True:
        try:
            dir_path = dir_stack.pop()
        except IndexError:
            break

        if install_mask.match(dir_path[base_dir_len:] + "/"):
            try:
                os.rmdir(dir_path)
            except OSError:
                pass