mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-12 00:19:08 +02:00
The existing case has the helper exit on its own, which reports ErrWaitDelay. The daemon meets the other shape: the process it launches is a launcher, the work happens in a grandchild, and the deadline kills the launcher while the grandchild holds the inherited pipe. Wait then reports the kill and the wait delay error is dropped, so the two paths through Cmd.Wait differ and only one of them was exercised.
126 lines
4.7 KiB
Go
126 lines
4.7 KiB
Go
//go:build !windows && !js
|
|
|
|
package certproof
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/shared/management/certposture"
|
|
)
|
|
|
|
// fakeHelper is a child process that drains its stdin and then prints script's output,
|
|
// standing in for a helper running in a user session the daemon cannot trust.
|
|
func fakeHelper(t *testing.T, script string) *exec.Cmd {
|
|
t.Helper()
|
|
return exec.Command("/bin/sh", "-c", "cat >/dev/null; "+script)
|
|
}
|
|
|
|
func printJSON(t *testing.T, v any) string {
|
|
t.Helper()
|
|
out, err := json.Marshal(v)
|
|
require.NoError(t, err)
|
|
return fmt.Sprintf("printf '%%s' '%s'", out)
|
|
}
|
|
|
|
func TestRunHelperCmd_KeepsOnlyRequestedProofs(t *testing.T) {
|
|
req := HelperRequest{PeerKey: peerKey, Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
|
resp := HelperResponse{Proofs: []certposture.Proof{
|
|
{Nonce: []byte("injected"), Signature: []byte("x")},
|
|
{Nonce: []byte("asked"), Signature: []byte("first")},
|
|
{Nonce: []byte("asked"), Signature: []byte("second")},
|
|
}}
|
|
|
|
proofs, err := runHelperCmd(fakeHelper(t, printJSON(t, resp)), req)
|
|
|
|
require.NoError(t, err)
|
|
require.Len(t, proofs, 1, "a helper may not return more proofs than challenges or proofs for nonces it was not asked about")
|
|
assert.Equal(t, []byte("first"), proofs[0].Signature, "the first proof for the requested nonce is kept")
|
|
}
|
|
|
|
func TestRunHelperCmd_RejectsOversizedOutput(t *testing.T) {
|
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
|
script := fmt.Sprintf("head -c %d /dev/zero", maxHelperStdout+1)
|
|
|
|
_, err := runHelperCmd(fakeHelper(t, script), req)
|
|
|
|
assert.ErrorIs(t, err, errHelperOutputTooLarge)
|
|
}
|
|
|
|
func TestRunHelperCmd_CapsStderrInError(t *testing.T) {
|
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
|
script := fmt.Sprintf("head -c %d /dev/zero | tr '\\0' 'a' >&2; exit 3", 10*maxHelperStderr)
|
|
|
|
_, err := runHelperCmd(fakeHelper(t, script), req)
|
|
|
|
require.Error(t, err)
|
|
assert.LessOrEqual(t, len(err.Error()), maxHelperStderr+100, "a chatty helper must not blow up the daemon's error or log line")
|
|
assert.True(t, strings.Contains(err.Error(), "exit status 3"), "the exit status is kept: %v", err)
|
|
}
|
|
|
|
func TestRunHelperCmd_ReturnsWhenAGrandchildHoldsTheOutputPipe(t *testing.T) {
|
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
|
resp := HelperResponse{Proofs: []certposture.Proof{{Nonce: []byte("asked"), Signature: []byte("sig")}}}
|
|
|
|
// The helper answers and exits, but leaves a background process holding the stdout
|
|
// it inherited. This is what a wedged `netbird posture cert-proof` behind a keychain
|
|
// prompt looks like from here: killing the process we launched does not close the
|
|
// pipe, so the copy out of it never sees EOF.
|
|
script := printJSON(t, resp) + "; sleep 10 &"
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := runHelperCmd(fakeHelper(t, script), req)
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
assert.ErrorIs(t, err, exec.ErrWaitDelay, "the output is incomplete, so the run must fail rather than report proofs")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("runHelperCmd never returned while a grandchild held the output pipe, so the collector's busy latch would stay set for the life of the daemon")
|
|
}
|
|
}
|
|
|
|
func TestRunHelperCmd_ReturnsWhenTheDeadlineKillsTheLauncher(t *testing.T) {
|
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
|
|
|
// The shape the daemon actually meets on macOS: the process it launches is only a
|
|
// launcher, the work happens in a grandchild, and the deadline reaps the launcher
|
|
// while the grandchild keeps the stdout it inherited open. Unlike the case above
|
|
// the process is killed, so Wait reports that rather than ErrWaitDelay.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", "cat >/dev/null; sleep 10 & sleep 10")
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
proofs, err := runHelperCmd(cmd, req)
|
|
assert.Empty(t, proofs, "a run the deadline cut short has nothing trustworthy to report")
|
|
done <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.Error(t, err, "the run was cut short, so it must not be reported as a success")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("runHelperCmd never returned after the deadline killed the launcher, so the collector's busy latch would stay set for the life of the daemon")
|
|
}
|
|
}
|
|
|
|
func TestRunHelperCmd_RejectsGarbage(t *testing.T) {
|
|
req := HelperRequest{Challenges: []HelperChallenge{{Nonce: []byte("asked")}}}
|
|
|
|
_, err := runHelperCmd(fakeHelper(t, "printf 'not json'"), req)
|
|
|
|
assert.ErrorContains(t, err, "decode helper response")
|
|
}
|