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

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

95 

96 

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 ) 

106 

107 api.pluggable_manifest_rule( 

108 OPARSER_PACKAGES, 

109 "transformations", 

110 list[TransformationRule], 

111 _unpack_list, 

112 register_value=False, 

113 ) 

114 

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 ) 

125 

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 ) 

137 

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. 

152 

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. 

157 

158 

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. 

164 

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

170 

171 Example: 

172 

173 ```yaml 

174 packages: 

175 PKG: 

176 $RULE_NAME: 

177 - on: configure 

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

179 ``` 

180 

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

183 

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

185 

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

199 

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. 

208 

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. 

218 

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. 

226 

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

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

229 

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 

240 

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. 

250 

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 

258 

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. 

264 

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

266 

267 ```shell 

268 cmd <<EOF 

269 foo 

270 bar 

271 EOF 

272 ``` 

273 

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

275 break the "heredoc". 

276 

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 ) 

285 

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. 

302 

303 Example: 

304 

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` 

317 

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

319 

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. 

325 

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. 

329 

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

331 

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

333 """), 

334 ), 

335 ], 

336 ), 

337 ) 

338 

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 ) 

352 

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 ) 

364 

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. 

381 

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. 

386 

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 

395 

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. 

407 

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. 

411 

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. 

419 

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 ) 

427 

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. 

444 

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. 

449 

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 

458 

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. 

470 

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. 

474 

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. 

482 

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 ) 

490 

491 

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

503 

504 

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

513 

514 

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] 

525 

526 

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

528class MaintainerProvidedMaintscriptSnippetContainer: 

529 snippets: collections.abc.Sequence[MaintscriptSnippetForNamedScript] 

530 

531 

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 

542 

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 ) 

560 

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 

563 

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 

592 

593 return service_definition.replace(**changes) 

594 

595 

596class BinaryVersionParsedFormat(DebputyParsedContent): 

597 binary_version: str 

598 

599 

600class BuiltUsingParsedFormat(DebputyParsedContentStandardConditional): 

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

602 

603 sources_for: str 

604 

605 

606class ListParsedFormat(DebputyParsedContent): 

607 elements: list[Any] 

608 

609 

610class ListOfTransformationRulesFormat(DebputyParsedContent): 

611 elements: list[TransformationRule] 

612 

613 

614class ListOfDpkgMaintscriptHelperCommandFormat(DebputyParsedContent): 

615 elements: list[DpkgMaintscriptHelperCommand] 

616 

617 

618class InstallationSearchDirsParsedFormat(DebputyParsedContent): 

619 installation_search_dirs: list[FileSystemExactMatchRule] 

620 

621 

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

623 

624 

625class DpkgGensymbolsOptionsFormat(DebputyParsedContent): 

626 check_level: DpkgGensymbolsCheckLevel 

627 

628 

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 

634 

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) 

644 

645 

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

653 

654 

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

662 

663 

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 ] 

674 

675 

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

697 

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 ) 

708 

709 def _read_file() -> str: 

710 with open(full_path) as fd: 

711 return fd.read() 

712 

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 

737 

738 

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 ) 

762 

763 

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) 

772 

773 

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) 

782 

783 

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

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

786 

787 

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) 

801 

802 

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

816 

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 ) 

834 

835 

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) 

844 

845 

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 

853 

854 

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] 

861 

862 

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] 

868 

869 

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) 

881 

882 

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 

900 

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

920 

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 ) 

934 

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) 

963 

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 

1009 

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 ) 

1021 

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 ) 

1034 

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

1045 

1046 snippet = UnboundMaintscriptSnippet( 

1047 definition_source.path, 

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

1049 ) 

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