Coverage for src/debputy/plugins/debputy/binary_package_rules.py: 70%
233 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-09-06 13:40 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-09-06 13:40 +0000
1import collections
2import dataclasses
3import os
4import re
5import textwrap
6import typing
7from typing import (
8 Any,
9 Iterator,
10 NotRequired,
11 Union,
12 Literal,
13 TypedDict,
14 Annotated,
15 Self,
16 cast,
17)
19from debian.deb822 import PkgRelation
21from debputy._manifest_constants import (
22 MK_INSTALLATION_SEARCH_DIRS,
23 MK_BINARY_VERSION,
24 MK_SERVICES,
25)
26from debputy.maintscript_snippet import (
27 DpkgMaintscriptHelperCommand,
28 UnboundMaintscriptSnippet,
29 SnippetResolver,
30 MaintscriptCondition,
31 DPKG_DEB_CONTROL_SCRIPTS,
32 SUPPORTED_UDEB_SCRIPTS,
33 MaintscriptSnippetForNamedScript,
34)
35from debputy.manifest_parser.base_types import (
36 DebputyParsedContentStandardConditional,
37 FileSystemExactMatchRule,
38)
39from debputy.manifest_parser.declarative_parser import ParserGenerator
40from debputy.manifest_parser.exceptions import ManifestParseException
41from debputy.manifest_parser.parse_hints import DebputyParseHint
42from debputy.manifest_parser.parser_data import ParserContextData
43from debputy.manifest_parser.tagging_types import DebputyParsedContent
44from debputy.manifest_parser.util import AttributePath
45from debputy.path_matcher import MatchRule, MATCH_ANYTHING, ExactFileSystemPath
46from debputy.plugin.api import reference_documentation
47from debputy.plugin.api.impl import (
48 DebputyPluginInitializerProvider,
49 ServiceDefinitionImpl,
50)
51from debputy.plugin.api.parser_tables import OPARSER_PACKAGES
52from debputy.plugin.api.spec import (
53 ServiceUpgradeRule,
54 ServiceDefinition,
55 DSD,
56 INTEGRATION_MODE_DH_DEBPUTY_RRR,
57 not_integrations,
58 documented_attr,
59)
60from debputy.plugins.debputy.types import (
61 BuiltUsingItem,
62 BuiltUsing,
63 StaticBuiltUsing,
64 MatchedBuiltUsingRelation,
65)
66from debputy.substitution import Substitution
67from debputy.transformation_rules import TransformationRule
68from debputy.util import _error, manifest_format_doc, PackageTypeSelector
70ACCEPTABLE_CLEAN_ON_REMOVAL_FOR_GLOBS_AND_EXACT_MATCHES = frozenset(
71 [
72 "./var/log",
73 ]
74)
77ACCEPTABLE_CLEAN_ON_REMOVAL_IF_EXACT_MATCH_OR_SUBDIR_OF = frozenset(
78 [
79 "./etc",
80 "./run",
81 "./var/lib",
82 "./var/cache",
83 "./var/games",
84 "./var/backups",
85 "./var/spool",
86 # linux-image uses these paths with some `rm -f`
87 "./usr/lib/modules",
88 "./lib/modules",
89 # udev special case
90 "./lib/udev",
91 "./usr/lib/udev",
92 # pciutils deletes /usr/share/misc/pci.ids.<ext>
93 "./usr/share/misc",
94 ]
95)
97RMDIR_IF_EMPTY = frozenset(
98 [
99 "./var/games",
100 ]
101)
104def register_binary_package_rules(api: DebputyPluginInitializerProvider) -> None:
105 api.pluggable_manifest_rule(
106 OPARSER_PACKAGES,
107 MK_BINARY_VERSION,
108 BinaryVersionParsedFormat,
109 _parse_binary_version,
110 source_format=str,
111 register_value=False,
112 )
114 api.pluggable_manifest_rule(
115 OPARSER_PACKAGES,
116 "transformations",
117 list[TransformationRule],
118 _unpack_list,
119 register_value=False,
120 )
122 api.pluggable_manifest_rule(
123 OPARSER_PACKAGES,
124 "conffile-management",
125 list[DpkgMaintscriptHelperCommand],
126 _unpack_list,
127 expected_debputy_integration_mode=not_integrations(
128 INTEGRATION_MODE_DH_DEBPUTY_RRR
129 ),
130 register_value=False,
131 )
133 api.pluggable_manifest_rule(
134 OPARSER_PACKAGES,
135 MK_SERVICES,
136 list[ServiceRuleParsedFormat],
137 _process_service_rules,
138 source_format=list[ServiceRuleSourceFormat],
139 expected_debputy_integration_mode=not_integrations(
140 INTEGRATION_MODE_DH_DEBPUTY_RRR
141 ),
142 register_value=False,
143 )
145 api.pluggable_manifest_rule(
146 OPARSER_PACKAGES,
147 "maintscript-snippets",
148 list[MaintscriptSnippetParsedFormat],
149 _process_maintscript_snippets,
150 expected_debputy_integration_mode=not_integrations(
151 INTEGRATION_MODE_DH_DEBPUTY_RRR
152 ),
153 register_value=True,
154 inline_reference_documentation=reference_documentation(
155 title="Provide custom maintscript snippets for the package (`$RULE_NAME`)",
156 synopsis="Provide custom maintscript snippets for the package",
157 description=textwrap.dedent("""\
158 Provide custom maintscript snippets for the package.
160 These can be useful if the package needs some bespoke maintainer script handling
161 that is not handled by standard snippets. The value should be a list of snippet
162 definitions with each definition declaring when it should trigger and what snippet
163 should be run at that time.
166 Each snippet is expected to be well-behave, which implies that they should not:
167 * Change the value or position arguments such as `$$1` (this includes not
168 calling `shift` outside functions).
169 * Change global state (without at least resetting it again) such as invoking `set +e`
170 redefining `PATH`, creating functions with the same name as binaries, etc.
172 Violating this "should not" requirement may destabilize other snippets run after
173 the given snippet. The reason and requirement is the same as the one `debhelper`
174 has for the state of the maintscript where its `#DEBHELPER#` token is placed. The
175 main difference is that `debputy` requires it through out the script and not just
176 "up to a magic token".
178 Example:
180 ```yaml
181 packages:
182 PKG:
183 $RULE_NAME:
184 - on: configure
185 snippet: "custom-configurator --deb-multiarch {{DEB_HOST_MULTIARCH}}"
186 ```
188 This example will cause `custom-configurator` to be run when the package is
189 being `configured` (this includes some common rollback cases, see docs for `configure`).
191 For short multi-line scripts, consider using the YAML `|` multi-line string:
193 ```yaml
194 packages:
195 PKG:
196 $RULE_NAME:
197 - on: configure
198 snippet: |
199 foo
200 bar || BAR_ERR=$$?
201 if [ "$$BAR_ERR" ]; then
202 echo "Woe is me, Woe is you and now this installation fails." >&2
203 exit 1
204 fi
205 ```
207 For longer and more complex scripts, the script can be in a separate file and loaded via
208 `snippet-from-file`.
209 """),
210 attributes=[
211 documented_attr(
212 "on",
213 textwrap.dedent("""\
214 Defines when the script should be applied.
216 The definitions here imply both a concrete `maintscript` and a condition for that
217 maintainer script. As an example, `on: purge` roughly translates into
218 "in the `postrm` script, when `[ "$$1" = "purge" ]` is true".
219 """),
220 ),
221 documented_attr(
222 "snippet",
223 textwrap.dedent("""\
224 Defines an inline snippet to be run.
226 This is the snippet to be run.
227 """),
228 ),
229 documented_attr(
230 "snippet_from_file",
231 textwrap.dedent("""\
232 Defines that the snippet should be read from the listed file.
234 This is an alternative to `snippet`. The file must be placed directly in
235 the `debian` and this option defines its basename.
237 This is mostly useful for longer or more complex snippets, where having properties
238 like full editor support or easy ability to run testing tools like `shellcheck`
239 are desired. It is also useful to keep the size of the manifest from growing
240 due to a large script.
241 """),
242 ),
243 documented_attr(
244 "requires",
245 textwrap.dedent("""\
246 Defines which features `debputy` should provide
248 Currently, only `debconf` is supported and means that `debputy` should ensure
249 that `debconf` is loaded before the snippet is run such that features like
250 `db_get` and `db_go` work.
251 """),
252 ),
253 documented_attr(
254 "apply_substitution",
255 textwrap.dedent("""\
256 Whether `debputy` substitutions should be applied to the snippet.
258 Set to `false` to disable substitutions.
259 """),
260 ),
261 documented_attr(
262 "indent",
263 textwrap.dedent("""\
264 Whether `debputy` should indent the script to make the generated script look nicer
266 By default, `debputy` applies a simple heuristic to determine whether it is safe
267 to do so. This option can be used to explicitly decides whether indentation should
268 be applied. It is always safe to set this to `false`, but it means that the snippet
269 is at the same indentation level as the condition that guards it. That makes it
270 harder to read the generated snippet for humans.
272 One case where indentation is known to be unsafe is around "heredocs" such as:
274 ```shell
275 cmd <<EOF
276 foo
277 bar
278 EOF
279 ```
281 Here, indenting will cause `EOF` to no longer be at the start of the line and
282 break the "heredoc".
284 The recommendation is to leave this at its default unless testing shows that
285 the automatic detection heurestics causes problems. In that case, explicitly
286 set it to `false`.
287 """),
288 ),
289 ],
290 ),
291 )
293 api.pluggable_manifest_rule(
294 OPARSER_PACKAGES,
295 "dpkg-gensymbols",
296 DpkgGensymbolsOptionsFormat,
297 DpkgGensymbolsOptions.parse,
298 expected_debputy_integration_mode=not_integrations(
299 INTEGRATION_MODE_DH_DEBPUTY_RRR
300 ),
301 # Pulled by makeshlibs.py, so registration is needed.
302 register_value=True,
303 inline_reference_documentation=reference_documentation(
304 title="Configure `dpkg-gensmybols` options (`$RULE_NAME`)",
305 description=textwrap.dedent(
306 """\
307 Configure the `dpkg-gensymbols` for the given binary
308 package.
310 Example:
312 packages:
313 PKG:
314 $RULE_NAME:
315 # Pass `-c4` to `dpkg-gensymbols`
316 check-level: 4
317 """,
318 ),
319 attributes=[
320 documented_attr(
321 "check_level",
322 textwrap.dedent("""\
323 Configure the check-level (`-c`) for `dpkg-gensymbols`
325 The levels are defined in [man:dpkg-gensymbols.1] as:
327 * `0`: Never fails.
328 * `1`: Fails if some symbols have disappeared.
329 * `2`: Fails if some new symbols have been introduced.
330 * `3`: Fails if some libraries have disappeared.
331 * `4`: Fails if some libraries have been introduced.
333 The higher levels include all the checks from lower levels.
334 As an example, using a value of `3` would include all the
335 checks from `3`, `2`, and `1` at the same time.
337 The default check-level in `dpkg-gensymbols` is `1`.
339 [man:dpkg-gensymbols.1]: https://manpages.debian.org/dpkg-gensymbols.1
340 """),
341 ),
342 ],
343 ),
344 )
346 api.pluggable_manifest_rule(
347 OPARSER_PACKAGES,
348 "clean-after-removal",
349 ListParsedFormat,
350 _parse_clean_after_removal,
351 # FIXME: debputy won't see the attributes for this one :'(
352 # (update `debputy_docs.yaml` when fixed)
353 source_format=list[Any],
354 expected_debputy_integration_mode=not_integrations(
355 INTEGRATION_MODE_DH_DEBPUTY_RRR
356 ),
357 register_value=False,
358 )
360 api.pluggable_manifest_rule(
361 OPARSER_PACKAGES,
362 MK_INSTALLATION_SEARCH_DIRS,
363 InstallationSearchDirsParsedFormat,
364 _parse_installation_search_dirs,
365 source_format=list[FileSystemExactMatchRule],
366 expected_debputy_integration_mode=not_integrations(
367 INTEGRATION_MODE_DH_DEBPUTY_RRR
368 ),
369 register_value=False,
370 )
372 api.pluggable_manifest_rule(
373 rule_type=OPARSER_PACKAGES,
374 rule_name="built-using",
375 parsed_format=list[BuiltUsingParsedFormat],
376 handler=_parse_built_using,
377 expected_debputy_integration_mode=not_integrations(
378 "dh-sequence-zz-debputy-rrr"
379 ),
380 inline_reference_documentation=reference_documentation(
381 title="Built-Using dependency relations (`$RULE_NAME`)",
382 description=textwrap.dedent(
383 """\
384 Generate a `Built-Using` dependency relation on the
385 build dependencies selected by the `sources-for`, which
386 may contain a `*` wildcard matching any number of
387 arbitrary characters.
389 The `built-using` should be used for static linking
390 where license of dependency libraries require the
391 exact source to be retained. Usually these libraries
392 will be under the license terms like GNU GPL.
394 packages:
395 PKG:
396 $RULE_NAME:
397 - sources-for: foo-*-source # foo-3.1.0-source
398 - sources-for: librust-*-dev # several matches
399 - sources-for: foo
400 when: # foo is always installed
401 arch-matches: amd64 # but only used on amd64
403 Either of these conditions prevents the generation:
404 * PKG is not part of the current build because of its
405 `Architecture` or `Build-Profiles` fields.
406 * The match in `Build-Depends` carries an
407 architecture or build profile restriction that does
408 not match the current run.
409 * The match in `Build-Depends` is not installed.
410 This should only happen inside alternatives, see below.
411 * The manifest item carries a `when:` condition that
412 evaluates to false. This may be useful when the match
413 must be installed for unrelated reasons.
415 Matches are searched in the `Build-Depends` field of
416 the source package, and either `Build-Depends-Indep`
417 or `Build-Depends-Arch` depending on PKG.
419 In alternatives like `a | b`, each option may match
420 separately. This is a compromise between
421 reproducibility on automatic builders (where the set
422 of installed package is constant), and least surprise
423 during local builds (where `b` may be installed
424 alone). There seems to be no one-size fits all
425 solution when both are installed.
427 Architecture qualifiers and version restrictions in
428 `Build-Depends` are ignored. The only allowed
429 co-installations require a common source and version.
430 """,
431 ),
432 ),
433 )
435 api.pluggable_manifest_rule(
436 rule_type=OPARSER_PACKAGES,
437 rule_name="static-built-using",
438 parsed_format=list[BuiltUsingParsedFormat],
439 handler=_parse_static_built_using,
440 expected_debputy_integration_mode=not_integrations(
441 "dh-sequence-zz-debputy-rrr"
442 ),
443 inline_reference_documentation=reference_documentation(
444 title="Static-Built-Using dependency relations (`$RULE_NAME`)",
445 description=textwrap.dedent(
446 """\
447 Generate a `Static-Built-Using` dependency relation on the
448 build dependencies selected by the `sources-for`, which
449 may contain a `*` wildcard matching any number of
450 arbitrary characters.
452 The `static-built-using` should be used for static linking
453 where license of dependency libraries do not require the
454 exact source to be retained. This is usually libraries under
455 permissive libraries like Apache-2.0 or MIT/X11/Expat.
457 packages:
458 PKG:
459 $RULE_NAME:
460 - sources-for: foo-*-source # foo-3.1.0-source
461 - sources-for: librust-*-dev # several matches
462 - sources-for: foo
463 when: # foo is always installed
464 arch-matches: amd64 # but only used on amd64
466 Either of these conditions prevents the generation:
467 * PKG is not part of the current build because of its
468 `Architecture` or `Build-Profiles` fields.
469 * The match in `Build-Depends` carries an
470 architecture or build profile restriction that does
471 not match the current run.
472 * The match in `Build-Depends` is not installed.
473 This should only happen inside alternatives, see below.
474 * The manifest item carries a `when:` condition that
475 evaluates to false. This may be useful when the match
476 must be installed for unrelated reasons.
478 Matches are searched in the `Build-Depends` field of
479 the source package, and either `Build-Depends-Indep`
480 or `Build-Depends-Arch` depending on PKG.
482 In alternatives like `a | b`, each option may match
483 separately. This is a compromise between
484 reproducibility on automatic builders (where the set
485 of installed package is constant), and least surprise
486 during local builds (where `b` may be installed
487 alone). There seems to be no one-size fits all
488 solution when both are installed.
490 Architecture qualifiers and version restrictions in
491 `Build-Depends` are ignored. The only allowed
492 co-installations require a common source and version.
493 """,
494 ),
495 ),
496 )
499class ServiceRuleSourceFormat(TypedDict):
500 service: str
501 type_of_service: NotRequired[str]
502 service_scope: NotRequired[Literal["system", "user"]]
503 enable_on_install: NotRequired[bool]
504 start_on_install: NotRequired[bool]
505 on_upgrade: NotRequired[ServiceUpgradeRule]
506 service_manager: NotRequired[
507 Annotated[str, DebputyParseHint.target_attribute("service_managers")]
508 ]
509 service_managers: NotRequired[list[str]]
512class ServiceRuleParsedFormat(DebputyParsedContent):
513 service: str
514 type_of_service: NotRequired[str]
515 service_scope: NotRequired[Literal["system", "user"]]
516 enable_on_install: NotRequired[bool]
517 start_on_install: NotRequired[bool]
518 on_upgrade: NotRequired[ServiceUpgradeRule]
519 service_managers: NotRequired[list[str]]
522class MaintscriptSnippetParsedFormat(DebputyParsedContent):
523 on: MaintscriptCondition
524 snippet: typing.NotRequired[str]
525 snippet_from_file: Annotated[
526 typing.NotRequired[str],
527 DebputyParseHint.conflicts_with_source_attributes("snippet"),
528 ]
529 requires: typing.NotRequired[typing.Literal["debconf"]]
530 apply_substitution: typing.NotRequired[bool]
531 indent: typing.NotRequired[bool]
534@dataclasses.dataclass(slots=True, frozen=True)
535class MaintainerProvidedMaintscriptSnippetContainer:
536 snippets: collections.abc.Sequence[MaintscriptSnippetForNamedScript]
539@dataclasses.dataclass(slots=True, frozen=True)
540class ServiceRule:
541 definition_source: str
542 service: str
543 type_of_service: str
544 service_scope: Literal["system", "user"]
545 enable_on_install: bool | None
546 start_on_install: bool | None
547 on_upgrade: ServiceUpgradeRule | None
548 service_managers: frozenset[str] | None
550 @classmethod
551 def from_service_rule_parsed_format(
552 cls,
553 data: ServiceRuleParsedFormat,
554 attribute_path: AttributePath,
555 ) -> "Self":
556 service_managers = data.get("service_managers")
557 return cls(
558 attribute_path.path,
559 data["service"],
560 data.get("type_of_service", "service"),
561 cast("Literal['system', 'user']", data.get("service_scope", "system")),
562 data.get("enable_on_install"),
563 data.get("start_on_install"),
564 data.get("on_upgrade"),
565 None if service_managers is None else frozenset(service_managers),
566 )
568 def applies_to_service_manager(self, service_manager: str) -> bool:
569 return self.service_managers is None or service_manager in self.service_managers
571 def apply_to_service_definition(
572 self,
573 service_definition: ServiceDefinition[DSD],
574 ) -> ServiceDefinition[DSD]:
575 assert isinstance(service_definition, ServiceDefinitionImpl)
576 if not service_definition.is_plugin_provided_definition:
577 _error(
578 f"Conflicting definitions related to {self.service} (type: {self.type_of_service},"
579 f" scope: {self.service_scope}). First definition at {service_definition.definition_source},"
580 f" the second at {self.definition_source}). If they are for different service managers,"
581 " you can often avoid this problem by explicitly defining which service managers are applicable"
582 ' to each rule via the "service-managers" keyword.'
583 )
584 changes = {
585 "definition_source": self.definition_source,
586 "is_plugin_provided_definition": False,
587 }
588 if (
589 self.service != service_definition.name
590 and self.service in service_definition.names
591 ):
592 changes["name"] = self.service
593 if self.enable_on_install is not None:
594 changes["auto_start_on_install"] = self.enable_on_install
595 if self.start_on_install is not None:
596 changes["auto_start_on_install"] = self.start_on_install
597 if self.on_upgrade is not None:
598 changes["on_upgrade"] = self.on_upgrade
600 return service_definition.replace(**changes)
603class BinaryVersionParsedFormat(DebputyParsedContent):
604 binary_version: str
607class BuiltUsingParsedFormat(DebputyParsedContentStandardConditional):
608 """Also used for static-built-using."""
610 sources_for: str
613class ListParsedFormat(DebputyParsedContent):
614 elements: list[Any]
617class ListOfTransformationRulesFormat(DebputyParsedContent):
618 elements: list[TransformationRule]
621class ListOfDpkgMaintscriptHelperCommandFormat(DebputyParsedContent):
622 elements: list[DpkgMaintscriptHelperCommand]
625class InstallationSearchDirsParsedFormat(DebputyParsedContent):
626 installation_search_dirs: list[FileSystemExactMatchRule]
629type DpkgGensymbolsCheckLevel = typing.Literal[0, 1, 2, 3, 4]
632class DpkgGensymbolsOptionsFormat(DebputyParsedContent):
633 check_level: DpkgGensymbolsCheckLevel
636@dataclasses.dataclass
637class DpkgGensymbolsOptions:
638 # We leave `check_level` optional at this stage to ensure consuming code is
639 # ready for it being optional later even though it is required now.
640 check_level: DpkgGensymbolsCheckLevel | None
642 @classmethod
643 def parse(
644 cls,
645 _name: str,
646 parsed_data: DpkgGensymbolsOptionsFormat,
647 _attribute_path: AttributePath,
648 _parser_context: ParserContextData,
649 ) -> typing.Self:
650 return cls(**parsed_data)
653def _parse_binary_version(
654 _name: str,
655 parsed_data: BinaryVersionParsedFormat,
656 _attribute_path: AttributePath,
657 _parser_context: ParserContextData,
658) -> str:
659 return parsed_data["binary_version"]
662def _parse_installation_search_dirs(
663 _name: str,
664 parsed_data: InstallationSearchDirsParsedFormat,
665 _attribute_path: AttributePath,
666 _parser_context: ParserContextData,
667) -> list[FileSystemExactMatchRule]:
668 return parsed_data["installation_search_dirs"]
671def _process_service_rules(
672 _name: str,
673 parsed_data: list[ServiceRuleParsedFormat],
674 attribute_path: AttributePath,
675 _parser_context: ParserContextData,
676) -> list[ServiceRule]:
677 return [
678 ServiceRule.from_service_rule_parsed_format(x, attribute_path[i])
679 for i, x in enumerate(parsed_data)
680 ]
683def _to_maintscript_snippet(
684 parsed_data: MaintscriptSnippetParsedFormat,
685 attribute_path: AttributePath,
686 substitution: Substitution,
687 package_type: PackageTypeSelector,
688 supported_scripts: frozenset[str],
689) -> MaintscriptSnippetForNamedScript:
690 snippet = parsed_data.get("snippet")
691 snippet_from_file = parsed_data.get("snippet_from_file")
692 trigger_condition = parsed_data["on"]
693 requires = parsed_data.get("requires")
694 apply_substitution = parsed_data.get("apply_substitution")
695 indent = parsed_data.get("indent")
696 if snippet is not None:
697 # The debputy parse hint should have ensured this.
698 assert snippet_from_file is None
699 snippet_resolver = SnippetResolver.snippet(snippet)
700 snippet_path = attribute_path["snippet"]
701 elif snippet_from_file is not None:
702 snippet_path = attribute_path["snippet_from_file"]
703 if "/" in snippet_from_file or snippet_from_file.startswith("."):
705 raise ManifestParseException(
706 f"The filename in {snippet_path.path} must be a basename (relative to debian/)"
707 f" and not start with a period."
708 )
709 full_path = os.path.join("debian", snippet_from_file)
710 if not os.path.isfile(full_path):
711 raise ManifestParseException(
712 f"The filename in {snippet_path.path} does not exist. Expected to it to be {full_path}"
713 f" from the source root"
714 )
716 def _read_file() -> str:
717 with open(full_path) as fd:
718 return fd.read()
720 snippet_resolver = SnippetResolver.lazy_snippet(_read_file)
721 else:
722 raise ManifestParseException(
723 f"Please provide exactly one of `snippet` or `snippet_from_file` at {attribute_path.path}"
724 )
725 if apply_substitution is None or apply_substitution:
726 snippet_resolver = snippet_resolver.apply_manifest_substitution(
727 snippet_path.path,
728 substitution,
729 )
730 s = trigger_condition.as_maintscript_snippet(
731 attribute_path.path,
732 snippet_resolver,
733 uses_debconf=requires == "debconf",
734 indent=indent,
735 )
736 if s.maintscript_name not in supported_scripts:
737 supported_script_names = ", ".join(sorted(supported_scripts))
738 raise ManifestParseException(
739 f"Definition at {attribute_path.path} is for the script `{s.maintscript_name}`",
740 f"but only the following maintscripts are supported for `{package_type}` packages:"
741 f" {supported_script_names}",
742 )
743 return s
746def _process_maintscript_snippets(
747 _name: str,
748 parsed_data: list[MaintscriptSnippetParsedFormat],
749 attribute_path: AttributePath,
750 parser_context: ParserContextData,
751) -> MaintainerProvidedMaintscriptSnippetContainer:
752 substitution = parser_context.substitution
753 bin_pkg = parser_context.current_binary_package_state.binary_package
754 supported_scripts = (
755 SUPPORTED_UDEB_SCRIPTS if bin_pkg.is_udeb else DPKG_DEB_CONTROL_SCRIPTS
756 )
757 return MaintainerProvidedMaintscriptSnippetContainer(
758 tuple(
759 _to_maintscript_snippet(
760 x,
761 attribute_path[i],
762 substitution,
763 bin_pkg.package_type,
764 supported_scripts,
765 )
766 for i, x in enumerate(parsed_data)
767 )
768 )
771def _parse_built_using(
772 _name: str,
773 parsed_data: list[BuiltUsingParsedFormat],
774 attribute_path: AttributePath,
775 parser_context: ParserContextData,
776) -> BuiltUsing:
777 items = _built_using_handler(parsed_data, attribute_path, parser_context)
778 return BuiltUsing(items)
781def _parse_static_built_using(
782 _name: str,
783 parsed_data: list[BuiltUsingParsedFormat],
784 attribute_path: AttributePath,
785 parser_context: ParserContextData,
786) -> StaticBuiltUsing:
787 items = _built_using_handler(parsed_data, attribute_path, parser_context)
788 return StaticBuiltUsing(items)
791_VALID_BUILT_USING_GLOB = re.compile("[a-z*][a-z0-9.+*-]*")
792_BUILT_USING_GLOB_TO_RE = str.maketrans({".": "[.]", "+": "[+]", "*": ".*"})
795def _built_using_matches(
796 regex: re.Pattern,
797 other: Literal["Build-Depends-Arch", "Build-Depends-Indep"],
798 parser_context: ParserContextData,
799) -> Iterator[MatchedBuiltUsingRelation]:
800 """Helper for _validate_built_using."""
801 for bd_field in ("Build-Depends", other):
802 raw = parser_context.source_package.fields.get(bd_field)
803 if raw is not None:
804 for options in PkgRelation.parse_relations(raw):
805 for idx, relation in enumerate(options):
806 if regex.fullmatch(relation["name"]) is not None:
807 yield MatchedBuiltUsingRelation(not idx, relation)
810def _validate_built_using(
811 parsed_data: BuiltUsingParsedFormat,
812 attribute_path: AttributePath,
813 parser_context: ParserContextData,
814) -> BuiltUsingItem:
815 """Helper for _built_using_handler."""
816 raw_glob = parsed_data["sources_for"]
817 if _VALID_BUILT_USING_GLOB.fullmatch(raw_glob) is None: 817 ↛ 818line 817 didn't jump to line 818 because the condition on line 817 was never true
818 raise ManifestParseException(
819 f"The glob {raw_glob!r} defined at {attribute_path["sources_for"].path} contained invalid characters."
820 f" It must only characters valid in a package name plus the `*` character"
821 )
822 regex = re.compile(raw_glob.translate(_BUILT_USING_GLOB_TO_RE))
824 pkg = parser_context.current_binary_package_state.binary_package
825 other: Literal["Build-Depends-Arch", "Build-Depends-Indep"]
826 if pkg.is_arch_all:
827 other = "Build-Depends-Indep"
828 else:
829 other = "Build-Depends-Arch"
830 matched_packages = tuple(_built_using_matches(regex, other, parser_context))
831 if not matched_packages: 831 ↛ 832line 831 didn't jump to line 832 because the condition on line 831 was never true
832 raise ManifestParseException(
833 f"The glob {raw_glob!r} defined at {attribute_path["sources_for"].path} matches no clause of Build-Depends or {other}."
834 " Either a Build-dependency is missing or the glob fails to match the intended build-dependency, or the glob superfluous and can be removed."
835 )
836 return BuiltUsingItem(
837 matched_packages,
838 parsed_data.get("when"),
839 attribute_path,
840 )
843def _built_using_handler(
844 parsed_data: list[BuiltUsingParsedFormat],
845 attribute_path: AttributePath,
846 parser_context: ParserContextData,
847) -> Iterator[BuiltUsingItem]:
848 """Helper for _parse_built_using and _parse_static_built_using."""
849 for idx, pd in enumerate(parsed_data):
850 yield _validate_built_using(pd, attribute_path[idx], parser_context)
853def _unpack_list(
854 _name: str,
855 parsed_data: list[Any],
856 _attribute_path: AttributePath,
857 _parser_context: ParserContextData,
858) -> list[Any]:
859 return parsed_data
862class CleanAfterRemovalRuleSourceFormat(TypedDict):
863 path: NotRequired[Annotated[str, DebputyParseHint.target_attribute("paths")]]
864 paths: NotRequired[list[str]]
865 delete_on: NotRequired[Literal["purge", "removal"]]
866 recursive: NotRequired[bool]
867 ignore_non_empty_dir: NotRequired[bool]
870class CleanAfterRemovalRule(DebputyParsedContent):
871 paths: list[str]
872 delete_on: NotRequired[Literal["purge", "removal"]]
873 recursive: NotRequired[bool]
874 ignore_non_empty_dir: NotRequired[bool]
877# FIXME: Not optimal that we are doing an initialization of ParserGenerator here. But the rule is not depending on any
878# complex types that is registered by plugins, so it will work for now.
879_CLEAN_AFTER_REMOVAL_RULE_PARSER = ParserGenerator().generate_parser(
880 CleanAfterRemovalRule,
881 source_content=Union[CleanAfterRemovalRuleSourceFormat, str, list[str]],
882 inline_reference_documentation=reference_documentation(
883 reference_documentation_url=manifest_format_doc(
884 "remove-runtime-created-paths-on-purge-or-post-removal-clean-after-removal"
885 ),
886 ),
887)
890# Order between clean_on_removal and conffile_management is
891# important. We want the dpkg conffile management rules to happen before the
892# clean clean_on_removal rules. Since the latter only affects `postrm`
893# and the order is reversed for `postrm` scripts (among other), we need do
894# clean_on_removal first to account for the reversing of order.
895#
896# FIXME: All of this is currently not really possible todo, but it should be.
897# (I think it is the correct order by "mistake" rather than by "design", which is
898# what this note is about)
899def _parse_clean_after_removal(
900 _name: str,
901 parsed_data: ListParsedFormat,
902 attribute_path: AttributePath,
903 parser_context: ParserContextData,
904) -> None: # TODO: Return and pass to a maintscript helper
905 raw_clean_after_removal = parsed_data["elements"]
906 package_state = parser_context.current_binary_package_state
908 for no, raw_transformation in enumerate(raw_clean_after_removal):
909 definition_source = attribute_path[no]
910 clean_after_removal_rules = _CLEAN_AFTER_REMOVAL_RULE_PARSER.parse_input(
911 raw_transformation,
912 definition_source,
913 parser_context=parser_context,
914 )
915 patterns = clean_after_removal_rules["paths"]
916 if patterns: 916 ↛ 918line 916 didn't jump to line 918 because the condition on line 916 was always true
917 definition_source.path_hint = patterns[0]
918 delete_on = clean_after_removal_rules.get("delete_on") or "purge"
919 recurse = clean_after_removal_rules.get("recursive") or False
920 ignore_non_empty_dir = (
921 clean_after_removal_rules.get("ignore_non_empty_dir") or False
922 )
923 if delete_on == "purge": 923 ↛ 926line 923 didn't jump to line 926 because the condition on line 923 was always true
924 condition = '[ "$1" = "purge" ]'
925 else:
926 condition = '[ "$1" = "remove" ]'
928 if ignore_non_empty_dir:
929 if recurse: 929 ↛ 930line 929 didn't jump to line 930 because the condition on line 929 was never true
930 raise ManifestParseException(
931 'The "recursive" and "ignore-non-empty-dir" options are mutually exclusive.'
932 f" Both were enabled at the same time in at {definition_source.path}"
933 )
934 for pattern in patterns:
935 if not pattern.endswith("/"): 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true
936 raise ManifestParseException(
937 'When ignore-non-empty-dir is True, then all patterns must end with a literal "/"'
938 f' to ensure they only apply to directories. The pattern "{pattern}" at'
939 f" {definition_source.path} did not."
940 )
942 substitution = parser_context.substitution
943 match_rules = [
944 MatchRule.from_path_or_glob(
945 p, definition_source.path, substitution=substitution
946 )
947 for p in patterns
948 ]
949 content_lines = [
950 f"if {condition}; then\n",
951 ]
952 auto_cleanup_dirs = set[str]()
953 for idx, match_rule in enumerate(match_rules):
954 original_pattern = patterns[idx]
955 if match_rule is MATCH_ANYTHING: 955 ↛ 956line 955 didn't jump to line 956 because the condition on line 955 was never true
956 raise ManifestParseException(
957 f'Using "{original_pattern}" in a clean rule would trash the system.'
958 f" Please restrict this pattern at {definition_source.path} considerably."
959 )
960 is_subdir_match = False
961 matched_directory: str | None
962 if isinstance(match_rule, ExactFileSystemPath):
963 matched_directory = (
964 os.path.dirname(match_rule.path)
965 if match_rule.path not in ("/", ".", "./")
966 else match_rule.path
967 )
968 is_subdir_match = True
969 else:
970 matched_directory = getattr(match_rule, "directory", None)
972 if matched_directory is None: 972 ↛ 973line 972 didn't jump to line 973 because the condition on line 972 was never true
973 raise ManifestParseException(
974 f'The pattern "{original_pattern}" defined at {definition_source.path} is not'
975 f" trivially anchored in a specific directory. Cowardly refusing to use it"
976 f" in a clean rule as it may trash the system if the pattern is overreaching."
977 f" Please avoid glob characters in the top level directories."
978 )
979 assert matched_directory.startswith("./") or matched_directory in (
980 ".",
981 "./",
982 "",
983 )
984 acceptable_directory = False
985 would_have_allowed_direct_match = False
986 while matched_directory not in (".", "./", ""):
987 # Our acceptable paths set includes "/var/lib" or "/etc". We require that the
988 # pattern is either an exact match, in which case it may match directly inside
989 # the acceptable directory OR it is a pattern against a subdirectory of the
990 # acceptable path. As an example:
991 #
992 # /etc/inputrc <-- OK, exact match
993 # /etc/foo/* <-- OK, subdir match
994 # /etc/* <-- ERROR, glob directly in the accepted directory.
995 if is_subdir_match and (
996 matched_directory
997 in ACCEPTABLE_CLEAN_ON_REMOVAL_IF_EXACT_MATCH_OR_SUBDIR_OF
998 ):
999 acceptable_directory = True
1000 break
1001 if (
1002 matched_directory
1003 in ACCEPTABLE_CLEAN_ON_REMOVAL_FOR_GLOBS_AND_EXACT_MATCHES
1004 ):
1005 # Special-case: In some directories (such as /var/log), we allow globs directly.
1006 # Notably, X11's log files are /var/log/Xorg.*.log
1007 acceptable_directory = True
1008 break
1009 if (
1010 matched_directory
1011 in ACCEPTABLE_CLEAN_ON_REMOVAL_IF_EXACT_MATCH_OR_SUBDIR_OF
1012 ):
1013 would_have_allowed_direct_match = True
1014 break
1015 matched_directory = os.path.dirname(matched_directory)
1016 is_subdir_match = True
1018 if would_have_allowed_direct_match and not acceptable_directory:
1019 raise ManifestParseException(
1020 f'The pattern "{original_pattern}" defined at {definition_source.path} seems to'
1021 " be overreaching. If it had been a path (and not use a glob), the rule would"
1022 " have been permitted."
1023 )
1024 elif not acceptable_directory:
1025 raise ManifestParseException(
1026 f'The pattern or path "{original_pattern}" defined at {definition_source.path} seems to'
1027 f' be overreaching or not limited to the set of "known acceptable" directories.'
1028 )
1030 try:
1031 shell_escaped_pattern = match_rule.shell_escape_pattern()
1032 except TypeError:
1033 raise ManifestParseException(
1034 f'Sorry, the pattern "{original_pattern}" defined at {definition_source.path}'
1035 f" is unfortunately not supported by `debputy` for clean-after-removal rules."
1036 f" If you can rewrite the rule to something like `/var/log/foo/*.log` or"
1037 f' similar "trivial" patterns. You may have to rewrite the pattern the rule '
1038 f" into multiple patterns to achieve this. This restriction is to enable "
1039 f' `debputy` to ensure the pattern is correctly executed plus catch "obvious'
1040 f' system trashing" patterns. Apologies for the inconvenience.'
1041 )
1043 if ignore_non_empty_dir:
1044 cmd = f' rmdir --ignore-fail-on-non-empty "${ DPKG_ROOT} "{shell_escaped_pattern}\n'
1045 elif recurse:
1046 cmd = f' rm -fr "${ DPKG_ROOT} "{shell_escaped_pattern}\n'
1047 elif original_pattern.endswith("/"):
1048 cmd = f' rmdir "${ DPKG_ROOT} "{shell_escaped_pattern}\n'
1049 else:
1050 cmd = f' rm -f "${ DPKG_ROOT} "{shell_escaped_pattern}\n'
1051 content_lines.append(cmd)
1052 if matched_directory in RMDIR_IF_EMPTY: 1052 ↛ 1053line 1052 didn't jump to line 1053 because the condition on line 1052 was never true
1053 auto_cleanup_dirs.add(matched_directory)
1055 for d in sorted(auto_cleanup_dirs): 1055 ↛ 1056line 1055 didn't jump to line 1056 because the loop on line 1055 never started
1056 dir_path = d.lstrip(".")
1057 content_lines.extend(
1058 f' rmdir --ignore-fail-on-non-empty "${ DPKG_ROOT} "{dir_path}\n'
1059 )
1061 content_lines.append("fi\n")
1063 snippet = UnboundMaintscriptSnippet(
1064 definition_source.path,
1065 SnippetResolver.snippet("".join(content_lines)),
1066 )
1067 package_state.maintscript_snippets["postrm"].append(snippet)