mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 15:49:06 +02:00
* [client] Export the only-owner-writable path check from elevate
Pure refactor, no behavior change: the existing checkOnlyOwnerWritable gets a
thin exported wrapper so callers outside the elevation path can reuse it. No
call site changes here.
* [client] Validate the saved service parameters before applying them
The install reads <stateDir>/service.json and applies it to the service it then
registers: its arguments, its config path and its environment. The restricted
ACL that saveServiceParams puts on the state directory is applied when the file
is written, which is not necessarily before the file is first read, so the
install now checks the file rather than assuming it.
A file whose ownership or permissions are not the ones saveServiceParams
produces is treated as absent, and the install proceeds with its defaults. The
check covers the directories above the file as well, so what is checked is what
is read.
* [client] Restrict which environment variables the service is registered with
--service-env, and the service.json it persists to, accepted any name. A small
set of them decides how a process resolves the executables and libraries it
loads, and the daemon needs none of those: it now refuses them when they are
passed explicitly, and drops them with a warning when they come back from a
service.json written by an older version, so an upgrade does not fail over a
variable nobody needs.
* [client] Resolve netsh by absolute path
The lookup consulted PATH first and fell back to System32, in both the copy the
userspace firewall uses and the one that tears the interface down. It now asks
Windows for the system directory, so the resolution no longer depends on the
environment the service happens to be started with.
* [client] Move the System32 lookup into a package both callers share
Pure refactor, no behavior change: client/iface and client/firewall/uspfilter
carried a copy each of the same function, and neither imports the other, so the
body moves to client/internal/wincmd — alongside winregistry, which is where
the client's other Windows-only helper already lives. Both call sites now read
wincmd.System32("netsh").
* [client] Cover the System32 lookup with a test
Asserts what the previous commits changed: the lookup is absolute, and neither
PATH nor %SystemRoot% moves it.
* [client] Refuse the loader environment families by prefix
Review follow-up on the previous commit:
- LD_* and DYLD_* are now refused whole rather than name by name. Their members
differ per platform and libc and grow with new OS releases, so a list of them
is out of date as soon as it is written — DYLD_FALLBACK_LIBRARY_PATH and
DYLD_FALLBACK_FRAMEWORK_PATH were already missing from it.
- The names are folded to upper case only on Windows, where a variable is the
same one however it is spelled. Elsewhere the environment is case-sensitive,
so Path and PATH are two variables and only the exact spelling is the one that
is read; the fold refused the wrong one.
- TEMP and TMP stay in the denylist, but the rationale and the message now say
what they actually decide: where the service writes, not what it loads.
643 lines
21 KiB
Go
643 lines
21 KiB
Go
//go:build !ios && !android
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/configs"
|
|
)
|
|
|
|
func TestServiceParamsPath(t *testing.T) {
|
|
original := configs.StateDir
|
|
t.Cleanup(func() { configs.StateDir = original })
|
|
|
|
configs.StateDir = "/var/lib/netbird"
|
|
assert.Equal(t, filepath.Join("/var/lib/netbird", "service.json"), serviceParamsPath())
|
|
|
|
configs.StateDir = "/custom/state"
|
|
assert.Equal(t, filepath.Join("/custom/state", "service.json"), serviceParamsPath())
|
|
}
|
|
|
|
func TestSaveAndLoadServiceParams(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
original := configs.StateDir
|
|
t.Cleanup(func() { configs.StateDir = original })
|
|
configs.StateDir = tmpDir
|
|
|
|
params := &serviceParams{
|
|
LogLevel: "debug",
|
|
DaemonAddr: "unix:///var/run/netbird.sock",
|
|
JSONSocket: "tcp://127.0.0.1:8080",
|
|
EnableJSONSocket: true,
|
|
ManagementURL: "https://my.server.com",
|
|
ConfigPath: "/etc/netbird/config.json",
|
|
LogFiles: []string{"/var/log/netbird/client.log", "console"},
|
|
DisableProfiles: true,
|
|
DisableUpdateSettings: false,
|
|
ServiceEnvVars: map[string]string{"NB_LOG_FORMAT": "json", "CUSTOM": "val"},
|
|
}
|
|
|
|
err := saveServiceParams(params)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the file exists and is valid JSON.
|
|
data, err := os.ReadFile(filepath.Join(tmpDir, "service.json"))
|
|
require.NoError(t, err)
|
|
assert.True(t, json.Valid(data))
|
|
|
|
loaded, err := loadServiceParams()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, loaded)
|
|
|
|
assert.Equal(t, params.LogLevel, loaded.LogLevel)
|
|
assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr)
|
|
assert.Equal(t, params.JSONSocket, loaded.JSONSocket)
|
|
assert.Equal(t, params.EnableJSONSocket, loaded.EnableJSONSocket)
|
|
assert.Equal(t, params.ManagementURL, loaded.ManagementURL)
|
|
assert.Equal(t, params.ConfigPath, loaded.ConfigPath)
|
|
assert.Equal(t, params.LogFiles, loaded.LogFiles)
|
|
assert.Equal(t, params.DisableProfiles, loaded.DisableProfiles)
|
|
assert.Equal(t, params.DisableUpdateSettings, loaded.DisableUpdateSettings)
|
|
assert.Equal(t, params.ServiceEnvVars, loaded.ServiceEnvVars)
|
|
}
|
|
|
|
func TestLoadServiceParams_FileNotExists(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
original := configs.StateDir
|
|
t.Cleanup(func() { configs.StateDir = original })
|
|
configs.StateDir = tmpDir
|
|
|
|
params, err := loadServiceParams()
|
|
assert.NoError(t, err)
|
|
assert.Nil(t, params)
|
|
}
|
|
|
|
func TestLoadServiceParams_InvalidJSON(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
original := configs.StateDir
|
|
t.Cleanup(func() { configs.StateDir = original })
|
|
configs.StateDir = tmpDir
|
|
|
|
err := os.WriteFile(filepath.Join(tmpDir, "service.json"), []byte("not json"), 0600)
|
|
require.NoError(t, err)
|
|
|
|
params, err := loadServiceParams()
|
|
assert.Error(t, err)
|
|
assert.Nil(t, params)
|
|
}
|
|
|
|
func TestCurrentServiceParams(t *testing.T) {
|
|
origLogLevel := logLevel
|
|
origDaemonAddr := daemonAddr
|
|
origJSONSocket := jsonSocket
|
|
origEnableJSONSocket := enableJSONSocket
|
|
origManagementURL := managementURL
|
|
origConfigPath := configPath
|
|
origLogFiles := logFiles
|
|
origProfilesDisabled := profilesDisabled
|
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() {
|
|
logLevel = origLogLevel
|
|
daemonAddr = origDaemonAddr
|
|
jsonSocket = origJSONSocket
|
|
enableJSONSocket = origEnableJSONSocket
|
|
managementURL = origManagementURL
|
|
configPath = origConfigPath
|
|
logFiles = origLogFiles
|
|
profilesDisabled = origProfilesDisabled
|
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
|
serviceEnvVars = origServiceEnvVars
|
|
})
|
|
|
|
logLevel = "trace"
|
|
daemonAddr = "tcp://127.0.0.1:9999"
|
|
jsonSocket = "tcp://127.0.0.1:8080"
|
|
enableJSONSocket = true
|
|
managementURL = "https://mgmt.example.com"
|
|
configPath = "/tmp/test-config.json"
|
|
logFiles = []string{"/tmp/test.log"}
|
|
profilesDisabled = true
|
|
updateSettingsDisabled = true
|
|
serviceEnvVars = []string{"FOO=bar", "BAZ=qux"}
|
|
|
|
params := currentServiceParams()
|
|
|
|
assert.Equal(t, "trace", params.LogLevel)
|
|
assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr)
|
|
assert.Equal(t, "tcp://127.0.0.1:8080", params.JSONSocket)
|
|
assert.True(t, params.EnableJSONSocket)
|
|
assert.Equal(t, "https://mgmt.example.com", params.ManagementURL)
|
|
assert.Equal(t, "/tmp/test-config.json", params.ConfigPath)
|
|
assert.Equal(t, []string{"/tmp/test.log"}, params.LogFiles)
|
|
assert.True(t, params.DisableProfiles)
|
|
assert.True(t, params.DisableUpdateSettings)
|
|
assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, params.ServiceEnvVars)
|
|
}
|
|
|
|
func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) {
|
|
origLogLevel := logLevel
|
|
origDaemonAddr := daemonAddr
|
|
origJSONSocket := jsonSocket
|
|
origEnableJSONSocket := enableJSONSocket
|
|
origManagementURL := managementURL
|
|
origConfigPath := configPath
|
|
origLogFiles := logFiles
|
|
origProfilesDisabled := profilesDisabled
|
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() {
|
|
logLevel = origLogLevel
|
|
daemonAddr = origDaemonAddr
|
|
jsonSocket = origJSONSocket
|
|
enableJSONSocket = origEnableJSONSocket
|
|
managementURL = origManagementURL
|
|
configPath = origConfigPath
|
|
logFiles = origLogFiles
|
|
profilesDisabled = origProfilesDisabled
|
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
|
serviceEnvVars = origServiceEnvVars
|
|
})
|
|
|
|
// Reset all flags to defaults.
|
|
logLevel = "info"
|
|
daemonAddr = "unix:///var/run/netbird.sock"
|
|
jsonSocket = defaultJSONSocket
|
|
enableJSONSocket = false
|
|
managementURL = ""
|
|
configPath = "/etc/netbird/config.json"
|
|
logFiles = []string{"/var/log/netbird/client.log"}
|
|
profilesDisabled = false
|
|
updateSettingsDisabled = false
|
|
serviceEnvVars = nil
|
|
|
|
// Reset Changed state on all relevant flags.
|
|
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
|
f.Changed = false
|
|
})
|
|
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
|
f.Changed = false
|
|
})
|
|
|
|
// Simulate user explicitly setting --log-level via CLI.
|
|
logLevel = "warn"
|
|
require.NoError(t, rootCmd.PersistentFlags().Set("log-level", "warn"))
|
|
|
|
saved := &serviceParams{
|
|
LogLevel: "debug",
|
|
DaemonAddr: "tcp://127.0.0.1:5555",
|
|
JSONSocket: "tcp://127.0.0.1:8080",
|
|
EnableJSONSocket: true,
|
|
ManagementURL: "https://saved.example.com",
|
|
ConfigPath: "/saved/config.json",
|
|
LogFiles: []string{"/saved/client.log"},
|
|
DisableProfiles: true,
|
|
DisableUpdateSettings: true,
|
|
ServiceEnvVars: map[string]string{"SAVED_KEY": "saved_val"},
|
|
}
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
applyServiceParams(cmd, saved)
|
|
|
|
// log-level was Changed, so it should keep "warn", not use saved "debug".
|
|
assert.Equal(t, "warn", logLevel)
|
|
|
|
// All other fields were not Changed, so they should use saved values.
|
|
assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr)
|
|
assert.Equal(t, "tcp://127.0.0.1:8080", jsonSocket)
|
|
assert.True(t, enableJSONSocket)
|
|
assert.Equal(t, "https://saved.example.com", managementURL)
|
|
assert.Equal(t, "/saved/config.json", configPath)
|
|
assert.Equal(t, []string{"/saved/client.log"}, logFiles)
|
|
assert.True(t, profilesDisabled)
|
|
assert.True(t, updateSettingsDisabled)
|
|
assert.Equal(t, []string{"SAVED_KEY=saved_val"}, serviceEnvVars)
|
|
}
|
|
|
|
func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) {
|
|
origProfilesDisabled := profilesDisabled
|
|
origUpdateSettingsDisabled := updateSettingsDisabled
|
|
origEnableJSONSocket := enableJSONSocket
|
|
t.Cleanup(func() {
|
|
profilesDisabled = origProfilesDisabled
|
|
updateSettingsDisabled = origUpdateSettingsDisabled
|
|
enableJSONSocket = origEnableJSONSocket
|
|
})
|
|
|
|
// Simulate current state where booleans are true (e.g. set by previous install).
|
|
profilesDisabled = true
|
|
updateSettingsDisabled = true
|
|
enableJSONSocket = true
|
|
|
|
// Reset Changed state so flags appear unset.
|
|
serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
|
f.Changed = false
|
|
})
|
|
|
|
// Saved params have both as false.
|
|
saved := &serviceParams{
|
|
DisableProfiles: false,
|
|
DisableUpdateSettings: false,
|
|
}
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
applyServiceParams(cmd, saved)
|
|
|
|
assert.False(t, profilesDisabled, "saved false should override current true")
|
|
assert.False(t, updateSettingsDisabled, "saved false should override current true")
|
|
assert.False(t, enableJSONSocket, "saved false should override current true")
|
|
}
|
|
|
|
func TestApplyServiceParams_ClearManagementURL(t *testing.T) {
|
|
origManagementURL := managementURL
|
|
t.Cleanup(func() { managementURL = origManagementURL })
|
|
|
|
managementURL = "https://leftover.example.com"
|
|
|
|
// Simulate saved params where management URL was explicitly cleared.
|
|
saved := &serviceParams{
|
|
LogLevel: "info",
|
|
DaemonAddr: "unix:///var/run/netbird.sock",
|
|
// ManagementURL intentionally empty: was cleared with --management-url "".
|
|
}
|
|
|
|
rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
|
f.Changed = false
|
|
})
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
applyServiceParams(cmd, saved)
|
|
|
|
assert.Equal(t, "", managementURL, "saved empty management URL should clear the current value")
|
|
}
|
|
|
|
func TestApplyServiceParams_NilParams(t *testing.T) {
|
|
origLogLevel := logLevel
|
|
t.Cleanup(func() { logLevel = origLogLevel })
|
|
|
|
logLevel = "info"
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
|
|
// Should be a no-op.
|
|
applyServiceParams(cmd, nil)
|
|
assert.Equal(t, "info", logLevel)
|
|
}
|
|
|
|
func TestApplyServiceEnvParams_MergeExplicitAndSaved(t *testing.T) {
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
|
|
|
// Set up a command with --service-env marked as Changed.
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
require.NoError(t, cmd.Flags().Set("service-env", "EXPLICIT=yes,OVERLAP=explicit"))
|
|
|
|
serviceEnvVars = []string{"EXPLICIT=yes", "OVERLAP=explicit"}
|
|
|
|
saved := &serviceParams{
|
|
ServiceEnvVars: map[string]string{
|
|
"SAVED": "val",
|
|
"OVERLAP": "saved",
|
|
},
|
|
}
|
|
|
|
applyServiceEnvParams(cmd, saved)
|
|
|
|
// Parse result for easier assertion.
|
|
result, err := parseServiceEnvVars(serviceEnvVars)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "yes", result["EXPLICIT"])
|
|
assert.Equal(t, "val", result["SAVED"])
|
|
// Explicit wins on conflict.
|
|
assert.Equal(t, "explicit", result["OVERLAP"])
|
|
}
|
|
|
|
func TestApplyServiceEnvParams_NotChanged(t *testing.T) {
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
|
|
|
serviceEnvVars = nil
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
|
|
saved := &serviceParams{
|
|
ServiceEnvVars: map[string]string{"FROM_SAVED": "val"},
|
|
}
|
|
|
|
applyServiceEnvParams(cmd, saved)
|
|
|
|
result, err := parseServiceEnvVars(serviceEnvVars)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result)
|
|
}
|
|
|
|
func TestParseServiceEnvVars_RejectsForbiddenNames(t *testing.T) {
|
|
for _, env := range []string{"PATH=C:\\somewhere", "LD_PRELOAD=/tmp/lib.so", "DYLD_FALLBACK_LIBRARY_PATH=/tmp"} {
|
|
_, err := parseServiceEnvVars([]string{"KEEP=me", env})
|
|
require.Errorf(t, err, "%s selects what the service resolves and must be refused", env)
|
|
}
|
|
}
|
|
|
|
func TestIsForbiddenServiceEnvVar(t *testing.T) {
|
|
// The loader families are matched by prefix, so a name nobody has heard of
|
|
// yet is refused too.
|
|
for _, name := range []string{
|
|
"PATH", "PATHEXT", "COMSPEC", "SYSTEMROOT", "WINDIR", "TEMP", "TMP",
|
|
"LD_PRELOAD", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_FALLBACK_FRAMEWORK_PATH",
|
|
} {
|
|
assert.Truef(t, isForbiddenServiceEnvVar(name), "%s must be refused", name)
|
|
}
|
|
|
|
// The prefix must not swallow names that merely start with the same letters.
|
|
for _, name := range []string{"NB_LOG_LEVEL", "NB_WG_DEBUG", "HTTPS_PROXY", "LDAP_URL", "DYLDX"} {
|
|
assert.Falsef(t, isForbiddenServiceEnvVar(name), "%s has no reason to be refused", name)
|
|
}
|
|
|
|
// On Windows a variable is the same one however it is spelled; elsewhere
|
|
// Path and PATH are two variables and only the exact one is read.
|
|
if runtime.GOOS == "windows" {
|
|
assert.True(t, isForbiddenServiceEnvVar("Path"))
|
|
assert.True(t, isForbiddenServiceEnvVar("ld_preload"))
|
|
} else {
|
|
assert.False(t, isForbiddenServiceEnvVar("Path"))
|
|
assert.False(t, isForbiddenServiceEnvVar("ld_preload"))
|
|
}
|
|
}
|
|
|
|
func TestApplyServiceEnvParams_DropsForbiddenSavedNames(t *testing.T) {
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
|
|
|
serviceEnvVars = nil
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
|
|
saved := &serviceParams{
|
|
ServiceEnvVars: map[string]string{"PATH": "C:\\attacker", "NB_LOG_FORMAT": "json"},
|
|
}
|
|
|
|
applyServiceEnvParams(cmd, saved)
|
|
|
|
result, err := parseServiceEnvVars(serviceEnvVars)
|
|
require.NoError(t, err, "a saved PATH must be dropped rather than fail the install")
|
|
assert.Equal(t, map[string]string{"NB_LOG_FORMAT": "json"}, result)
|
|
}
|
|
|
|
func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) {
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
|
|
|
// Simulate --service-env "" which produces [""] in the slice.
|
|
serviceEnvVars = []string{""}
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.Flags().StringSlice("service-env", nil, "")
|
|
require.NoError(t, cmd.Flags().Set("service-env", ""))
|
|
|
|
saved := &serviceParams{
|
|
ServiceEnvVars: map[string]string{"OLD_VAR": "should_be_cleared"},
|
|
}
|
|
|
|
applyServiceEnvParams(cmd, saved)
|
|
|
|
assert.Nil(t, serviceEnvVars, "explicit empty --service-env should clear all saved env vars")
|
|
}
|
|
|
|
func TestCurrentServiceParams_EmptyEnvVarsAfterParse(t *testing.T) {
|
|
origServiceEnvVars := serviceEnvVars
|
|
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
|
|
|
// Simulate --service-env "" which produces [""] in the slice.
|
|
serviceEnvVars = []string{""}
|
|
|
|
params := currentServiceParams()
|
|
|
|
// After parsing, the empty string is skipped, resulting in an empty map.
|
|
// The map should still be set (not nil) so it overwrites saved values.
|
|
assert.NotNil(t, params.ServiceEnvVars, "empty env vars should produce empty map, not nil")
|
|
assert.Empty(t, params.ServiceEnvVars, "no valid env vars should be parsed from empty string")
|
|
}
|
|
|
|
// TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are
|
|
// referenced in both currentServiceParams() and applyServiceParams(). If a new field is
|
|
// added to serviceParams but not wired into these functions, this test fails.
|
|
func TestServiceParams_FieldsCoveredInFunctions(t *testing.T) {
|
|
fset := token.NewFileSet()
|
|
file, err := parser.ParseFile(fset, "service_params.go", nil, 0)
|
|
require.NoError(t, err)
|
|
|
|
// Collect all JSON field names from the serviceParams struct.
|
|
structFields := extractStructJSONFields(t, file, "serviceParams")
|
|
require.NotEmpty(t, structFields, "failed to find serviceParams struct fields")
|
|
|
|
// Collect field names referenced in currentServiceParams and applyServiceParams.
|
|
currentFields := extractFuncFieldRefs(t, file, "currentServiceParams", structFields)
|
|
applyFields := extractFuncFieldRefs(t, file, "applyServiceParams", structFields)
|
|
// applyServiceEnvParams handles ServiceEnvVars indirectly.
|
|
applyEnvFields := extractFuncFieldRefs(t, file, "applyServiceEnvParams", structFields)
|
|
for k, v := range applyEnvFields {
|
|
applyFields[k] = v
|
|
}
|
|
|
|
for _, field := range structFields {
|
|
assert.Contains(t, currentFields, field,
|
|
"serviceParams field %q is not captured in currentServiceParams()", field)
|
|
assert.Contains(t, applyFields, field,
|
|
"serviceParams field %q is not restored in applyServiceParams()/applyServiceEnvParams()", field)
|
|
}
|
|
}
|
|
|
|
// TestServiceParams_BuildArgsCoversAllFlags ensures that buildServiceArguments references
|
|
// all serviceParams fields that should become CLI args. ServiceEnvVars is excluded because
|
|
// it flows through newSVCConfig() EnvVars, not CLI args.
|
|
func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
|
|
fset := token.NewFileSet()
|
|
file, err := parser.ParseFile(fset, "service_params.go", nil, 0)
|
|
require.NoError(t, err)
|
|
|
|
structFields := extractStructJSONFields(t, file, "serviceParams")
|
|
require.NotEmpty(t, structFields)
|
|
|
|
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
|
|
require.NoError(t, err)
|
|
|
|
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
|
|
fieldsNotInArgs := map[string]bool{
|
|
"ServiceEnvVars": true,
|
|
}
|
|
|
|
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")
|
|
|
|
// Forward: every struct field must appear in buildServiceArguments.
|
|
for _, field := range structFields {
|
|
if fieldsNotInArgs[field] {
|
|
continue
|
|
}
|
|
globalVar := fieldToGlobalVar(field)
|
|
assert.Contains(t, buildFields, globalVar,
|
|
"serviceParams field %q (global %q) is not referenced in buildServiceArguments()", field, globalVar)
|
|
}
|
|
|
|
// Reverse: every service-related global used in buildServiceArguments must
|
|
// have a corresponding serviceParams field. This catches a developer adding
|
|
// a new flag to buildServiceArguments without adding it to the struct.
|
|
globalToField := make(map[string]string, len(structFields))
|
|
for _, field := range structFields {
|
|
globalToField[fieldToGlobalVar(field)] = field
|
|
}
|
|
// Identifiers in buildServiceArguments that are not service params
|
|
// (builtins, boilerplate, loop variables).
|
|
nonParamGlobals := map[string]bool{
|
|
"args": true, "append": true, "string": true, "_": true,
|
|
"logFile": true, // range variable over logFiles
|
|
}
|
|
for ref := range buildFields {
|
|
if nonParamGlobals[ref] {
|
|
continue
|
|
}
|
|
_, inStruct := globalToField[ref]
|
|
assert.True(t, inStruct,
|
|
"buildServiceArguments() references global %q which has no corresponding serviceParams field", ref)
|
|
}
|
|
}
|
|
|
|
// extractStructJSONFields returns field names from a named struct type.
|
|
func extractStructJSONFields(t *testing.T, file *ast.File, structName string) []string {
|
|
t.Helper()
|
|
var fields []string
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
ts, ok := n.(*ast.TypeSpec)
|
|
if !ok || ts.Name.Name != structName {
|
|
return true
|
|
}
|
|
st, ok := ts.Type.(*ast.StructType)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, f := range st.Fields.List {
|
|
if len(f.Names) > 0 {
|
|
fields = append(fields, f.Names[0].Name)
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
return fields
|
|
}
|
|
|
|
// extractFuncFieldRefs returns which of the given field names appear inside the
|
|
// named function, either as selector expressions (params.FieldName) or as
|
|
// composite literal keys (&serviceParams{FieldName: ...}).
|
|
func extractFuncFieldRefs(t *testing.T, file *ast.File, funcName string, fields []string) map[string]bool {
|
|
t.Helper()
|
|
fieldSet := make(map[string]bool, len(fields))
|
|
for _, f := range fields {
|
|
fieldSet[f] = true
|
|
}
|
|
|
|
found := make(map[string]bool)
|
|
fn := findFuncDecl(file, funcName)
|
|
require.NotNil(t, fn, "function %s not found", funcName)
|
|
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
switch v := n.(type) {
|
|
case *ast.SelectorExpr:
|
|
if fieldSet[v.Sel.Name] {
|
|
found[v.Sel.Name] = true
|
|
}
|
|
case *ast.KeyValueExpr:
|
|
if ident, ok := v.Key.(*ast.Ident); ok && fieldSet[ident.Name] {
|
|
found[ident.Name] = true
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
return found
|
|
}
|
|
|
|
// extractFuncGlobalRefs returns all identifier names referenced in the named function body.
|
|
func extractFuncGlobalRefs(t *testing.T, file *ast.File, funcName string) map[string]bool {
|
|
t.Helper()
|
|
fn := findFuncDecl(file, funcName)
|
|
require.NotNil(t, fn, "function %s not found", funcName)
|
|
|
|
refs := make(map[string]bool)
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
if ident, ok := n.(*ast.Ident); ok {
|
|
refs[ident.Name] = true
|
|
}
|
|
return true
|
|
})
|
|
return refs
|
|
}
|
|
|
|
func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
|
|
for _, decl := range file.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if ok && fn.Name.Name == name {
|
|
return fn
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fieldToGlobalVar maps serviceParams field names to the package-level variable
|
|
// names used in buildServiceArguments and applyServiceParams.
|
|
func fieldToGlobalVar(field string) string {
|
|
m := map[string]string{
|
|
"LogLevel": "logLevel",
|
|
"DaemonAddr": "daemonAddr",
|
|
"JSONSocket": "jsonSocket",
|
|
"ManagementURL": "managementURL",
|
|
"ConfigPath": "configPath",
|
|
"LogFiles": "logFiles",
|
|
"DisableProfiles": "profilesDisabled",
|
|
"DisableUpdateSettings": "updateSettingsDisabled",
|
|
"EnableCapture": "captureEnabled",
|
|
"DisableNetworks": "networksDisabled",
|
|
"EnableJSONSocket": "enableJSONSocket",
|
|
"ServiceEnvVars": "serviceEnvVars",
|
|
}
|
|
if v, ok := m[field]; ok {
|
|
return v
|
|
}
|
|
// Default: lowercase first letter.
|
|
return strings.ToLower(field[:1]) + field[1:]
|
|
}
|
|
|
|
func TestEnvMapToSlice(t *testing.T) {
|
|
m := map[string]string{"A": "1", "B": "2"}
|
|
s := envMapToSlice(m)
|
|
assert.Len(t, s, 2)
|
|
assert.Contains(t, s, "A=1")
|
|
assert.Contains(t, s, "B=2")
|
|
}
|
|
|
|
func TestEnvMapToSlice_Empty(t *testing.T) {
|
|
s := envMapToSlice(map[string]string{})
|
|
assert.Empty(t, s)
|
|
}
|