feat: persist and dispatch client command input
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Package supervisor defines the narrow process-control seam shared by the
|
||||
// client runtime and platform implementations. It intentionally contains no
|
||||
// operating-system handles or process APIs.
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStartSpec = errors.New("invalid supervisor start specification")
|
||||
ErrUnsupported = errors.New("supervisor operation is unsupported")
|
||||
)
|
||||
|
||||
type SignalKind uint8
|
||||
|
||||
const (
|
||||
SignalTerm SignalKind = iota + 1
|
||||
SignalKill
|
||||
)
|
||||
|
||||
// StartSpec is already validated at the protocol boundary. The supervisor
|
||||
// still checks the identity/revision/source invariants because it is a second
|
||||
// durability boundary and may be called after a restart.
|
||||
type StartSpec struct {
|
||||
IssueUUID domain.UUID
|
||||
CommandRevision uint64
|
||||
Execution *rvboxv1.ExecutionSpec
|
||||
WorkingDirectory string
|
||||
Environment map[string]string
|
||||
ExecutionProfiles []string
|
||||
}
|
||||
|
||||
func (spec StartSpec) Validate() error {
|
||||
if _, err := domain.ParseUUIDv7(spec.IssueUUID.String()); err != nil || spec.CommandRevision == 0 || spec.Execution == nil || spec.Execution.Source == nil {
|
||||
return ErrInvalidStartSpec
|
||||
}
|
||||
if spec.WorkingDirectory == "" {
|
||||
return ErrInvalidStartSpec
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveIdentity is immutable process evidence captured before launch.
|
||||
// Empty user/session fields mean a Session 0 service context.
|
||||
type EffectiveIdentity struct {
|
||||
Context string
|
||||
SessionID uint32
|
||||
UserSID string
|
||||
LogonSID string
|
||||
Elevated bool
|
||||
Integrity string
|
||||
}
|
||||
|
||||
type Process interface {
|
||||
IssueUUID() domain.UUID
|
||||
Identity() EffectiveIdentity
|
||||
Wait(context.Context) (ExitStatus, error)
|
||||
WriteStdin(context.Context, []byte, bool) error
|
||||
CloseStdin(context.Context) error
|
||||
}
|
||||
|
||||
type ExitStatus struct {
|
||||
Code int32
|
||||
Signaled bool
|
||||
Signal SignalKind
|
||||
StartedAt time.Time
|
||||
FinishedAt time.Time
|
||||
OutputDrained bool
|
||||
Output bool
|
||||
}
|
||||
|
||||
type SignalOutcome struct {
|
||||
Delivered bool
|
||||
Escalated bool
|
||||
Detail string
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
type ResourceSnapshot struct {
|
||||
CPUTime time.Duration
|
||||
ResidentBytes uint64
|
||||
IOReadBytes uint64
|
||||
IOWriteBytes uint64
|
||||
ProcessCount uint64
|
||||
ObservedAt time.Time
|
||||
Complete bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Supervisor interface {
|
||||
Start(context.Context, StartSpec) (Process, error)
|
||||
Signal(context.Context, Process, SignalKind) (SignalOutcome, error)
|
||||
Snapshot(context.Context, Process) (ResourceSnapshot, error)
|
||||
StopAll(context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
func TestStartSpecValidation_BH_SUPERVISOR_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
issue, err := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-000000000091")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []StartSpec{
|
||||
{},
|
||||
{IssueUUID: issue, CommandRevision: 1, WorkingDirectory: `C:\work`},
|
||||
{IssueUUID: issue, CommandRevision: 1, Execution: &rvboxv1.ExecutionSpec{}, WorkingDirectory: `C:\work`},
|
||||
{IssueUUID: issue, CommandRevision: 1, Execution: &rvboxv1.ExecutionSpec{Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo ok"}}},
|
||||
}
|
||||
for index, spec := range cases {
|
||||
if !errors.Is(spec.Validate(), ErrInvalidStartSpec) {
|
||||
t.Fatalf("case %d validation = %v, want ErrInvalidStartSpec", index, spec.Validate())
|
||||
}
|
||||
}
|
||||
valid := StartSpec{IssueUUID: issue, CommandRevision: 1, Execution: &rvboxv1.ExecutionSpec{Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo ok"}}, WorkingDirectory: `C:\work`}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEnvironmentKey = errors.New("invalid Windows environment key")
|
||||
ErrInvalidEnvironmentValue = errors.New("invalid Windows environment value")
|
||||
ErrDuplicateEnvironmentKey = errors.New("duplicate Windows environment key")
|
||||
ErrEnvironmentTooLarge = errors.New("Windows environment block is too large")
|
||||
)
|
||||
|
||||
// EnvironmentEntry is an ordered, case-preserving environment assignment.
|
||||
// Windows compares names case-insensitively, so Folded is never emitted and is
|
||||
// used only to make duplicate handling deterministic.
|
||||
type EnvironmentEntry struct {
|
||||
Key string
|
||||
Value string
|
||||
Folded string
|
||||
}
|
||||
|
||||
// MergeEnvironment overlays request values onto the token-derived base
|
||||
// environment. Shell resolution happens before this function is called; an
|
||||
// override therefore cannot redirect the configured executable.
|
||||
func MergeEnvironment(base, overrides map[string]string) ([]EnvironmentEntry, error) {
|
||||
entries := make(map[string]EnvironmentEntry, len(base)+len(overrides))
|
||||
add := func(key, value string, override bool) error {
|
||||
if !validEnvironmentKey(key, override) {
|
||||
return ErrInvalidEnvironmentKey
|
||||
}
|
||||
if strings.IndexByte(value, 0) >= 0 || !utf8.ValidString(value) {
|
||||
return ErrInvalidEnvironmentValue
|
||||
}
|
||||
folded := strings.ToUpper(key)
|
||||
if _, exists := entries[folded]; exists {
|
||||
if !override {
|
||||
return ErrDuplicateEnvironmentKey
|
||||
}
|
||||
delete(entries, folded)
|
||||
}
|
||||
entries[folded] = EnvironmentEntry{Key: key, Value: value, Folded: folded}
|
||||
return nil
|
||||
}
|
||||
baseKeys := make([]string, 0, len(base))
|
||||
for key := range base {
|
||||
baseKeys = append(baseKeys, key)
|
||||
}
|
||||
sort.Strings(baseKeys)
|
||||
for _, key := range baseKeys {
|
||||
if err := add(key, base[key], false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
overrideKeys := make([]string, 0, len(overrides))
|
||||
for key := range overrides {
|
||||
overrideKeys = append(overrideKeys, key)
|
||||
}
|
||||
sort.Strings(overrideKeys)
|
||||
for _, key := range overrideKeys {
|
||||
if err := add(key, overrides[key], true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result := make([]EnvironmentEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
result = append(result, entry)
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool {
|
||||
if result[left].Folded == result[right].Folded {
|
||||
return result[left].Key < result[right].Key
|
||||
}
|
||||
return result[left].Folded < result[right].Folded
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BuildEnvironmentBlock converts the merged entries to the UTF-16 block
|
||||
// expected by CreateProcessAsUser. The final two NUL code units are included.
|
||||
func BuildEnvironmentBlock(base, overrides map[string]string) ([]uint16, error) {
|
||||
entries, err := MergeEnvironment(base, overrides)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var units []uint16
|
||||
for _, entry := range entries {
|
||||
units = append(units, utf16.Encode([]rune(entry.Key+"="+entry.Value))...)
|
||||
units = append(units, 0)
|
||||
if len(units) > 32767 {
|
||||
return nil, ErrEnvironmentTooLarge
|
||||
}
|
||||
}
|
||||
units = append(units, 0)
|
||||
return units, nil
|
||||
}
|
||||
|
||||
func validEnvironmentKey(key string, override bool) bool {
|
||||
if key == "" || strings.IndexByte(key, 0) >= 0 || !utf8.ValidString(key) {
|
||||
return false
|
||||
}
|
||||
if override && (strings.ContainsRune(key, '=') || strings.HasPrefix(key, "=")) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(key, "=") {
|
||||
// CreateEnvironmentBlock may return drive-current-directory pseudo
|
||||
// variables such as =C:=C:\\work. They are accepted only as base data.
|
||||
return !override && strings.Count(key, "=") == 1
|
||||
}
|
||||
return !strings.ContainsRune(key, '=')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvironmentOverlayIsCaseInsensitiveAndDeterministic_HP_WINENV_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := map[string]string{"Path": `C:\Windows`, "TEMP": `C:\Temp`, "=C:": `C:\Windows`}
|
||||
overrides := map[string]string{"PATH": `C:\Pinned`, "Name": "rvbox"}
|
||||
entries, err := MergeEnvironment(base, overrides)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 4 || entries[0].Folded != "=C:" || entries[1].Folded != "NAME" || entries[2].Folded != "PATH" || entries[2].Value != `C:\Pinned` {
|
||||
t.Fatalf("merged entries = %#v", entries)
|
||||
}
|
||||
block, err := BuildEnvironmentBlock(base, overrides)
|
||||
if err != nil || len(block) == 0 || block[len(block)-1] != 0 || block[len(block)-2] != 0 {
|
||||
t.Fatalf("environment block = len=%d err=%v", len(block), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverlayRejectsMalformedAndDuplicateBase_BH_WINENV_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := MergeEnvironment(map[string]string{"Path": "one", "PATH": "two"}, nil); !errors.Is(err, ErrDuplicateEnvironmentKey) {
|
||||
t.Fatalf("duplicate base error = %v", err)
|
||||
}
|
||||
if _, err := MergeEnvironment(nil, map[string]string{"BAD=KEY": "value"}); !errors.Is(err, ErrInvalidEnvironmentKey) {
|
||||
t.Fatalf("invalid override key error = %v", err)
|
||||
}
|
||||
if _, err := MergeEnvironment(nil, map[string]string{"NUL": "bad\x00value"}); !errors.Is(err, ErrInvalidEnvironmentValue) {
|
||||
t.Fatalf("invalid override value error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user