Coverage for src/debputy/plugins/debputy/metadata_detectors.py: 91%

240 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-09-07 09:27 +0000

1import itertools 

2import os 

3import re 

4import textwrap 

5from typing import Iterable, Iterator 

6 

7from debputy.packaging.alternatives import SYSTEM_DEFAULT_PATH_DIRS 

8from debputy.plugin.api import ( 

9 VirtualPath, 

10 BinaryCtrlAccessor, 

11 PackageProcessingContext, 

12) 

13from debputy.plugins.debputy.paths import ( 

14 INITRAMFS_HOOK_DIR, 

15 SYSTEMD_TMPFILES_DIR, 

16 GSETTINGS_SCHEMA_DIR, 

17 SYSTEMD_SYSUSERS_DIR, 

18) 

19from debputy.plugins.debputy.types import DebputyCapability 

20from debputy.util import assume_not_none, _warn 

21 

22DPKG_ROOT = '"${DPKG_ROOT}"' 

23DPKG_ROOT_UNQUOTED = "${DPKG_ROOT}" 

24 

25KERNEL_MODULE_EXTENSIONS = tuple( 

26 f"{ext}{comp_ext}" 

27 for ext, comp_ext in itertools.product( 

28 (".o", ".ko"), 

29 ("", ".gz", ".bz2", ".xz"), 

30 ) 

31) 

32 

33 

34def detect_initramfs_hooks( 

35 fs_root: VirtualPath, 

36 ctrl: BinaryCtrlAccessor, 

37 _unused: PackageProcessingContext, 

38) -> None: 

39 hook_dir = fs_root.lookup(INITRAMFS_HOOK_DIR) 

40 if not hook_dir: 

41 return 

42 for _ in hook_dir.iterdir: 

43 # Only add the trigger if the directory is non-empty. It is unlikely to matter a lot, 

44 # but we do this to match debhelper. 

45 break 

46 else: 

47 return 

48 

49 ctrl.dpkg_trigger("activate-noawait", "update-initramfs") 

50 

51 

52def _all_tmpfiles_conf(fs_root: VirtualPath) -> Iterable[VirtualPath]: 

53 seen_tmpfiles = set() 

54 tmpfiles_dirs = [ 

55 SYSTEMD_TMPFILES_DIR, 

56 "./etc/tmpfiles.d", 

57 ] 

58 for tmpfiles_dir_path in tmpfiles_dirs: 

59 tmpfiles_dir = fs_root.lookup(tmpfiles_dir_path) 

60 if not tmpfiles_dir: 

61 continue 

62 for path in tmpfiles_dir.iterdir: 

63 if ( 

64 not path.is_file 

65 or not path.name.endswith(".conf") 

66 or path.name in seen_tmpfiles 

67 ): 

68 continue 

69 seen_tmpfiles.add(path.name) 

70 yield path 

71 

72 

73def detect_systemd_tmpfiles( 

74 fs_root: VirtualPath, 

75 ctrl: BinaryCtrlAccessor, 

76 _unused: PackageProcessingContext, 

77) -> None: 

78 tmpfiles_confs = [ 

79 x.name for x in sorted(_all_tmpfiles_conf(fs_root), key=lambda x: x.name) 

80 ] 

81 if not tmpfiles_confs: 

82 return 

83 

84 tmpfiles_escaped = ctrl.maintscript.escape_shell_words(*tmpfiles_confs) 

85 

86 snippet = textwrap.dedent( 

87 f"""\ 

88 if [ -x "$(command -v systemd-tmpfiles)" ]; then 

89 systemd-tmpfiles ${ DPKG_ROOT:+--root="$DPKG_ROOT"} --create {tmpfiles_escaped} || true 

90 fi 

91 """ 

92 ) 

93 

94 ctrl.maintscript.on_configure(snippet) 

95 

96 

97def _all_sysusers_conf(fs_root: VirtualPath) -> Iterable[VirtualPath]: 

98 sysusers_dir = fs_root.lookup(SYSTEMD_SYSUSERS_DIR) 

99 if not sysusers_dir: 

100 return 

101 for child in sysusers_dir.iterdir: 

102 if not child.name.endswith(".conf"): 

103 continue 

104 yield child 

105 

106 

107def detect_systemd_sysusers( 

108 fs_root: VirtualPath, 

109 ctrl: BinaryCtrlAccessor, 

110 _unused: PackageProcessingContext, 

111) -> None: 

112 sysusers_confs = [p.name for p in _all_sysusers_conf(fs_root)] 

113 if not sysusers_confs: 

114 return 

115 

116 sysusers_escaped = ctrl.maintscript.escape_shell_words(*sysusers_confs) 

117 

118 snippet = textwrap.dedent( 

119 f"""\ 

120 systemd-sysusers ${ DPKG_ROOT:+--root="$DPKG_ROOT"} --create {sysusers_escaped} || true 

121 """ 

122 ) 

123 

124 ctrl.substvars.add_dependency( 

125 "misc:Depends", "systemd | systemd-standalone-sysusers | systemd-sysusers" 

126 ) 

127 ctrl.maintscript.on_configure(snippet) 

128 

129 

130def detect_commands( 

131 fs_root: VirtualPath, 

132 ctrl: BinaryCtrlAccessor, 

133 context: PackageProcessingContext, 

134) -> None: 

135 if context.binary_package.is_udeb: 

136 return 

137 for path_name in SYSTEM_DEFAULT_PATH_DIRS: 

138 path_dir = fs_root.lookup(path_name) 

139 if path_dir is None or not path_dir.is_dir: 

140 continue 

141 for child in path_dir.iterdir: 

142 if not (child.is_file or child.is_symlink): 

143 continue 

144 ctrl.substvars.add_dependency("misc:Commands", child.name) 

145 

146 

147def detect_icons( 

148 fs_root: VirtualPath, 

149 ctrl: BinaryCtrlAccessor, 

150 _unused: PackageProcessingContext, 

151) -> None: 

152 icons_root_dir = fs_root.lookup("./usr/share/icons") 

153 if not icons_root_dir: 

154 return 

155 icon_dirs = [] 

156 for subdir in icons_root_dir.iterdir: 

157 if subdir.name in ("gnome", "hicolor"): 

158 # dh_icons skips this for some reason. 

159 continue 

160 for p in subdir.all_paths(): 

161 if p.is_file and p.name.endswith((".png", ".svg", ".xpm", ".icon")): 

162 icon_dirs.append(subdir.absolute) 

163 break 

164 if not icon_dirs: 

165 return 

166 

167 icon_dir_list_escaped = ctrl.maintscript.escape_shell_words(*icon_dirs) 

168 

169 postinst_snippet = textwrap.dedent( 

170 f"""\ 

171 if command -v update-icon-caches >/dev/null; then 

172 update-icon-caches {icon_dir_list_escaped} 

173 fi 

174 """ 

175 ) 

176 

177 postrm_snippet = textwrap.dedent( 

178 f"""\ 

179 if command -v update-icon-caches >/dev/null; then 

180 update-icon-caches {icon_dir_list_escaped} 

181 fi 

182 """ 

183 ) 

184 

185 ctrl.maintscript.on_configure(postinst_snippet) 

186 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet) 

187 

188 

189def detect_gsettings_dependencies( 

190 fs_root: VirtualPath, 

191 ctrl: BinaryCtrlAccessor, 

192 _unused: PackageProcessingContext, 

193) -> None: 

194 gsettings_schema_dir = fs_root.lookup(GSETTINGS_SCHEMA_DIR) 

195 if not gsettings_schema_dir: 

196 return 

197 

198 for path in gsettings_schema_dir.all_paths(): 

199 if path.is_file and path.name.endswith((".xml", ".override")): 

200 ctrl.substvars.add_dependency( 

201 "misc:Depends", "dconf-gsettings-backend | gsettings-backend" 

202 ) 

203 break 

204 

205 

206def detect_kernel_modules( 

207 fs_root: VirtualPath, 

208 ctrl: BinaryCtrlAccessor, 

209 _unused: PackageProcessingContext, 

210) -> None: 

211 for prefix in [".", "./usr"]: 

212 module_root_dir = fs_root.lookup(f"{prefix}/lib/modules") 

213 

214 if not module_root_dir: 

215 continue 

216 

217 module_version_dirs = [] 

218 

219 for module_version_dir in module_root_dir.iterdir: 

220 if not module_version_dir.is_dir: 

221 continue 

222 

223 for fs_path in module_version_dir.all_paths(): 

224 if fs_path.name.endswith(KERNEL_MODULE_EXTENSIONS): 

225 module_version_dirs.append(module_version_dir.name) 

226 break 

227 

228 for module_version in module_version_dirs: 

229 module_version_escaped = ctrl.maintscript.escape_shell_words(module_version) 

230 postinst_snippet = textwrap.dedent( 

231 f"""\ 

232 if [ -e /boot/System.map-{module_version_escaped} ]; then 

233 depmod -a -F /boot/System.map-{module_version_escaped} {module_version_escaped} || true 

234 fi 

235 """ 

236 ) 

237 

238 postrm_snippet = textwrap.dedent( 

239 f"""\ 

240 if [ -e /boot/System.map-{module_version_escaped} ]; then 

241 depmod -a -F /boot/System.map-{module_version_escaped} {module_version_escaped} || true 

242 fi 

243 """ 

244 ) 

245 

246 ctrl.maintscript.on_configure(postinst_snippet) 

247 # TODO: This should probably be on removal. However, this is what debhelper did and we should 

248 # do the same until we are sure (not that it matters a lot). 

249 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet) 

250 

251 

252def detect_xfonts( 

253 fs_root: VirtualPath, 

254 ctrl: BinaryCtrlAccessor, 

255 context: PackageProcessingContext, 

256) -> None: 

257 xfonts_root_dir = fs_root.lookup("./usr/share/fonts/X11/") 

258 if not xfonts_root_dir: 

259 return 

260 

261 cmds = [] 

262 cmds_postinst = [] 

263 cmds_postrm = [] 

264 escape_shell_words = ctrl.maintscript.escape_shell_words 

265 package_name = context.binary_package.name 

266 

267 for xfonts_dir in xfonts_root_dir.iterdir: 

268 xfonts_dirname = xfonts_dir.name 

269 if not xfonts_dir.is_dir or xfonts_dirname.startswith("."): 

270 continue 

271 if fs_root.lookup(f"./etc/X11/xfonts/{xfonts_dirname}/{package_name}.scale"): 

272 cmds.append(escape_shell_words("update-fonts-scale", xfonts_dirname)) 

273 cmds.append( 

274 escape_shell_words("update-fonts-dir", "--x11r7-layout", xfonts_dirname) 

275 ) 

276 alias_file = fs_root.lookup( 

277 f"./etc/X11/xfonts/{xfonts_dirname}/{package_name}.alias" 

278 ) 

279 if alias_file: 

280 cmds_postinst.append( 

281 escape_shell_words( 

282 "update-fonts-alias", 

283 "--include", 

284 alias_file.absolute, 

285 xfonts_dirname, 

286 ) 

287 ) 

288 cmds_postrm.append( 

289 escape_shell_words( 

290 "update-fonts-alias", 

291 "--exclude", 

292 alias_file.absolute, 

293 xfonts_dirname, 

294 ) 

295 ) 

296 

297 if not cmds: 

298 return 

299 

300 postinst_snippet = textwrap.dedent( 

301 f"""\ 

302 if command -v update-fonts-dir >/dev/null; then 

303 {';'.join(itertools.chain(cmds, cmds_postinst))} 

304 fi 

305 """ 

306 ) 

307 

308 postrm_snippet = textwrap.dedent( 

309 f"""\ 

310 if [ -x "`command -v update-fonts-dir`" ]; then 

311 {';'.join(itertools.chain(cmds, cmds_postrm))} 

312 fi 

313 """ 

314 ) 

315 

316 ctrl.maintscript.unconditionally_in_script("postinst", postinst_snippet) 

317 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet) 

318 ctrl.substvars.add_dependency("misc:Depends", "xfonts-utils") 

319 

320 

321# debputy does not support python2, so we do not list python / python2. 

322_PYTHON_PUBLIC_DIST_DIR_NAMES = re.compile(r"(?:pypy|python)3(?:[.]\d+)?") 

323 

324 

325def _public_python_dist_dirs(fs_root: VirtualPath) -> Iterator[VirtualPath]: 

326 usr_lib = fs_root.lookup("./usr/lib") 

327 root_dirs = [] 

328 if usr_lib: 

329 root_dirs.append(usr_lib) 

330 

331 dbg_root = fs_root.lookup("./usr/lib/debug/usr/lib") 

332 if dbg_root: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 root_dirs.append(dbg_root) 

334 

335 for root_dir in root_dirs: 

336 python_dirs = ( 

337 path 

338 for path in root_dir.iterdir 

339 if path.is_dir and _PYTHON_PUBLIC_DIST_DIR_NAMES.match(path.name) 

340 ) 

341 for python_dir in python_dirs: 

342 dist_packages = python_dir.get("dist-packages") 

343 if not dist_packages: 

344 continue 

345 yield dist_packages 

346 

347 

348def _has_py_file_in_dir(d: VirtualPath) -> bool: 

349 return any(f.is_file and f.name.endswith(".py") for f in d.all_paths()) 

350 

351 

352def detect_pycompile_files( 

353 fs_root: VirtualPath, 

354 ctrl: BinaryCtrlAccessor, 

355 context: PackageProcessingContext, 

356) -> None: 

357 package = context.binary_package.name 

358 # TODO: Support configurable list of private dirs 

359 private_search_dirs = [ 

360 fs_root.lookup(os.path.join(d, package)) 

361 for d in [ 

362 "./usr/share", 

363 "./usr/share/games", 

364 "./usr/lib", 

365 f"./usr/lib/{context.binary_package.deb_multiarch}", 

366 "./usr/lib/games", 

367 ] 

368 ] 

369 private_search_dirs_with_py_files = [ 

370 p for p in private_search_dirs if p is not None and _has_py_file_in_dir(p) 

371 ] 

372 public_search_dirs_has_py_files = any( 

373 p is not None and _has_py_file_in_dir(p) 

374 for p in _public_python_dist_dirs(fs_root) 

375 ) 

376 

377 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files: 

378 return 

379 

380 # The dh_python3 helper also supports -V and -X. We do not use them. They can be 

381 # replaced by bcep support instead, which is how we will be supporting this kind 

382 # of configuration down the line. 

383 ctrl.maintscript.unconditionally_in_script( 

384 "prerm", 

385 textwrap.dedent( 

386 f"""\ 

387 if command -v py3clean >/dev/null 2>&1; then 

388 py3clean -p {package} 

389 else 

390 dpkg -L {package} | sed -En -e '/^(.*)\\/(.+)\\.py$/s,,rm "\\1/__pycache__/\\2".*,e' 

391 find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir 

392 fi 

393 """ 

394 ), 

395 ) 

396 if public_search_dirs_has_py_files: 

397 ctrl.maintscript.on_configure( 

398 textwrap.dedent( 

399 f"""\ 

400 if command -v py3compile >/dev/null 2>&1; then 

401 py3compile -p {package} 

402 fi 

403 if command -v pypy3compile >/dev/null 2>&1; then 

404 pypy3compile -p {package} || true 

405 fi 

406 """ 

407 ) 

408 ) 

409 for private_dir in private_search_dirs_with_py_files: 

410 escaped_dir = ctrl.maintscript.escape_shell_words(private_dir.absolute) 

411 ctrl.maintscript.on_configure( 

412 textwrap.dedent( 

413 f"""\ 

414 if command -v py3compile >/dev/null 2>&1; then 

415 py3compile -p {package} {escaped_dir} 

416 fi 

417 if command -v pypy3compile >/dev/null 2>&1; then 

418 pypy3compile -p {package} {escaped_dir} || true 

419 fi 

420 """ 

421 ) 

422 ) 

423 

424 

425def translate_capabilities( 

426 fs_root: VirtualPath, 

427 ctrl: BinaryCtrlAccessor, 

428 _context: PackageProcessingContext, 

429) -> None: 

430 caps = [] 

431 maintscript = ctrl.maintscript 

432 for p in fs_root.all_paths(): 

433 if not p.is_file: 

434 continue 

435 metadata_ref = p.metadata(DebputyCapability) 

436 capability = metadata_ref.value 

437 if capability is None: 

438 continue 

439 

440 abs_path = maintscript.escape_shell_words(p.absolute) 

441 

442 cap_script = "".join( 

443 [ 

444 " # Triggered by: {DEFINITION_SOURCE}\n" 

445 " _TPATH=$(dpkg-divert --truename {ABS_PATH})\n", 

446 ' if setcap {CAP} "{DPKG_ROOT_UNQUOTED}${{_TPATH}}"; then\n', 

447 ' chmod {MODE} "{DPKG_ROOT_UNQUOTED}${{_TPATH}}"\n', 

448 ' echo "Successfully applied capabilities {CAP} on ${{_TPATH}}"\n', 

449 " else\n", 

450 # We do not reset the mode here; generally a re-install or upgrade would re-store both mode, 

451 # and remove the capabilities. 

452 ' echo "The setcap failed to processes {CAP} on ${{_TPATH}}; falling back to no capability support" >&2\n', 

453 " fi\n", 

454 ] 

455 ).format( 

456 CAP=maintscript.escape_shell_words(capability.capabilities).replace( 

457 "\\+", "+" 

458 ), 

459 DPKG_ROOT_UNQUOTED=DPKG_ROOT_UNQUOTED, 

460 ABS_PATH=abs_path, 

461 MODE=maintscript.escape_shell_words(str(capability.capability_mode)), 

462 DEFINITION_SOURCE=capability.definition_source.replace("\n", "\\n"), 

463 ) 

464 assert cap_script.endswith("\n") 

465 caps.append(cap_script) 

466 

467 if not caps: 

468 return 

469 

470 maintscript.on_configure( 

471 textwrap.dedent( 

472 """\ 

473 if command -v setcap > /dev/null; then 

474 {SET_CAP_COMMANDS} 

475 unset _TPATH 

476 else 

477 echo "The setcap utility is not installed available; falling back to no capability support" >&2 

478 fi 

479 """ 

480 ).format( 

481 SET_CAP_COMMANDS="".join(caps).rstrip("\n"), 

482 ) 

483 ) 

484 

485 

486def pam_auth_update( 

487 fs_root: VirtualPath, 

488 ctrl: BinaryCtrlAccessor, 

489 _context: PackageProcessingContext, 

490) -> None: 

491 pam_configs = fs_root.lookup("/usr/share/pam-configs") 

492 if not pam_configs: 

493 return 

494 maintscript = ctrl.maintscript 

495 for pam_config in pam_configs.iterdir: 

496 if not pam_config.is_file: 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true

497 continue 

498 maintscript.on_configure("pam-auth-update --package\n") 

499 maintscript.on_before_removal( 

500 textwrap.dedent( 

501 f"""\ 

502 if [ "${ DPKG_MAINTSCRIPT_PACKAGE_REFCOUNT:-1} " = 1 ]; then 

503 pam-auth-update --package --remove {maintscript.escape_shell_words(pam_config.name)} 

504 fi 

505 """ 

506 ) 

507 ) 

508 

509 

510def auto_depends_arch_any_solink( 

511 fs_foot: VirtualPath, 

512 ctrl: BinaryCtrlAccessor, 

513 context: PackageProcessingContext, 

514) -> None: 

515 package = context.binary_package 

516 if package.is_arch_all: 

517 return 

518 libbasedir = fs_foot.lookup("usr/lib") 

519 if not libbasedir: 

520 return 

521 libmadir = libbasedir.get(package.deb_multiarch) 

522 if libmadir: 522 ↛ 525line 522 didn't jump to line 525 because the condition on line 522 was always true

523 libdirs = [libmadir, libbasedir] 

524 else: 

525 libdirs = [libbasedir] 

526 targets = [] 

527 for libdir in libdirs: 

528 for path in libdir.iterdir: 

529 if not path.is_symlink or not path.name.endswith(".so"): 

530 continue 

531 target = path.readlink() 

532 resolved = assume_not_none(path.parent_dir).lookup(target) 

533 if resolved is not None: 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true

534 continue 

535 targets.append((libdir.path, target)) 

536 

537 roots = list(context.accessible_package_roots()) 

538 if not roots: 

539 return 

540 

541 for libdir_path, target in targets: 

542 final_path = os.path.join(libdir_path, target) 

543 matches = [] 

544 for opkg, ofs_root in roots: 

545 m = ofs_root.lookup(final_path) 

546 if not m: 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true

547 continue 

548 matches.append(opkg) 

549 if not matches or len(matches) > 1: 

550 if matches: 550 ↛ 557line 550 didn't jump to line 557 because the condition on line 550 was always true

551 all_matches = ", ".join(p.name for p in matches) 

552 _warn( 

553 f"auto-depends-solink: The {final_path} was found in multiple packages ({all_matches}):" 

554 f" Not generating a dependency." 

555 ) 

556 else: 

557 _warn( 

558 f"auto-depends-solink: The {final_path} was NOT found in any accessible package:" 

559 " Not generating a dependency. This detection only works when both packages are arch:any" 

560 " and they have the same build-profiles." 

561 ) 

562 continue 

563 pkg_dep = matches[0] 

564 # The debputy API should not allow this constraint to fail 

565 assert pkg_dep.is_arch_all == package.is_arch_all 

566 # If both packages are arch:all or both are arch:any, we can generate a tight dependency 

567 relation = f"{pkg_dep.name} (= ${ binary:Version} )" 

568 ctrl.substvars.add_dependency("misc:Depends", relation)