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

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) 

18 

19from debian.deb822 import PkgRelation 

20 

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 

69 

70ACCEPTABLE_CLEAN_ON_REMOVAL_FOR_GLOBS_AND_EXACT_MATCHES = frozenset( 

71 [ 

72 "./var/log", 

73 ] 

74) 

75 

76 

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) 

96 

97RMDIR_IF_EMPTY = frozenset( 

98 [ 

99 "./var/games", 

100 ] 

101) 

102 

103 

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 ) 

113 

114 api.pluggable_manifest_rule( 

115 OPARSER_PACKAGES, 

116 "transformations", 

117 list[TransformationRule], 

118 _unpack_list, 

119 register_value=False, 

120 ) 

121 

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 ) 

132 

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 ) 

144 

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. 

159 

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. 

164 

165 

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. 

171 

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". 

177 

178 Example: 

179 

180 ```yaml 

181 packages: 

182 PKG: 

183 $RULE_NAME: 

184 - on: configure 

185 snippet: "custom-configurator --deb-multiarch {{DEB_HOST_MULTIARCH}}" 

186 ``` 

187 

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`). 

190 

191 For short multi-line scripts, consider using the YAML `|` multi-line string: 

192 

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 ``` 

206 

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. 

215 

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. 

225 

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. 

233 

234 This is an alternative to `snippet`. The file must be placed directly in 

235 the `debian` and this option defines its basename. 

236 

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 

247 

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. 

257 

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 

265 

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. 

271 

272 One case where indentation is known to be unsafe is around "heredocs" such as: 

273 

274 ```shell 

275 cmd <<EOF 

276 foo 

277 bar 

278 EOF 

279 ``` 

280 

281 Here, indenting will cause `EOF` to no longer be at the start of the line and 

282 break the "heredoc". 

283 

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 ) 

292 

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. 

309 

310 Example: 

311 

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` 

324 

325 The levels are defined in [man:dpkg-gensymbols.1] as: 

326 

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. 

332 

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. 

336 

337 The default check-level in `dpkg-gensymbols` is `1`. 

338 

339 [man:dpkg-gensymbols.1]: https://manpages.debian.org/dpkg-gensymbols.1 

340 """), 

341 ), 

342 ], 

343 ), 

344 ) 

345 

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 ) 

359 

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 ) 

371 

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. 

388 

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. 

393 

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 

402 

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. 

414 

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. 

418 

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. 

426 

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 ) 

434 

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. 

451 

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. 

456 

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 

465 

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. 

477 

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. 

481 

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. 

489 

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 ) 

497 

498 

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]] 

510 

511 

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]] 

520 

521 

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] 

532 

533 

534@dataclasses.dataclass(slots=True, frozen=True) 

535class MaintainerProvidedMaintscriptSnippetContainer: 

536 snippets: collections.abc.Sequence[MaintscriptSnippetForNamedScript] 

537 

538 

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 

549 

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 ) 

567 

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 

570 

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 

599 

600 return service_definition.replace(**changes) 

601 

602 

603class BinaryVersionParsedFormat(DebputyParsedContent): 

604 binary_version: str 

605 

606 

607class BuiltUsingParsedFormat(DebputyParsedContentStandardConditional): 

608 """Also used for static-built-using.""" 

609 

610 sources_for: str 

611 

612 

613class ListParsedFormat(DebputyParsedContent): 

614 elements: list[Any] 

615 

616 

617class ListOfTransformationRulesFormat(DebputyParsedContent): 

618 elements: list[TransformationRule] 

619 

620 

621class ListOfDpkgMaintscriptHelperCommandFormat(DebputyParsedContent): 

622 elements: list[DpkgMaintscriptHelperCommand] 

623 

624 

625class InstallationSearchDirsParsedFormat(DebputyParsedContent): 

626 installation_search_dirs: list[FileSystemExactMatchRule] 

627 

628 

629type DpkgGensymbolsCheckLevel = typing.Literal[0, 1, 2, 3, 4] 

630 

631 

632class DpkgGensymbolsOptionsFormat(DebputyParsedContent): 

633 check_level: DpkgGensymbolsCheckLevel 

634 

635 

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 

641 

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) 

651 

652 

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"] 

660 

661 

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"] 

669 

670 

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 ] 

681 

682 

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("."): 

704 

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 ) 

715 

716 def _read_file() -> str: 

717 with open(full_path) as fd: 

718 return fd.read() 

719 

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 

744 

745 

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 ) 

769 

770 

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) 

779 

780 

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) 

789 

790 

791_VALID_BUILT_USING_GLOB = re.compile("[a-z*][a-z0-9.+*-]*") 

792_BUILT_USING_GLOB_TO_RE = str.maketrans({".": "[.]", "+": "[+]", "*": ".*"}) 

793 

794 

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) 

808 

809 

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)) 

823 

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 ) 

841 

842 

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) 

851 

852 

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 

860 

861 

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] 

868 

869 

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] 

875 

876 

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) 

888 

889 

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 

907 

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" ]' 

927 

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 ) 

941 

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) 

971 

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 

1017 

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 ) 

1029 

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 ) 

1042 

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) 

1054 

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 ) 

1060 

1061 content_lines.append("fi\n") 

1062 

1063 snippet = UnboundMaintscriptSnippet( 

1064 definition_source.path, 

1065 SnippetResolver.snippet("".join(content_lines)), 

1066 ) 

1067 package_state.maintscript_snippets["postrm"].append(snippet)