Use a native-built inert collector in NAS watchdog controls

The private NAS fixture copied /bin/sleep and relied on its renamed process identity. Build a bounded Go collector instead and require its fixed readiness marker through the generated wrapper's retained stderr, as well as exact kernel process ownership. Preserve actual wrappers, teardown/parser, all stop and cleanup assertions, and existing deadlines. This removes a platform-owned executable assumption; it does not diagnose or clear the preceding macOS failures or establish native appliance recovery.

Contract-Neutral: Private fixture executable and readiness proof only; production installer, process ownership, safety deadlines and wire contracts are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-13 15:20:17 +01:00
parent d8e7ebd31a
commit a3679d6a43
3 changed files with 111 additions and 2 deletions
+18 -2
View File
@@ -425,7 +425,20 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; }
root = Path(directory).resolve()
for name in ('state', 'bin', 'tools', 'flash', 'logs'): (root / name).mkdir(mode=0o700)
runtime = root / 'bin/pulse-agent'; stored = root / 'state/stored-agent'
stored.write_bytes(Path('/bin/sleep').read_bytes()); stored.chmod(0o700)
# Supply a native Go executable like the actual collector,
# not a renamed platform-owned /bin/sleep with implicit
# executable/process semantics. The generated production
# wrapper still copies it to the exact runtime path, and real
# kernel rows still drive the unchanged ownership/stop checks.
build = subprocess.run(
['go', 'build', '-trimpath', '-o', str(stored),
'./scripts/installtests/nasfixturecollector'],
cwd=ROOT, capture_output=True, text=True, timeout=30,
)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
self.assertTrue(stored.is_file())
self.assertFalse(stored.is_symlink())
stored.chmod(0o700)
watchdog = root / 'state/start-pulse-agent.sh'
(root / 'tools/pkill').write_text('#!/bin/sh\nexit 0\n'); (root / 'tools/pkill').chmod(0o700)
(root / 'tools/flash').write_text('#!/bin/sh\nexit 0\n'); (root / 'tools/flash').chmod(0o700)
@@ -467,6 +480,7 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; }
control = root / 'control.sh'
control.write_text('set -euo pipefail\n' + setup + '\n' + functions + '\ncommand() { [[ "$*" != "-v systemctl" ]] || return 1; builtin command "$@"; }\nquiesce_nas_agent_for_removal ' + platform + '\n')
output = root / 'watchdog.log'
collector_log = root / 'logs' / ('pulse-agent-watchdog.log' if platform == 'QNAP' else 'watchdog.log')
with output.open('wb') as stream:
process = subprocess.Popen([str(watchdog)] if platform == 'QNAP' else ['bash', str(watchdog)], env=env,
stdout=stream, stderr=stream, start_new_session=True)
@@ -474,7 +488,9 @@ remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; }
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
listing = subprocess.run([str(fixture_ps), '-ax', '-o', 'comm=', '-o', 'args='], capture_output=True, text=True, check=True).stdout
if generated_collector_is_running(listing, runtime): break
if (generated_collector_is_running(listing, runtime)
and collector_log.is_file()
and 'private NAS fixture collector ready\n' in collector_log.read_text()): break
self.assertIsNone(process.poll(), output.read_text()); time.sleep(.05)
else:
# Retain only owned fixture rows/logs, never other
@@ -0,0 +1,35 @@
// nasfixturecollector is an inert, native-built input for the private NAS
// watchdog/removal fixture. It has no agent, service, network or state access.
package main
import (
"errors"
"fmt"
"io"
"os"
"time"
)
const ready = "private NAS fixture collector ready\n"
func run(args []string, output io.Writer, wait func(time.Duration)) error {
// The generated wrapper passes this one fixed lifetime. Reject anything
// else rather than allowing an accidentally unbounded fixture process.
if len(args) != 1 || args[0] != "30" {
return errors.New("private NAS fixture requires its fixed 30-second lifetime")
}
if n, err := io.WriteString(output, ready); err != nil || n != len(ready) {
return errors.New("private NAS fixture could not record readiness")
}
wait(30 * time.Second)
return nil
}
func main() {
// Both generated wrappers discard stdout and retain stderr in their own
// watchdog log. This fixed marker discloses no process arguments or paths.
if err := run(os.Args[1:], os.Stderr, time.Sleep); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -0,0 +1,58 @@
package main
import (
"bytes"
"errors"
"io"
"testing"
"time"
)
func TestCollectorRecordsReadinessBeforeBoundedWait(t *testing.T) {
var output bytes.Buffer
calls := 0
if err := run([]string{"30"}, &output, func(duration time.Duration) {
calls++
if duration != 30*time.Second || output.String() != ready {
t.Fatalf("wait %s before readiness %q", duration, output.String())
}
}); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("wait called %d times", calls)
}
}
func TestCollectorRejectsChangedOrMissingLifetime(t *testing.T) {
for _, args := range [][]string{nil, {"0"}, {"-1"}, {"300"}, {"30", "extra"}} {
t.Run("invalid", func(t *testing.T) {
var output bytes.Buffer
err := run(args, &output, func(time.Duration) { t.Fatal("invalid input started a collector") })
if err == nil || output.Len() != 0 {
t.Fatalf("invalid input produced readiness or success: %v, %q", err, output.String())
}
})
}
}
type refusedOutput struct{}
func (refusedOutput) Write([]byte) (int, error) { return 0, errors.New("synthetic write refusal") }
type shortOutput struct{}
func (shortOutput) Write([]byte) (int, error) { return 1, nil }
func TestCollectorCannotWaitAfterUnrecordedReadiness(t *testing.T) {
for _, output := range []io.Writer{refusedOutput{}, shortOutput{}} {
if err := run([]string{"30"}, output, func(time.Duration) {
t.Fatal("failed readiness started a collector")
}); err == nil {
t.Fatal("failed readiness accepted")
}
}
if err := run([]string{"30"}, io.Discard, func(time.Duration) {}); err != nil {
t.Fatal(err)
}
}