diff --git a/CLAUDE.md b/CLAUDE.md index 4769cf1..31ec8c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,7 @@ Start with: - `docs/ROADMAP.md` for sequenced work. - `docs/COMMAND_REFERENCE.md` for CLI contracts. - `docs/OPERATIONS.md` and `docs/RUNBOOKS.md` for operator workflows. +- `docs/PACKAGING.md` for package layout and distro install paths. - `docs/THREAT_MODEL.md` for security boundaries and non-goals. ## Engineering Rules @@ -180,5 +181,8 @@ When changing behavior, update the relevant set: - `docs/THREAT_MODEL.md` for trust-boundary/security-model changes. - `docs/ROADMAP.md` for planned or completed roadmap movement. - `docs/SCHEMAS.md` for stable JSON contracts. +- `docs/PACKAGING.md` for package/install behavior. +- `packaging/release-artifacts.sh` for release tarballs, checksums, and optional + GPG signatures. - `docs/VERSION.json` and `docs/INDEX.md` for documentation versioning. - `config/enodia-sentinel.toml` for config knobs. diff --git a/Makefile b/Makefile index df779e2..bb0a979 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,10 @@ LIBDIR := $(PREFIX)/lib/enodia-sentinel SYSTEMDDIR := /etc/systemd/system CONFDIR := /etc LOGDIR := /var/log/enodia-sentinel +DOCDIR := $(PREFIX)/share/doc/enodia-sentinel +TMPFILESDIR := /etc/tmpfiles.d -.PHONY: install uninstall enable disable status logs check baseline drill test clean +.PHONY: install uninstall enable disable status logs check baseline drill test release-artifacts clean # Installs the stdlib-only Python package as a plain directory + launcher # wrapper (no pip, no virtualenv, no site-packages). Zero runtime deps. @@ -17,13 +19,27 @@ install: install -Dm644 systemd/enodia-sentinel.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service install -Dm644 systemd/enodia-sentinel-web.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service install -Dm644 packaging/enodia-sentinel-fim.hook $(DESTDIR)/etc/pacman.d/hooks/enodia-sentinel-fim.hook + install -Dm644 packaging/enodia-sentinel.tmpfiles $(DESTDIR)$(TMPFILESDIR)/enodia-sentinel.conf @if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \ install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \ - echo "Installed default config at $(CONFDIR)/enodia-sentinel.toml"; \ + echo "Installed default config at $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml"; \ else \ install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new; \ - echo "Existing config preserved; new template at $(CONFDIR)/enodia-sentinel.toml.new"; \ + echo "Existing config preserved; new template at $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new"; \ fi + install -Dm644 README.md $(DESTDIR)$(DOCDIR)/README.md + install -Dm644 docs/INDEX.md $(DESTDIR)$(DOCDIR)/INDEX.md + install -Dm644 docs/COMMAND_REFERENCE.md $(DESTDIR)$(DOCDIR)/COMMAND_REFERENCE.md + install -Dm644 docs/SCHEMAS.md $(DESTDIR)$(DOCDIR)/SCHEMAS.md + install -Dm644 docs/PACKAGING.md $(DESTDIR)$(DOCDIR)/PACKAGING.md + install -Dm644 docs/OPERATIONS.md $(DESTDIR)$(DOCDIR)/OPERATIONS.md + install -Dm644 docs/RUNBOOKS.md $(DESTDIR)$(DOCDIR)/RUNBOOKS.md + install -Dm644 docs/RULES.md $(DESTDIR)$(DOCDIR)/RULES.md + install -Dm644 docs/SPECIFICATION.md $(DESTDIR)$(DOCDIR)/SPECIFICATION.md + install -Dm644 docs/ROADMAP.md $(DESTDIR)$(DOCDIR)/ROADMAP.md + install -Dm644 docs/THREAT_MODEL.md $(DESTDIR)$(DOCDIR)/THREAT_MODEL.md + install -Dm644 docs/VERSION.json $(DESTDIR)$(DOCDIR)/VERSION.json + install -Dm644 systemd/enodia-sentinel-ebpf.conf $(DESTDIR)$(DOCDIR)/examples/enodia-sentinel-ebpf.conf install -dm750 $(DESTDIR)$(LOGDIR) @echo "Installed. Run 'sudo make enable' to start the service." @@ -34,7 +50,9 @@ uninstall: rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service rm -f $(DESTDIR)/etc/pacman.d/hooks/enodia-sentinel-fim.hook + rm -f $(DESTDIR)$(TMPFILESDIR)/enodia-sentinel.conf rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new + rm -rf $(DESTDIR)$(DOCDIR) @echo "Uninstalled. Config and logs preserved." enable: @@ -66,5 +84,8 @@ web: drill: ./src/sentinel-redteam +release-artifacts: + packaging/release-artifacts.sh + clean: find . -type d -name __pycache__ -prune -exec rm -rf {} + diff --git a/README.md b/README.md index 6f93adb..0f74f62 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Project docs: response, fleet, and assurance layers. - [Operations guide](docs/OPERATIONS.md) — install checks, health checks, alert workflow, baseline hygiene, and evidence export. +- [Packaging guide](docs/PACKAGING.md) — Arch package layout plus Debian/RPM + source-install paths. - [JSON schemas](docs/SCHEMAS.md) — stable v1 contracts for alerts, incidents, status, response plans, and response audit records. - [IR runbooks](docs/RUNBOOKS.md) — confirm/preserve/contain/recover playbooks diff --git a/docs/INDEX.md b/docs/INDEX.md index b07ccd5..52a4e30 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -15,6 +15,7 @@ same patch as any command, schema, or behavior documentation changes. | [COMMAND_REFERENCE.md](COMMAND_REFERENCE.md) | Manpage-style CLI and service command contract. | | [SCHEMAS.md](SCHEMAS.md) | Stable JSON schema IDs and required v1 fields. | | [OPERATIONS.md](OPERATIONS.md) | Install checks, health checks, alert workflow, baseline hygiene, and evidence export. | +| [PACKAGING.md](PACKAGING.md) | Arch package layout and Debian/RPM source-install paths. | | [RUNBOOKS.md](RUNBOOKS.md) | Incident response playbooks for common Sentinel findings. | | [RULES.md](RULES.md) | Generated built-in event-rule reference by SID. | diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..52eebb5 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,153 @@ +# Enodia Sentinel Packaging + +Applies to package version: `0.7.0` +Docs version: `0.7` +Schema generation: `v1` + +Sentinel is a stdlib-only Python package installed as a plain source directory +plus launcher wrapper. The service and package are designed around Linux hosts +with systemd. + +## Arch Linux + +Arch is the primary packaged target. + +Build from the repository root: + +```bash +packaging/build-package.sh +``` + +Install the generated package: + +```bash +sudo pacman -U enodia-sentinel-*.pkg.tar.zst +sudo systemctl daemon-reload +sudo systemctl enable --now enodia-sentinel.service +``` + +The Arch package installs: + +| Path | Purpose | +|---|---| +| `/usr/bin/enodia-sentinel` | Launcher wrapper for the Python package. | +| `/usr/bin/sentinel-redteam` | Safe local red-team harness. | +| `/usr/lib/enodia-sentinel/enodia_sentinel/` | Stdlib-only Python package. | +| `/etc/enodia-sentinel.toml` | Operator configuration, preserved as a pacman backup file. | +| `/usr/lib/systemd/system/enodia-sentinel.service` | Hardened daemon unit. | +| `/usr/lib/systemd/system/enodia-sentinel-web.service` | Read-only dashboard unit. | +| `/usr/share/libalpm/hooks/enodia-sentinel-fim.hook` | Post-transaction FIM baseline refresh hook. | +| `/usr/lib/tmpfiles.d/enodia-sentinel.conf` | Runtime log directory definition. | +| `/usr/share/doc/enodia-sentinel/` | Versioned operator docs and examples. | +| `/var/log/enodia-sentinel/` | Alert snapshots, events, incidents, response plans, and audit logs. | + +The pacman hook calls `/usr/bin/enodia-sentinel fim-update` after package +transactions so legitimate package changes refresh the FIM baseline. It does +not silence unexplained non-package drift. + +Optional Arch packages: + +- `python-bpfcc` for eBPF exec/syscall telemetry. +- `bpftrace` for short-lived exec capture inside forensic snapshots. +- `libnotify` for local desktop notifications. +- `libarchive` for signed-cache-package `.MTREE` verification helpers. + +The default service is hardened for polling mode. To enable eBPF, copy the +packaged drop-in example and reload systemd: + +```bash +sudo install -Dm644 /usr/share/doc/enodia-sentinel/examples/enodia-sentinel-ebpf.conf \ + /etc/systemd/system/enodia-sentinel.service.d/ebpf.conf +sudo systemctl daemon-reload +sudo systemctl restart enodia-sentinel.service +``` + +## Debian and Ubuntu + +There is no native `.deb` package yet. Use the source install path: + +```bash +sudo apt install python3 iproute2 procps +sudo make install PREFIX=/usr/local +sudo systemctl daemon-reload +sudo systemctl enable --now enodia-sentinel.service +``` + +Notes: + +- The installed launcher does not require pip or a virtualenv. +- `fim-check --packages` uses the available package-manager backend; Debian + package verification support should be validated for the target host before + relying on it operationally. +- The source install path includes the pacman hook file for parity with Arch + source installs. On non-Arch systems it is inert unless pacman/libalpm is + installed; Debian installs should manage FIM baseline refresh through + operator procedure or a future dpkg trigger package. + +## RPM-family Linux + +There is no native RPM package yet. Use the same source install path: + +```bash +sudo dnf install python3 iproute procps-ng +sudo make install PREFIX=/usr/local +sudo systemctl daemon-reload +sudo systemctl enable --now enodia-sentinel.service +``` + +Notes: + +- Native RPM packaging should eventually install systemd units under the distro + unit path, preserve `/etc/enodia-sentinel.toml`, and create a package-manager + trigger equivalent to the Arch FIM refresh hook. +- Validate package verification behavior on the target distro before enabling + signed-package mismatch alerting. + +## Package Hardening Checklist + +- Package version matches `pyproject.toml` and `enodia_sentinel.__version__`. +- Config is preserved across upgrades. +- Log directory is created with mode `0750`. +- Service units remain hardened and read-mostly by default. +- eBPF privilege relaxation is shipped only as an opt-in drop-in example. +- Package-manager hooks use absolute paths and do not depend on ambient `PATH`. +- Versioned operator docs are installed with the package. + +## Release Artifacts + +Release artifacts are built with: + +```bash +make release-artifacts +``` + +The release target writes to `dist/` by default: + +| Artifact | Purpose | +|---|---| +| `enodia-sentinel-.tar.gz` | Versioned source release. | +| `SHA256SUMS` | SHA-256 checksums for the source tarball and release manifest. | +| `RELEASE_MANIFEST.json` | Project/version/git metadata for the generated release. | +| `SHA256SUMS.asc` | Optional detached GPG signature over `SHA256SUMS`. | + +The script refuses a dirty git worktree by default so a release maps to a known +commit. For local packaging smoke tests only, set `RELEASE_ALLOW_DIRTY=1`. + +To sign checksum artifacts: + +```bash +RELEASE_SIGN=1 make release-artifacts +``` + +To select a specific GPG key: + +```bash +RELEASE_SIGN=1 RELEASE_SIGNING_KEY= make release-artifacts +``` + +Verification flow for operators: + +```bash +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS +``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 71e794c..b05a68a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -93,8 +93,8 @@ Purpose: define a stable single-host product. audits. - ✅ Add compatibility tests for schema evolution. - ✅ Add documentation versioning and manpage-style command reference. -- Harden packaging for Arch first, then document Debian/RPM install paths. -- Add signed release artifacts and checksums. +- ✅ Harden packaging for Arch first, then document Debian/RPM install paths. +- ✅ Add signed release artifacts and checksums. - Improve dashboard from alert browser to local console: incidents, posture, integrity state, response plans, and watchdog status. - ✅ Add a local dashboard Settings menu with persistent themes for dark diff --git a/docs/VERSION.json b/docs/VERSION.json index 7f484b4..d212438 100644 --- a/docs/VERSION.json +++ b/docs/VERSION.json @@ -4,5 +4,7 @@ "docs_version": "0.7", "schema_generation": "v1", "command_reference": "docs/COMMAND_REFERENCE.md", - "schema_reference": "docs/SCHEMAS.md" + "schema_reference": "docs/SCHEMAS.md", + "packaging_reference": "docs/PACKAGING.md", + "release_artifact_target": "make release-artifacts" } diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 525f421..b5cf949 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Enodia pkgname=enodia-sentinel -pkgver=0.6.0 +pkgver=0.7.0 pkgrel=1 pkgdesc="Linux IDS/IPS/EDR agent with integrity, anti-rootkit, forensic evidence, and explicit prevention workflows" arch=('any') @@ -9,7 +9,8 @@ license=('GPL-3.0-or-later') depends=('python>=3.11' 'iproute2' 'procps-ng') optdepends=('python-bpfcc: eBPF event-driven execve/syscall monitors (catches short-lived processes and memory telemetry)' 'bpftrace: execve tracing of short-lived processes in snapshots' - 'libnotify: desktop notifications on alert') + 'libnotify: desktop notifications on alert' + 'libarchive: signed-package verification against cached package .MTREE manifests') backup=('etc/enodia-sentinel.toml') source=() sha256sums=() @@ -24,7 +25,21 @@ package() { install -Dm644 systemd/enodia-sentinel.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel.service" install -Dm644 systemd/enodia-sentinel-web.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel-web.service" install -Dm644 packaging/enodia-sentinel-fim.hook "$pkgdir/usr/share/libalpm/hooks/enodia-sentinel-fim.hook" + install -Dm644 packaging/enodia-sentinel.tmpfiles "$pkgdir/usr/lib/tmpfiles.d/enodia-sentinel.conf" install -Dm644 config/enodia-sentinel.toml "$pkgdir/etc/enodia-sentinel.toml" install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md" + install -Dm644 docs/INDEX.md "$pkgdir/usr/share/doc/$pkgname/INDEX.md" + install -Dm644 docs/COMMAND_REFERENCE.md "$pkgdir/usr/share/doc/$pkgname/COMMAND_REFERENCE.md" + install -Dm644 docs/SCHEMAS.md "$pkgdir/usr/share/doc/$pkgname/SCHEMAS.md" + install -Dm644 docs/PACKAGING.md "$pkgdir/usr/share/doc/$pkgname/PACKAGING.md" + install -Dm644 docs/OPERATIONS.md "$pkgdir/usr/share/doc/$pkgname/OPERATIONS.md" + install -Dm644 docs/RUNBOOKS.md "$pkgdir/usr/share/doc/$pkgname/RUNBOOKS.md" + install -Dm644 docs/RULES.md "$pkgdir/usr/share/doc/$pkgname/RULES.md" + install -Dm644 docs/SPECIFICATION.md "$pkgdir/usr/share/doc/$pkgname/SPECIFICATION.md" + install -Dm644 docs/ROADMAP.md "$pkgdir/usr/share/doc/$pkgname/ROADMAP.md" + install -Dm644 docs/THREAT_MODEL.md "$pkgdir/usr/share/doc/$pkgname/THREAT_MODEL.md" + install -Dm644 docs/VERSION.json "$pkgdir/usr/share/doc/$pkgname/VERSION.json" + install -Dm644 systemd/enodia-sentinel-ebpf.conf "$pkgdir/usr/share/doc/$pkgname/examples/enodia-sentinel-ebpf.conf" install -dm750 "$pkgdir/var/log/enodia-sentinel" } diff --git a/packaging/build-package.sh b/packaging/build-package.sh index 4699c5d..8208031 100755 --- a/packaging/build-package.sh +++ b/packaging/build-package.sh @@ -1,9 +1,14 @@ #!/bin/bash # SPDX-License-Identifier: GPL-3.0-or-later # Build the Arch package from the repo root. -set -e +set -eu cd "$(dirname "$0")" +command -v makepkg >/dev/null 2>&1 || { + echo "error: makepkg not found; install pacman/base-devel tooling first" >&2 + exit 127 +} echo "Building enodia-sentinel package..." -makepkg -f --noconfirm +umask 022 +makepkg -f --cleanbuild --noconfirm mv -f *.pkg.tar.zst ../ 2>/dev/null || true echo "Done. Install with: sudo pacman -U enodia-sentinel-*.pkg.tar.zst" diff --git a/packaging/enodia-sentinel-fim.hook b/packaging/enodia-sentinel-fim.hook index b998eec..c30b398 100644 --- a/packaging/enodia-sentinel-fim.hook +++ b/packaging/enodia-sentinel-fim.hook @@ -1,6 +1,6 @@ # Refresh the Enodia Sentinel file-integrity baseline after every package # transaction, so legitimate package changes never trigger a FIM alert. -# Install as: /etc/pacman.d/hooks/enodia-sentinel-fim.hook +# Packaged as: /usr/share/libalpm/hooks/enodia-sentinel-fim.hook [Trigger] Operation = Install Operation = Upgrade @@ -14,5 +14,5 @@ Target = opt/* [Action] Description = Refreshing Enodia Sentinel file-integrity baseline... When = PostTransaction -Exec = /usr/bin/env enodia-sentinel fim-update +Exec = /usr/bin/enodia-sentinel fim-update Depends = python diff --git a/packaging/enodia-sentinel.tmpfiles b/packaging/enodia-sentinel.tmpfiles new file mode 100644 index 0000000..0c3552a --- /dev/null +++ b/packaging/enodia-sentinel.tmpfiles @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Runtime log/state directory for Enodia Sentinel. +d /var/log/enodia-sentinel 0750 root root - diff --git a/packaging/release-artifacts.sh b/packaging/release-artifacts.sh new file mode 100755 index 0000000..686ce41 --- /dev/null +++ b/packaging/release-artifacts.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Build release artifacts: source tarball, checksum file, manifest, optional +# detached signature over SHA256SUMS. +set -euo pipefail + +cd "$(dirname "$0")/.." + +version="$(python3 - <<'PY' +import tomllib +from pathlib import Path +print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]) +PY +)" +project="enodia-sentinel" +prefix="${project}-${version}" +distdir="${DISTDIR:-dist}" +archive="${prefix}.tar.gz" +manifest="RELEASE_MANIFEST.json" +checksums="SHA256SUMS" +signature="SHA256SUMS.asc" +allow_dirty="${RELEASE_ALLOW_DIRTY:-0}" +sign="${RELEASE_SIGN:-0}" +signing_key="${RELEASE_SIGNING_KEY:-}" + +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + dirty=0 + git diff --quiet || dirty=1 + git diff --cached --quiet || dirty=1 + if [ -n "$(git ls-files --others --exclude-standard)" ]; then + dirty=1 + fi + git_commit="$(git rev-parse --verify HEAD)" + if [ "$dirty" -ne 0 ] && [ "$allow_dirty" != "1" ]; then + echo "error: worktree is dirty; commit changes or set RELEASE_ALLOW_DIRTY=1" >&2 + exit 2 + fi +else + dirty=1 + git_commit="" +fi + +mkdir -p "$distdir" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/enodia-release.XXXXXX")" +cleanup() { + rm -rf "$tmp" +} +trap cleanup EXIT + +mkdir -p "$tmp/$prefix" +tar \ + --exclude='./.git' \ + --exclude='./dist' \ + --exclude='./__pycache__' \ + --exclude='*/__pycache__' \ + --exclude='./.pytest_cache' \ + --exclude='./.mypy_cache' \ + --exclude='./.ruff_cache' \ + --exclude='*.pyc' \ + --exclude='*.pkg.tar.zst' \ + -cf - . | tar -xf - -C "$tmp/$prefix" + +tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner \ + -czf "$distdir/$archive" -C "$tmp" "$prefix" + +generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +cat > "$distdir/$manifest" < "$checksums" +) + +if [ "$sign" = "1" ]; then + command -v gpg >/dev/null 2>&1 || { + echo "error: RELEASE_SIGN=1 requires gpg" >&2 + exit 127 + } + gpg_args=(--batch --yes --armor --detach-sign) + if [ -n "$signing_key" ]; then + gpg_args+=(--local-user "$signing_key") + fi + gpg "${gpg_args[@]}" -o "$distdir/$signature" "$distdir/$checksums" + echo "Signed $distdir/$signature" +else + echo "Signing skipped; set RELEASE_SIGN=1 to create $signature" +fi + +echo "Wrote $distdir/$archive" +echo "Wrote $distdir/$checksums" +echo "Wrote $distdir/$manifest" diff --git a/tests/test_docs_versioning.py b/tests/test_docs_versioning.py index aa0e0f8..5b6155d 100644 --- a/tests/test_docs_versioning.py +++ b/tests/test_docs_versioning.py @@ -26,10 +26,12 @@ class TestDocsVersioning(unittest.TestCase): self.assertEqual(docs_version["schema_generation"], "v1") self.assertEqual(docs_version["command_reference"], "docs/COMMAND_REFERENCE.md") self.assertEqual(docs_version["schema_reference"], "docs/SCHEMAS.md") + self.assertEqual(docs_version["packaging_reference"], "docs/PACKAGING.md") + self.assertEqual(docs_version["release_artifact_target"], "make release-artifacts") def test_versioned_docs_headers_are_present(self): version = json.loads((DOCS / "VERSION.json").read_text()) - for name in ("INDEX.md", "COMMAND_REFERENCE.md", "SCHEMAS.md"): + for name in ("INDEX.md", "COMMAND_REFERENCE.md", "SCHEMAS.md", "PACKAGING.md"): with self.subTest(name=name): text = (DOCS / name).read_text() self.assertIn( diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..a476ccc --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Static checks for package metadata and install hardening.""" +import hashlib +import json +import os +import re +import subprocess +import tempfile +import tomllib +import unittest +from pathlib import Path + +from enodia_sentinel import __version__ + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(path: str) -> str: + return (ROOT / path).read_text() + + +class TestArchPackaging(unittest.TestCase): + def test_pkgbuild_version_matches_project(self): + pkgbuild = _read("packaging/PKGBUILD") + pyproject = tomllib.loads(_read("pyproject.toml")) + docs_version = json.loads(_read("docs/VERSION.json")) + + pkgver = re.search(r"^pkgver=([^\n]+)$", pkgbuild, re.M) + self.assertIsNotNone(pkgver) + self.assertEqual(pkgver.group(1), pyproject["project"]["version"]) + self.assertEqual(pkgver.group(1), __version__) + self.assertEqual(docs_version["package_version"], pkgver.group(1)) + + def test_pkgbuild_installs_hardened_runtime_files(self): + pkgbuild = _read("packaging/PKGBUILD") + for expected in ( + "/usr/bin/enodia-sentinel", + "/usr/lib/systemd/system/enodia-sentinel.service", + "/usr/lib/systemd/system/enodia-sentinel-web.service", + "/usr/share/libalpm/hooks/enodia-sentinel-fim.hook", + "/usr/lib/tmpfiles.d/enodia-sentinel.conf", + "/usr/share/doc/$pkgname/PACKAGING.md", + "/usr/share/doc/$pkgname/VERSION.json", + "/usr/share/doc/$pkgname/examples/enodia-sentinel-ebpf.conf", + "/var/log/enodia-sentinel", + ): + self.assertIn(expected, pkgbuild) + self.assertIn("backup=('etc/enodia-sentinel.toml')", pkgbuild) + self.assertIn("install -dm750", pkgbuild) + + def test_alpm_hook_uses_absolute_sentinel_path(self): + hook = _read("packaging/enodia-sentinel-fim.hook") + self.assertIn("Exec = /usr/bin/enodia-sentinel fim-update", hook) + self.assertNotIn("/usr/bin/env enodia-sentinel", hook) + self.assertIn("When = PostTransaction", hook) + + def test_tmpfiles_declares_private_log_dir(self): + tmpfiles = _read("packaging/enodia-sentinel.tmpfiles") + self.assertIn("d /var/log/enodia-sentinel 0750 root root -", tmpfiles) + + def test_build_script_uses_clean_package_build(self): + script = _read("packaging/build-package.sh") + self.assertIn("set -eu", script) + self.assertIn("command -v makepkg", script) + self.assertIn("--cleanbuild", script) + + +class TestReleaseArtifacts(unittest.TestCase): + def test_release_script_generates_checksummed_artifacts(self): + with tempfile.TemporaryDirectory() as d: + env = os.environ.copy() + env.update({ + "DISTDIR": d, + "RELEASE_ALLOW_DIRTY": "1", + "RELEASE_SIGN": "0", + }) + result = subprocess.run( + [str(ROOT / "packaging" / "release-artifacts.sh")], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + self.assertIn("Signing skipped", result.stdout) + + version = tomllib.loads(_read("pyproject.toml"))["project"]["version"] + archive = Path(d) / f"enodia-sentinel-{version}.tar.gz" + manifest = Path(d) / "RELEASE_MANIFEST.json" + checksums = Path(d) / "SHA256SUMS" + + self.assertTrue(archive.is_file()) + self.assertTrue(manifest.is_file()) + self.assertTrue(checksums.is_file()) + self.assertFalse((Path(d) / "SHA256SUMS.asc").exists()) + + data = json.loads(manifest.read_text()) + self.assertEqual(data["project"], "enodia-sentinel") + self.assertEqual(data["version"], version) + self.assertEqual(data["dirty"], 1) + self.assertIn(archive.name, data["artifacts"]) + self.assertIn("SHA256SUMS", data["artifacts"]) + + expected = { + archive.name: hashlib.sha256(archive.read_bytes()).hexdigest(), + manifest.name: hashlib.sha256(manifest.read_bytes()).hexdigest(), + } + actual = {} + for line in checksums.read_text().splitlines(): + digest, name = line.split(maxsplit=1) + actual[name] = digest + self.assertEqual(actual, expected) + + def test_release_script_supports_gpg_signing(self): + script = _read("packaging/release-artifacts.sh") + self.assertIn("RELEASE_SIGN", script) + self.assertIn("RELEASE_SIGNING_KEY", script) + self.assertIn("--armor --detach-sign", script) + self.assertIn("SHA256SUMS.asc", script) + + +class TestSourceInstallPackaging(unittest.TestCase): + def test_makefile_installs_docs_and_tmpfiles(self): + makefile = _read("Makefile") + self.assertIn("TMPFILESDIR := /etc/tmpfiles.d", makefile) + self.assertIn("packaging/enodia-sentinel.tmpfiles", makefile) + self.assertIn("docs/PACKAGING.md", makefile) + self.assertIn("docs/VERSION.json", makefile) + self.assertIn("systemd/enodia-sentinel-ebpf.conf", makefile) + + def test_packaging_guide_documents_distro_paths(self): + guide = _read("docs/PACKAGING.md") + for phrase in ( + "Arch is the primary packaged target", + "sudo pacman -U enodia-sentinel-*.pkg.tar.zst", + "There is no native `.deb` package yet", + "There is no native RPM package yet", + "Package-manager hooks use absolute paths", + ): + self.assertIn(phrase, guide) + + +if __name__ == "__main__": + unittest.main()