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