26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
401 lines
14 KiB
Go
401 lines
14 KiB
Go
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kunchenguid/no-mistakes/internal/paths"
|
|
)
|
|
|
|
func TestStartInstallsSystemdUnitAndStartsManagedDaemon(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("systemd unit rendering depends on POSIX path formatting")
|
|
}
|
|
p := paths.WithRoot(filepath.Join(t.TempDir(), "nm-home"))
|
|
if err := p.EnsureDirs(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
home := t.TempDir()
|
|
|
|
cleanup := stubServiceRuntime(t)
|
|
defer cleanup()
|
|
runtimeGOOS = "linux"
|
|
serviceUserHomeDir = func() (string, error) { return home, nil }
|
|
serviceExecutablePath = func() (string, error) { return "/usr/local/bin/no-mistakes", nil }
|
|
|
|
var commands []string
|
|
serviceCommandRunner = func(name string, args ...string) ([]byte, error) {
|
|
commands = append(commands, name+" "+strings.Join(args, " "))
|
|
return nil, nil
|
|
}
|
|
checks := 0
|
|
daemonHealthCheck = func(*paths.Paths) (bool, error) {
|
|
checks++
|
|
return checks >= 2, nil
|
|
}
|
|
|
|
if err := Start(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
unitPath := filepath.Join(home, ".config", "systemd", "user", systemdServiceName(p))
|
|
data, err := os.ReadFile(unitPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(data)
|
|
for _, want := range []string{
|
|
"Description=no-mistakes background daemon",
|
|
"ExecStart=/usr/local/bin/no-mistakes daemon run --root " + p.Root(),
|
|
"WorkingDirectory=" + p.Root(),
|
|
"Environment=\"HOME=" + home + "\"",
|
|
"Restart=always",
|
|
"WantedBy=default.target",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("systemd unit should contain %q, got:\n%s", want, text)
|
|
}
|
|
}
|
|
want := []string{
|
|
"systemctl --user daemon-reload",
|
|
"systemctl --user enable " + systemdServiceName(p),
|
|
"systemctl --user start " + systemdServiceName(p),
|
|
}
|
|
if len(commands) != len(want) {
|
|
t.Fatalf("expected %d systemctl commands, got %v", len(want), commands)
|
|
}
|
|
for i, wantCmd := range want {
|
|
if commands[i] != wantCmd {
|
|
t.Fatalf("command[%d] = %q, want %q", i, commands[i], wantCmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallSystemdUserServiceDoesNotRemoveLegacyUnitForDifferentRoot(t *testing.T) {
|
|
p := paths.WithRoot(filepath.Join(t.TempDir(), "nm-home"))
|
|
if err := p.EnsureDirs(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
home := t.TempDir()
|
|
|
|
cleanup := stubServiceRuntime(t)
|
|
defer cleanup()
|
|
runtimeGOOS = "linux"
|
|
serviceUserHomeDir = func() (string, error) { return home, nil }
|
|
|
|
legacyPath := filepath.Join(home, ".config", "systemd", "user", legacySystemdServiceName)
|
|
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
otherRoot := filepath.Join(t.TempDir(), "other-nm-home")
|
|
legacyUnit := renderSystemdUnit("/usr/local/bin/no-mistakes", paths.WithRoot(otherRoot), home)
|
|
if err := os.WriteFile(legacyPath, []byte(legacyUnit), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var commands []string
|
|
serviceCommandRunner = func(name string, args ...string) ([]byte, error) {
|
|
commands = append(commands, name+" "+strings.Join(args, " "))
|
|
return nil, nil
|
|
}
|
|
|
|
if err := installSystemdUserService(p, "/usr/local/bin/no-mistakes"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(legacyPath); err != nil {
|
|
t.Fatalf("legacy unit for different root should remain: %v", err)
|
|
}
|
|
if len(commands) != 2 {
|
|
t.Fatalf("install should not stop unrelated legacy service, got commands %v", commands)
|
|
}
|
|
}
|
|
|
|
func TestInstallSystemdUserServiceKeepsLegacyUnitOnEnableFailure(t *testing.T) {
|
|
p := paths.WithRoot(filepath.Join(t.TempDir(), "nm-home"))
|
|
if err := p.EnsureDirs(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
home := t.TempDir()
|
|
|
|
cleanup := stubServiceRuntime(t)
|
|
defer cleanup()
|
|
runtimeGOOS = "linux"
|
|
serviceUserHomeDir = func() (string, error) { return home, nil }
|
|
|
|
legacyPath := filepath.Join(home, ".config", "systemd", "user", legacySystemdServiceName)
|
|
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacyPath, []byte("[Unit]\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var commands []string
|
|
serviceCommandRunner = func(name string, args ...string) ([]byte, error) {
|
|
command := name + " " + strings.Join(args, " ")
|
|
commands = append(commands, command)
|
|
if command == "systemctl --user enable "+systemdServiceName(p) {
|
|
return nil, fmt.Errorf("enable failed")
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
err := installSystemdUserService(p, "/usr/local/bin/no-mistakes")
|
|
if err == nil {
|
|
t.Fatal("installSystemdUserService should fail when enable fails")
|
|
}
|
|
if _, statErr := os.Stat(legacyPath); statErr != nil {
|
|
t.Fatalf("legacy unit should remain after failed scoped install: %v", statErr)
|
|
}
|
|
for _, command := range commands {
|
|
if strings.Contains(command, "--user disable "+legacySystemdServiceName) || strings.Contains(command, "--user stop "+legacySystemdServiceName) {
|
|
t.Fatalf("legacy cleanup should not run before successful scoped install, got %q", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServiceProxyEnvSkipsUnsetAndEmpty(t *testing.T) {
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
|
|
|
|
got := serviceProxyEnv()
|
|
if len(got) != 1 || got[0][0] != "HTTPS_PROXY" || got[0][1] != "http://127.0.0.1:7897" {
|
|
t.Fatalf("serviceProxyEnv() = %v, want a single HTTPS_PROXY entry", got)
|
|
}
|
|
}
|
|
|
|
// TestServiceProxyEnvDedupesOnCaseInsensitivePlatforms reproduces the Windows
|
|
// CI failure: with case-insensitive env-var names, HTTPS_PROXY and https_proxy
|
|
// resolve to one variable with one value, and forwarding both spellings would
|
|
// bake a duplicate entry into the rendered service definition. serviceProxyEnv
|
|
// must collapse them into a single entry. runtimeGOOS is forced to "windows" so
|
|
// the dedup branch is exercised deterministically on any host; both spellings
|
|
// are set to the same value, mirroring how a case-insensitive environment
|
|
// resolves them.
|
|
func TestServiceProxyEnvDedupesOnCaseInsensitivePlatforms(t *testing.T) {
|
|
oldGOOS := runtimeGOOS
|
|
runtimeGOOS = "windows"
|
|
t.Cleanup(func() { runtimeGOOS = oldGOOS })
|
|
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
|
|
t.Setenv("https_proxy", "http://127.0.0.1:7897")
|
|
|
|
got := serviceProxyEnv()
|
|
if len(got) != 1 || got[0][0] != "HTTPS_PROXY" || got[0][1] != "http://127.0.0.1:7897" {
|
|
t.Fatalf("serviceProxyEnv() = %v, want a single de-duplicated HTTPS_PROXY entry", got)
|
|
}
|
|
}
|
|
|
|
// TestServiceProxyEnvForwardsBothSpellingsOnCaseSensitivePlatforms guards that
|
|
// macOS/Linux behaviour is unchanged: when both spellings are set to different
|
|
// values they are distinct variables and BOTH are forwarded verbatim, in
|
|
// declaration order. Skipped on Windows, where the two spellings cannot hold
|
|
// different values.
|
|
func TestServiceProxyEnvForwardsBothSpellingsOnCaseSensitivePlatforms(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("env-var names are case-insensitive on Windows; the two spellings are one variable")
|
|
}
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
t.Setenv("HTTP_PROXY", "http://upper:1/")
|
|
t.Setenv("http_proxy", "http://lower:2/")
|
|
|
|
got := serviceProxyEnv()
|
|
want := [][2]string{
|
|
{"HTTP_PROXY", "http://upper:1/"},
|
|
{"http_proxy", "http://lower:2/"},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("serviceProxyEnv() = %v, want both spellings forwarded verbatim %v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestServiceProxyEnvForwardsLowerOnlySpelling is the regression guard for the
|
|
// curl case: a proxy exported only as the lower-case http_proxy must reach the
|
|
// daemon as http_proxy, not normalised to HTTP_PROXY, because curl honours only
|
|
// the lower-case spelling for plain-HTTP requests. Skipped on Windows, where
|
|
// the spelling is not preserved (and need not be, as lookups are
|
|
// case-insensitive there).
|
|
func TestServiceProxyEnvForwardsLowerOnlySpelling(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("env-var names are case-insensitive on Windows; spelling is not preserved")
|
|
}
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
t.Setenv("http_proxy", "http://127.0.0.1:7897")
|
|
|
|
got := serviceProxyEnv()
|
|
if len(got) != 1 || got[0][0] != "http_proxy" || got[0][1] != "http://127.0.0.1:7897" {
|
|
t.Fatalf("serviceProxyEnv() = %v, want the lower-case http_proxy spelling preserved", got)
|
|
}
|
|
}
|
|
|
|
func TestRenderSystemdUnitForwardsProxyEnv(t *testing.T) {
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
|
|
t.Setenv("NO_PROXY", "localhost,127.0.0.1")
|
|
|
|
unit := renderSystemdUnit("/usr/local/bin/no-mistakes", paths.WithRoot(t.TempDir()), "/home/u")
|
|
for _, want := range []string{
|
|
`Environment="HTTPS_PROXY=http://127.0.0.1:7897"`,
|
|
`Environment="NO_PROXY=localhost,127.0.0.1"`,
|
|
} {
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("systemd unit should forward proxy env %q, got:\n%s", want, unit)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderSystemdUnitForwardsEveryProxyEnvKey guards that the renderer and
|
|
// proxyEnvKeys cannot drift apart: every declared key handed to the renderer -
|
|
// both the upper- and lower-case spellings - must reach the unit as its own
|
|
// Environment= line. The proxy environment is injected directly rather than
|
|
// read from the process environment so the assertion is independent of the
|
|
// host's env-var case sensitivity (it ran on Windows CI, where setting both
|
|
// spellings to distinct values is impossible); serviceProxyEnv's own
|
|
// platform-specific behaviour is covered by the TestServiceProxyEnv* tests.
|
|
func TestRenderSystemdUnitForwardsEveryProxyEnvKey(t *testing.T) {
|
|
var proxyEnv [][2]string
|
|
for _, key := range proxyEnvKeys {
|
|
proxyEnv = append(proxyEnv, [2]string{key, "val-" + key})
|
|
}
|
|
|
|
unit := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", paths.WithRoot(t.TempDir()), "/home/u", proxyEnv)
|
|
for _, key := range proxyEnvKeys {
|
|
want := `Environment="` + key + "=val-" + key + `"`
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("systemd unit should forward %s, want %q, got:\n%s", key, want, unit)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRenderSystemdUnitEscapesPercentInProxyEnv guards that percent signs in a
|
|
// forwarded proxy value are doubled (% -> %%) so systemd's specifier expansion
|
|
// (which runs on Environment= directive values) does not corrupt or reject
|
|
// percent-encoded credentials. A proxy URL such as
|
|
// http://user:p%40ss%3Aw0rd@proxy:8080 - the normal encoding for reserved
|
|
// characters in a user/pass - would otherwise have %40/%3A treated as unit
|
|
// specifiers (%4 is unknown and rejects the assignment on systemd >= v249,
|
|
// silently corrupting the value on older releases). The launchd plist path is
|
|
// XML and not subject to specifier expansion, so it is intentionally untouched.
|
|
func TestRenderSystemdUnitEscapesPercentInProxyEnv(t *testing.T) {
|
|
proxyEnv := [][2]string{
|
|
{"HTTPS_PROXY", "http://user:p%40ss%3Aw0rd@proxy:8080"},
|
|
}
|
|
|
|
unit := renderSystemdUnitWithProxyEnv("/usr/local/bin/no-mistakes", paths.WithRoot(t.TempDir()), "/home/u", proxyEnv)
|
|
want := `Environment="HTTPS_PROXY=http://user:p%%40ss%%3Aw0rd@proxy:8080"`
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("systemd unit should double %% in proxy env so it survives specifier expansion, want %q, got:\n%s", want, unit)
|
|
}
|
|
if strings.Contains(unit, `Environment="HTTPS_PROXY=http://user:p%40ss%3Aw0rd@proxy:8080"`) {
|
|
t.Fatalf("systemd unit forwarded a single-%% proxy value that specifier expansion would corrupt, got:\n%s", unit)
|
|
}
|
|
}
|
|
|
|
func TestWriteServiceFileTightensModeWhenProxyPresent(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX file modes (0644/0600) are not enforced on Windows; the proxy-bearing service file is only generated on macOS/Linux")
|
|
}
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "unit")
|
|
render := func(proxyEnv [][2]string) string { return "content" }
|
|
|
|
// No proxy: the conventional 0644 is kept.
|
|
if err := writeServiceFile(path, nil, render); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info, err := os.Stat(path); err != nil {
|
|
t.Fatal(err)
|
|
} else if got := info.Mode().Perm(); got != 0o644 {
|
|
t.Fatalf("without proxy, mode = %o, want 0644", got)
|
|
}
|
|
|
|
// Proxy present: re-installing over the existing 0644 file must tighten it
|
|
// to owner-only 0600 so forwarded credentials are not world-readable.
|
|
t.Setenv("HTTPS_PROXY", "http://user:pass@127.0.0.1:7897")
|
|
if err := writeServiceFile(path, nil, render); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info, err := os.Stat(path); err != nil {
|
|
t.Fatal(err)
|
|
} else if got := info.Mode().Perm(); got != 0o600 {
|
|
t.Fatalf("with proxy, mode = %o, want 0600", got)
|
|
}
|
|
}
|
|
|
|
// TestWriteServiceFileReplacesAtomicallyWhenProxyPresent guards that
|
|
// credential-bearing content (a forwarded proxy URL can embed user:pass) is
|
|
// never written into a pre-existing world-readable 0644 file and only tightened
|
|
// afterwards. Writing in place with os.WriteFile would reuse the existing 0644
|
|
// file - leaving the credentials world-readable until a follow-up Chmod - so
|
|
// the proxy path must write a fresh 0600 file and atomically rename it over the
|
|
// target. The replacement is observable as a different underlying file
|
|
// (os.SameFile == false) that carries the new content at 0600, with no leftover
|
|
// temp files in the directory.
|
|
func TestWriteServiceFileReplacesAtomicallyWhenProxyPresent(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX file modes (0644/0600) are not enforced on Windows; the proxy-bearing service file is only generated on macOS/Linux")
|
|
}
|
|
for _, key := range proxyEnvKeys {
|
|
t.Setenv(key, "")
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "unit")
|
|
|
|
if err := writeServiceFile(path, nil, func([][2]string) string { return "no-proxy" }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := before.Mode().Perm(); got != 0o644 {
|
|
t.Fatalf("without proxy, mode = %o, want 0644", got)
|
|
}
|
|
|
|
t.Setenv("HTTPS_PROXY", "http://user:pass@127.0.0.1:7897")
|
|
if err := writeServiceFile(path, nil, func([][2]string) string { return "secret-proxy-content" }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := after.Mode().Perm(); got != 0o600 {
|
|
t.Fatalf("with proxy, mode = %o, want 0600", got)
|
|
}
|
|
if os.SameFile(before, after) {
|
|
t.Fatal("proxy re-install rewrote credentials into the existing 0644 file in place; it must atomically replace it with a fresh 0600 file so credentials are never world-readable")
|
|
}
|
|
if data, err := os.ReadFile(path); err != nil {
|
|
t.Fatal(err)
|
|
} else if string(data) != "secret-proxy-content" {
|
|
t.Fatalf("content = %q, want %q", data, "secret-proxy-content")
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected only the unit file to remain, got %d entries: %v", len(entries), entries)
|
|
}
|
|
}
|