From 8106d4a7b0604afa9ebbc53094cf00b475ac2d65 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Tue, 6 Oct 2026 18:12:38 +0200 Subject: [PATCH] cmds.pkg.CmdVerify: Add command Add the "packages verify" command: it runs Distro.verify() with the app's package filter and prints one line per flagged file. Signed-off-by: Jan Lindemann Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v1.2.0 --- src/python/jw/pkg/cmds/pkg/CmdVerify.py | 29 +++++++ .../integration/jw_pkg/help/test-expected.txt | 10 +++ test/unit/python/jw/pkg/lib/pm/dpkg/Makefile | 7 ++ test/unit/python/jw/pkg/lib/pm/dpkg/test.py | 81 +++++++++++++++++++ test/unit/python/jw/pkg/lib/pm/rpm/test.py | 47 ++++++++++- 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/python/jw/pkg/cmds/pkg/CmdVerify.py create mode 100644 test/unit/python/jw/pkg/lib/pm/dpkg/Makefile create mode 100644 test/unit/python/jw/pkg/lib/pm/dpkg/test.py diff --git a/src/python/jw/pkg/cmds/pkg/CmdVerify.py b/src/python/jw/pkg/cmds/pkg/CmdVerify.py new file mode 100644 index 00000000..9de6afaa --- /dev/null +++ b/src/python/jw/pkg/cmds/pkg/CmdVerify.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +from .Cmd import Cmd, Parent + +if TYPE_CHECKING: + from argparse import ArgumentParser, Namespace + +class CmdVerify(Cmd): # export + + def __init__(self, parent: Parent) -> None: + super().__init__( + parent, + 'verify', + help = ( + 'Verify the integrity of installed packages: report files' + ' modified or deleted outside of the package manager' + ) + ) + + @override + def add_arguments(self, parser: ArgumentParser) -> None: + super().add_arguments(parser) + + @override + async def _run(self, args: Namespace) -> None: + for file in await self.distro.verify(): + print(file) diff --git a/test/integration/jw_pkg/help/test-expected.txt b/test/integration/jw_pkg/help/test-expected.txt index b0363da8..0b0c0b39 100644 --- a/test/integration/jw_pkg/help/test-expected.txt +++ b/test/integration/jw_pkg/help/test-expected.txt @@ -66,6 +66,8 @@ Available subcommands of packages: reboot-required Check whether the machine needs rebooting refresh Refresh the distribution's notion of available packages select Select packages by filter + verify Verify the integrity of installed packages: report files + modified or deleted outside of the package manager ============= Running: jw-pkg.py -t ../../../.. --log-level info packages delete --help usage: jw-pkg.py packages delete [-h] [names ...] @@ -140,6 +142,14 @@ Select packages by filter positional arguments: filter Package filter string +options: + -h, --help show this help message and exit +============= Running: jw-pkg.py -t ../../../.. --log-level info packages verify --help +usage: jw-pkg.py packages verify [-h] + +Verify the integrity of installed packages: report files modified or deleted +outside of the package manager + options: -h, --help show this help message and exit ============= Running: jw-pkg.py -t ../../../.. --log-level info platform --help diff --git a/test/unit/python/jw/pkg/lib/pm/dpkg/Makefile b/test/unit/python/jw/pkg/lib/pm/dpkg/Makefile new file mode 100644 index 00000000..4801122b --- /dev/null +++ b/test/unit/python/jw/pkg/lib/pm/dpkg/Makefile @@ -0,0 +1,7 @@ +TOPDIR = ../../../../../../../.. + +include $(TOPDIR)/make/proj.mk +include $(JWBDIR)/make/py-run.mk + +all: +test: run diff --git a/test/unit/python/jw/pkg/lib/pm/dpkg/test.py b/test/unit/python/jw/pkg/lib/pm/dpkg/test.py new file mode 100644 index 00000000..66f06df6 --- /dev/null +++ b/test/unit/python/jw/pkg/lib/pm/dpkg/test.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import asyncio + +from typing import override + +from jw.pkg.lib.ec.Local import Local +from jw.pkg.lib.pm.dpkg import list_files +from jw.pkg.lib.Result import Result + +class CannedLocal(Local): + + def __init__(self, stdout: str) -> None: + super().__init__('local') + self.stdout = stdout + self.cmds: list[list[str]] = [] + + @override + async def _run( + self, + cmd: list[str], + wd: str | None, + verbose: bool, + cmd_input: bytes | None, + mod_env: dict[str, str] | None, + interactive: bool, + log_prefix: str, + ) -> Result: + self.cmds.append(cmd) + return Result(stdout = self.stdout.encode(), status = 0) + +# -- One listing over several packages +cl = CannedLocal('/usr/bin/b\n/etc/b/b.conf.jw-tmpl\n') +assert asyncio.run(list_files(['a', 'b'], ec = cl)) == \ + ['/usr/bin/b', '/etc/b/b.conf.jw-tmpl'] +assert cl.cmds[-1] == [ + '/usr/bin/dpkg', + '-L', + 'a', + 'b', +] + +# -- The unfiltered selection is the standard interface, two +# queries: select the installed packages, then list their files +# in one command; the list output carries a package-name header +# and a blank line per package, both dropped +class CannedTwoStage(Local): + + def __init__(self, query: str, listing: str) -> None: + super().__init__('local') + self.query = query + self.listing = listing + self.cmds: list[list[str]] = [] + + @override + async def _run( + self, + cmd: list[str], + wd: str | None, + verbose: bool, + cmd_input: bytes | None, + mod_env: dict[str, str] | None, + interactive: bool, + log_prefix: str, + ) -> Result: + self.cmds.append(cmd) + out = self.query if '-W' in cmd else self.listing + return Result(stdout = out.encode(), status = 0) + +cl2 = CannedTwoStage('a\nb\n', 'a:\n/usr/bin/a\n\nb:\n/etc/b/b.conf.jw-tmpl\n') +assert asyncio.run(list_files(None, ec = cl2)) == \ + ['/usr/bin/a', '/etc/b/b.conf.jw-tmpl'] +assert cl2.cmds == [ + ['/usr/bin/dpkg-query', '-W', '-f=${?installed:binary:Package}\n'], + ['/usr/bin/dpkg-query', '-L', 'a', 'b'], +] + +# -- An empty selection lists nothing and runs no query +cl3 = CannedTwoStage('', '') +assert asyncio.run(list_files([], ec = cl3)) == [] +assert cl3.cmds == [] diff --git a/test/unit/python/jw/pkg/lib/pm/rpm/test.py b/test/unit/python/jw/pkg/lib/pm/rpm/test.py index c743d13b..e42ace0a 100644 --- a/test/unit/python/jw/pkg/lib/pm/rpm/test.py +++ b/test/unit/python/jw/pkg/lib/pm/rpm/test.py @@ -1,7 +1,13 @@ from __future__ import annotations +import asyncio + +from typing import override + +from jw.pkg.lib.ec.Local import Local from jw.pkg.lib.FlaggedFile import FlaggedFile -from jw.pkg.lib.pm.rpm import _parse_verify_output +from jw.pkg.lib.pm.rpm import _parse_verify_output, list_files +from jw.pkg.lib.Result import Result # -- _parse_verify_output -- @@ -56,3 +62,42 @@ f = FlaggedFile('pkg', '/etc/foo', 'SM5....T.') assert repr(f) == 'pkg /etc/foo SM5....T.' print('All rpm pm tests passed') + +# -- list_files -- + +class CannedLocal(Local): + + def __init__(self, stdout: str) -> None: + super().__init__('local') + self.stdout = stdout + self.cmds: list[list[str]] = [] + + @override + async def _run( + self, + cmd: list[str], + wd: str | None, + verbose: bool, + cmd_input: bytes | None, + mod_env: dict[str, str] | None, + interactive: bool, + log_prefix: str, + ) -> Result: + self.cmds.append(cmd) + return Result(stdout = self.stdout.encode(), status = 0) + +# -- One query over several packages, the output split into lines +cl = CannedLocal('/usr/bin/a\n/etc/a/a.conf.jw-tmpl\n') +assert asyncio.run(list_files(['a', 'b'], ec = cl)) == \ + ['/usr/bin/a', '/etc/a/a.conf.jw-tmpl'] +assert cl.cmds[-1] == ['/usr/bin/rpm', '-ql', 'a', 'b'] + +# -- The unfiltered selection is one query without package names +cl = CannedLocal('/usr/bin/a\n/etc/a/a.conf.jw-tmpl\n') +assert asyncio.run(list_files(None, ec = cl)) == ['/usr/bin/a', '/etc/a/a.conf.jw-tmpl'] +assert cl.cmds[-1] == ['/usr/bin/rpm', '-qal'] + +# -- An empty selection lists nothing and runs no query +cl = CannedLocal('') +assert asyncio.run(list_files([], ec = cl)) == [] +assert cl.cmds == []