Keep DSM config restoration atomically non-clobbering

A pathname absence check followed by mv could overwrite a concurrent root-created job. Use exact-target non-replacement links for override creation and config restoration; directory races also refuse without creating nested artifacts. Confined concurrent-file/directory controls are mandatory. Partial activation is never restarted and conflicting staged recovery is never discarded.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-12 14:11:23 +01:00
parent adcc223f55
commit a7271f5840
5 changed files with 39 additions and 9 deletions
@@ -33,13 +33,13 @@ configuration inode is staged outside Upstart's `.conf` namespace until checked
reload, exact unknown-job status and independent process absence establish
retirement. On unconfirmed retirement it is restored only if the original path
is still absent, leaving the manual override and all local recovery state;
a concurrently created config is never overwritten and the staged recovery copy
is retained. Only confirmed retirement removes the staged config and manual
atomic non-replacement links refuse concurrently created files or directories,
including during restore; the staged recovery copy is retained on conflict. Only confirmed retirement removes the staged config and manual
before credential/state erasure. No credentials, arguments or config contents
are returned by these observations. Existing same-instance URL, exact record,
TLS, systemd/OpenRC/rc.d/SysV and state-directory ownership remain unchanged.
The registered `scripts/installtests/install_sh_test.go` requires all fourteen
The registered `scripts/installtests/install_sh_test.go` requires all fifteen
`agent_upstart_teardown.py` production-function/caller controls, including exact
file restoration, effective activation, late refusal, stopped/absent/repeated
removal and real timeout-child termination. These are confined ordinary-user
@@ -34,13 +34,13 @@ configuration inode is staged outside Upstart's `.conf` namespace until checked
reload, exact unknown-job status and independent process absence establish
retirement. On unconfirmed retirement it is restored only if the original path
is still absent, leaving the manual override and all local recovery state;
a concurrently created config is never overwritten and the staged recovery copy
is retained. Only confirmed retirement removes the staged config and manual
atomic non-replacement links refuse concurrently created files or directories,
including during restore; the staged recovery copy is retained on conflict. Only confirmed retirement removes the staged config and manual
before credential/state erasure. No credentials, arguments or config contents
are returned by these observations. Existing same-instance URL, exact record,
TLS, systemd/OpenRC/rc.d/SysV and state-directory ownership remain unchanged.
The registered `scripts/installtests/install_sh_test.go` requires all fourteen
The registered `scripts/installtests/install_sh_test.go` requires all fifteen
`agent_upstart_teardown.py` production-function/caller controls, including exact
file restoration, effective activation, late refusal, stopped/absent/repeated
removal and real timeout-child termination. These are confined ordinary-user
+7 -2
View File
@@ -1622,7 +1622,7 @@ quiesce_upstart_agent_for_removal() {
pending=$(mktemp "${override}.XXXXXX") || return 1
# Link without replacement: an override created concurrently is not
# ours to overwrite. The temporary file contains no credential.
if ! { printf 'manual\n' > "$pending" && chmod 644 "$pending" && ln -- "$pending" "$override"; }; then
if ! { printf 'manual\n' > "$pending" && chmod 644 "$pending" && ln -T -- "$pending" "$override"; }; then
rm -f -- "$pending"
return 1
fi
@@ -1656,7 +1656,12 @@ teardown_upstart_agent_service() {
confirmed=true
fi
if [[ "$confirmed" != true ]]; then
if [[ ! -e "$conf" && ! -L "$conf" ]]; then mv -- "$retired" "$conf" || return 1; fi
if [[ ! -e "$conf" && ! -L "$conf" ]]; then
# Atomic no-replacement restore, including a target that
# becomes a directory after the preceding pathname check.
ln -T -- "$retired" "$conf" || return 1
rm -f -- "$retired" || return 1
fi
return 1
fi
rm -f -- "$retired" || return 1
@@ -188,6 +188,18 @@ rm() {
printf 'file:remove\n' >> "$EVENTS"
builtin command rm "$@"
}
ln() {
local arg
for arg in "$@"; do [[ "$arg" == -T || "$arg" == -- || "$arg" == "$FIXTURE/"* ]] || return 97; done
[[ "$1" == -T && "$2" == -- ]] || return 98
if [[ "$FAULT" == concurrent-override-file && "$4" == "$FIXTURE/init/pulse-agent.override" ]]; then
printf 'concurrent override retained\n' > "$4"
elif [[ "$FAULT" == concurrent-override-directory && "$4" == "$FIXTURE/init/pulse-agent.override" ]]; then
mkdir "$4"
fi
printf 'file:link\n' >> "$EVENTS"
builtin command ln "$@"
}
mv() {
local arg
for arg in "$@"; do [[ "$arg" == -- || "$arg" == "$FIXTURE/"* ]] || return 97; done
@@ -283,6 +295,18 @@ remove_safe_profile_state_dir() { builtin command rm -r -- "$SAFE_PROFILE_STATE_
self.assertEqual(after, before)
self.assertEqual(events, [])
def test_concurrent_override_file_or_directory_is_not_replaced_or_written_inside(self):
for fault in ("concurrent-override-file", "concurrent-override-directory"):
with self.subTest(fault=fault):
r, events, before, after, _ = self.exercise(fault=fault)
self.assertNotEqual(r.returncode, 0, r.stderr)
self.assertNotIn("remote:remove", events)
self.assertNotIn("manager:reload", events)
for name in before: self.assertEqual(after[name], before[name])
self.assertEqual(set(after) - set(before), {"init/pulse-agent.override"})
payload = b"concurrent override retained\n" if fault.endswith("file") else None
self.assertEqual(after["init/pulse-agent.override"][2], payload)
def test_missing_tools_refuse_without_remote_or_erasure(self):
for fault in ("missing-initctl", "missing-timeout"):
with self.subTest(fault=fault):
+2 -1
View File
@@ -8481,6 +8481,7 @@ func TestInstallSHUpstartTeardownRequiresObservedQuiescence(t *testing.T) {
"test_effective_disabled_config_is_required_after_override_reload",
"test_independent_process_inventory_rejects_orphan_or_unavailable_read",
"test_untrusted_config_override_and_prior_retirement_refuse_without_changes",
"test_concurrent_override_file_or_directory_is_not_replaced_or_written_inside",
"test_missing_tools_refuse_without_remote_or_erasure",
"test_remote_refusal_keeps_stopped_disabled_recovery_without_restart",
"test_late_manager_refusal_retains_recovery_after_remote_confirmation",
@@ -8495,7 +8496,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 14 tests in ") || !strings.Contains(string(output), "\nOK\n") {
if !strings.Contains(string(output), "\nRan 15 tests in ") || !strings.Contains(string(output), "\nOK\n") {
t.Fatalf("incomplete DSM Upstart verdict:\n%s", output)
}
t.Logf("%s", output)