diff --git a/agent/internal/winexec/encode.go b/agent/internal/winexec/encode.go new file mode 100644 index 0000000..8ea7cc8 --- /dev/null +++ b/agent/internal/winexec/encode.go @@ -0,0 +1,24 @@ +// Package winexec runs PowerShell on Windows hosts. +// +// It exists because three subsystems — updates, workload collection and +// workload logs — all need the same invocation, and because getting a +// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own +// parser is a problem worth solving once. +package winexec + +import ( + "encoding/base64" + "unicode/utf16" +) + +// EncodeCommand renders a script for powershell.exe -EncodedCommand: UTF-16LE, +// no byte-order mark, base64. This is deliberately free of build tags so it is +// tested on a Linux development machine like every other pure function here. +func EncodeCommand(script string) string { + units := utf16.Encode([]rune(script)) + b := make([]byte, 0, len(units)*2) + for _, u := range units { + b = append(b, byte(u), byte(u>>8)) + } + return base64.StdEncoding.EncodeToString(b) +} diff --git a/agent/internal/winexec/encode_test.go b/agent/internal/winexec/encode_test.go new file mode 100644 index 0000000..e404e16 --- /dev/null +++ b/agent/internal/winexec/encode_test.go @@ -0,0 +1,20 @@ +package winexec + +import "testing" + +func TestEncodeCommand(t *testing.T) { + // "hi" as UTF-16LE is 68 00 69 00, which base64-encodes to aABpAA==. + if got := EncodeCommand("hi"); got != "aABpAA==" { + t.Fatalf("EncodeCommand(hi) = %q, want aABpAA==", got) + } +} + +func TestEncodeCommandMultiline(t *testing.T) { + // Only that it round-trips through the same encoding PowerShell expects: + // every ASCII byte followed by a zero byte, no BOM. + got := EncodeCommand("a\nb") + want := "YQAKAGIA" + if got != want { + t.Fatalf("EncodeCommand = %q, want %q", got, want) + } +} diff --git a/agent/internal/winexec/run_windows.go b/agent/internal/winexec/run_windows.go new file mode 100644 index 0000000..bb12790 --- /dev/null +++ b/agent/internal/winexec/run_windows.go @@ -0,0 +1,31 @@ +package winexec + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// Run executes a PowerShell script and returns its stdout. +// +// powershell.exe rather than pwsh: everything this agent runs through here +// touches Windows Update COM or CIM, both of which are most reliable under +// Windows PowerShell 5.1, and 5.1 is present on every supported Windows while +// pwsh is an optional install. +func Run(ctx context.Context, script string) (string, error) { + cmd := exec.CommandContext(ctx, "powershell.exe", + "-NoProfile", "-NonInteractive", "-EncodedCommand", EncodeCommand(script)) + + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr))) + } + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("powershell: timed out") + } + return "", fmt.Errorf("powershell: %w", err) + } + return string(out), nil +}