Coverage for src/debputy/manifest_parser/base_types.py: 73%
262 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-02-14 10:41 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-02-14 10:41 +0000
1import dataclasses
2import os
3import subprocess
4from functools import lru_cache
5from typing import Literal, TYPE_CHECKING, NotRequired
6from collections.abc import Sequence, Mapping, Iterable, MutableMapping
8from debputy.manifest_conditions import ManifestCondition
9from debputy.manifest_parser.exceptions import ManifestParseException
10from debputy.manifest_parser.tagging_types import DebputyParsedContent
11from debputy.manifest_parser.util import (
12 AttributePath,
13 _SymbolicModeSegment,
14 parse_symbolic_mode,
15)
16from debputy.path_matcher import MatchRule, ExactFileSystemPath
17from debputy.substitution import NULL_SUBSTITUTION, Substitution
18from debputy.util import (
19 _normalize_path,
20 _error,
21 _warn,
22 _debug_log,
23 _is_debug_log_enabled,
24)
26if TYPE_CHECKING:
27 from debputy.manifest_parser.parser_data import ParserContextData
30@dataclasses.dataclass(slots=True, frozen=True)
31class OwnershipDefinition:
32 entity_name: str
33 entity_id: int
36class DebputyParsedContentStandardConditional(DebputyParsedContent):
37 when: NotRequired[ManifestCondition]
40ROOT_DEFINITION = OwnershipDefinition("root", 0)
43BAD_OWNER_NAMES = {
44 "_apt", # All things owned by _apt are generated by apt after installation
45 "nogroup", # It is not supposed to own anything as it is an entity used for dropping permissions
46 "nobody", # It is not supposed to own anything as it is an entity used for dropping permissions
47}
48BAD_OWNER_IDS = {
49 65534, # ID of nobody / nogroup
50}
53def _parse_ownership(
54 v: str | int,
55 attribute_path: AttributePath,
56) -> tuple[str | None, int | None]:
57 if isinstance(v, str) and ":" in v: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 if v == ":":
59 raise ManifestParseException(
60 f'Invalid ownership value "{v}" at {attribute_path.path}: Ownership is redundant if it is ":"'
61 f" (blank name and blank id). Please provide non-default values or remove the definition."
62 )
63 entity_name: str | None
64 entity_id: int | None
65 entity_name, entity_id_str = v.split(":")
66 if entity_name == "":
67 entity_name = None
68 if entity_id_str != "":
69 entity_id = int(entity_id_str)
70 else:
71 entity_id = None
72 return entity_name, entity_id
74 if isinstance(v, int):
75 return None, v
76 if v.isdigit(): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 raise ManifestParseException(
78 f'Invalid ownership value "{v}" at {attribute_path.path}: The provided value "{v}" is a string (implying'
79 " name lookup), but it contains an integer (implying id lookup). Please use a regular int for id lookup"
80 f' (removing the quotes) or add a ":" in the end ("{v}:") as a disambiguation if you are *really* looking'
81 " for an entity with that name."
82 )
83 return v, None
86@lru_cache
87def _load_ownership_table_from_file(
88 name: Literal["passwd.master", "group.master"],
89) -> tuple[Mapping[str, OwnershipDefinition], Mapping[int, OwnershipDefinition]]:
90 filename = os.path.join("/usr/share/base-passwd", name)
91 name_table = {}
92 uid_table = {}
93 for owner_def in _read_ownership_def_from_base_password_template(filename):
94 # Could happen if base-passwd template has two users with the same ID. We assume this will not occur.
95 assert owner_def.entity_name not in name_table
96 assert owner_def.entity_id not in uid_table
97 name_table[owner_def.entity_name] = owner_def
98 uid_table[owner_def.entity_id] = owner_def
100 return name_table, uid_table
103def _read_ownership_def_from_base_password_template(
104 template_file: str,
105) -> Iterable[OwnershipDefinition]:
106 with open(template_file) as fd:
107 for line in fd:
108 entity_name, _star, entity_id, _remainder = line.split(":", 3)
109 if entity_id == "0" and entity_name == "root":
110 yield ROOT_DEFINITION
111 else:
112 yield OwnershipDefinition(entity_name, int(entity_id))
115class FileSystemMode:
116 @classmethod
117 def parse_filesystem_mode(
118 cls,
119 mode_raw: str,
120 attribute_path: AttributePath,
121 ) -> "FileSystemMode":
122 if mode_raw and mode_raw[0].isdigit():
123 return OctalMode.parse_filesystem_mode(mode_raw, attribute_path)
124 return SymbolicMode.parse_filesystem_mode(mode_raw, attribute_path)
126 def compute_mode(self, current_mode: int, is_dir: bool) -> int:
127 raise NotImplementedError
130@dataclasses.dataclass(slots=True, frozen=True)
131class SymbolicMode(FileSystemMode):
132 provided_mode: str
133 segments: Sequence[_SymbolicModeSegment]
135 @classmethod
136 def parse_filesystem_mode(
137 cls,
138 mode_raw: str,
139 attribute_path: AttributePath,
140 ) -> "SymbolicMode":
141 segments = list(parse_symbolic_mode(mode_raw, attribute_path))
142 return SymbolicMode(mode_raw, segments)
144 def __str__(self) -> str:
145 return self.symbolic_mode()
147 @property
148 def is_symbolic_mode(self) -> bool:
149 return False
151 def symbolic_mode(self) -> str:
152 return self.provided_mode
154 def compute_mode(self, current_mode: int, is_dir: bool) -> int:
155 final_mode = current_mode
156 for segment in self.segments:
157 final_mode = segment.apply(final_mode, is_dir)
158 return final_mode
161@dataclasses.dataclass(slots=True, frozen=True)
162class OctalMode(FileSystemMode):
163 octal_mode: int
165 @classmethod
166 def parse_filesystem_mode(
167 cls,
168 mode_raw: str,
169 attribute_path: AttributePath,
170 ) -> "FileSystemMode":
171 try:
172 mode = int(mode_raw, base=8)
173 except ValueError as e:
174 error_msg = 'An octal mode must be all digits between 0-7 (such as "644")'
175 raise ManifestParseException(
176 f"Cannot parse {attribute_path.path} as an octal mode: {error_msg}"
177 ) from e
178 return OctalMode(mode)
180 @property
181 def is_octal_mode(self) -> bool:
182 return True
184 def compute_mode(self, _current_mode: int, _is_dir: bool) -> int:
185 return self.octal_mode
187 def __str__(self) -> str:
188 return f"0{oct(self.octal_mode)[2:]}"
191@dataclasses.dataclass(slots=True, frozen=True)
192class _StaticFileSystemOwnerGroup:
193 ownership_definition: OwnershipDefinition
195 @property
196 def entity_name(self) -> str:
197 return self.ownership_definition.entity_name
199 @property
200 def entity_id(self) -> int:
201 return self.ownership_definition.entity_id
203 @classmethod
204 def from_manifest_value(
205 cls,
206 raw_input: str | int,
207 attribute_path: AttributePath,
208 ) -> "_StaticFileSystemOwnerGroup":
209 provided_name, provided_id = _parse_ownership(raw_input, attribute_path)
210 owner_def = cls._resolve(raw_input, provided_name, provided_id, attribute_path)
211 if ( 211 ↛ 215line 211 didn't jump to line 215 because the condition on line 211 was never true
212 owner_def.entity_name in BAD_OWNER_NAMES
213 or owner_def.entity_id in BAD_OWNER_IDS
214 ):
215 raise ManifestParseException(
216 f'Refusing to use "{raw_input}" as {cls._owner_type()} (defined at {attribute_path.path})'
217 f' as it resolves to "{owner_def.entity_name}:{owner_def.entity_id}" and no path should have this'
218 f" entity as {cls._owner_type()} as it is unsafe."
219 )
220 return cls(owner_def)
222 @classmethod
223 def _resolve(
224 cls,
225 raw_input: str | int,
226 provided_name: str | None,
227 provided_id: int | None,
228 attribute_path: AttributePath,
229 ) -> OwnershipDefinition:
230 table_name = cls._ownership_table_name()
231 name_table, id_table = _load_ownership_table_from_file(table_name)
232 name_match = (
233 name_table.get(provided_name) if provided_name is not None else None
234 )
235 id_match = id_table.get(provided_id) if provided_id is not None else None
236 if id_match is None and name_match is None: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 name_part = provided_name if provided_name is not None else "N/A"
238 id_part = provided_id if provided_id is not None else "N/A"
239 raise ManifestParseException(
240 f'Cannot resolve "{raw_input}" as {cls._owner_type()} (from {attribute_path.path}):'
241 f" It is not known to be a static {cls._owner_type()} from base-passwd."
242 f' The value was interpreted as name: "{name_part}" and id: {id_part}'
243 )
244 if id_match is None:
245 assert name_match is not None
246 return name_match
247 if name_match is None: 247 ↛ 250line 247 didn't jump to line 250 because the condition on line 247 was always true
248 assert id_match is not None
249 return id_match
250 if provided_name != id_match.entity_name:
251 raise ManifestParseException(
252 f"Bad {cls._owner_type()} declaration: The id {provided_id} resolves to {id_match.entity_name}"
253 f" according to base-passwd, but the packager declared to should have been {provided_name}"
254 f" at {attribute_path.path}"
255 )
256 if provided_id != name_match.entity_id:
257 raise ManifestParseException(
258 f"Bad {cls._owner_type()} declaration: The name {provided_name} resolves to {name_match.entity_id}"
259 f" according to base-passwd, but the packager declared to should have been {provided_id}"
260 f" at {attribute_path.path}"
261 )
262 return id_match
264 @classmethod
265 def _owner_type(cls) -> Literal["owner", "group"]:
266 raise NotImplementedError
268 @classmethod
269 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
270 raise NotImplementedError
273class StaticFileSystemOwner(_StaticFileSystemOwnerGroup):
274 @classmethod
275 def _owner_type(cls) -> Literal["owner", "group"]:
276 return "owner"
278 @classmethod
279 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
280 return "passwd.master"
283class StaticFileSystemGroup(_StaticFileSystemOwnerGroup):
284 @classmethod
285 def _owner_type(cls) -> Literal["owner", "group"]:
286 return "group"
288 @classmethod
289 def _ownership_table_name(cls) -> Literal["passwd.master", "group.master"]:
290 return "group.master"
293@dataclasses.dataclass(slots=True, frozen=True)
294class SymlinkTarget:
295 raw_symlink_target: str
296 attribute_path: AttributePath
297 symlink_target: str
299 @classmethod
300 def parse_symlink_target(
301 cls,
302 raw_symlink_target: str,
303 attribute_path: AttributePath,
304 substitution: Substitution,
305 ) -> "SymlinkTarget":
306 return SymlinkTarget(
307 raw_symlink_target,
308 attribute_path,
309 substitution.substitute(raw_symlink_target, attribute_path.path),
310 )
313class FileSystemMatchRule:
314 @property
315 def raw_match_rule(self) -> str:
316 raise NotImplementedError
318 @property
319 def attribute_path(self) -> AttributePath:
320 raise NotImplementedError
322 @property
323 def match_rule(self) -> MatchRule:
324 raise NotImplementedError
326 @classmethod
327 def parse_path_match(
328 cls,
329 raw_match_rule: str,
330 attribute_path: AttributePath,
331 parser_context: "ParserContextData | None",
332 ) -> "FileSystemMatchRule":
333 if parser_context is None: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true
334 subs = NULL_SUBSTITUTION
335 else:
336 subs = parser_context.substitution
337 return cls.from_path_match(raw_match_rule, attribute_path, subs)
339 @classmethod
340 def from_path_match(
341 cls,
342 raw_match_rule: str,
343 attribute_path: AttributePath,
344 substitution: "Substitution",
345 ) -> "FileSystemMatchRule":
346 try:
347 mr = MatchRule.from_path_or_glob(
348 raw_match_rule,
349 attribute_path.path,
350 substitution=substitution,
351 )
352 except ValueError as e:
353 raise ManifestParseException(
354 f'Could not parse "{raw_match_rule}" (defined at {attribute_path.path})'
355 f" as a path or a glob: {e.args[0]}"
356 )
358 if isinstance(mr, ExactFileSystemPath):
359 return FileSystemExactMatchRule(
360 raw_match_rule,
361 attribute_path,
362 mr,
363 )
364 return FileSystemGenericMatch(
365 raw_match_rule,
366 attribute_path,
367 mr,
368 )
371@dataclasses.dataclass(slots=True, frozen=True)
372class FileSystemGenericMatch(FileSystemMatchRule):
373 raw_match_rule: str
374 attribute_path: AttributePath
375 match_rule: MatchRule
378@dataclasses.dataclass(slots=True, frozen=True)
379class FileSystemExactMatchRule(FileSystemMatchRule):
380 raw_match_rule: str
381 attribute_path: AttributePath
382 match_rule: ExactFileSystemPath
384 @classmethod
385 def from_path_match(
386 cls,
387 raw_match_rule: str,
388 attribute_path: AttributePath,
389 substitution: "Substitution",
390 ) -> "FileSystemExactMatchRule":
391 try:
392 normalized = _normalize_path(raw_match_rule)
393 except ValueError as e:
394 raise ManifestParseException(
395 f'The path "{raw_match_rule}" provided in {attribute_path.path} should be relative to the'
396 ' root of the package and not use any ".." or "." segments.'
397 ) from e
398 if normalized == ".": 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true
399 raise ManifestParseException(
400 f'The path "{raw_match_rule}" matches a file system root and that is not a valid match'
401 f' at "{attribute_path.path}". Please narrow the provided path.'
402 )
403 mr = ExactFileSystemPath(
404 substitution.substitute(normalized, attribute_path.path)
405 )
406 if mr.path.endswith("/") and issubclass(cls, FileSystemExactNonDirMatchRule): 406 ↛ 407line 406 didn't jump to line 407 because the condition on line 406 was never true
407 raise ManifestParseException(
408 f'The path "{raw_match_rule}" at {attribute_path.path} resolved to'
409 f' "{mr.path}". Since the resolved path ends with a slash ("/"), this'
410 " means only a directory can match. However, this attribute should"
411 " match a *non*-directory"
412 )
413 return cls(
414 raw_match_rule,
415 attribute_path,
416 mr,
417 )
420class FileSystemExactNonDirMatchRule(FileSystemExactMatchRule):
421 pass
424class DpkgBuildflagsCache:
426 __slots__ = ("_cache_keys",)
428 def __init__(self) -> None:
429 self._cache_keys: dict[frozenset[tuple[str, str]], Mapping[str, str]] = {}
431 def run_dpkg_buildflags(
432 self,
433 env: Mapping[str, str],
434 definition_source: str | None,
435 ) -> Mapping[str, str]:
436 cache_key = frozenset((k, v) for k, v in env.items() if k.startswith("DEB_"))
437 dpkg_env = self._cache_keys.get(cache_key)
438 if dpkg_env is not None:
439 return dpkg_env
440 dpkg_env = {}
441 try:
442 bf_output = subprocess.check_output(["dpkg-buildflags"], env=env)
443 except FileNotFoundError:
444 if definition_source is None:
445 _error(
446 "The dpkg-buildflags command was not available and is necessary to set the relevant"
447 "env variables by default."
448 )
449 _error(
450 "The dpkg-buildflags command was not available and is necessary to set the relevant"
451 f"env variables for the environment defined at {definition_source}."
452 )
453 except subprocess.CalledProcessError as e:
454 if definition_source is None:
455 _error(
456 f"The dpkg-buildflags command failed with exit code {e.returncode}. Please review the output from"
457 f" dpkg-buildflags above to resolve the issue."
458 )
459 _error(
460 f"The dpkg-buildflags command failed with exit code {e.returncode}. Please review the output from"
461 f" dpkg-buildflags above to resolve the issue. The environment definition that triggered this call"
462 f" was {definition_source}"
463 )
464 else:
465 warned = False
466 for line in bf_output.decode("utf-8").splitlines(keepends=False):
467 if "=" not in line or line.startswith("="): 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 if not warned:
469 _warn(
470 f"Unexpected output from dpkg-buildflags (not a K=V line): {line}"
471 )
472 continue
473 k, v = line.split("=", 1)
474 if k.strip() != k: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 if not warned:
476 _warn(
477 f'Unexpected output from dpkg-buildflags (Key had spaces): "{line}"'
478 )
479 continue
480 dpkg_env[k] = v
481 self._cache_keys[cache_key] = dpkg_env
482 return dpkg_env
485_DPKG_BUILDFLAGS_CACHE = DpkgBuildflagsCache()
486del DpkgBuildflagsCache
489class BuildEnvironmentDefinition:
491 def dpkg_buildflags_env(
492 self,
493 env: Mapping[str, str],
494 definition_source: str | None,
495 ) -> Mapping[str, str]:
496 return _DPKG_BUILDFLAGS_CACHE.run_dpkg_buildflags(env, definition_source)
498 def log_computed_env(self, source: str, computed_env: Mapping[str, str]) -> None:
499 _debug_log(f"Computed environment variables from {source}")
500 for k, v in computed_env.items():
501 _debug_log(f" {k}={v}")
503 def update_env(self, env: MutableMapping[str, str]) -> None:
504 dpkg_env = self.dpkg_buildflags_env(env, None)
505 if _is_debug_log_enabled():
506 self.log_computed_env("dpkg-buildflags", dpkg_env)
507 env.update(dpkg_env)
510class BuildEnvironments:
512 def __init__(
513 self,
514 environments: dict[str, BuildEnvironmentDefinition],
515 default_environment: BuildEnvironmentDefinition | None,
516 ) -> None:
517 self.environments = environments
518 self.default_environment = default_environment