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
« 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
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
22DPKG_ROOT = '"${DPKG_ROOT}"'
23DPKG_ROOT_UNQUOTED = "${DPKG_ROOT}"
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)
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
49 ctrl.dpkg_trigger("activate-noawait", "update-initramfs")
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
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
84 tmpfiles_escaped = ctrl.maintscript.escape_shell_words(*tmpfiles_confs)
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 )
94 ctrl.maintscript.on_configure(snippet)
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
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
116 sysusers_escaped = ctrl.maintscript.escape_shell_words(*sysusers_confs)
118 snippet = textwrap.dedent(
119 f"""\
120 systemd-sysusers ${ DPKG_ROOT:+--root="$DPKG_ROOT"} --create {sysusers_escaped} || true
121 """
122 )
124 ctrl.substvars.add_dependency(
125 "misc:Depends", "systemd | systemd-standalone-sysusers | systemd-sysusers"
126 )
127 ctrl.maintscript.on_configure(snippet)
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)
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
167 icon_dir_list_escaped = ctrl.maintscript.escape_shell_words(*icon_dirs)
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 )
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 )
185 ctrl.maintscript.on_configure(postinst_snippet)
186 ctrl.maintscript.unconditionally_in_script("postrm", postrm_snippet)
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
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
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")
214 if not module_root_dir:
215 continue
217 module_version_dirs = []
219 for module_version_dir in module_root_dir.iterdir:
220 if not module_version_dir.is_dir:
221 continue
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
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 )
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 )
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)
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
261 cmds = []
262 cmds_postinst = []
263 cmds_postrm = []
264 escape_shell_words = ctrl.maintscript.escape_shell_words
265 package_name = context.binary_package.name
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 )
297 if not cmds:
298 return
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 )
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 )
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")
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+)?")
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)
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)
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
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())
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 )
377 if not public_search_dirs_has_py_files and not private_search_dirs_with_py_files:
378 return
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 )
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
440 abs_path = maintscript.escape_shell_words(p.absolute)
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)
467 if not caps:
468 return
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 )
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 )
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))
537 roots = list(context.accessible_package_roots())
538 if not roots:
539 return
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)