From 539efa68d632029ac4ac35ced55d3305a2710a3b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:29:06 +0100 Subject: [PATCH 1/2] Repair removal documentation and portable DSM fixture checks Bind removal guidance to checked NAS quiescence and boot retirement instead of retired installation markers. Model DSM hard-link semantics through real exclusive link syscalls so macOS contract jobs no longer depend on GNU ln, retaining raced-directory adverse controls and every existing manager/recovery assertion. Contract-Neutral: Tests only; production removal, documentation and teardown contracts are unchanged. Change-source: pulse-maintainer --- .../installtests/agent_upstart_teardown.py | 74 ++++++++++++++++++- scripts/installtests/install_sh_test.go | 4 +- scripts/tests/test_agent_uninstall_docs.py | 26 ++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/scripts/installtests/agent_upstart_teardown.py b/scripts/installtests/agent_upstart_teardown.py index 8a403c677c..0d42c09a9c 100644 --- a/scripts/installtests/agent_upstart_teardown.py +++ b/scripts/installtests/agent_upstart_teardown.py @@ -11,6 +11,7 @@ from pathlib import Path import shlex import stat import subprocess +import sys import tempfile import unittest @@ -113,6 +114,32 @@ save(); sys.exit(0) ''' +LINK_MODEL = r''' +import os, pathlib, sys +args = sys.argv[1:] +if len(args) == 4 and args[:2] == ['-T', '--']: + no_target_directory, source, target = True, args[2], args[3] +elif len(args) == 3 and args[0] == '--': + no_target_directory, source, target = False, args[1], args[2] +else: + sys.exit(98) +root = pathlib.Path(os.environ['FIXTURE']).resolve() +for name in (source, target): + path = pathlib.Path(name) + if not path.is_absolute() or '..' in path.parts or not path.parent.resolve().is_relative_to(root): + sys.exit(97) +# Exercise the real exclusive link operation, not a simulated successful exit. +# DSM's ln -T must not follow a raced target directory. The old flagless form +# deliberately retains directory-following semantics for adverse source checks. +if not no_target_directory and os.path.isdir(target): + target = os.path.join(target, os.path.basename(source)) +try: + os.link(source, target, follow_symlinks=False) +except OSError: + sys.exit(1) +''' + + class AgentUpstartTeardown(unittest.TestCase): source = SOURCE.read_text() @@ -123,6 +150,12 @@ class AgentUpstartTeardown(unittest.TestCase): tools = root / "tools"; tools.mkdir() for name in ("ps", "initctl"): p = tools / name; p.write_text(MODEL); p.chmod(0o700) + link = tools / "fixture-link.py"; link.write_text(LINK_MODEL) + # Native macOS ln lacks -T. No host ln implementation may determine + # whether these closed DSM semantics or restoration controls pass. + external_link = tools / "ln" + external_link.write_text("#!/bin/sh\nprintf 'unexpected external ln\\n' >&2\nexit 96\n") + external_link.chmod(0o700) if fault == "missing-initctl": (tools / "initctl").unlink() init = root / "init"; init.mkdir() config = init / "pulse-agent.conf" @@ -160,7 +193,7 @@ class AgentUpstartTeardown(unittest.TestCase): values = dict(FIXTURE=root, EVENTS=root / "events", STATE_DIR=root / "state", PRIVILEGED_HELPER_STATE_DIR=root / "helper", SAFE_PROFILE_STATE_DIR=root / "profile", PRIVILEGE_SUDOERS_FILE=root / "absent-sudoers", PRIVILEGE_HELPER_DIR=root / "absent-helper", - REPLY=reply, FAULT=fault) + REPLY=reply, FAULT=fault, FIXTURE_PYTHON=sys.executable, FIXTURE_LINK=link) setup = "\n".join(k + "=" + shlex.quote(str(v)) for k, v in values.items()) + "\n" shell = r''' set -euo pipefail @@ -213,8 +246,9 @@ ln() { race_restore_config "$target" fi printf 'file:link\n' >> "$EVENTS" - # Preserve the actual external command exit for the production handler. - if builtin command ln "$@"; then return 0; else return $?; fi + # A portable private link model preserves the actual syscall exit, including + # raced files/directories. Never fall back to success or weaken production -T. + if "$FIXTURE_PYTHON" "$FIXTURE_LINK" "$@"; then return 0; else return $?; fi } mv() { local arg @@ -273,6 +307,40 @@ remove_safe_profile_state_dir() { builtin command rm -r -- "$SAFE_PROFILE_STATE_ self.assertTrue(added <= {"init/pulse-agent.override"}, added) if added: self.assertEqual(after["init/pulse-agent.override"][2], b"manual\n") + def test_link_fixture_preserves_exclusive_no_target_directory_semantics(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source"; source.write_bytes(b"original recovery\n") + target = root / "target" + env = dict(os.environ, FIXTURE=str(root)) + def link(*args): + return subprocess.run([sys.executable, "-c", LINK_MODEL, *map(str, args)], + env=env, capture_output=True, timeout=5).returncode + self.assertEqual(link('-T', '--', source, target), 0) + self.assertEqual(target.stat().st_ino, source.stat().st_ino) + target.unlink(); target.write_bytes(b"concurrent recovery\n") + self.assertEqual(link('-T', '--', source, target), 1) + self.assertEqual(target.read_bytes(), b"concurrent recovery\n") + target.unlink(); target.mkdir() + self.assertEqual(link('-T', '--', source, target), 1) + self.assertEqual(list(target.iterdir()), []) + self.assertEqual(link('--', source, target), 0) + self.assertEqual((target / source.name).stat().st_ino, source.stat().st_ino) + self.assertEqual(link('-f', '--', source, target), 98) + self.assertEqual(link('-T', '--', source, root / '..' / 'foreign'), 97) + + def test_link_fixture_exposes_the_old_directory_restore_race(self): + # The portable model must not launder the unsafe old argv into -T. + self.source = self.source.replace('ln -T --', 'ln --') + r, events, before, after, _ = self.exercise(fault="concurrent-restore-directory") + self.assertNotEqual(r.returncode, 0) + self.assertIn("remote:remove", events) + self.assertNotIn("state:remove", events) + nested = [n for n in after if n.startswith('init/pulse-agent.conf/')] + self.assertEqual(len(nested), 1) + self.assertEqual(after[nested[0]], before['init/pulse-agent.conf']) + self.assertFalse(any(n.startswith('init/pulse-agent.conf.removal.') for n in after)) + def test_refused_manager_precedes_remote_and_state_erasure(self): for fault in ("refused-stop", "refused-reload", "ignored-stop", "ignored-manual"): with self.subTest(fault=fault): diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index ac1a77e06b..d5b19281e0 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -8478,6 +8478,8 @@ func TestInstallSHUpstartTeardownRequiresObservedQuiescence(t *testing.T) { t.Fatalf("DSM Upstart retirement and production EXIT cleanup controls: %v\n%s", err, output) } for _, name := range []string{ + "test_link_fixture_preserves_exclusive_no_target_directory_semantics", + "test_link_fixture_exposes_the_old_directory_restore_race", "test_refused_manager_precedes_remote_and_state_erasure", "test_failed_empty_partial_diagnostic_status_cannot_authorise_removal", "test_effective_disabled_config_is_required_after_override_reload", @@ -8498,7 +8500,7 @@ func TestInstallSHUpstartTeardownRequiresObservedQuiescence(t *testing.T) { t.Fatalf("DSM Upstart control %s did not pass:\n%s", name, output) } } - if !strings.Contains(string(output), "\nRan 15 tests in ") || !strings.Contains(string(output), "\nOK\n") { + if !strings.Contains(string(output), "\nRan 17 tests in ") || !strings.Contains(string(output), "\nOK\n") { t.Fatalf("incomplete DSM Upstart verdict:\n%s", output) } t.Logf("%s", output) diff --git a/scripts/tests/test_agent_uninstall_docs.py b/scripts/tests/test_agent_uninstall_docs.py index 3e770b84ed..3f2058b502 100644 --- a/scripts/tests/test_agent_uninstall_docs.py +++ b/scripts/tests/test_agent_uninstall_docs.py @@ -49,12 +49,34 @@ class AgentUninstallDocsTest(unittest.TestCase): for operation in ('remove_agent_state_dir "$STATE_DIR"', "teardown_action_runner_service", "teardown_privileged_helper_service", "remove_privileged_helper_state_dir", "remove_safe_profile_state_dir", - 'rm -f /var/log/pulse-agent.log', 'GO_SCRIPT="/boot/config/go"', - "remove_qnap_autorun_block"): + 'rm -f /var/log/pulse-agent.log'): self.assertIn(operation, self.removal) + self.assert_nas_boot_retirement_before_erasure(self.removal) self.assertIn('Remove-Item $StateDir -Recurse -Force', (ROOT / "scripts/install.ps1").read_text()) + def assert_nas_boot_retirement_before_erasure(self, removal): + # Removal no longer shares installation's best-effort boot-file edits. + # Bind the existing boot-entry warning to the checked NAS lifecycle. + quiesce = 'quiesce_nas_agent_for_removal "$uninstall_nas_platform"' + retire = 'teardown_nas_agent_boot_owner "$uninstall_nas_platform"' + for call in (quiesce, retire): + self.assertIn(call + ' ||\n fail ', removal) + self.assertLess(removal.index(quiesce), removal.index('uninstall_collector_registration')) + self.assertLess(removal.index(retire), removal.index('remove_agent_state_dir "$STATE_DIR"')) + for old_edit in ('GO_SCRIPT="/boot/config/go"', 'remove_qnap_autorun_block'): + self.assertNotIn(old_edit, removal) + + def test_nas_boot_guidance_rejects_missing_late_or_ignored_retirement(self): + call = 'teardown_nas_agent_boot_owner "$uninstall_nas_platform"' + late = self.removal.replace(call, ':').replace('remove_agent_state_dir "$STATE_DIR"', + 'remove_agent_state_dir "$STATE_DIR"; ' + call + ' ||\n fail "late" 1') + for changed in (self.removal.replace(call, ':'), late, + self.removal.replace(call + ' ||', call + ' || true #')): + with self.subTest(mutation=changed): + with self.assertRaises(AssertionError): + self.assert_nas_boot_retirement_before_erasure(changed) + def test_saved_state_trust_and_explicit_path_authority_are_not_bypassed(self): for fact in ("same trusted Pulse instance", "on the affected agent host", "top-level `install.sh`", "Uninstallation needs no new token", From 862dbc0699e453219db79cad4549a4a2aa964c17 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:51:44 +0100 Subject: [PATCH 2/2] Bind beta.2 metadata checks to its recorded cumulative range The complete installer suite exposed a stale assertion requiring a beta.1 comparison after the reviewed changelog correctly named its actual v6.5.0 base. Keep the existing packet, signing, installer, rollback and compatibility checks, and require both the truthful stable-based introduction and matching changelog heading. Contract-Neutral: Test-only correction for the unchanged, already-recorded beta.2 changelog range; no release or runtime contract changes. Change-source: pulse-maintainer --- scripts/installtests/build_release_assets_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 78f73b83c4..081a29d9e8 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1320,6 +1320,12 @@ func TestCurrentPrereleasePacketTracksInstallMetadata(t *testing.T) { ) assertFileDoesNotContain(t, releaseNotesPath, "## Fixes") comparisonSummary := "This changelog describes the changes since `v" + comparisonVersion + "`" + if version == "6.6.0-beta.2" { + // This packet's recorded list is cumulative from stable, not beta.1. + // Keep its introduction bound to that actual list and rollback base. + comparisonSummary = "This cumulative changelog describes the changes included in `v" + version + "` since stable `v" + previous + "`" + assertFileContainsAllNormalized(t, changelogPath, "## Changes since v"+previous) + } if version == "6.4.0-rc.10" { comparisonSummary = "The `v6.4.0-rc.9` release staged an immutable draft, tag, and exact-version artifacts but did not activate publicly." }