Coverage for src/debputy/manifest_parser/base_types.py: 84%
213 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
1import dataclasses
2import os
3from functools import lru_cache
4from typing import (
5 TypedDict,
6 NotRequired,
7 Sequence,
8 Optional,
9 Union,
10 Literal,
11 Tuple,
12 Mapping,
13 Iterable,
14 TYPE_CHECKING,
15 Callable,
16 Type,
17 Generic,
18)
20from debputy.manifest_parser.exceptions import ManifestParseException
21from debputy.manifest_parser.util import (
22 AttributePath,
23 _SymbolicModeSegment,
24 parse_symbolic_mode,
25)
26from debputy.path_matcher import MatchRule, ExactFileSystemPath
27from debputy.substitution import Substitution
28from debputy.types import S
29from debputy.util import _normalize_path, T
31if TYPE_CHECKING:
32 from debputy.manifest_conditions import ManifestCondition
33 from debputy.manifest_parser.parser_data import ParserContextData
36class DebputyParsedContent(TypedDict):
37 pass
40class DebputyDispatchableType:
41 __slots__ = ()
44class DebputyParsedContentStandardConditional(DebputyParsedContent):
45 when: NotRequired["ManifestCondition"]
48@dataclasses.dataclass(slots=True, frozen=True)
49class OwnershipDefinition:
50 entity_name: str
51 entity_id: int
54@dataclasses.dataclass
55class TypeMapping(Generic[S, T]):
56 target_type: Type[T]
57 source_type: Type[S]
58 mapper: Callable[[S, AttributePath, Optional["ParserContextData"]], T]
61ROOT_DEFINITION = OwnershipDefinition("root", 0)
64BAD_OWNER_NAMES = {
65 "_apt", # All things owned by _apt are generated by apt after installation
66 "nogroup", # It is not supposed to own anything as it is an entity used for dropping permissions
67 "nobody", # It is not supposed to own anything as it is an entity used for dropping permissions
68}
69BAD_OWNER_IDS = {
70 65534, # ID of nobody / nogroup
71}
74def _parse_ownership(
75 v: Union[str, int],
76 attribute_path: AttributePath,
77) -> Tuple[Optional[str], Optional[int]]:
78 if isinstance(v, str) and ":" in v: 78 ↛ 79line 78 didn't jump to line 79, because the condition on line 78 was never true
79 if v == ":":
80 raise ManifestParseException(
81 f'Invalid ownership value "{v}" at {attribute_path.path}: Ownership is redundant if it is ":"'
82 f" (blank name and blank id). Please provide non-default values or remove the definition."
83 )
84 entity_name: Optional[str]
85 entity_id: Optional[int]
86 entity_name, entity_id_str = v.split(":")
87 if entity_name == "":
88 entity_name = None
89 if entity_id_str != "":
90 entity_id = int(entity_id_str)
91 else:
92 entity_id = None
93 return entity_name, entity_id
95 if isinstance(v, int):
96 return None, v
97 if v.isdigit(): 97 ↛ 98line 97 didn't jump to line 98, because the condition on line 97 was never true
98 raise ManifestParseException(
99 f'Invalid ownership value "{v}" at {attribute_path.path}: The provided value "{v}" is a string (implying'
100 " name lookup), but it contains an integer (implying id lookup). Please use a regular int for id lookup"
101 f' (removing the quotes) or add a ":" in the end ("{v}:") as a disambiguation if you are *really* looking'
102 " for an entity with that name."
103 )
104 return v, None
107@lru_cache
108def _load_ownership_table_from_file(
109 name: Literal["passwd.master", "group.master"],
110) -> Tuple[Mapping[str, OwnershipDefinition], Mapping[int, OwnershipDefinition]]:
111 filename = os.path.join("/usr/share/base-passwd", name)
112 name_table = {}
113 uid_table = {}
114 for owner_def in _read_ownership_def_from_base_password_template(filename):
115 # Could happen if base-passwd template has two users with the same ID. We assume this will not occur.
116 assert owner_def.entity_name not in name_table
117 assert owner_def.entity_id not in uid_table
118 name_table[owner_def.entity_name] = owner_def
119 uid_table[owner_def.entity_id] = owner_def
121 return name_table, uid_table
124def _read_ownership_def_from_base_password_template(
125 template_file: str,
126) -> Iterable[OwnershipDefinition]:
127 with open(template_file) as fd:
128 for line in fd:
129 entity_name, _star, entity_id, _remainder = line.split(":", 3)
130 if entity_id == "0" and entity_name == "root":
131 yield ROOT_DEFINITION
132 else:
133 yield OwnershipDefinition(entity_name, int(entity_id))
136class FileSystemMode:
137 @classmethod
138 def parse_filesystem_mode(
139 cls,
140 mode_raw: str,
141 attribute_path: AttributePath,
142 ) -> "FileSystemMode":
143 if mode_raw and mode_raw[0].isdigit():
144 return OctalMode.parse_filesystem_mode(mode_raw, attribute_path)
145 return SymbolicMode.parse_filesystem_mode(mode_raw, attribute_path)
147 def compute_mode(self, current_mode: int, is_dir: bool) -> int:
148 raise NotImplementedError
151@dataclasses.dataclass(slots=True, frozen=True)
152class SymbolicMode(FileSystemMode):
153 provided_mode: str
154 segments: Sequence[_SymbolicModeSegment]
156 @classmethod
157 def parse_filesystem_mode(
158 cls,
159 mode_raw: str,
160 attribute_path: AttributePath,
161 ) -> "SymbolicMode":
162 segments = list(parse_symbolic_mode(mode_raw, attribute_path))
163 return SymbolicMode(mode_raw, segments)
165 def __str__(self) -> str:
166 return self.symbolic_mode()
168 @property
169 def is_symbolic_mode(self) -> bool:
170 return False
172 def symbolic_mode(self) -> str:
173 return self.provided_mode
175 def compute_mode(self, current_mode: int, is_dir: bool) -> int:
176 final_mode = current_mode
177 for segment in self.segments:
178 final_mode = segment.apply(final_mode, is_dir)
179 return final_mode
182@dataclasses.dataclass(slots=True, frozen=True)
183class OctalMode(FileSystemMode):
184 octal_mode: int
186 @classmethod
187 def parse_filesystem_mode(
188 cls,
189 mode_raw: str,
190 attribute_path: AttributePath,
191 ) -> "FileSystemMode":
192 try:
193 mode = int(mode_raw, base=8)
194 except ValueError as e:
195 error_msg = 'An octal mode must be all digits between 0-7 (such as "644")'
196 raise ManifestParseException(
197 f"Cannot parse {attribute_path.path} as an octal mode: {error_msg}"
198 ) from e
199 return OctalMode(mode)
201 @property
202 def is_octal_mode(self) -> bool:
203 return True
205 def compute_mode(self, _current_mode: int, _is_dir: bool) -> int:
206 return self.octal_mode
208 def __str__(self) -> str:
209 return f"0{oct(self.octal_mode)[2:]}"
212@dataclasses.dataclass(slots=True, frozen=True)
213class _StaticFileSystemOwnerGroup:
214 ownership_definition: OwnershipDefinition
216 @property
217 def entity_name(self) -> str:
218 return self.ownership_definition.entity_name
220 @property
221 def entity_id(self) -> int:
222 return self.ownership_definition.entity_id
224 @classmethod
225 def from_manifest_value(
226 cls,
227 raw_input: Union[str, int],
228 attribute_path: AttributePath,
229 ) -> "_StaticFileSystemOwnerGroup":
230 provided_name, provided_id = _parse_ownership(raw_input, attribute_path)
231 owner_def = cls._resolve(raw_input, provided_name, provided_id, attribute_path)
232 if ( 232 ↛ 236line 232 didn't jump to line 236
233 owner_def.entity_name in BAD_OWNER_NAMES
234 or owner_def.entity_id in BAD_OWNER_IDS
235 ):
236 raise ManifestParseException(
237 f'Refusing to use "{raw_input}" as {cls._owner_type()} (defined at {attribute_path.path})'
238 f' as it resolves to "{owner_def.entity_name}:{owner_def.entity_id}" and no path should have this'
239 f" entity as {cls._owner_type()} as it is unsafe."
240 )
241 return cls(owner_def)
243 @classmethod
244 def _resolve(
245 cls,
246 raw_input: Union[str, int],
247 provided_name: Optional[str],
248 provided_id: Optional[int],
249 attribute_path: AttributePath,
250 ) -> OwnershipDefinition:
251 table_name = cls._ownership_table_name()
252 name_table, id_table = _load_ownership_table_from_file(table_name)
253 name_match = (
254 name_table.get(provided_name) if provided_name is not None else None
255 )
256 id_match = id_table.get(provided_id) if provided_id is not None else None
257 if id_match is None and name_match is None: 257 ↛ 258line 257 didn't jump to line 258, because the condition on line 257 was never true
258 name_part = provided_name if provided_name is not None else "N/A"
259 id_part = provided_id if provided_id is not None else "N/A"
260 raise ManifestParseException(
261 f'Cannot resolve "{raw_input}" as {cls._owner_type()} (from {attribute_path.path}):'
262 f" It is not known to be a static {cls._owner_type()} from base-passwd."
263 f' The value was interpreted as name: "{name_part}" and id: {id_part}'
264 )
265 if id_match is None:
266 assert name_match is not None
267 return name_match
268 if name_match is None: 268 ↛ 271line 268 didn't jump to line 271, because the condition on line 268 was never false
269 assert id_match is not None
270 return id_match
271 if provided_name != id_match.entity_name:
272 raise ManifestParseException(
273 f"Bad {cls._owner_type()} declaration: The id {provided_id} resolves to {id_match.entity_name}"
274 f" according to base-passwd, but the packager declared to should have been {provided_name}"
275 f" at {attribute_path.path}"
276 )
277 if provided_id != name_match.entity_id:
278 raise ManifestParseException(
279 f"Bad {cls._owner_type} declaration: The name {provided_name} resolves to {name_match.entity_id}"
280 f" according to base-passwd, but the packager declared to should have been {provided_id}"
281 f" at {attribute_path.path}"
282 )
283 return id_match
285 @classmethod
286 def _owner_type(cls) -> Literal["owner", "group"]:
287 raise NotImplementedError
289 @classmethod
290 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
291 raise NotImplementedError
294class StaticFileSystemOwner(_StaticFileSystemOwnerGroup):
295 @classmethod
296 def _owner_type(cls) -> Literal["owner", "group"]:
297 return "owner"
299 @classmethod
300 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
301 return "passwd.master"
304class StaticFileSystemGroup(_StaticFileSystemOwnerGroup):
305 @classmethod
306 def _owner_type(cls) -> Literal["owner", "group"]:
307 return "group"
309 @classmethod
310 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
311 return "group.master"
314@dataclasses.dataclass(slots=True, frozen=True)
315class SymlinkTarget:
316 raw_symlink_target: str
317 attribute_path: AttributePath
318 symlink_target: str
320 @classmethod
321 def parse_symlink_target(
322 cls,
323 raw_symlink_target: str,
324 attribute_path: AttributePath,
325 substitution: Substitution,
326 ) -> "SymlinkTarget":
327 return SymlinkTarget(
328 raw_symlink_target,
329 attribute_path,
330 substitution.substitute(raw_symlink_target, attribute_path.path),
331 )
334class FileSystemMatchRule:
335 @property
336 def raw_match_rule(self) -> str:
337 raise NotImplementedError
339 @property
340 def attribute_path(self) -> AttributePath:
341 raise NotImplementedError
343 @property
344 def match_rule(self) -> MatchRule:
345 raise NotImplementedError
347 @classmethod
348 def parse_path_match(
349 cls,
350 raw_match_rule: str,
351 attribute_path: AttributePath,
352 parser_context: "ParserContextData",
353 ) -> "FileSystemMatchRule":
354 return cls.from_path_match(
355 raw_match_rule, attribute_path, parser_context.substitution
356 )
358 @classmethod
359 def from_path_match(
360 cls,
361 raw_match_rule: str,
362 attribute_path: AttributePath,
363 substitution: "Substitution",
364 ) -> "FileSystemMatchRule":
365 try:
366 mr = MatchRule.from_path_or_glob(
367 raw_match_rule,
368 attribute_path.path,
369 substitution=substitution,
370 )
371 except ValueError as e:
372 raise ManifestParseException(
373 f'Could not parse "{raw_match_rule}" (defined at {attribute_path.path})'
374 f" as a path or a glob: {e.args[0]}"
375 )
377 if isinstance(mr, ExactFileSystemPath):
378 return FileSystemExactMatchRule(
379 raw_match_rule,
380 attribute_path,
381 mr,
382 )
383 return FileSystemGenericMatch(
384 raw_match_rule,
385 attribute_path,
386 mr,
387 )
390@dataclasses.dataclass(slots=True, frozen=True)
391class FileSystemGenericMatch(FileSystemMatchRule):
392 raw_match_rule: str
393 attribute_path: AttributePath
394 match_rule: MatchRule
397@dataclasses.dataclass(slots=True, frozen=True)
398class FileSystemExactMatchRule(FileSystemMatchRule):
399 raw_match_rule: str
400 attribute_path: AttributePath
401 match_rule: ExactFileSystemPath
403 @classmethod
404 def from_path_match(
405 cls,
406 raw_match_rule: str,
407 attribute_path: AttributePath,
408 substitution: "Substitution",
409 ) -> "FileSystemExactMatchRule":
410 try:
411 normalized = _normalize_path(raw_match_rule)
412 except ValueError as e:
413 raise ManifestParseException(
414 f'The path "{raw_match_rule}" provided in {attribute_path.path} should be relative to the'
415 ' root of the package and not use any ".." or "." segments.'
416 ) from e
417 if normalized == ".": 417 ↛ 418line 417 didn't jump to line 418, because the condition on line 417 was never true
418 raise ManifestParseException(
419 f'The path "{raw_match_rule}" matches a file system root and that is not a valid match'
420 f' at "{attribute_path.path}". Please narrow the provided path.'
421 )
422 mr = ExactFileSystemPath(
423 substitution.substitute(normalized, attribute_path.path)
424 )
425 if mr.path.endswith("/") and issubclass(cls, FileSystemExactNonDirMatchRule): 425 ↛ 426line 425 didn't jump to line 426, because the condition on line 425 was never true
426 raise ManifestParseException(
427 f'The path "{raw_match_rule}" at {attribute_path.path} resolved to'
428 f' "{mr.path}". Since the resolved path ends with a slash ("/"), this'
429 " means only a directory can match. However, this attribute should"
430 " match a *non*-directory"
431 )
432 return cls(
433 raw_match_rule,
434 attribute_path,
435 mr,
436 )
439class FileSystemExactNonDirMatchRule(FileSystemExactMatchRule):
440 pass