From 6aae2cf68081eeb1a783f05c3fbaac239d5dfe82 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 28 Jan 2026 12:34:37 -0500 Subject: [PATCH 1/3] fix: env vars in exec and systemd mode --- .air.toml | 2 +- lib/instances/manager_test.go | 171 +++++++++++++++++++++++++++++- lib/instances/qemu_test.go | 177 ++++++++++++++++++++++++++++++++ lib/system/init/mode_exec.go | 2 + lib/system/init/mode_systemd.go | 40 +++++++- 5 files changed, 388 insertions(+), 4 deletions(-) diff --git a/.air.toml b/.air.toml index d2bc9a17..5ee2db21 100644 --- a/.air.toml +++ b/.air.toml @@ -5,7 +5,7 @@ tmp_dir = "tmp" [build] args_bin = [] bin = "./tmp/main" - cmd = "go build -tags containers_image_openpgp -o ./tmp/main ./cmd/api" + cmd = "make build-embedded && go build -tags containers_image_openpgp -o ./tmp/main ./cmd/api" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata", "bin", "scripts", "data", "kernel"] exclude_file = [] diff --git a/lib/instances/manager_test.go b/lib/instances/manager_test.go index 3771bd17..839f2b90 100644 --- a/lib/instances/manager_test.go +++ b/lib/instances/manager_test.go @@ -675,6 +675,14 @@ func TestBasicEndToEnd(t *testing.T) { t.Logf("Volume test output:\n%s", output) t.Log("Volume read/write test passed!") + // Test environment variables are accessible via exec (tests guest-agent has env vars) + t.Log("Testing environment variables via exec...") + output, exitCode, err = runCmd("printenv", "TEST_VAR") + require.NoError(t, err, "printenv should execute") + require.Equal(t, 0, exitCode, "printenv should succeed") + assert.Equal(t, "test_value", strings.TrimSpace(output), "Environment variable should be accessible via exec") + t.Log("Environment variable accessible via exec!") + // Test streaming logs with live updates t.Log("Testing log streaming with live updates...") streamCtx, streamCancel := context.WithCancel(ctx) @@ -747,6 +755,168 @@ func TestBasicEndToEnd(t *testing.T) { t.Log("Instance and volume lifecycle test complete!") } +// TestEntrypointEnvVars verifies that environment variables are passed to the entrypoint process. +// This uses bitnami/redis which configures REDIS_PASSWORD from an env var - if auth is required, +// it proves the entrypoint received and used the env var. +func TestEntrypointEnvVars(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("Skipping test that requires root") + } + + mgr, tmpDir := setupTestManager(t) // Automatically registers cleanup + ctx := context.Background() + + // Get image manager + p := paths.New(tmpDir) + imageManager, err := images.NewManager(p, 1, nil) + require.NoError(t, err) + + // Pull bitnami/redis image + t.Log("Pulling bitnami/redis image...") + redisImage, err := imageManager.CreateImage(ctx, images.CreateImageRequest{ + Name: "docker.io/bitnami/redis:latest", + }) + require.NoError(t, err) + + // Wait for image to be ready + t.Log("Waiting for image build to complete...") + imageName := redisImage.Name + for i := 0; i < 60; i++ { + img, err := imageManager.GetImage(ctx, imageName) + if err == nil && img.Status == images.StatusReady { + redisImage = img + break + } + if err == nil && img.Status == images.StatusFailed { + t.Fatalf("Image build failed: %s", *img.Error) + } + time.Sleep(1 * time.Second) + } + require.Equal(t, images.StatusReady, redisImage.Status, "Image should be ready after 60 seconds") + t.Log("Redis image ready") + + // Ensure system files + systemManager := system.NewManager(p) + t.Log("Ensuring system files...") + err = systemManager.EnsureSystemFiles(ctx) + require.NoError(t, err) + t.Log("System files ready") + + // Initialize network (needed for loopback interface in guest) + networkManager := network.NewManager(p, &config.Config{ + DataDir: tmpDir, + BridgeName: "vmbr0", + SubnetCIDR: "10.100.0.0/16", + DNSServer: "1.1.1.1", + }, nil) + t.Log("Initializing network...") + err = networkManager.Initialize(ctx, nil) + require.NoError(t, err) + t.Log("Network initialized") + + // Create instance with REDIS_PASSWORD env var + testPassword := "test_secret_password_123" + req := CreateInstanceRequest{ + Name: "test-redis-env", + Image: "docker.io/bitnami/redis:latest", + Size: 2 * 1024 * 1024 * 1024, + HotplugSize: 512 * 1024 * 1024, + OverlaySize: 10 * 1024 * 1024 * 1024, + Vcpus: 2, + NetworkEnabled: true, // Need network for loopback to work properly + Env: map[string]string{ + "REDIS_PASSWORD": testPassword, + }, + } + + t.Log("Creating redis instance with REDIS_PASSWORD...") + inst, err := mgr.CreateInstance(ctx, req) + require.NoError(t, err) + require.NotNil(t, inst) + assert.Equal(t, StateRunning, inst.State) + t.Logf("Instance created: %s", inst.Id) + + // Wait for redis to be ready (bitnami/redis takes longer to start) + t.Log("Waiting for redis to be ready...") + time.Sleep(15 * time.Second) + + // Helper to run command in guest with retry + runCmd := func(command ...string) (string, int, error) { + var lastOutput string + var lastExitCode int + var lastErr error + + dialer, err := hypervisor.NewVsockDialer(inst.HypervisorType, inst.VsockSocket, inst.VsockCID) + if err != nil { + return "", -1, err + } + + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + time.Sleep(200 * time.Millisecond) + } + + var stdout, stderr bytes.Buffer + exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: command, + Stdout: &stdout, + Stderr: &stderr, + TTY: false, + }) + + output := stdout.String() + if stderr.Len() > 0 { + output += stderr.String() + } + output = strings.TrimSpace(output) + + if err != nil { + lastErr = err + lastOutput = output + lastExitCode = -1 + continue + } + + lastOutput = output + lastExitCode = exit.Code + lastErr = nil + + if output != "" || exit.Code == 0 { + return output, exit.Code, nil + } + } + + return lastOutput, lastExitCode, lastErr + } + + // Test 1: PING without auth should fail + t.Log("Testing redis PING without auth (should fail)...") + output, _, err := runCmd("redis-cli", "PING") + require.NoError(t, err) + assert.Contains(t, output, "NOAUTH", "Redis should require authentication") + + // Test 2: PING with correct password should succeed + t.Log("Testing redis PING with correct password (should succeed)...") + output, exitCode, err := runCmd("redis-cli", "-a", testPassword, "PING") + require.NoError(t, err) + require.Equal(t, 0, exitCode) + assert.Contains(t, output, "PONG", "Redis should respond to authenticated PING") + + // Test 3: Verify requirepass config matches our env var + t.Log("Verifying redis requirepass config...") + output, exitCode, err = runCmd("redis-cli", "-a", testPassword, "CONFIG", "GET", "requirepass") + require.NoError(t, err) + require.Equal(t, 0, exitCode) + assert.Contains(t, output, testPassword, "Redis requirepass should match REDIS_PASSWORD env var") + + t.Log("Entrypoint environment variable test passed!") + + // Cleanup + t.Log("Cleaning up...") + err = mgr.DeleteInstance(ctx, inst.Id) + require.NoError(t, err) +} + func TestStorageOperations(t *testing.T) { // Test storage layer without starting VMs tmpDir := t.TempDir() @@ -1001,4 +1171,3 @@ func (r *testInstanceResolver) ResolveInstance(ctx context.Context, nameOrID str // For tests, just return nameOrID as both name and id return nameOrID, nameOrID, nil } - diff --git a/lib/instances/qemu_test.go b/lib/instances/qemu_test.go index 358a7ae2..6ff8118e 100644 --- a/lib/instances/qemu_test.go +++ b/lib/instances/qemu_test.go @@ -507,6 +507,14 @@ func TestQEMUBasicEndToEnd(t *testing.T) { t.Logf("Volume test output:\n%s", output) t.Log("Volume read/write test passed!") + // Test environment variables are accessible via exec (tests guest-agent has env vars) + t.Log("Testing environment variables via exec...") + output, exitCode, err = runCmd("printenv", "TEST_VAR") + require.NoError(t, err, "printenv should execute") + require.Equal(t, 0, exitCode, "printenv should succeed") + assert.Equal(t, "test_value", strings.TrimSpace(output), "Environment variable should be accessible via exec") + t.Log("Environment variable accessible via exec!") + // Delete instance t.Log("Deleting instance...") err = manager.DeleteInstance(ctx, inst.Id) @@ -537,6 +545,175 @@ func TestQEMUBasicEndToEnd(t *testing.T) { t.Log("QEMU instance lifecycle test complete!") } +// TestQEMUEntrypointEnvVars verifies that environment variables are passed to the entrypoint process. +// This uses bitnami/redis which configures REDIS_PASSWORD from an env var - if auth is required, +// it proves the entrypoint received and used the env var. +func TestQEMUEntrypointEnvVars(t *testing.T) { + if os.Getuid() != 0 { + t.Skip("Skipping test that requires root") + } + + // Require QEMU to be installed + starter := qemu.NewStarter() + if _, err := starter.GetBinaryPath(nil, ""); err != nil { + t.Fatalf("QEMU not available: %v", err) + } + + mgr, tmpDir := setupTestManagerForQEMU(t) + ctx := context.Background() + + // Get image manager + p := paths.New(tmpDir) + imageManager, err := images.NewManager(p, 1, nil) + require.NoError(t, err) + + // Pull bitnami/redis image + t.Log("Pulling bitnami/redis image...") + redisImage, err := imageManager.CreateImage(ctx, images.CreateImageRequest{ + Name: "docker.io/bitnami/redis:latest", + }) + require.NoError(t, err) + + // Wait for image to be ready + t.Log("Waiting for image build to complete...") + imageName := redisImage.Name + for i := 0; i < 60; i++ { + img, err := imageManager.GetImage(ctx, imageName) + if err == nil && img.Status == images.StatusReady { + redisImage = img + break + } + if err == nil && img.Status == images.StatusFailed { + t.Fatalf("Image build failed: %s", *img.Error) + } + time.Sleep(1 * time.Second) + } + require.Equal(t, images.StatusReady, redisImage.Status, "Image should be ready after 60 seconds") + t.Log("Redis image ready") + + // Ensure system files + systemManager := system.NewManager(p) + t.Log("Ensuring system files...") + err = systemManager.EnsureSystemFiles(ctx) + require.NoError(t, err) + t.Log("System files ready") + + // Initialize network (needed for loopback interface in guest) + networkManager := network.NewManager(p, &config.Config{ + DataDir: tmpDir, + BridgeName: "vmbr0", + SubnetCIDR: "10.100.0.0/16", + DNSServer: "1.1.1.1", + }, nil) + t.Log("Initializing network...") + err = networkManager.Initialize(ctx, nil) + require.NoError(t, err) + t.Log("Network initialized") + + // Create instance with REDIS_PASSWORD env var + testPassword := "test_secret_password_123" + req := CreateInstanceRequest{ + Name: "test-redis-env", + Image: "docker.io/bitnami/redis:latest", + Size: 2 * 1024 * 1024 * 1024, + HotplugSize: 512 * 1024 * 1024, + OverlaySize: 10 * 1024 * 1024 * 1024, + Vcpus: 2, + NetworkEnabled: true, // Need network for loopback to work properly + Env: map[string]string{ + "REDIS_PASSWORD": testPassword, + }, + } + + t.Log("Creating redis instance with REDIS_PASSWORD...") + inst, err := mgr.CreateInstance(ctx, req) + require.NoError(t, err) + require.NotNil(t, inst) + assert.Equal(t, StateRunning, inst.State) + assert.Equal(t, hypervisor.TypeQEMU, inst.HypervisorType, "Instance should use QEMU hypervisor") + t.Logf("Instance created: %s", inst.Id) + + // Wait for redis to be ready (bitnami/redis takes longer to start) + t.Log("Waiting for redis to be ready...") + time.Sleep(15 * time.Second) + + // Helper to run command in guest with retry + runCmd := func(command ...string) (string, int, error) { + var lastOutput string + var lastExitCode int + var lastErr error + + dialer, err := hypervisor.NewVsockDialer(inst.HypervisorType, inst.VsockSocket, inst.VsockCID) + if err != nil { + return "", -1, err + } + + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + time.Sleep(200 * time.Millisecond) + } + + var stdout, stderr bytes.Buffer + exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{ + Command: command, + Stdout: &stdout, + Stderr: &stderr, + TTY: false, + }) + + output := stdout.String() + if stderr.Len() > 0 { + output += stderr.String() + } + output = strings.TrimSpace(output) + + if err != nil { + lastErr = err + lastOutput = output + lastExitCode = -1 + continue + } + + lastOutput = output + lastExitCode = exit.Code + lastErr = nil + + if output != "" || exit.Code == 0 { + return output, exit.Code, nil + } + } + + return lastOutput, lastExitCode, lastErr + } + + // Test 1: PING without auth should fail + t.Log("Testing redis PING without auth (should fail)...") + output, _, err := runCmd("redis-cli", "PING") + require.NoError(t, err) + assert.Contains(t, output, "NOAUTH", "Redis should require authentication") + + // Test 2: PING with correct password should succeed + t.Log("Testing redis PING with correct password (should succeed)...") + output, exitCode, err := runCmd("redis-cli", "-a", testPassword, "PING") + require.NoError(t, err) + require.Equal(t, 0, exitCode) + assert.Contains(t, output, "PONG", "Redis should respond to authenticated PING") + + // Test 3: Verify requirepass config matches our env var + t.Log("Verifying redis requirepass config...") + output, exitCode, err = runCmd("redis-cli", "-a", testPassword, "CONFIG", "GET", "requirepass") + require.NoError(t, err) + require.Equal(t, 0, exitCode) + assert.Contains(t, output, testPassword, "Redis requirepass should match REDIS_PASSWORD env var") + + t.Log("QEMU entrypoint environment variable test passed!") + + // Cleanup + t.Log("Cleaning up...") + err = mgr.DeleteInstance(ctx, inst.Id) + require.NoError(t, err) +} + // TestQEMUStandbyAndRestore tests the standby/restore cycle with QEMU. // This tests QEMU's migrate-to-file snapshot mechanism. func TestQEMUStandbyAndRestore(t *testing.T) { diff --git a/lib/system/init/mode_exec.go b/lib/system/init/mode_exec.go index 549bb599..e5744da6 100644 --- a/lib/system/init/mode_exec.go +++ b/lib/system/init/mode_exec.go @@ -37,12 +37,14 @@ func runExecMode(log *Logger, cfg *vmconfig.Config) { os.Setenv("HOME", "/root") // Start guest-agent in background (skip if guest-agent was not copied) + // Pass environment variables so they're available via hypeman exec var agentCmd *exec.Cmd if cfg.SkipGuestAgent { log.Info("exec", "skipping guest-agent (skip_guest_agent=true)") } else { log.Info("exec", "starting guest-agent in background") agentCmd = exec.Command("/opt/hypeman/guest-agent") + agentCmd.Env = buildEnv(cfg.Env) agentCmd.Stdout = os.Stdout agentCmd.Stderr = os.Stderr if err := agentCmd.Start(); err != nil { diff --git a/lib/system/init/mode_systemd.go b/lib/system/init/mode_systemd.go index 744d7ed5..8b0de147 100644 --- a/lib/system/init/mode_systemd.go +++ b/lib/system/init/mode_systemd.go @@ -18,11 +18,12 @@ func runSystemdMode(log *Logger, cfg *vmconfig.Config) { const newroot = "/overlay/newroot" // Inject hypeman-agent.service (skip if guest-agent was not copied) + // Pass environment variables so they're available via hypeman exec if cfg.SkipGuestAgent { log.Info("systemd", "skipping agent service injection (skip_guest_agent=true)") } else { log.Info("systemd", "injecting hypeman-agent.service") - if err := injectAgentService(newroot); err != nil { + if err := injectAgentService(newroot, cfg.Env); err != nil { log.Error("systemd", "failed to inject service", err) // Continue anyway - VM will work, just without agent } @@ -61,7 +62,9 @@ func runSystemdMode(log *Logger, cfg *vmconfig.Config) { } // injectAgentService creates the systemd service unit for the hypeman guest-agent. -func injectAgentService(newroot string) error { +// It also writes an environment file with the configured env vars so they're +// available to commands run via hypeman exec. +func injectAgentService(newroot string, env map[string]string) error { serviceContent := `[Unit] Description=Hypeman Guest Agent After=network.target @@ -70,6 +73,7 @@ Wants=network.target [Service] Type=simple ExecStart=/opt/hypeman/guest-agent +EnvironmentFile=-/etc/hypeman/env Restart=always RestartSec=3 StandardOutput=journal @@ -81,6 +85,7 @@ WantedBy=multi-user.target serviceDir := newroot + "/etc/systemd/system" wantsDir := serviceDir + "/multi-user.target.wants" + hypemanDir := newroot + "/etc/hypeman" // Create directories if err := os.MkdirAll(serviceDir, 0755); err != nil { @@ -89,6 +94,17 @@ WantedBy=multi-user.target if err := os.MkdirAll(wantsDir, 0755); err != nil { return err } + if err := os.MkdirAll(hypemanDir, 0755); err != nil { + return err + } + + // Write environment file with configured env vars + // Format: KEY=VALUE, one per line + envContent := buildEnvFileContent(env) + envPath := hypemanDir + "/env" + if err := os.WriteFile(envPath, []byte(envContent), 0644); err != nil { + return err + } // Write service file servicePath := serviceDir + "/hypeman-agent.service" @@ -102,3 +118,23 @@ WantedBy=multi-user.target return os.Symlink("../hypeman-agent.service", symlinkPath) } +// buildEnvFileContent creates systemd environment file content from env map. +// Includes default PATH and HOME if not already set. +func buildEnvFileContent(env map[string]string) string { + var content string + + // Add user's environment variables + for k, v := range env { + content += fmt.Sprintf("%s=%s\n", k, v) + } + + // Add defaults only if not already set by user + if _, ok := env["PATH"]; !ok { + content += "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n" + } + if _, ok := env["HOME"]; !ok { + content += "HOME=/root\n" + } + + return content +} From d1307d30e29564b5c941ff83761f432dd76bdcfe Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 28 Jan 2026 12:37:06 -0500 Subject: [PATCH 2/3] Update readme --- lib/system/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/system/README.md b/lib/system/README.md index 0707175f..30afa646 100644 --- a/lib/system/README.md +++ b/lib/system/README.md @@ -73,6 +73,8 @@ It replaces the previous shell-based init script with cleaner logic and structur - **Exec mode** (default): Init chroots to container rootfs, runs entrypoint as child process, then waits on guest-agent to keep VM alive - **Systemd mode** (auto-detected on host): Init chroots to container rootfs, then execs /sbin/init so systemd becomes PID 1 +**Environment variables:** In exec mode, env vars are passed directly to the entrypoint and guest-agent processes. In systemd mode, env vars are written to `/etc/hypeman/env` and loaded via `EnvironmentFile` in the `hypeman-agent.service` unit. + **Systemd detection:** Host-side detection in `lib/images/systemd.go` checks if image CMD is `/sbin/init`, `/lib/systemd/systemd`, or similar. The detected mode is passed to the initrd via `INIT_MODE` in the config disk. From 43869c853b43d0393c44b831138c4499c656ceaf Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 28 Jan 2026 13:31:29 -0500 Subject: [PATCH 3/3] Handle shell escaping in systemd envs --- go.mod | 1 + go.sum | 4 + lib/system/init/mode_systemd.go | 5 +- lib/system/init/mode_systemd_test.go | 177 +++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 lib/system/init/mode_systemd_test.go diff --git a/go.mod b/go.mod index 7bc68b7d..e7966543 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kernel/hypeman go 1.25.4 require ( + al.essio.dev/pkg/shellescape v1.6.0 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/creack/pty v1.1.24 github.com/cyphar/filepath-securejoin v0.6.1 diff --git a/go.sum b/go.sum index 3edd3725..28447764 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= @@ -96,6 +98,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/lib/system/init/mode_systemd.go b/lib/system/init/mode_systemd.go index 8b0de147..fb00fc79 100644 --- a/lib/system/init/mode_systemd.go +++ b/lib/system/init/mode_systemd.go @@ -5,6 +5,7 @@ import ( "os" "syscall" + "al.essio.dev/pkg/shellescape" "github.com/kernel/hypeman/lib/vmconfig" ) @@ -120,12 +121,14 @@ WantedBy=multi-user.target // buildEnvFileContent creates systemd environment file content from env map. // Includes default PATH and HOME if not already set. +// Values are properly quoted and escaped for systemd's EnvironmentFile format +// using shellescape.Quote() which handles shell-style quoting. func buildEnvFileContent(env map[string]string) string { var content string // Add user's environment variables for k, v := range env { - content += fmt.Sprintf("%s=%s\n", k, v) + content += fmt.Sprintf("%s=%s\n", k, shellescape.Quote(v)) } // Add defaults only if not already set by user diff --git a/lib/system/init/mode_systemd_test.go b/lib/system/init/mode_systemd_test.go new file mode 100644 index 00000000..2f560f34 --- /dev/null +++ b/lib/system/init/mode_systemd_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "testing" + + "al.essio.dev/pkg/shellescape" + "github.com/stretchr/testify/assert" +) + +func TestBuildEnvFileContent(t *testing.T) { + tests := []struct { + name string + env map[string]string + contains []string + }{ + { + name: "simple env vars", + env: map[string]string{ + "FOO": "bar", + "BAZ": "qux", + }, + contains: []string{ + "FOO=bar\n", + "BAZ=qux\n", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n", + "HOME=/root\n", + }, + }, + { + name: "env var with spaces uses single quotes", + env: map[string]string{ + "MESSAGE": "hello world", + }, + contains: []string{ + "MESSAGE='hello world'\n", + }, + }, + { + name: "env var with double quotes", + env: map[string]string{ + "QUOTED": `say "hello"`, + }, + contains: []string{ + // shellescape uses single quotes, so double quotes don't need escaping + `QUOTED='say "hello"'` + "\n", + }, + }, + { + name: "env var with dollar sign", + env: map[string]string{ + "VAR": "$HOME/path", + }, + contains: []string{ + // Inside single quotes, $ is literal + "VAR='$HOME/path'\n", + }, + }, + { + name: "custom PATH overrides default", + env: map[string]string{ + "PATH": "/custom/path", + }, + contains: []string{ + "PATH=/custom/path\n", + }, + }, + { + name: "custom HOME overrides default", + env: map[string]string{ + "HOME": "/home/user", + }, + contains: []string{ + "HOME=/home/user\n", + }, + }, + { + name: "empty env gets defaults", + env: map[string]string{}, + contains: []string{ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n", + "HOME=/root\n", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := buildEnvFileContent(tc.env) + for _, expected := range tc.contains { + assert.Contains(t, result, expected) + } + }) + } + + // Test that custom PATH doesn't get default PATH added + t.Run("custom PATH excludes default", func(t *testing.T) { + result := buildEnvFileContent(map[string]string{"PATH": "/custom"}) + assert.Contains(t, result, "PATH=/custom\n") + assert.NotContains(t, result, "/usr/local/sbin") + }) + + // Test that custom HOME doesn't get default HOME added + t.Run("custom HOME excludes default", func(t *testing.T) { + result := buildEnvFileContent(map[string]string{"HOME": "/home/user"}) + assert.Contains(t, result, "HOME=/home/user\n") + // Count occurrences of HOME= + count := 0 + for i := 0; i < len(result)-5; i++ { + if result[i:i+5] == "HOME=" { + count++ + } + } + assert.Equal(t, 1, count, "HOME should appear exactly once") + }) +} + +// TestShellescape verifies shellescape.Quote behavior for documentation +func TestShellescape(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple value unchanged", + input: "hello", + expected: "hello", + }, + { + name: "empty string", + input: "", + expected: "''", + }, + { + name: "spaces get single quoted", + input: "hello world", + expected: "'hello world'", + }, + { + name: "double quotes safe in single quotes", + input: `say "hello"`, + expected: `'say "hello"'`, + }, + { + name: "single quote gets escaped", + input: "it's fine", + expected: "'it'\"'\"'s fine'", + }, + { + name: "dollar sign safe in single quotes", + input: "$HOME/file", + expected: "'$HOME/file'", + }, + { + name: "backtick safe in single quotes", + input: "`command`", + expected: "'`command`'", + }, + { + name: "newline gets quoted", + input: "line1\nline2", + expected: "'line1\nline2'", + }, + { + name: "path unchanged", + input: "/usr/local/bin", + expected: "/usr/local/bin", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := shellescape.Quote(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}