package windows import ( "bytes" "crypto/sha256" "errors" "fmt" "strings" "unicode/utf8" rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1" ) // The Windows supervisor never resolves a shell through PATH. ShellPaths is // the already-defaulted configuration snapshot supplied by the daemon; the // native launch adapter re-stats the selected path immediately before use. type ShellPaths struct { CMD string PowerShell string } var ( ErrUnsupportedShell = errors.New("shell is unsupported on Windows") ErrInvalidShellPath = errors.New("Windows shell path must be an absolute executable path") ErrInvalidWrapperSource = errors.New("invalid generated wrapper source") ErrWrapperTooLarge = errors.New("generated wrapper exceeds the configured limit") ErrWrapperDigestMismatch = errors.New("script body does not match its descriptor digest") ErrWrapperLengthMismatch = errors.New("script body does not match its descriptor length") ErrInvalidWrapperPath = errors.New("generated wrapper path is invalid") ErrInvalidWorkingDirectory = errors.New("Windows working directory is invalid") ) // WrapperEncoding documents how the private generated file is encoded. CMD // receives the source bytes unchanged; PowerShell gets a UTF-8 BOM so the // inbox Windows PowerShell implementation does not interpret non-ASCII text // using the legacy system code page. type WrapperEncoding uint8 const ( WrapperEncodingUTF8 WrapperEncoding = iota + 1 WrapperEncodingUTF8BOM ) // ShellPlan is the immutable invocation contract for one supported Windows // shell. Arguments intentionally exclude the application name: the native // adapter passes ApplicationName separately to CreateProcessAsUser while // CommandLine contains the complete argv-compatible command line. type ShellPlan struct { Type rvboxv1.ShellType ApplicationName string Arguments []string WrapperExtension string Encoding WrapperEncoding } // Wrapper is the private generated script body. Filename is never derived // from caller input; the native materializer appends Extension to an // unpredictable per-command basename. type Wrapper struct { Extension string Encoding WrapperEncoding Bytes []byte SHA256 [sha256.Size]byte } // LaunchPlan contains only deterministic launch inputs. Handles, ACLs, Job // Objects, token selection and release barriers belong to the platform-native // supervisor and are deliberately absent here. type LaunchPlan struct { ApplicationName string Arguments []string CommandLine string WrapperPath string WorkingDirectory string Environment []uint16 } // ResolveShell chooses exactly the configured executable for shellType. It // does not consult PATH, COMSPEC, file associations, environment overrides, // or a wrapper's extension. func ResolveShell(shellType rvboxv1.ShellType, paths ShellPaths) (ShellPlan, error) { plan, err := shellTemplate(shellType) if err != nil { return ShellPlan{}, err } if shellType == rvboxv1.ShellType_SHELL_CMD { plan.ApplicationName = paths.CMD } else { plan.ApplicationName = paths.PowerShell } if err := ValidateWindowsExecutablePath(plan.ApplicationName); err != nil { return ShellPlan{}, fmt.Errorf("%w: %v", ErrInvalidShellPath, err) } return plan, nil } // BuildWrapper validates and copies command/script bytes. For script sources, // descriptor length and SHA-256 are checked before any materialization. The // returned bytes are safe to hand to a private generated-file writer. func BuildWrapper(spec *rvboxv1.ExecutionSpec, scriptBody []byte, maxBytes uint64) (Wrapper, error) { if spec == nil { return Wrapper{}, ErrInvalidWrapperSource } plan, err := shellTemplate(spec.GetShellType()) if err != nil { return Wrapper{}, err } var source []byte switch value := spec.Source.(type) { case *rvboxv1.ExecutionSpec_CommandText: if !utf8.ValidString(value.CommandText) { return Wrapper{}, fmt.Errorf("%w: command text is not UTF-8", ErrInvalidWrapperSource) } source = []byte(value.CommandText) case *rvboxv1.ExecutionSpec_Script: if value.Script == nil || len(value.Script.Sha256) != sha256.Size { return Wrapper{}, fmt.Errorf("%w: script descriptor is incomplete", ErrInvalidWrapperSource) } if uint64(len(scriptBody)) != value.Script.SizeBytes { return Wrapper{}, ErrWrapperLengthMismatch } digest := sha256.Sum256(scriptBody) if !bytes.Equal(digest[:], value.Script.Sha256) { return Wrapper{}, ErrWrapperDigestMismatch } source = append([]byte(nil), scriptBody...) default: return Wrapper{}, fmt.Errorf("%w: exactly one command or script source is required", ErrInvalidWrapperSource) } if uint64(len(source)) > maxBytes && maxBytes != 0 { return Wrapper{}, ErrWrapperTooLarge } if bytes.IndexByte(source, 0) >= 0 { return Wrapper{}, fmt.Errorf("%w: source contains NUL", ErrInvalidWrapperSource) } if plan.Encoding == WrapperEncodingUTF8BOM { source = append([]byte{0xef, 0xbb, 0xbf}, source...) } if uint64(len(source)) > maxBytes && maxBytes != 0 { return Wrapper{}, ErrWrapperTooLarge } return Wrapper{Extension: plan.WrapperExtension, Encoding: plan.Encoding, Bytes: source, SHA256: sha256.Sum256(source)}, nil } func shellTemplate(shellType rvboxv1.ShellType) (ShellPlan, error) { switch shellType { case rvboxv1.ShellType_SHELL_SH, rvboxv1.ShellType_SHELL_BASH: return ShellPlan{Type: shellType, WrapperExtension: ".sh", Encoding: WrapperEncodingUTF8}, nil case rvboxv1.ShellType_SHELL_CMD: return ShellPlan{Type: shellType, Arguments: []string{"/D", "/S", "/C"}, WrapperExtension: ".cmd", Encoding: WrapperEncodingUTF8}, nil case rvboxv1.ShellType_SHELL_POWERSHELL: return ShellPlan{Type: shellType, Arguments: []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-File"}, WrapperExtension: ".ps1", Encoding: WrapperEncodingUTF8BOM}, nil default: return ShellPlan{}, fmt.Errorf("%w: %s", ErrUnsupportedShell, shellType) } } // BuildLaunchPlan appends the generated wrapper path to the fixed shell // argument vector and quotes the complete vector with the reviewed Windows // routine. Environment is expected to be BuildEnvironmentBlock output. func (plan ShellPlan) BuildLaunchPlan(wrapperPath, workingDirectory string, environment []uint16) (LaunchPlan, error) { if err := ValidateWindowsExecutablePath(plan.ApplicationName); err != nil { return LaunchPlan{}, fmt.Errorf("%w: %v", ErrInvalidShellPath, err) } if !validAbsoluteWindowsPath(wrapperPath) || strings.ToLower(strings.TrimSpace(wrapperPath)) != strings.ToLower(wrapperPath) || !strings.EqualFold(extensionOfWindowsPath(wrapperPath), plan.WrapperExtension) { return LaunchPlan{}, ErrInvalidWrapperPath } if !validAbsoluteWindowsPath(workingDirectory) { return LaunchPlan{}, ErrInvalidWorkingDirectory } arguments := append([]string(nil), plan.Arguments...) arguments = append(arguments, wrapperPath) full := append([]string{plan.ApplicationName}, arguments...) commandLine, err := BuildCommandLine(full) if err != nil { return LaunchPlan{}, err } return LaunchPlan{ApplicationName: plan.ApplicationName, Arguments: arguments, CommandLine: commandLine, WrapperPath: wrapperPath, WorkingDirectory: workingDirectory, Environment: append([]uint16(nil), environment...)}, nil } // ValidateWindowsExecutablePath applies the lexical checks that are possible // before a native re-stat. The Windows adapter must still verify that the // path names the same regular executable immediately before launch. func ValidateWindowsExecutablePath(path string) error { if !validAbsoluteWindowsPath(path) || strings.TrimSpace(path) != path { return ErrInvalidShellPath } extension := strings.ToLower(extensionOfWindowsPath(path)) switch extension { case ".exe", ".com", ".cmd", ".bat": return nil default: return fmt.Errorf("unsupported executable extension %q", extension) } } // ValidAbsoluteWindowsPath reports whether value passes the lexical absolute // path checks. It does not touch the filesystem; native callers must still // re-stat the object and verify its ACL immediately before use. func ValidAbsoluteWindowsPath(value string) bool { return validAbsoluteWindowsPath(value) } func validAbsoluteWindowsPath(value string) bool { if value == "" || strings.IndexByte(value, 0) >= 0 || !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\t") { return false } value = strings.ReplaceAll(value, "/", `\`) if strings.HasPrefix(value, `\\`) { parts := strings.Split(strings.TrimPrefix(value, `\\`), `\`) if len(parts) < 2 || parts[0] == "" || parts[1] == "" { return false } } else if len(value) < 3 || !((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) || value[1] != ':' || value[2] != '\\' { return false } for _, part := range strings.Split(value, `\`) { if part == "" || part == "." || part == ".." { continue } if strings.HasSuffix(part, " ") || strings.HasSuffix(part, ".") { return false } for _, character := range part { if character < 0x20 || character == 0x7f { return false } } } return true } func extensionOfWindowsPath(path string) string { path = strings.ReplaceAll(path, "/", `\`) index := strings.LastIndexByte(path, '\\') if index >= 0 { path = path[index+1:] } dot := strings.LastIndexByte(path, '.') if dot < 0 { return "" } return path[dot:] }