337 lines
10 KiB
Go
337 lines
10 KiB
Go
//go:build darwin
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestBuildPlist_KeepAliveDoesNotRestartOnCleanExit(t *testing.T) {
|
|
cfg := Config{
|
|
BinaryPath: "/opt/cc-connect/cc-connect",
|
|
WorkDir: "/tmp/wd",
|
|
LogFile: "/tmp/log",
|
|
LogMaxSize: 10485760,
|
|
EnvPATH: "/usr/bin",
|
|
}
|
|
xml := buildPlist(cfg)
|
|
if !strings.Contains(xml, "<key>SuccessfulExit</key>") {
|
|
t.Fatal("plist should use KeepAlive dict with SuccessfulExit so exit 0 does not respawn")
|
|
}
|
|
// Boolean KeepAlive causes launchd to restart after every exit, including SIGTERM shutdown.
|
|
if strings.Contains(xml, "<key>KeepAlive</key>\n\t<true/>") {
|
|
t.Fatal("plist must not use boolean KeepAlive true")
|
|
}
|
|
// launchd.plist(5): SuccessfulExit=true means restart ONLY after a successful
|
|
// (exit 0) exit; false means restart ONLY after an unsuccessful exit. cc-connect
|
|
// returns 0 on graceful SIGTERM shutdown but a non-zero status on crash, so the
|
|
// daemon's "restart on failure but not on graceful stop" intent maps to
|
|
// SuccessfulExit=false. The previous wiring used <true/>, which was the inverse:
|
|
// it respawned after every clean SIGTERM shutdown and did NOT recover from
|
|
// crashes. Pin the correct value here so a future edit can't silently re-invert
|
|
// it.
|
|
if !strings.Contains(xml, "<key>SuccessfulExit</key>\n\t\t<false/>") {
|
|
t.Fatalf("plist must set SuccessfulExit=false so crashes restart and clean SIGTERM does not respawn; got:\n%s", xml)
|
|
}
|
|
if !strings.Contains(xml, "<key>LimitLoadToSessionType</key>") ||
|
|
!strings.Contains(xml, "<string>Aqua</string>") ||
|
|
!strings.Contains(xml, "<string>Background</string>") {
|
|
t.Fatal("plist should allow both Aqua and Background sessions")
|
|
}
|
|
}
|
|
|
|
func TestPreferredLaunchdDomainFallsBackToUserWhenGUIDomainUnavailable(t *testing.T) {
|
|
orig := runLaunchctl
|
|
t.Cleanup(func() { runLaunchctl = orig })
|
|
|
|
guiDomain := launchdGUIDomain()
|
|
userDomain := launchdUserDomain()
|
|
runLaunchctl = func(args ...string) (string, error) {
|
|
if len(args) >= 2 && args[0] == "print" && args[1] == guiDomain {
|
|
return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125")
|
|
}
|
|
if len(args) >= 2 && args[0] == "print" && args[1] == userDomain {
|
|
return "subsystem", nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
if got := preferredLaunchdDomain(); got != userDomain {
|
|
t.Fatalf("preferredLaunchdDomain() = %q, want %q", got, userDomain)
|
|
}
|
|
}
|
|
|
|
func TestLaunchdStatusUsesUserDomainWhenGUIDomainUnavailable(t *testing.T) {
|
|
orig := runLaunchctl
|
|
t.Cleanup(func() { runLaunchctl = orig })
|
|
|
|
guiDomain := launchdGUIDomain()
|
|
userDomain := launchdUserDomain()
|
|
guiTarget := launchdTarget(guiDomain)
|
|
userTarget := launchdTarget(userDomain)
|
|
runLaunchctl = func(args ...string) (string, error) {
|
|
if len(args) < 2 || args[0] != "print" {
|
|
return "", nil
|
|
}
|
|
switch args[1] {
|
|
case guiDomain, guiTarget:
|
|
return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125")
|
|
case userDomain:
|
|
return "subsystem", nil
|
|
case userTarget:
|
|
return "pid = 4321\nstate = running", nil
|
|
default:
|
|
return "", fmt.Errorf("unexpected target %q", args[1])
|
|
}
|
|
}
|
|
|
|
mgr := &launchdManager{}
|
|
st, err := mgr.Status()
|
|
if err != nil {
|
|
t.Fatalf("Status() error = %v", err)
|
|
}
|
|
if !st.Running {
|
|
t.Fatal("Status().Running = false, want true")
|
|
}
|
|
if st.PID != 4321 {
|
|
t.Fatalf("Status().PID = %d, want 4321", st.PID)
|
|
}
|
|
}
|
|
|
|
func TestRestartPrefersGUIDomainWhenAvailable(t *testing.T) {
|
|
orig := runLaunchctl
|
|
t.Cleanup(func() { runLaunchctl = orig })
|
|
|
|
dir := t.TempDir()
|
|
origHome := os.Getenv("HOME")
|
|
t.Setenv("HOME", dir)
|
|
if origHome != "" {
|
|
t.Cleanup(func() { _ = os.Setenv("HOME", origHome) })
|
|
}
|
|
plistPath := launchdPlistPath()
|
|
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
|
|
guiDomain := launchdGUIDomain()
|
|
userDomain := launchdUserDomain()
|
|
guiTarget := launchdTarget(guiDomain)
|
|
userTarget := launchdTarget(userDomain)
|
|
|
|
var calls []string
|
|
runLaunchctl = func(args ...string) (string, error) {
|
|
calls = append(calls, strings.Join(args, " "))
|
|
if len(args) < 2 {
|
|
return "", nil
|
|
}
|
|
switch args[0] {
|
|
case "print":
|
|
switch args[1] {
|
|
case guiDomain:
|
|
return "subsystem", nil
|
|
case guiTarget:
|
|
return "Bootstrap failed: 113: Could not find service", fmt.Errorf("exit status 113")
|
|
case userTarget:
|
|
return "pid = 4321\nstate = running", nil
|
|
default:
|
|
return "", fmt.Errorf("unexpected print target %q", args[1])
|
|
}
|
|
case "bootout":
|
|
return "", nil
|
|
case "bootstrap":
|
|
if args[1] != guiDomain {
|
|
t.Fatalf("bootstrap domain = %q, want %q", args[1], guiDomain)
|
|
}
|
|
return "", nil
|
|
case "kickstart":
|
|
if args[len(args)-1] != guiTarget {
|
|
t.Fatalf("kickstart target = %q, want %q", args[len(args)-1], guiTarget)
|
|
}
|
|
return "", nil
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
mgr := &launchdManager{}
|
|
if err := mgr.Restart(); err != nil {
|
|
t.Fatalf("Restart() error = %v", err)
|
|
}
|
|
|
|
if !containsCall(calls, "bootstrap "+guiDomain+" "+plistPath) {
|
|
t.Fatalf("expected bootstrap to gui domain, calls = %#v", calls)
|
|
}
|
|
if !containsCall(calls, "kickstart -kp "+guiTarget) {
|
|
t.Fatalf("expected kickstart to gui target, calls = %#v", calls)
|
|
}
|
|
}
|
|
|
|
func TestRestartKeepsUserDomainWhenGUIDomainUnavailable(t *testing.T) {
|
|
orig := runLaunchctl
|
|
t.Cleanup(func() { runLaunchctl = orig })
|
|
|
|
dir := t.TempDir()
|
|
origHome := os.Getenv("HOME")
|
|
t.Setenv("HOME", dir)
|
|
if origHome != "" {
|
|
t.Cleanup(func() { _ = os.Setenv("HOME", origHome) })
|
|
}
|
|
plistPath := launchdPlistPath()
|
|
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
|
|
guiDomain := launchdGUIDomain()
|
|
userDomain := launchdUserDomain()
|
|
userTarget := launchdTarget(userDomain)
|
|
|
|
var calls []string
|
|
runLaunchctl = func(args ...string) (string, error) {
|
|
calls = append(calls, strings.Join(args, " "))
|
|
if len(args) < 2 {
|
|
return "", nil
|
|
}
|
|
switch args[0] {
|
|
case "print":
|
|
switch args[1] {
|
|
case guiDomain:
|
|
return "Bootstrap failed: 125: Domain does not support specified action", fmt.Errorf("exit status 125")
|
|
case userDomain:
|
|
return "subsystem", nil
|
|
case userTarget:
|
|
return "pid = 4321\nstate = running", nil
|
|
default:
|
|
return "", fmt.Errorf("unexpected print target %q", args[1])
|
|
}
|
|
case "bootout":
|
|
return "", nil
|
|
case "bootstrap":
|
|
if args[1] != userDomain {
|
|
t.Fatalf("bootstrap domain = %q, want %q", args[1], userDomain)
|
|
}
|
|
return "", nil
|
|
case "kickstart":
|
|
if args[len(args)-1] != userTarget {
|
|
t.Fatalf("kickstart target = %q, want %q", args[len(args)-1], userTarget)
|
|
}
|
|
return "", nil
|
|
default:
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
mgr := &launchdManager{}
|
|
if err := mgr.Restart(); err != nil {
|
|
t.Fatalf("Restart() error = %v", err)
|
|
}
|
|
|
|
if !containsCall(calls, "bootstrap "+userDomain+" "+plistPath) {
|
|
t.Fatalf("expected bootstrap to user domain, calls = %#v", calls)
|
|
}
|
|
if !containsCall(calls, "kickstart -kp "+userTarget) {
|
|
t.Fatalf("expected kickstart to user target, calls = %#v", calls)
|
|
}
|
|
}
|
|
|
|
// collectXMLText walks the XML stream and returns every chardata text node.
|
|
// Used by the plist-escape test to verify path values round-trip through
|
|
// xml.Decoder regardless of how deeply they are nested.
|
|
func collectXMLText(t *testing.T, data []byte) []string {
|
|
t.Helper()
|
|
dec := xml.NewDecoder(strings.NewReader(string(data)))
|
|
var out []string
|
|
for {
|
|
tok, err := dec.Token()
|
|
if tok == nil {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("xml decode token: %v", err)
|
|
}
|
|
if cd, ok := tok.(xml.CharData); ok {
|
|
s := strings.TrimSpace(string(cd))
|
|
if s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func containsCall(calls []string, want string) bool {
|
|
for _, call := range calls {
|
|
if call == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestBuildPlist_EscapesXMLSpecialCharsInPaths pins the bug where unescaped
|
|
// '&', '<', '>', '"', and '\” in cfg paths produced malformed XML that
|
|
// `launchctl bootstrap` rejected.
|
|
func TestBuildPlist_EscapesXMLSpecialCharsInPaths(t *testing.T) {
|
|
cfg := Config{
|
|
BinaryPath: "/opt/cc-connect/bin & <tools>/cc-connect",
|
|
WorkDir: "/Users/jane/Projects/dev & test/cc-connect",
|
|
LogFile: "/Users/jane/Library/Logs/cc \"connect\".log",
|
|
LogMaxSize: 10485760,
|
|
EnvPATH: "/usr/bin:/path/with'apostrophe/bin",
|
|
}
|
|
out := buildPlist(cfg)
|
|
|
|
// 1) Result must parse as well-formed XML — without escaping, bare '&'
|
|
// or unbalanced '<' inside <string> elements break the parser.
|
|
if err := xml.Unmarshal([]byte(out), new(struct{ XMLName xml.Name })); err != nil {
|
|
t.Fatalf("buildPlist output is not valid XML: %v\n%s", err, out)
|
|
}
|
|
|
|
// 2) Round-trip the values through the XML parser and make sure the
|
|
// original characters survive the encode/decode cycle. Walk every
|
|
// text node rather than relying on a positional path, since the
|
|
// plist nests <array>/<dict>/<string> at multiple depths.
|
|
values := collectXMLText(t, []byte(out))
|
|
mustContain := []string{cfg.BinaryPath, cfg.WorkDir, cfg.LogFile, cfg.EnvPATH}
|
|
for _, want := range mustContain {
|
|
found := false
|
|
for _, got := range values {
|
|
if got == want {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("decoded plist does not contain expected value %q\nvalues = %#v", want, values)
|
|
}
|
|
}
|
|
|
|
// 3) The raw XML output must not contain a bare '&' followed by anything
|
|
// other than a recognized entity reference — that's what would crash
|
|
// launchctl.
|
|
for i := 0; i < len(out); i++ {
|
|
if out[i] != '&' {
|
|
continue
|
|
}
|
|
rest := out[i:]
|
|
if !(strings.HasPrefix(rest, "&") ||
|
|
strings.HasPrefix(rest, "<") ||
|
|
strings.HasPrefix(rest, ">") ||
|
|
strings.HasPrefix(rest, """) ||
|
|
strings.HasPrefix(rest, "'") ||
|
|
strings.HasPrefix(rest, "&#")) {
|
|
t.Fatalf("bare '&' at offset %d (not a valid entity ref): %q", i, rest[:min(len(rest), 30)])
|
|
}
|
|
}
|
|
}
|