feat: add interactive Windows installer

This commit is contained in:
2026-09-11 10:10:48 +00:00
parent 9e181b81dc
commit 6882a6808a
11 changed files with 390 additions and 20 deletions
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/rvbox/rvbox/internal/config"
)
const (
defaultInstallerStateDir = `C:\ProgramData\RVBox\data`
// Public is a pre-existing directory usable by the active user, LocalSystem,
// and LocalService fallback. Durable RVBox state stays separately protected.
defaultInstallerWorkDir = `C:\Users\Public`
)
// packagedClientConfig creates the deliberately small first-install TOML. The
// strict production decoder validates it before an elevated installer writes
// anything, so the SCM service never starts from a hand-built invalid file.
func packagedClientConfig(serverURL, clientID string) ([]byte, error) {
serverURL = strings.TrimSpace(serverURL)
clientID = installerClientID(clientID)
if serverURL == "" {
return nil, fmt.Errorf("this RVBox package has no bootstrap server URL")
}
content := fmt.Sprintf("# Generated by the RVBox installer. Edit only through a deliberate reconfiguration.\n[client]\nserver_url = %s\nstate_dir = %s\nclient_id = %s\ndaemon_cwd = %s\n\n[observability]\n# Disabled by default; enable a loopback listener only when diagnostics are needed.\nlisten = \"\"\nlog_file = \"\"\n", strconv.Quote(serverURL), strconv.Quote(defaultInstallerStateDir), strconv.Quote(clientID), strconv.Quote(defaultInstallerWorkDir))
if _, err := config.DecodeClient([]byte(content), config.ClientOptions{Platform: config.PlatformWindows}); err != nil {
return nil, fmt.Errorf("validate packaged client configuration: %w", err)
}
return []byte(content), nil
}
func installerClientID(hostname string) string {
hostname = strings.TrimSpace(hostname)
var result strings.Builder
for _, value := range []byte(hostname) {
if value >= 0x21 && value <= 0x7e {
result.WriteByte(value)
} else {
result.WriteByte('-')
}
}
value := strings.Trim(result.String(), "-")
if value == "" {
value = "rvbox-client"
}
if len(value) > 128 {
value = value[:128]
}
return value
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows
package main
import (
"errors"
"io"
)
func runInteractiveInstaller(io.Writer, io.Writer) error {
return errors.New("the v1 interactive installer is implemented for Windows only")
}
func installPackagedDefault(io.Writer) error {
return errors.New("the v1 packaged installer is implemented for Windows only")
}
+207
View File
@@ -0,0 +1,207 @@
//go:build windows
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"github.com/rvbox/rvbox/internal/client/windowsservice"
"golang.org/x/sys/windows"
)
const (
installerInfoIcon = 0x00000040
installerErrorIcon = 0x00000010
)
var (
installerUser32 = syscall.NewLazyDLL("user32.dll")
installerMessageBox = installerUser32.NewProc("MessageBoxW")
)
// runInteractiveInstaller is the double-click entry point. It does no
// machine-wide mutation itself: ShellExecute's runas verb causes Windows to
// show the standard UAC consent prompt before the elevated child installs.
func runInteractiveInstaller(_ io.Writer, _ io.Writer) error {
executable, err := os.Executable()
if err != nil {
showInstallerMessage("RVBox setup could not locate its executable.", installerErrorIcon)
return err
}
file, err := windows.UTF16PtrFromString(executable)
if err != nil {
return err
}
arguments, err := windows.UTF16PtrFromString("--install-default")
if err != nil {
return err
}
if err := windows.ShellExecute(0, installerUTF16("runas"), file, arguments, nil, windows.SW_SHOWNORMAL); err != nil {
showInstallerMessage("RVBox setup was cancelled or could not obtain administrator approval.\n\nNo changes were made.", installerErrorIcon)
return fmt.Errorf("request installer elevation: %w", err)
}
return nil
}
// installPackagedDefault executes only in the UAC-elevated child. It preserves
// a pre-existing operator configuration, atomically updates the executable,
// installs/updates the SCM service, and starts it.
func installPackagedDefault(_ io.Writer) error {
err := installPackagedDefaultFiles()
if err != nil {
showInstallerMessage("RVBox could not be installed.\n\n"+err.Error()+"\n\nNo existing configuration was replaced.", installerErrorIcon)
return err
}
showInstallerMessage("RVBox is installed and its Windows service is starting.\n\nThe notification-area icon starts automatically at the next sign-in.", installerInfoIcon)
return nil
}
func installPackagedDefaultFiles() error {
if strings.TrimSpace(bootstrapServerURL) == "" {
return errors.New("this RVBox package was built without a bootstrap server URL")
}
source, err := os.Executable()
if err != nil {
return fmt.Errorf("locate installer executable: %w", err)
}
programFiles := os.Getenv("ProgramFiles")
if programFiles == "" {
programFiles = `C:\Program Files`
}
programData := os.Getenv("ProgramData")
if programData == "" {
programData = `C:\ProgramData`
}
targetDirectory := filepath.Join(programFiles, "RVBox")
targetExecutable := filepath.Join(targetDirectory, "rvbox.exe")
configDirectory := filepath.Join(programData, "RVBox")
configPath := filepath.Join(configDirectory, "client.toml")
// A running service can keep the prior executable open. Stop it before the
// atomic replacement; a missing service is already a successful first run.
if err := windowsservice.Stop(30); err != nil {
return fmt.Errorf("stop existing RVBox service: %w", err)
}
if err := os.MkdirAll(targetDirectory, 0o755); err != nil {
return fmt.Errorf("create installation directory: %w", err)
}
if !sameWindowsPath(source, targetExecutable) {
if err := copyInstallerExecutable(source, targetExecutable); err != nil {
return err
}
}
if err := os.MkdirAll(configDirectory, 0o700); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
hostname, hostnameErr := os.Hostname()
if hostnameErr != nil {
hostname = "rvbox-client"
}
content, configErr := packagedClientConfig(bootstrapServerURL, hostname)
if configErr != nil {
return configErr
}
if err := writeMachineConfig(configPath, content); err != nil {
return err
}
} else if err != nil {
return fmt.Errorf("inspect existing configuration: %w", err)
}
if err := windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: targetExecutable, ConfigPath: configPath, Startup: windowsservice.StartupAutomatic}); err != nil {
return fmt.Errorf("install and start RVBox service: %w", err)
}
return nil
}
func copyInstallerExecutable(source, target string) error {
input, err := os.Open(source)
if err != nil {
return fmt.Errorf("open installer executable: %w", err)
}
defer input.Close()
temporary := target + ".new"
_ = os.Remove(temporary)
output, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
if err != nil {
return fmt.Errorf("create replacement executable: %w", err)
}
_, copyErr := io.Copy(output, input)
if syncErr := output.Sync(); copyErr == nil {
copyErr = syncErr
}
if closeErr := output.Close(); copyErr == nil {
copyErr = closeErr
}
if copyErr != nil {
_ = os.Remove(temporary)
return fmt.Errorf("copy installer executable: %w", copyErr)
}
from, fromErr := windows.UTF16PtrFromString(temporary)
to, toErr := windows.UTF16PtrFromString(target)
if fromErr != nil || toErr != nil {
_ = os.Remove(temporary)
return errors.New("encode replacement executable path")
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("activate replacement executable: %w", err)
}
return nil
}
func writeMachineConfig(path string, content []byte) error {
temporary := path + ".new"
_ = os.Remove(temporary)
if err := os.WriteFile(temporary, content, 0o600); err != nil {
return fmt.Errorf("write default configuration: %w", err)
}
from, fromErr := windows.UTF16PtrFromString(temporary)
to, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
_ = os.Remove(temporary)
return errors.New("encode configuration path")
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("activate default configuration: %w", err)
}
return protectMachineConfig(path)
}
func protectMachineConfig(path string) error {
descriptor, err := windows.SecurityDescriptorFromString("D:P(A;;FA;;;SY)(A;;FA;;;BA)")
if err != nil {
return err
}
dacl, _, err := descriptor.DACL()
if err != nil {
return err
}
if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil {
return fmt.Errorf("protect generated configuration: %w", err)
}
return nil
}
func sameWindowsPath(first, second string) bool {
return strings.EqualFold(filepath.Clean(first), filepath.Clean(second))
}
func installerUTF16(value string) *uint16 {
encoded, _ := windows.UTF16PtrFromString(value)
return encoded
}
func showInstallerMessage(message string, icon uintptr) {
caption := installerUTF16("RVBox Setup")
text := installerUTF16(message)
_, _, _ = installerMessageBox.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(caption)), icon)
}
+19 -8
View File
@@ -38,6 +38,11 @@ func main() {
var nativeTestContextFailures map[clientwindows.ExecutionContext]bool
// bootstrapServerURL is set only for a packaged Windows installer. A fresh
// machine has no service connection from which it could discover this value,
// so release packaging must deliberately supply the intended WSS endpoint.
var bootstrapServerURL string
// run is deliberately a small mode dispatcher. The SCM service invokes only
// --service with an explicit config path; tray/helper modes cannot silently
// turn an ordinary process invocation into a privileged service.
@@ -47,10 +52,10 @@ func run(args []string, output, diagnostics io.Writer) error {
return err
}
if len(args) == 0 {
return errors.New("an internal mode is required (use --help)")
return runInteractiveInstaller(output, diagnostics)
}
if args[0] == "--help" || args[0] == "-h" {
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
_, err := io.WriteString(output, "usage: rvbox [--install-default]|--service|--tray|--launcher|--signal-helper|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
return err
}
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
@@ -63,6 +68,7 @@ func run(args []string, output, diagnostics io.Writer) error {
channel := flags.String("channel", "", "private launcher channel (internal use only)")
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
install := flags.Bool("install-service", false, "install or update the machine-wide service")
installDefault := flags.Bool("install-default", false, "install the packaged default configuration (internal installer use)")
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
configure := flags.Bool("configure-service", false, "configure machine-wide service startup mode")
startup := flags.String("startup", string(windowsservice.StartupAutomatic), "service startup mode: automatic or manual")
@@ -77,7 +83,7 @@ func run(args []string, output, diagnostics io.Writer) error {
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
selected := 0
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
for _, value := range []bool{*serviceMode, *trayMode, *launcherMode, *signalHelperMode, *checkConfig, *install, *installDefault, *uninstall, *configure, *start, *stop, *restart} {
if value {
selected++
}
@@ -100,6 +106,9 @@ func run(args []string, output, diagnostics io.Writer) error {
}
return windowsservice.Install(windowsservice.InstallSpec{ExecutablePath: executable, ConfigPath: *configPath, Startup: windowsservice.StartupAutomatic})
}
if *installDefault {
return installPackagedDefault(diagnostics)
}
if *uninstall {
return windowsservice.Uninstall()
}
@@ -169,11 +178,13 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
diagnostics = io.MultiWriter(formattedRuntimeLog, diagnostics)
}
health := observability.New()
go func() {
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
}
}()
if configured.Observability.Listen != "" {
go func() {
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
}
}()
}
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}})
if err != nil {
health.SetDirty(true)
+34
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rvbox/rvbox/internal/client/spool"
@@ -82,6 +83,39 @@ func TestNonWindowsServiceModesRemainExplicitlyUnsupported_BH_WINCLI_01(t *testi
}
}
func TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04(t *testing.T) {
t.Parallel()
content, err := packagedClientConfig("wss://controller.example.test/v1/agent", "desktop-01")
if err != nil {
t.Fatal(err)
}
text := string(content)
for _, want := range []string{
`server_url = "wss://controller.example.test/v1/agent"`,
`client_id = "desktop-01"`,
`state_dir = "C:\\ProgramData\\RVBox\\data"`,
`daemon_cwd = "C:\\Users\\Public"`,
`listen = ""`,
} {
if !strings.Contains(text, want) {
t.Fatalf("generated configuration missing %q:\n%s", want, text)
}
}
if _, err := packagedClientConfig("", "desktop-01"); err == nil {
t.Fatal("missing bootstrap URL was accepted")
}
}
func TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05(t *testing.T) {
t.Parallel()
if got := installerClientID(" desktop host\n"); got != "desktop-host" {
t.Fatalf("normalized hostname = %q", got)
}
if got := installerClientID("\x00"); got != "rvbox-client" {
t.Fatalf("fallback hostname = %q", got)
}
}
func TestPublishClientTelemetrySample_HP_OPS_07(t *testing.T) {
t.Parallel()
health := observability.New()
+3 -1
View File
@@ -78,7 +78,9 @@ plus `manifest.json` only after the Windows executable has optionally been
signed:
```sh
scripts/release build --version 1.0.0-rc.1
scripts/release build \
--version 1.0.0-rc.1 \
--bootstrap-server-url wss://rvbox.example.test/v1/agent
(cd dist/rvbox-1.0.0-rc.1 && sha256sum -c SHA256SUMS)
dist/rvbox-1.0.0-rc.1/rvbox-server-linux-ARCH --version
dist/rvbox-1.0.0-rc.1/rvc-linux-ARCH --version
+14
View File
@@ -60,6 +60,20 @@ func TestClientDefaultsDecodeForWindowsWithoutNativeFilesystem(t *testing.T) {
}
}
func TestClientMayDisableObservabilityListener_HP_CFG_10(t *testing.T) {
t.Parallel()
client, err := DecodeClient([]byte("[client]\nstate_dir = \"C:\\\\ProgramData\\\\RVBox\\\\state\"\ndaemon_cwd = \"C:\\\\Users\\\\Public\"\n\n[observability]\nlisten = \"\"\nlog_file = \"\"\n"), ClientOptions{Platform: PlatformWindows})
if err != nil {
t.Fatalf("DecodeClient disabled observability: %v", err)
}
if client.Observability.Listen != "" || client.Observability.LogFile != "" {
t.Fatalf("disabled observability = %+v", client.Observability)
}
if _, err := DecodeServer([]byte("[observability]\nlisten = \"\"\n")); err == nil {
t.Fatal("server accepted a disabled observability listener")
}
}
func TestStrictTOMLRejections_HP_CFG_01(t *testing.T) {
t.Parallel()
+10 -5
View File
@@ -119,7 +119,7 @@ func validateServer(raw serverFile) (*Server, error) {
if err := protocolLimits(raw.Protocol.MaxAgentEnvelopeBytes, raw.Protocol.MaxExecutionSpecBytes, raw.Protocol.MaxRawChunkBytes, raw.Protocol.MaxScriptBytes, raw.Protocol.MaxControlRequestBytes, raw.Protocol.MaxJSONRPCBodyBytes); err != nil {
return nil, err
}
observability, err := validateObservability(raw.Observability, PlatformUnix)
observability, err := validateObservability(raw.Observability, PlatformUnix, false)
if err != nil {
return nil, err
}
@@ -220,7 +220,7 @@ func validateClient(raw clientFile, options ClientOptions) (*Client, error) {
if err != nil {
return nil, err
}
observability, err := validateObservability(raw.Observability, options.Platform)
observability, err := validateObservability(raw.Observability, options.Platform, true)
if err != nil {
return nil, err
}
@@ -439,9 +439,14 @@ func validateDeviceRate(key string, value uint64) error {
return nil
}
func validateObservability(raw observabilityFile, platform Platform) (Observability, error) {
if err := validateListener("observability.listen", raw.Listen); err != nil {
return Observability{}, err
func validateObservability(raw observabilityFile, platform Platform, allowDisabled bool) (Observability, error) {
if raw.Listen == "" && !allowDisabled {
return Observability{}, fmt.Errorf("observability.listen must be a host:port listener")
}
if raw.Listen != "" {
if err := validateListener("observability.listen", raw.Listen); err != nil {
return Observability{}, err
}
}
for name, value := range map[string]string{"liveness_path": raw.LivenessPath, "readiness_path": raw.ReadinessPath, "metrics_path": raw.MetricsPath} {
if err := validateHTTPPath("observability."+name, value); err != nil {
+17 -5
View File
@@ -9,7 +9,7 @@ compose_file=$repo_root/deploy/compose.yaml
usage() {
cat <<'EOF'
usage: scripts/release build --version VERSION [--output DIR] [--sign-windows-hook FILE]
usage: scripts/release build --version VERSION --bootstrap-server-url WSS_URL [--output DIR] [--sign-windows-hook FILE]
Builds a fresh immutable bundle containing:
rvbox-server-linux-amd64
@@ -23,8 +23,10 @@ Builds a fresh immutable bundle containing:
The default output is dist/rvbox-VERSION. --output must remain below this
repository's ignored dist/ tree. VERSION must be a safe release label
([0-9A-Za-z][0-9A-Za-z._+-]{0,63}); an existing final output is never replaced.
Artifacts are built inside the pinned Docker toolchain with that exact version
embedded in `--version`. The optional signing hook is a regular executable run
WSS_URL is the deliberate first-install endpoint embedded only in the Windows
installer; it must be an absolute `wss://` URL and is never inferred from a
test fixture. Artifacts are built inside the pinned Docker toolchain with that
exact version embedded in `--version`. The optional signing hook is a regular executable run
on the host as: HOOK WINDOWS_EXE VERSION. It receives RVBOX_ARTIFACT and
RVBOX_VERSION as environment variables and must sign the supplied executable
in place. SHA256SUMS and manifest.json are generated only after it succeeds.
@@ -43,9 +45,11 @@ command=${1:-}
version=
output=
sign_hook=
bootstrap_server_url=
while [ "$#" -gt 0 ]; do
case $1 in
--version) [ "$#" -ge 2 ] || fail "--version needs a value"; version=$2; shift 2 ;;
--bootstrap-server-url) [ "$#" -ge 2 ] || fail "--bootstrap-server-url needs a value"; bootstrap_server_url=$2; shift 2 ;;
--output) [ "$#" -ge 2 ] || fail "--output needs a directory"; output=$2; shift 2 ;;
--sign-windows-hook) [ "$#" -ge 2 ] || fail "--sign-windows-hook needs an executable file"; sign_hook=$2; shift 2 ;;
--help|-h) usage; exit 0 ;;
@@ -58,6 +62,13 @@ case $version in
fail "--version must match [0-9A-Za-z][0-9A-Za-z._+-]{0,63}"
;;
esac
case $bootstrap_server_url in
wss://*) ;;
*) fail "--bootstrap-server-url must be an absolute wss:// URL" ;;
esac
case $bootstrap_server_url in
*[!A-Za-z0-9:/._-]*) fail "--bootstrap-server-url contains unsupported characters" ;;
esac
if [ -z "$output" ]; then output=$repo_root/dist/rvbox-$version; fi
case $output in
/*) ;;
@@ -93,6 +104,7 @@ docker compose -f "$compose_file" run --rm toolchain sh -ec '
version=$1
out=$2
flags="-s -w -X main.buildVersion=$version"
windows_flags="$flags -X main.bootstrapServerURL=$3"
resource=/workspace/cmd/rvbox/rvbox-release_windows_amd64.syso
icon="$out/.rvbox-release-icon.png"
trap "rm -f -- \"$resource\" \"$icon\"" EXIT HUP INT TERM
@@ -102,8 +114,8 @@ docker compose -f "$compose_file" run --rm toolchain sh -ec '
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvbox-server-linux-arm64" ./cmd/rvbox-server
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-amd64" ./cmd/rvc
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$flags" -o "$out/rvc-linux-arm64" ./cmd/rvc
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox
' sh "$version" "$container_partial"
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-H=windowsgui $windows_flags" -o "$out/rvbox-windows-amd64.exe" ./cmd/rvbox
' sh "$version" "$container_partial" "$bootstrap_server_url"
signed=false
if [ -n "$sign_hook" ]; then
+15
View File
@@ -17,6 +17,21 @@ layer = "unit"
status = "implemented"
tests = ["internal/config/effective_test.go:TestEffectiveConfigurationGolden_HP_CFG_09"]
[[requirements]]
id = "HP-CFG-10"
layer = "unit"
status = "implemented"
tests = ["internal/config/config_test.go:TestClientMayDisableObservabilityListener_HP_CFG_10"]
[[requirements]]
id = "HP-WINCLI-04"
layer = "unit"
status = "implemented"
tests = [
"cmd/rvbox/main_test.go:TestPackagedClientConfigUsesMinimalSafeDefaults_HP_WINCLI_04",
"cmd/rvbox/main_test.go:TestInstallerClientIDNormalizesHostnames_HP_WINCLI_05",
]
[[requirements]]
id = "HP-CTL-06"
layer = "unit"
+3 -1
View File
@@ -101,7 +101,9 @@ password_hash_from_terminal() {
password=
restore_tty=false
if [ "$password_stdin" = true ]; then
IFS= read -r password || fail "could not read password from stdin"
# Password files commonly omit a final newline. POSIX read returns
# non-zero at that EOF even after assigning the final nonempty line.
IFS= read -r password || [ -n "$password" ] || fail "could not read password from stdin"
else
[ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin"
printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2