diff --git a/packages/gateway-v2/ssh_handler.go b/packages/gateway-v2/ssh_handler.go index b12ff52d..a8a9f0c1 100644 --- a/packages/gateway-v2/ssh_handler.go +++ b/packages/gateway-v2/ssh_handler.go @@ -22,6 +22,7 @@ type sshExecEnvelope struct { Username string `json:"username"` Password string `json:"password"` PrivateKey string `json:"privateKey"` + Passphrase string `json:"passphrase"` Certificate string `json:"certificate"` TimeoutMs int `json:"timeoutMs"` } @@ -45,18 +46,25 @@ type sshExecErrorBody struct { Message string `json:"message"` } +func parseSSHExecPrivateKey(privateKey, passphrase string) (ssh.Signer, error) { + if passphrase != "" { + return ssh.ParsePrivateKeyWithPassphrase([]byte(privateKey), []byte(passphrase)) + } + return ssh.ParsePrivateKey([]byte(privateKey)) +} + func buildSSHExecAuth(env sshExecEnvelope) ([]ssh.AuthMethod, error) { switch env.AuthMethod { case "password": return []ssh.AuthMethod{ssh.Password(env.Password)}, nil case "public-key": - signer, err := ssh.ParsePrivateKey([]byte(env.PrivateKey)) + signer, err := parseSSHExecPrivateKey(env.PrivateKey, env.Passphrase) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil case "certificate": - signer, err := ssh.ParsePrivateKey([]byte(env.PrivateKey)) + signer, err := parseSSHExecPrivateKey(env.PrivateKey, env.Passphrase) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } diff --git a/packages/gateway-v2/winrm/winrm.go b/packages/gateway-v2/winrm/winrm.go index a91eb65c..01b79e25 100644 --- a/packages/gateway-v2/winrm/winrm.go +++ b/packages/gateway-v2/winrm/winrm.go @@ -495,6 +495,205 @@ func RemoveFiles(ctx context.Context, creds Credentials, paths []string) error { return nil } +// CommandResult is the outcome of a command run on the host. A non-zero ExitCode means the command failed. +type CommandResult struct { + Stdout string + Stderr string + ExitCode int + Truncated bool +} + +// maxCommandOutputBytes caps the output captured per stream so a verbose command can't exhaust memory. +const maxCommandOutputBytes = 64 * 1024 + +// limitedBuffer buffers up to limit bytes and sets truncated once more arrives. Writes never fail. The +// retained tail means truncation can drop output without losing the trailer, which is written last. +type limitedBuffer struct { + buf *bytes.Buffer + limit int + truncated bool + tail []byte +} + +const commandTailBytes = 512 + +func (w *limitedBuffer) Write(p []byte) (int, error) { + if remaining := w.limit - w.buf.Len(); remaining > 0 { + if len(p) > remaining { + w.buf.Write(p[:remaining]) + w.truncated = true + } else { + w.buf.Write(p) + } + } else if len(p) > 0 { + w.truncated = true + } + + w.tail = append(w.tail, p...) + if len(w.tail) > commandTailBytes { + w.tail = append([]byte(nil), w.tail[len(w.tail)-commandTailBytes:]...) + } + return len(p), nil +} + +// commandScriptTemplate wraps the operator's command (%[1]s) so it reports its exit code in a stdout +// trailer tagged with a per-run nonce (%[2]s). An exit from inside the try block arrives as 0. +const commandScriptTemplate = `$ErrorActionPreference = 'Stop' +$%[3]s = 0 +try { +%[1]s +if (-not $?) { $%[3]s = if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } } +} catch { +[Console]::Error.WriteLine($_.Exception.Message) +$%[3]s = 1 +} +[Console]::Out.WriteLine('') +[Console]::Out.WriteLine('%[2]s:' + $%[3]s) +` + +func buildCommandScript(command, nonce string) string { + return fmt.Sprintf(commandScriptTemplate, command, nonce, commandOutcomeVariable(nonce)) +} + +func commandOutcomeVariable(nonce string) string { + return strings.ReplaceAll(nonce, "-", "") +} + +func newCommandNonce() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("failed to prepare the command: %w", err) + } + return "infisical-" + hex.EncodeToString(buf), nil +} + +// takeCommandTrailer splits the trailer off stdout, matching the marker anywhere so output with no +// trailing newline can't hide it. +func takeCommandTrailer(stdout, nonce string) (code int, remaining string, ok bool) { + marker := nonce + ":" + start := strings.LastIndex(stdout, marker) + if start < 0 { + return 0, stdout, false + } + + code, err := strconv.Atoi(strings.TrimSpace(stdout[start+len(marker):])) + if err != nil { + return 0, stdout, false + } + // Drop only the blank line the script writes, so output ending in blank lines keeps them. + return code, strings.TrimSuffix(strings.TrimSuffix(stdout[:start], "\n"), "\r"), true +} + +// noOutcomeMessage covers both causes: PowerShell parses the whole script before running any of it. +const noOutcomeMessage = "The command did not report a result: it either called exit, which stops the " + + "script early, or did not parse. Use `throw \"reason\"` to fail the sync deliberately." + +// maxEncodedCommandChars bounds the -EncodedCommand command line, which Windows caps at 8191 characters. +const maxEncodedCommandChars = 8000 + +var clixmlEntities = strings.NewReplacer("<", "<", ">", ">", "&", "&", """, `"`, "'", "'") + +// normalizePowerShellStderr converts the CLIXML PowerShell writes to stderr when it has no console. +func normalizePowerShellStderr(s string) string { + if !strings.HasPrefix(strings.TrimSpace(s), "#< CLIXML") { + return s + } + matches := clixmlErrSegment.FindAllStringSubmatch(s, -1) + if len(matches) == 0 { + // Keep the raw payload rather than dropping the diagnostic. + return s + } + + var parts []string + for _, match := range matches { + text := match[1] + text = strings.ReplaceAll(text, "_x000D__x000A_", "\n") + text = strings.ReplaceAll(text, "_x000D_", "") + text = strings.ReplaceAll(text, "_x000A_", "\n") + text = clixmlEntities.Replace(text) + if trimmed := strings.TrimSpace(text); trimmed != "" { + parts = append(parts, trimmed) + } + } + return strings.Join(parts, "\n") +} + +// resolveCommandOutcome turns what the script reported into the code and stderr the caller sees. An +// unstated outcome has to fail: the script carries no `exit`, so powershell.exe returns 0 either way. +func resolveCommandOutcome(code int, stated bool, stderr string) (int, string) { + if stated { + return code, stderr + } + // Prepended, because the caller quotes the first line of stderr as the failure reason. + return 1, strings.TrimSpace(noOutcomeMessage + "\n" + stderr) +} + +// RunCommand runs a command on the host. The script goes on the command line as -EncodedCommand, so it +// is length-bounded and visible in the host's process table. +func RunCommand( + ctx context.Context, + creds Credentials, + command string, + timeout time.Duration, +) (CommandResult, error) { + nonce, err := newCommandNonce() + if err != nil { + return CommandResult{}, err + } + + encoded := winrm.Powershell(buildCommandScript(command, nonce)) + if encoded == "" { + return CommandResult{}, errors.New("failed to encode the command") + } + if len(encoded) > maxEncodedCommandChars { + return CommandResult{}, fmt.Errorf( + "the command is too long to run on Windows once encoded (%d of %d characters). Shorten it, "+ + "or move the logic into a script on the host and call that script", + len(encoded), maxEncodedCommandChars, + ) + } + + client, clientErr := newClient(ctx, creds) + if clientErr != nil { + return CommandResult{}, clientErr + } + + runCtx := ctx + if timeout > 0 { + var cancel context.CancelFunc + runCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + var stdout, stderr bytes.Buffer + stdoutWriter := &limitedBuffer{buf: &stdout, limit: maxCommandOutputBytes} + stderrWriter := &limitedBuffer{buf: &stderr, limit: maxCommandOutputBytes} + + _, runErr := client.RunWithContext(runCtx, encoded, stdoutWriter, stderrWriter) + if runErr != nil { + if errors.Is(runCtx.Err(), context.DeadlineExceeded) { + return CommandResult{}, fmt.Errorf("command timed out after %s", timeout) + } + return CommandResult{}, wrapTransportError(runErr) + } + + result := CommandResult{ + Stderr: normalizePowerShellStderr(stderr.String()), + Truncated: stdoutWriter.truncated || stderrWriter.truncated, + } + + code, remaining, stated := takeCommandTrailer(stdout.String(), nonce) + result.Stdout = remaining + if !stated { + // Truncation drops the head, so fall back to the retained tail. + code, _, stated = takeCommandTrailer(string(stdoutWriter.tail), nonce) + } + + result.ExitCode, result.Stderr = resolveCommandOutcome(code, stated, result.Stderr) + + return result, nil +} + // escapePowerShellSingleQuotes escapes a value for a PowerShell single-quoted string by doubling the single quote. func escapePowerShellSingleQuotes(s string) string { return strings.ReplaceAll(s, "'", "''") diff --git a/packages/gateway-v2/winrm/winrm_command_test.go b/packages/gateway-v2/winrm/winrm_command_test.go new file mode 100644 index 00000000..ffe2c2dc --- /dev/null +++ b/packages/gateway-v2/winrm/winrm_command_test.go @@ -0,0 +1,322 @@ +package winrm + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/masterzen/winrm" +) + +func TestEscapePowerShellSingleQuotesNeutralizesInjection(t *testing.T) { + got := escapePowerShellSingleQuotes(`it's'; Remove-Item C:\ -Recurse; #`) + + if !strings.Contains(got, `it''s''; Remove-Item C:\ -Recurse; #`) { + t.Fatalf("expected doubled single quotes, got %q", got) + } + if strings.Contains(got, `'it's'`) { + t.Fatalf("single quote was not escaped, got %q", got) + } +} + +func TestBuildCommandScriptPropagatesFailures(t *testing.T) { + script := buildCommandScript("Write-Output ok", "infisical-nonce123") + + for _, expected := range []string{ + "$ErrorActionPreference = 'Stop'", + "if (-not $?) { $infisicalnonce123 = if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } }", + "[Console]::Error.WriteLine($_.Exception.Message)", + "[Console]::Out.WriteLine('infisical-nonce123:' + $infisicalnonce123)", + } { + if !strings.Contains(script, expected) { + t.Fatalf("expected script to contain %q, got:\n%s", expected, script) + } + } + if strings.Contains(script, "exit $LASTEXITCODE") || strings.Contains(script, "exit 1") { + t.Fatalf("the script must state its outcome rather than exit with a code, got:\n%s", script) + } +} + +func TestTakeCommandTrailer(t *testing.T) { + const nonce = "infisical-abc" + + t.Run("reads the stated code and strips the trailer", func(t *testing.T) { + // The blank line is the separator the script writes before the trailer. + code, remaining, ok := takeCommandTrailer("reloaded\r\n\r\n"+nonce+":7\r\n", nonce) + if !ok || code != 7 { + t.Fatalf("expected code 7 to be read, got code=%d ok=%v", code, ok) + } + // The command's own newline survives; only the separator the script added is removed. + if remaining != "reloaded\r\n" { + t.Fatalf("expected only the trailer and its separator to be stripped, got %q", remaining) + } + }) + + t.Run("reports no trailer when the command exited early", func(t *testing.T) { + if _, _, ok := takeCommandTrailer("partial output\r\n", nonce); ok { + t.Fatal("expected output with no trailer to report none") + } + }) + + t.Run("finds the trailer even when the command left no newline before it", func(t *testing.T) { + code, remaining, ok := takeCommandTrailer("no trailing newline"+nonce+":3", nonce) + if !ok || code != 3 { + t.Fatalf("expected the marker to be found mid-line, got code=%d ok=%v", code, ok) + } + if remaining != "no trailing newline" { + t.Fatalf("expected the command's own output to survive, got %q", remaining) + } + }) + + t.Run("keeps the last marker when output contains an earlier lookalike", func(t *testing.T) { + code, _, ok := takeCommandTrailer("echoed "+nonce+":9\r\n"+nonce+":0", nonce) + if !ok || code != 0 { + t.Fatalf("expected the trailing marker to win, got code=%d ok=%v", code, ok) + } + }) + + t.Run("a command echoing a trailer of its own cannot forge one", func(t *testing.T) { + if _, _, ok := takeCommandTrailer("infisical-guessed:0\r\n", nonce); ok { + t.Fatal("expected a foreign marker to be ignored") + } + }) + + t.Run("leaves stdout untouched when there is no trailer", func(t *testing.T) { + _, remaining, _ := takeCommandTrailer("just output", nonce) + if remaining != "just output" { + t.Fatalf("expected stdout to survive unchanged, got %q", remaining) + } + }) +} + +func TestNewCommandNonceIsUniquePerRun(t *testing.T) { + first, err := newCommandNonce() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + second, _ := newCommandNonce() + + if first == second { + t.Fatal("expected a fresh nonce per run so a command cannot predict it") + } + if !strings.HasPrefix(first, "infisical-") || len(first) < 20 { + t.Fatalf("unexpected nonce shape %q", first) + } +} + +func TestNormalizePowerShellStderrExtractsClixmlText(t *testing.T) { + clixml := `#< CLIXML +` + + `Cannot find any service with service name 'NoSuchSvc'._x000D__x000A_` + + `At line:1 char:1` + + got := normalizePowerShellStderr(clixml) + + if strings.Contains(got, "CLIXML") || strings.Contains(got, " element, otherwise extraction succeeds and the fallback isn't hit. + clixml := "#< CLIXML\n" + `7` + + got := normalizePowerShellStderr(clixml) + + if got != clixml { + t.Fatalf("expected the raw payload to be returned unchanged, got %q", got) + } +} + +func TestNormalizePowerShellStderrDropsEmptyEnvelope(t *testing.T) { + got := normalizePowerShellStderr("#< CLIXML\n ") + + if got != "" { + t.Fatalf("expected an all-blank envelope to yield an empty string, got %q", got) + } +} + +func TestBuildCommandScriptDoesNotDuplicateProgressPreference(t *testing.T) { + if strings.Contains(buildCommandScript("Write-Output ok", "infisical-nonce123"), "$ProgressPreference") { + t.Fatal("expected the wrapper to leave $ProgressPreference to winrm.Powershell") + } +} + +func TestRunCommandRejectsAnOverlongEncodedCommand(t *testing.T) { + // The length check runs before any connection, so no host is needed. + _, err := RunCommand(context.Background(), Credentials{}, strings.Repeat("a", 10_000), time.Second) + + if err == nil { + t.Fatal("expected an over-long command to be rejected") + } + if !strings.Contains(err.Error(), "too long to run on Windows once encoded") { + t.Fatalf("expected the encoded-length error, got %v", err) + } +} + +func TestHandlerCommandCapAlwaysFitsInsideTheEncodedCeiling(t *testing.T) { + // Mirrors maxWinrmCommandChars in the parent package. + const handlerCommandCap = 2048 + + encoded := winrm.Powershell(buildCommandScript(strings.Repeat("a", handlerCommandCap), "infisical-0123456789abcdef0123456789abcdef")) + + if len(encoded) > maxEncodedCommandChars { + t.Fatalf("a command at the handler's %d-char cap encodes to %d, past the %d ceiling", + handlerCommandCap, len(encoded), maxEncodedCommandChars) + } + t.Logf("handler cap %d chars -> %d encoded, ceiling %d", handlerCommandCap, len(encoded), maxEncodedCommandChars) +} + +func TestResolveCommandOutcome(t *testing.T) { + t.Run("uses the code the script stated", func(t *testing.T) { + code, stderr := resolveCommandOutcome(5, true, "boom") + if code != 5 || stderr != "boom" { + t.Fatalf("expected the stated code to pass through, got code=%d stderr=%q", code, stderr) + } + }) + + t.Run("an unstated outcome fails and never reports success", func(t *testing.T) { + // The script carries no `exit`, so powershell.exe returns 0 for a failed command too. Taking + // the process exit code here would report a failed command as a successful sync. + code, stderr := resolveCommandOutcome(0, false, "") + if code == 0 { + t.Fatal("expected an unstated outcome to fail, not to inherit a zero exit code") + } + if !strings.Contains(stderr, "did not report a result") { + t.Fatalf("expected the reason to be explained, got %q", stderr) + } + }) + + t.Run("the explanation covers a parse error, not only exit", func(t *testing.T) { + // PowerShell parses the whole script before running any of it, so a command that does not parse + // produces no trailer either. Blaming exit alone would misname the commonest mistake. + _, stderr := resolveCommandOutcome(0, false, "") + if !strings.Contains(stderr, "did not parse") { + t.Fatalf("expected parse failures to be covered, got %q", stderr) + } + }) + + t.Run("leads with the explanation so the caller quotes it", func(t *testing.T) { + _, stderr := resolveCommandOutcome(0, false, "some earlier noise") + if !strings.HasPrefix(stderr, "The command did not report a result") { + t.Fatalf("expected the explanation first, got %q", stderr) + } + }) +} + +func TestLimitedBufferKeepsTheTailPastTheCap(t *testing.T) { + // A command that floods stdout and then fails must still be reported as failed: the trailer is the + // last thing written, so it has to survive the head being capped. + var buf bytes.Buffer + writer := &limitedBuffer{buf: &buf, limit: 16} + + writer.Write([]byte(strings.Repeat("x", 4096))) + writer.Write([]byte("\nnonce-abc:7\n")) + + if !writer.truncated { + t.Fatal("expected the head to be marked truncated") + } + code, _, ok := takeCommandTrailer(string(writer.tail), "nonce-abc") + if !ok || code != 7 { + t.Fatalf("expected the trailer to survive truncation, got code=%d ok=%v", code, ok) + } + if len(writer.tail) > commandTailBytes { + t.Fatalf("expected the tail to stay bounded, got %d bytes", len(writer.tail)) + } +} + +func TestTakeCommandTrailerKeepsDeliberateBlankLines(t *testing.T) { + // Only the separator the script writes is removed, so output that ends in blank lines keeps them. + _, remaining, ok := takeCommandTrailer("line\n\n\n\nnonce-abc:0\n", "nonce-abc") + if !ok { + t.Fatal("expected the trailer to be found") + } + if remaining != "line\n\n\n" { + t.Fatalf("expected only the separator to be stripped, got %q", remaining) + } +} + +func TestBuildCommandScriptNamesTheOutcomeVariablePerRun(t *testing.T) { + // The command shares scope with the wrapper, so a fixed name could be assigned by the command + // itself and report the wrong outcome. Naming it after the nonce makes that impossible to hit. + first := buildCommandScript("Write-Output ok", "infisical-aaaa") + second := buildCommandScript("Write-Output ok", "infisical-bbbb") + + if !strings.Contains(first, "$infisicalaaaa") || !strings.Contains(second, "$infisicalbbbb") { + t.Fatal("expected the outcome variable to be named after the run's nonce") + } + if strings.Contains(first, "$InfisicalExitCode") { + t.Fatal("expected no fixed variable name a command could collide with") + } + if strings.Contains(commandOutcomeVariable("infisical-aaaa"), "-") { + t.Fatal("a PowerShell identifier cannot contain a dash") + } +} + +func TestBuildCommandScriptSurvivesACommandTouchingTheOldName(t *testing.T) { + // The exact collision the review raised: a command that assigns the wrapper's variable. + script := buildCommandScript("$InfisicalExitCode = 0; throw \"boom\"", "infisical-cccc") + + // The command's assignment hits its own variable, not the one the trailer reports. + if !strings.Contains(script, "[Console]::Out.WriteLine('infisical-cccc:' + $infisicalcccc)") { + t.Fatalf("expected the trailer to read the run's own variable, got:\n%s", script) + } +} diff --git a/packages/gateway-v2/winrm_handler.go b/packages/gateway-v2/winrm_handler.go index dd5055cc..64c7dcd7 100644 --- a/packages/gateway-v2/winrm_handler.go +++ b/packages/gateway-v2/winrm_handler.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "net/http" + "strings" "sync" "time" @@ -67,6 +68,19 @@ type winrmRemoveParams struct { Paths []string `json:"paths"` } +type winrmRunCommandParams struct { + winrmTransportParams + Command string `json:"command"` + TimeoutMs int `json:"timeoutMs"` +} + +type winrmRunCommandResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exitCode"` + Truncated bool `json:"truncated"` +} + type winrmRotateParams struct { winrmTransportParams Kind string `json:"kind"` // "local" or "domain" @@ -106,6 +120,10 @@ const ( winrmOpDeadline = 120 * time.Second winrmConnDeadline = winrmOpDeadline + 15*time.Second maxWinrmRequestBodyBytes = 4 * 1024 * 1024 + + maxWinrmCommandChars = 2048 + defaultWinrmCommandTimeout = 30 * time.Second + maxWinrmCommandTimeout = 90 * time.Second ) // serveWinrmOverTLS reads a single HTTP request off the TLS relay connection and dispatches it to the mux. @@ -153,6 +171,7 @@ var serveWinrmMux = sync.OnceValue(func() *http.ServeMux { mux.HandleFunc("/v1/test-connection", wrapWinrm(handleWinrmConnectionTest)) mux.HandleFunc("/v1/deliver-files", wrapWinrm(handleWinrmDeliverFiles)) mux.HandleFunc("/v1/remove-files", wrapWinrm(handleWinrmRemoveFiles)) + mux.HandleFunc("/v1/run-command", wrapWinrm(handleWinrmRunCommand)) mux.HandleFunc("/v1/enumerate-accounts", wrapWinrm(handleWinrmEnumerateAccounts)) mux.HandleFunc("/v1/enumerate-dependencies", wrapWinrm(handleWinrmEnumerateDependencies)) mux.HandleFunc("/v1/rotate-credential", wrapWinrm(handleWinrmRotateCredential)) @@ -292,6 +311,43 @@ func handleWinrmRemoveFiles(ctx context.Context, env *winrmRequestEnvelope) (any return map[string]any{"removed": len(p.Paths)}, nil } +// handleWinrmRunCommand runs a single command on the host. A non-zero exit code is not an RPC error +func handleWinrmRunCommand(ctx context.Context, env *winrmRequestEnvelope) (any, error) { + var p winrmRunCommandParams + if err := json.Unmarshal(env.Params, &p); err != nil { + return nil, fmt.Errorf("malformed run-command params") + } + if strings.TrimSpace(p.Command) == "" { + return nil, fmt.Errorf("command is required") + } + if len(p.Command) > maxWinrmCommandChars { + return nil, fmt.Errorf("command exceeds %d characters", maxWinrmCommandChars) + } + + result, err := winrm.RunCommand(ctx, credsFromEnv(ctx, env, p.winrmTransportParams), p.Command, resolveWinrmCommandTimeout(p.TimeoutMs)) + if err != nil { + return nil, err + } + return winrmRunCommandResult{ + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + Truncated: result.Truncated, + }, nil +} + +// resolveWinrmCommandTimeout clamps a requested timeout in milliseconds to the ceiling. +func resolveWinrmCommandTimeout(timeoutMs int) time.Duration { + // Compare in milliseconds: converting to a Duration first overflows int64 for large values. + if timeoutMs <= 0 { + return defaultWinrmCommandTimeout + } + if timeoutMs > int(maxWinrmCommandTimeout/time.Millisecond) { + return maxWinrmCommandTimeout + } + return time.Duration(timeoutMs) * time.Millisecond +} + func handleWinrmEnumerateAccounts(ctx context.Context, env *winrmRequestEnvelope) (any, error) { var tp winrmTransportParams if len(env.Params) > 0 { diff --git a/packages/gateway-v2/winrm_run_command_test.go b/packages/gateway-v2/winrm_run_command_test.go new file mode 100644 index 00000000..bf241306 --- /dev/null +++ b/packages/gateway-v2/winrm_run_command_test.go @@ -0,0 +1,38 @@ +package gatewayv2 + +import ( + "testing" + "time" +) + +func TestResolveWinrmCommandTimeout(t *testing.T) { + cases := []struct { + name string + timeoutMs int + want time.Duration + }{ + {"unset falls back to the default", 0, defaultWinrmCommandTimeout}, + {"negative falls back to the default", -1, defaultWinrmCommandTimeout}, + {"a value inside the range is honoured", 45_000, 45 * time.Second}, + {"the ceiling itself is honoured", int(maxWinrmCommandTimeout / time.Millisecond), maxWinrmCommandTimeout}, + {"above the ceiling clamps down to it", 600_000, maxWinrmCommandTimeout}, + {"a value large enough to overflow a Duration still clamps", 9_300_000_000_000, maxWinrmCommandTimeout}, + } + + for _, c := range cases { + if got := resolveWinrmCommandTimeout(c.timeoutMs); got != c.want { + t.Errorf("%s: resolveWinrmCommandTimeout(%d) = %s, want %s", c.name, c.timeoutMs, got, c.want) + } + } +} + +func TestWinrmCommandBoundsAreConsistent(t *testing.T) { + if defaultWinrmCommandTimeout > maxWinrmCommandTimeout { + t.Fatal("the default timeout must fit inside the ceiling") + } + // The op deadline must be longer than the longest command, otherwise the envelope times out first. + if maxWinrmCommandTimeout >= winrmOpDeadline { + t.Fatalf("maxWinrmCommandTimeout (%s) must stay under winrmOpDeadline (%s)", + maxWinrmCommandTimeout, winrmOpDeadline) + } +}