feat: validate agent protocol boundaries
This commit is contained in:
@@ -11,3 +11,4 @@ All versions are exact in `go.mod`, generated code, or the toolchain image.
|
|||||||
| protoc-gen-go-grpc 1.6.2 | Go gRPC generator | Official maintained gRPC-Go generator. |
|
| protoc-gen-go-grpc 1.6.2 | Go gRPC generator | Official maintained gRPC-Go generator. |
|
||||||
| google/uuid 1.6.0 | Parse canonical UUIDs and verify RFC variant/version bits | Stable maintained package; RVBox owns the monotonic UUIDv7 generator so clock and ordering behavior remain directly testable. |
|
| google/uuid 1.6.0 | Parse canonical UUIDs and verify RFC variant/version bits | Stable maintained package; RVBox owns the monotonic UUIDv7 generator so clock and ordering behavior remain directly testable. |
|
||||||
| go-toml/v2 2.3.1 | Strict configuration decoding | Last maintained release line before TOML 1.1 parsing was enabled; RVBox v1 intentionally accepts TOML 1.0 only. |
|
| go-toml/v2 2.3.1 | Strict configuration decoding | Last maintained release line before TOML 1.1 parsing was enabled; RVBox v1 intentionally accepts TOML 1.0 only. |
|
||||||
|
| klauspost/compress 1.19.0 | Zstandard command-output compression | Maintained pure-Go codec with decoder memory controls; RVBox additionally limits streamed decoded output before allocation. |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.27.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/klauspost/compress v1.19.0
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1
|
github.com/pelletier/go-toml/v2 v2.3.1
|
||||||
google.golang.org/grpc v1.83.2
|
google.golang.org/grpc v1.83.2
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.36.12
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||||
|
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package agentproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidOutputChunk = errors.New("invalid output chunk")
|
||||||
|
|
||||||
|
// DecodeOutputChunk validates declared sizes before bounded decompression.
|
||||||
|
func DecodeOutputChunk(chunk *rvboxv1.OutputChunk, maxRawBytes uint64) ([]byte, error) {
|
||||||
|
if chunk == nil || (chunk.Stream != rvboxv1.StreamKind_STREAM_STDOUT && chunk.Stream != rvboxv1.StreamKind_STREAM_STDERR) || chunk.CompressedSize != uint64(len(chunk.Data)) || chunk.UncompressedSize > maxRawBytes {
|
||||||
|
return nil, ErrInvalidOutputChunk
|
||||||
|
}
|
||||||
|
switch chunk.Compression {
|
||||||
|
case rvboxv1.Compression_COMPRESSION_NONE:
|
||||||
|
if chunk.UncompressedSize != uint64(len(chunk.Data)) {
|
||||||
|
return nil, ErrInvalidOutputChunk
|
||||||
|
}
|
||||||
|
return bytes.Clone(chunk.Data), nil
|
||||||
|
case rvboxv1.Compression_COMPRESSION_ZSTD:
|
||||||
|
if chunk.UncompressedSize == 0 && len(chunk.Data) != 0 {
|
||||||
|
return nil, ErrInvalidOutputChunk
|
||||||
|
}
|
||||||
|
decoder, err := zstd.NewReader(bytes.NewReader(chunk.Data), zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxMemory(maxRawBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: zstd header: %v", ErrInvalidOutputChunk, err)
|
||||||
|
}
|
||||||
|
defer decoder.Close()
|
||||||
|
decoded, err := io.ReadAll(io.LimitReader(decoder, int64(maxRawBytes)+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: zstd decode: %v", ErrInvalidOutputChunk, err)
|
||||||
|
}
|
||||||
|
if uint64(len(decoded)) != chunk.UncompressedSize || uint64(len(decoded)) > maxRawBytes {
|
||||||
|
return nil, ErrInvalidOutputChunk
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
default:
|
||||||
|
return nil, ErrInvalidOutputChunk
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// Package agentproto validates generated Agent protocol DTOs at the wire edge.
|
||||||
|
package agentproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
|
"github.com/rvbox/rvbox/internal/domain"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEnvelopeTooLarge = errors.New("decoded agent envelope exceeds limit")
|
||||||
|
ErrExecutionSpecTooLarge = errors.New("serialized execution spec exceeds limit")
|
||||||
|
ErrInvalidEnvelope = errors.New("invalid agent envelope")
|
||||||
|
ErrInvalidExecutionSpec = errors.New("invalid execution spec")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Limits struct {
|
||||||
|
MaxEnvelopeBytes uint64
|
||||||
|
MaxExecutionSpecBytes uint64
|
||||||
|
MaxRawChunkBytes uint64
|
||||||
|
MaxScriptBytes uint64
|
||||||
|
MaxDetailBytes uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultLimits() Limits {
|
||||||
|
return Limits{MaxEnvelopeBytes: 1 << 20, MaxExecutionSpecBytes: 768 << 10, MaxRawChunkBytes: 64 << 10, MaxScriptBytes: 10 << 20, MaxDetailBytes: 4 << 10}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeEnvelope checks the binary message size before protobuf allocation.
|
||||||
|
func DecodeEnvelope(data []byte, limits Limits, peerPlatform rvboxv1.Platform) (*rvboxv1.AgentEnvelope, error) {
|
||||||
|
if uint64(len(data)) > limits.MaxEnvelopeBytes {
|
||||||
|
return nil, ErrEnvelopeTooLarge
|
||||||
|
}
|
||||||
|
var envelope rvboxv1.AgentEnvelope
|
||||||
|
if err := (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(data, &envelope); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode protobuf: %v", ErrInvalidEnvelope, err)
|
||||||
|
}
|
||||||
|
if err := ValidateEnvelope(&envelope, limits, peerPlatform); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &envelope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateEnvelope(envelope *rvboxv1.AgentEnvelope, limits Limits, peerPlatform rvboxv1.Platform) error {
|
||||||
|
if envelope == nil || envelope.Payload == nil {
|
||||||
|
return fmt.Errorf("%w: missing recognized payload", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if uint64(proto.Size(envelope)) > limits.MaxEnvelopeBytes {
|
||||||
|
return ErrEnvelopeTooLarge
|
||||||
|
}
|
||||||
|
_, hello := envelope.Payload.(*rvboxv1.AgentEnvelope_ClientHello)
|
||||||
|
if hello {
|
||||||
|
if envelope.SessionId != "" || envelope.SessionGeneration != 0 {
|
||||||
|
return fmt.Errorf("%w: ClientHello must not claim a session", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
} else if envelope.SessionId == "" || envelope.SessionGeneration == 0 || len(envelope.SessionId) > 128 || !utf8.ValidString(envelope.SessionId) || strings.IndexByte(envelope.SessionId, 0) >= 0 {
|
||||||
|
return fmt.Errorf("%w: session ID/generation required", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch payload := envelope.Payload.(type) {
|
||||||
|
case *rvboxv1.AgentEnvelope_ClientHello:
|
||||||
|
return validateClientHello(payload.ClientHello)
|
||||||
|
case *rvboxv1.AgentEnvelope_ServerWelcome:
|
||||||
|
if payload.ServerWelcome == nil || payload.ServerWelcome.SelectedProtocol == nil || payload.ServerWelcome.ServerTime == nil || payload.ServerWelcome.ServerTime.CheckValid() != nil {
|
||||||
|
return fmt.Errorf("%w: invalid ServerWelcome", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateProtocolVersion(payload.ServerWelcome.SelectedProtocol)
|
||||||
|
case *rvboxv1.AgentEnvelope_CommandDispatch:
|
||||||
|
return validateCommandDispatch(payload.CommandDispatch, limits, peerPlatform)
|
||||||
|
case *rvboxv1.AgentEnvelope_CommandAccepted:
|
||||||
|
return validateCommandAccepted(payload.CommandAccepted, limits)
|
||||||
|
case *rvboxv1.AgentEnvelope_CommandEvent:
|
||||||
|
return validateCommandEvent(payload.CommandEvent, limits)
|
||||||
|
case *rvboxv1.AgentEnvelope_EventAck:
|
||||||
|
if payload.EventAck == nil {
|
||||||
|
return fmt.Errorf("%w: missing event acknowledgement", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueAndSequence(payload.EventAck.GetIssueUuid(), payload.EventAck.GetThroughEventSeq())
|
||||||
|
case *rvboxv1.AgentEnvelope_StdinWrite:
|
||||||
|
if payload.StdinWrite == nil || payload.StdinWrite.WriteSeq == 0 || uint64(len(payload.StdinWrite.Data)) > limits.MaxRawChunkBytes {
|
||||||
|
return fmt.Errorf("%w: invalid stdin write", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueUUID(payload.StdinWrite.IssueUuid)
|
||||||
|
case *rvboxv1.AgentEnvelope_CloseStdin:
|
||||||
|
if payload.CloseStdin == nil || payload.CloseStdin.WriteSeq == 0 {
|
||||||
|
return fmt.Errorf("%w: invalid close stdin", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueUUID(payload.CloseStdin.IssueUuid)
|
||||||
|
case *rvboxv1.AgentEnvelope_SignalCommand:
|
||||||
|
if payload.SignalCommand == nil || payload.SignalCommand.CommandRevision == 0 || !validSignal(payload.SignalCommand.Signal) {
|
||||||
|
return fmt.Errorf("%w: invalid signal request", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueUUID(payload.SignalCommand.IssueUuid)
|
||||||
|
case *rvboxv1.AgentEnvelope_ScriptChunk:
|
||||||
|
if payload.ScriptChunk == nil || len(payload.ScriptChunk.Sha256) != sha256.Size || uint64(len(payload.ScriptChunk.Data)) > limits.MaxRawChunkBytes || payload.ScriptChunk.Offset > limits.MaxScriptBytes || uint64(len(payload.ScriptChunk.Data)) > limits.MaxScriptBytes-payload.ScriptChunk.Offset {
|
||||||
|
return fmt.Errorf("%w: invalid script chunk", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueUUID(payload.ScriptChunk.IssueUuid)
|
||||||
|
case *rvboxv1.AgentEnvelope_ScriptCommit:
|
||||||
|
if payload.ScriptCommit == nil || payload.ScriptCommit.SizeBytes > limits.MaxScriptBytes || len(payload.ScriptCommit.Sha256) != sha256.Size {
|
||||||
|
return fmt.Errorf("%w: invalid script commit", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueUUID(payload.ScriptCommit.IssueUuid)
|
||||||
|
case *rvboxv1.AgentEnvelope_ReconcileRequest:
|
||||||
|
return validateReconcileRequest(payload.ReconcileRequest)
|
||||||
|
case *rvboxv1.AgentEnvelope_ReconcileSnapshot:
|
||||||
|
return validateReconcileSnapshot(payload.ReconcileSnapshot)
|
||||||
|
case *rvboxv1.AgentEnvelope_ReconcileResult:
|
||||||
|
return validateReconcileResult(payload.ReconcileResult)
|
||||||
|
case *rvboxv1.AgentEnvelope_ClientCapacity:
|
||||||
|
if payload.ClientCapacity == nil || payload.ClientCapacity.MaxRunningCommands == 0 || payload.ClientCapacity.MaxQueuedCommands == 0 || payload.ClientCapacity.RunningCommands > payload.ClientCapacity.MaxRunningCommands || payload.ClientCapacity.QueuedCommands > payload.ClientCapacity.MaxQueuedCommands {
|
||||||
|
return fmt.Errorf("%w: invalid client capacity", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case *rvboxv1.AgentEnvelope_Error:
|
||||||
|
if payload.Error == nil || payload.Error.Error == nil || payload.Error.Error.Code == rvboxv1.ControlError_CODE_UNSPECIFIED || uint64(len(payload.Error.Error.Message)) > limits.MaxDetailBytes {
|
||||||
|
return fmt.Errorf("%w: invalid agent error", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unsupported payload", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateExecutionSpec(spec *rvboxv1.ExecutionSpec, limits Limits, peerPlatform rvboxv1.Platform) error {
|
||||||
|
if spec == nil || spec.Source == nil {
|
||||||
|
return fmt.Errorf("%w: exactly one command or script source is required", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
if uint64(proto.Size(spec)) > limits.MaxExecutionSpecBytes {
|
||||||
|
return ErrExecutionSpecTooLarge
|
||||||
|
}
|
||||||
|
if !validShell(spec.ShellType, peerPlatform) {
|
||||||
|
return fmt.Errorf("%w: unsupported shell %s", ErrInvalidExecutionSpec, spec.ShellType)
|
||||||
|
}
|
||||||
|
if spec.Elevated && peerPlatform != rvboxv1.Platform_PLATFORM_WINDOWS {
|
||||||
|
return fmt.Errorf("%w: elevation is unsupported on non-Windows v1 clients", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
windowsKeys := make(map[string]bool, len(spec.EnvOverrides))
|
||||||
|
for key, value := range spec.EnvOverrides {
|
||||||
|
if key == "" || strings.IndexByte(key, 0) >= 0 || strings.Contains(key, "=") || strings.IndexByte(value, 0) >= 0 || !utf8.ValidString(key) || !utf8.ValidString(value) {
|
||||||
|
return fmt.Errorf("%w: invalid environment override", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
if peerPlatform == rvboxv1.Platform_PLATFORM_WINDOWS {
|
||||||
|
folded := strings.ToUpper(key)
|
||||||
|
if windowsKeys[folded] {
|
||||||
|
return fmt.Errorf("%w: case-colliding Windows environment keys", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
windowsKeys[folded] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateProfiles(spec.ExecutionProfiles); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch source := spec.Source.(type) {
|
||||||
|
case *rvboxv1.ExecutionSpec_CommandText:
|
||||||
|
if !utf8.ValidString(source.CommandText) {
|
||||||
|
return fmt.Errorf("%w: command text is not UTF-8", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
case *rvboxv1.ExecutionSpec_Script:
|
||||||
|
if err := validateScriptDescriptor(source.Script, limits); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unknown source", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateClientHello(hello *rvboxv1.ClientHello) error {
|
||||||
|
if hello == nil || validateClientID(hello.ClientId) != nil || hello.SupportedProtocol == nil || hello.SentAt == nil || hello.SentAt.CheckValid() != nil || hello.ClientInstanceId == "" || len(hello.ClientInstanceId) > 128 || hello.MaxRunningCommands == 0 || hello.MaxQueuedCommands == 0 || hello.Platform == rvboxv1.Platform_PLATFORM_UNSPECIFIED || len(hello.SupportedShells) == 0 {
|
||||||
|
return fmt.Errorf("%w: invalid ClientHello", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
seenShells := map[rvboxv1.ShellType]bool{}
|
||||||
|
for _, shell := range hello.SupportedShells {
|
||||||
|
if seenShells[shell] || !validShell(shell, hello.Platform) {
|
||||||
|
return fmt.Errorf("%w: invalid ClientHello shell advertisement", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
seenShells[shell] = true
|
||||||
|
}
|
||||||
|
return validateProtocolRange(hello.SupportedProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCommandDispatch(dispatch *rvboxv1.CommandDispatch, limits Limits, peerPlatform rvboxv1.Platform) error {
|
||||||
|
if dispatch == nil || dispatch.CommandRevision == 0 || dispatch.TargetSessionGeneration == 0 || dispatch.IssueTime == nil || dispatch.IssueTime.CheckValid() != nil {
|
||||||
|
return fmt.Errorf("%w: invalid command dispatch", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if err := validateIssueUUID(dispatch.IssueUuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dispatch.QueueExpiryTime != nil && dispatch.QueueExpiryTime.CheckValid() != nil {
|
||||||
|
return fmt.Errorf("%w: invalid queue expiry", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return ValidateExecutionSpec(dispatch.Spec, limits, peerPlatform)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCommandAccepted(accepted *rvboxv1.CommandAccepted, limits Limits) error {
|
||||||
|
if accepted == nil || accepted.CommandRevision == 0 {
|
||||||
|
return fmt.Errorf("%w: invalid command acceptance", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if err := validateIssueUUID(accepted.IssueUuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if accepted.Accepted && accepted.Rejection != nil {
|
||||||
|
return fmt.Errorf("%w: accepted command carries rejection", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if !accepted.Accepted && (accepted.Rejection == nil || accepted.Rejection.Code == rvboxv1.ControlError_CODE_UNSPECIFIED) {
|
||||||
|
return fmt.Errorf("%w: rejected command lacks error", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if accepted.Rejection != nil && (uint64(len(accepted.Rejection.Message)) > limits.MaxDetailBytes || (accepted.Rejection.IssueUuid != "" && accepted.Rejection.IssueUuid != accepted.IssueUuid)) {
|
||||||
|
return fmt.Errorf("%w: invalid acceptance rejection detail", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCommandEvent(event *rvboxv1.CommandEvent, limits Limits) error {
|
||||||
|
if event == nil || event.Payload == nil || event.ObservedAt == nil || event.ObservedAt.CheckValid() != nil {
|
||||||
|
return fmt.Errorf("%w: invalid command event", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if err := validateIssueAndSequence(event.IssueUuid, event.EventSeq); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch payload := event.Payload.(type) {
|
||||||
|
case *rvboxv1.CommandEvent_Output:
|
||||||
|
_, err := DecodeOutputChunk(payload.Output, limits.MaxRawChunkBytes)
|
||||||
|
return err
|
||||||
|
case *rvboxv1.CommandEvent_Lifecycle:
|
||||||
|
if payload.Lifecycle == nil || payload.Lifecycle.Lifecycle == rvboxv1.CommandLifecycle_COMMAND_LIFECYCLE_UNSPECIFIED || payload.Lifecycle.CommandRevision == 0 || uint64(len(payload.Lifecycle.Detail)) > limits.MaxDetailBytes {
|
||||||
|
return fmt.Errorf("%w: invalid lifecycle event", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
case *rvboxv1.CommandEvent_OutputTruncation:
|
||||||
|
if payload.OutputTruncation == nil || uint64(len(payload.OutputTruncation.Reason)) > limits.MaxDetailBytes || payload.OutputTruncation.Source == rvboxv1.OutputTruncationSource_OUTPUT_TRUNCATION_SOURCE_UNSPECIFIED {
|
||||||
|
return fmt.Errorf("%w: invalid output truncation", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return domain.ValidateOutputTruncation(payload.OutputTruncation)
|
||||||
|
case *rvboxv1.CommandEvent_OutputIncomplete:
|
||||||
|
if payload.OutputIncomplete == nil || !validStreams(payload.OutputIncomplete.Streams) || uint64(len(payload.OutputIncomplete.Reason)) > limits.MaxDetailBytes {
|
||||||
|
return fmt.Errorf("%w: invalid incomplete-output event", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
case *rvboxv1.CommandEvent_StdinAck:
|
||||||
|
if payload.StdinAck == nil || payload.StdinAck.WriteSeq == 0 {
|
||||||
|
return fmt.Errorf("%w: invalid stdin acknowledgement", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
case *rvboxv1.CommandEvent_SignalResult:
|
||||||
|
if payload.SignalResult == nil || payload.SignalResult.CommandRevision == 0 || !validSignal(payload.SignalResult.Signal) {
|
||||||
|
return fmt.Errorf("%w: invalid signal result", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
case *rvboxv1.CommandEvent_ScriptStatus:
|
||||||
|
if payload.ScriptStatus == nil || payload.ScriptStatus.ReceivedBytes > limits.MaxScriptBytes {
|
||||||
|
return fmt.Errorf("%w: invalid script status", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
case *rvboxv1.CommandEvent_Resource:
|
||||||
|
if payload.Resource == nil || uint64(len(payload.Resource.DiagnosticDetail)) > limits.MaxDetailBytes {
|
||||||
|
return fmt.Errorf("%w: missing resource snapshot", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: unsupported command event payload", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateScriptDescriptor(descriptor *rvboxv1.ScriptDescriptor, limits Limits) error {
|
||||||
|
if descriptor == nil || descriptor.Filename == "" || len(descriptor.Filename) > 255 || descriptor.SizeBytes > limits.MaxScriptBytes || len(descriptor.Sha256) != sha256.Size || !utf8.ValidString(descriptor.Filename) {
|
||||||
|
return fmt.Errorf("%w: invalid script descriptor", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
if filepath.Base(descriptor.Filename) != descriptor.Filename || strings.ContainsAny(descriptor.Filename, `/\`) || descriptor.Filename == "." || descriptor.Filename == ".." {
|
||||||
|
return fmt.Errorf("%w: script filename must be display-only basename", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
if strings.TrimRight(descriptor.Filename, " .") != descriptor.Filename {
|
||||||
|
return fmt.Errorf("%w: script filename has unsafe Windows suffix", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
for _, current := range []byte(descriptor.Filename) {
|
||||||
|
if current < 0x20 || current == 0x7f {
|
||||||
|
return fmt.Errorf("%w: script filename contains control byte", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := strings.ToUpper(strings.TrimSuffix(descriptor.Filename, filepath.Ext(descriptor.Filename)))
|
||||||
|
reserved := map[string]bool{"CON": true, "PRN": true, "AUX": true, "NUL": true, "CLOCK$": true}
|
||||||
|
if reserved[base] || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') {
|
||||||
|
return fmt.Errorf("%w: reserved script filename", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProfiles(profiles []rvboxv1.ExecutionProfile) error {
|
||||||
|
seen := map[rvboxv1.ExecutionProfile]bool{}
|
||||||
|
dimensions := map[rvboxv1.ExecutionProfile]string{
|
||||||
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM: "cpu", rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_HEAVY: "cpu",
|
||||||
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_MEDIUM: "memory", rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_HEAVY: "memory",
|
||||||
|
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_MEDIUM: "disk", rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_HEAVY: "disk",
|
||||||
|
}
|
||||||
|
usedDimensions := map[string]bool{}
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if profile == rvboxv1.ExecutionProfile_EXECUTION_PROFILE_UNSPECIFIED || seen[profile] {
|
||||||
|
return fmt.Errorf("%w: unspecified or duplicate execution profile", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
seen[profile] = true
|
||||||
|
if profile == rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT {
|
||||||
|
if len(profiles) != 1 {
|
||||||
|
return fmt.Errorf("%w: LIGHT is exclusive", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dimension, exists := dimensions[profile]
|
||||||
|
if !exists || usedDimensions[dimension] {
|
||||||
|
return fmt.Errorf("%w: conflicting or unknown execution profile", ErrInvalidExecutionSpec)
|
||||||
|
}
|
||||||
|
usedDimensions[dimension] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProtocolRange(value *rvboxv1.ProtocolRange) error {
|
||||||
|
if value.Major == 0 || value.MinMinor > value.MaxMinor {
|
||||||
|
return fmt.Errorf("%w: invalid protocol range", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func validateProtocolVersion(value *rvboxv1.ProtocolVersion) error {
|
||||||
|
if value.Major == 0 {
|
||||||
|
return fmt.Errorf("%w: invalid protocol version", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func validateIssueUUID(value string) error {
|
||||||
|
if _, err := domain.ParseUUIDv7(value); err != nil {
|
||||||
|
return fmt.Errorf("%w: %v", ErrInvalidEnvelope, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func validateIssueAndSequence(issue string, sequence uint64) error {
|
||||||
|
if sequence == 0 {
|
||||||
|
return fmt.Errorf("%w: sequence must be nonzero", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
return validateIssueUUID(issue)
|
||||||
|
}
|
||||||
|
func validateClientID(value string) error {
|
||||||
|
if len(value) == 0 || len(value) > 128 {
|
||||||
|
return errors.New("invalid client ID length")
|
||||||
|
}
|
||||||
|
for _, current := range []byte(value) {
|
||||||
|
if current < 0x21 || current > 0x7e {
|
||||||
|
return errors.New("client ID must be printable ASCII without spaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func validShell(shell rvboxv1.ShellType, platform rvboxv1.Platform) bool {
|
||||||
|
if platform == rvboxv1.Platform_PLATFORM_WINDOWS {
|
||||||
|
return shell == rvboxv1.ShellType_SHELL_CMD || shell == rvboxv1.ShellType_SHELL_POWERSHELL
|
||||||
|
}
|
||||||
|
if platform == rvboxv1.Platform_PLATFORM_LINUX || platform == rvboxv1.Platform_PLATFORM_DARWIN || platform == rvboxv1.Platform_PLATFORM_OTHER_UNIX {
|
||||||
|
return shell == rvboxv1.ShellType_SHELL_SH || shell == rvboxv1.ShellType_SHELL_BASH
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func validSignal(signal rvboxv1.SignalKind) bool {
|
||||||
|
return signal >= rvboxv1.SignalKind_SIGNAL_HUP && signal <= rvboxv1.SignalKind_SIGNAL_USR2
|
||||||
|
}
|
||||||
|
|
||||||
|
func validStreams(streams []rvboxv1.StreamKind) bool {
|
||||||
|
if len(streams) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := map[rvboxv1.StreamKind]bool{}
|
||||||
|
for _, stream := range streams {
|
||||||
|
if (stream != rvboxv1.StreamKind_STREAM_STDOUT && stream != rvboxv1.StreamKind_STREAM_STDERR) || seen[stream] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[stream] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReconcileRequest(request *rvboxv1.ReconcileRequest) error {
|
||||||
|
if request == nil {
|
||||||
|
return fmt.Errorf("%w: missing reconcile request", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, target := range request.Targets {
|
||||||
|
if target == nil || seen[target.IssueUuid] || target.CommandRevision == 0 || len(target.ImmutableRequestSha256) != sha256.Size {
|
||||||
|
return fmt.Errorf("%w: invalid reconcile target", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if err := validateIssueUUID(target.IssueUuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen[target.IssueUuid] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReconcileSnapshot(snapshot *rvboxv1.ReconcileSnapshot) error {
|
||||||
|
if snapshot == nil {
|
||||||
|
return fmt.Errorf("%w: missing reconcile snapshot", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, command := range snapshot.RetainedCommands {
|
||||||
|
if command == nil || seen[command.IssueUuid] || command.CommandRevision == 0 || command.Lifecycle == rvboxv1.CommandLifecycle_COMMAND_LIFECYCLE_UNSPECIFIED || len(command.ImmutableRequestSha256) != sha256.Size {
|
||||||
|
return fmt.Errorf("%w: invalid reconcile command", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if err := validateIssueUUID(command.IssueUuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen[command.IssueUuid] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReconcileResult(result *rvboxv1.ReconcileResult) error {
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("%w: missing reconcile result", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, issueUUID := range append(append([]string{}, result.TerminateLocalIssueUuids...), result.DiscardLocalTerminalIssueUuids...) {
|
||||||
|
if seen[issueUUID] {
|
||||||
|
return fmt.Errorf("%w: duplicate reconcile result UUID", ErrInvalidEnvelope)
|
||||||
|
}
|
||||||
|
if err := validateIssueUUID(issueUUID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen[issueUUID] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package agentproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testIssueUUID = "01890a5d-ac96-7a11-b234-123456789abc"
|
||||||
|
|
||||||
|
func TestDecodeEnvelopeBoundaries_HP_PROTO_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := DefaultLimits()
|
||||||
|
hello := &rvboxv1.AgentEnvelope{Payload: &rvboxv1.AgentEnvelope_ClientHello{ClientHello: validHello()}}
|
||||||
|
encoded, err := proto.Marshal(hello)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, err := DecodeEnvelope(encoded, limits, rvboxv1.Platform_PLATFORM_WINDOWS)
|
||||||
|
if err != nil || decoded.GetClientHello() == nil {
|
||||||
|
t.Fatalf("DecodeEnvelope(valid) = (%v, %v)", decoded, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := DecodeEnvelope(make([]byte, limits.MaxEnvelopeBytes+1), limits, rvboxv1.Platform_PLATFORM_WINDOWS); !errors.Is(err, ErrEnvelopeTooLarge) {
|
||||||
|
t.Fatalf("oversized error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecodeEnvelope([]byte{0xff}, limits, rvboxv1.Platform_PLATFORM_WINDOWS); !errors.Is(err, ErrInvalidEnvelope) {
|
||||||
|
t.Fatalf("malformed error = %v", err)
|
||||||
|
}
|
||||||
|
noPayload, _ := proto.Marshal(&rvboxv1.AgentEnvelope{})
|
||||||
|
if _, err := DecodeEnvelope(noPayload, limits, rvboxv1.Platform_PLATFORM_WINDOWS); !errors.Is(err, ErrInvalidEnvelope) {
|
||||||
|
t.Fatalf("missing payload error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvelopeSessionFencingShape_BH_SES_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hello := &rvboxv1.AgentEnvelope{SessionId: "claimed", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ClientHello{ClientHello: validHello()}}
|
||||||
|
if err := ValidateEnvelope(hello, DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("ClientHello with session accepted")
|
||||||
|
}
|
||||||
|
welcome := &rvboxv1.AgentEnvelope{Payload: &rvboxv1.AgentEnvelope_ServerWelcome{ServerWelcome: &rvboxv1.ServerWelcome{SelectedProtocol: &rvboxv1.ProtocolVersion{Major: 1}, ServerTime: fixedTimestamp()}}}
|
||||||
|
if err := ValidateEnvelope(welcome, DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("non-hello without session accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutionSpecValidation_HP_PROTO_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := DefaultLimits()
|
||||||
|
valid := validCommandSpec()
|
||||||
|
if err := ValidateExecutionSpec(valid, limits, rvboxv1.Platform_PLATFORM_LINUX); err != nil {
|
||||||
|
t.Fatalf("valid spec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*rvboxv1.ExecutionSpec)
|
||||||
|
platform rvboxv1.Platform
|
||||||
|
}{
|
||||||
|
{"missing source", func(spec *rvboxv1.ExecutionSpec) { spec.Source = nil }, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"wrong shell", func(spec *rvboxv1.ExecutionSpec) { spec.ShellType = rvboxv1.ShellType_SHELL_CMD }, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"Unix elevation", func(spec *rvboxv1.ExecutionSpec) { spec.Elevated = true }, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"bad env key", func(spec *rvboxv1.ExecutionSpec) { spec.EnvOverrides = map[string]string{"A=B": "x"} }, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"duplicate profile", func(spec *rvboxv1.ExecutionSpec) {
|
||||||
|
spec.ExecutionProfiles = []rvboxv1.ExecutionProfile{rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM, rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM}
|
||||||
|
}, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"same dimension", func(spec *rvboxv1.ExecutionSpec) {
|
||||||
|
spec.ExecutionProfiles = []rvboxv1.ExecutionProfile{rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM, rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_HEAVY}
|
||||||
|
}, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"light combination", func(spec *rvboxv1.ExecutionSpec) {
|
||||||
|
spec.ExecutionProfiles = []rvboxv1.ExecutionProfile{rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT, rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_HEAVY}
|
||||||
|
}, rvboxv1.Platform_PLATFORM_LINUX},
|
||||||
|
{"unspecified platform", func(spec *rvboxv1.ExecutionSpec) {}, rvboxv1.Platform_PLATFORM_UNSPECIFIED},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
spec := proto.Clone(valid).(*rvboxv1.ExecutionSpec)
|
||||||
|
test.mutate(spec)
|
||||||
|
if err := ValidateExecutionSpec(spec, limits, test.platform); err == nil {
|
||||||
|
t.Fatal("ValidateExecutionSpec() succeeded")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tooSmall := limits
|
||||||
|
tooSmall.MaxExecutionSpecBytes = uint64(proto.Size(valid) - 1)
|
||||||
|
if err := ValidateExecutionSpec(valid, tooSmall, rvboxv1.Platform_PLATFORM_LINUX); !errors.Is(err, ErrExecutionSpecTooLarge) {
|
||||||
|
t.Fatalf("spec size error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScriptDescriptorHostileNames_BH_SCRIPT_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := DefaultLimits()
|
||||||
|
valid := &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_POWERSHELL, Source: &rvboxv1.ExecutionSpec_Script{Script: &rvboxv1.ScriptDescriptor{Filename: "update.ps1", SizeBytes: 1, Sha256: make([]byte, 32)}}}
|
||||||
|
if err := ValidateExecutionSpec(valid, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err != nil {
|
||||||
|
t.Fatalf("valid script: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"../x.ps1", `dir\x.ps1`, "CON.txt", "NUL", "bad\x00.ps1", ".", ".."} {
|
||||||
|
spec := proto.Clone(valid).(*rvboxv1.ExecutionSpec)
|
||||||
|
spec.GetScript().Filename = name
|
||||||
|
if err := ValidateExecutionSpec(spec, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Errorf("script filename %q accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badDigest := proto.Clone(valid).(*rvboxv1.ExecutionSpec)
|
||||||
|
badDigest.GetScript().Sha256 = []byte{1}
|
||||||
|
if err := ValidateExecutionSpec(badDigest, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("wrong script digest length accepted")
|
||||||
|
}
|
||||||
|
trailing := proto.Clone(valid).(*rvboxv1.ExecutionSpec)
|
||||||
|
trailing.GetScript().Filename = "safe.ps1."
|
||||||
|
if err := ValidateExecutionSpec(trailing, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("unsafe Windows trailing dot accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindowsEnvironmentCaseCollision_BH_LAUNCH_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
spec := &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_CMD, EnvOverrides: map[string]string{"Path": "one", "PATH": "two"}, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo ok"}}
|
||||||
|
if err := ValidateExecutionSpec(spec, DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("case-colliding Windows environment accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandDispatchUUIDRevisionAndTimestamp_BH_IDEM_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dispatch := &rvboxv1.CommandDispatch{IssueUuid: testIssueUUID, CommandRevision: 1, TargetSessionGeneration: 2, IssueTime: fixedTimestamp(), Spec: validCommandSpec()}
|
||||||
|
envelope := &rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 2, Payload: &rvboxv1.AgentEnvelope_CommandDispatch{CommandDispatch: dispatch}}
|
||||||
|
if err := ValidateEnvelope(envelope, DefaultLimits(), rvboxv1.Platform_PLATFORM_LINUX); err != nil {
|
||||||
|
t.Fatalf("valid dispatch: %v", err)
|
||||||
|
}
|
||||||
|
for _, mutate := range []func(*rvboxv1.CommandDispatch){
|
||||||
|
func(value *rvboxv1.CommandDispatch) { value.IssueUuid = "not-a-uuid" },
|
||||||
|
func(value *rvboxv1.CommandDispatch) { value.CommandRevision = 0 },
|
||||||
|
func(value *rvboxv1.CommandDispatch) { value.TargetSessionGeneration = 0 },
|
||||||
|
func(value *rvboxv1.CommandDispatch) {
|
||||||
|
value.IssueTime = timestamppb.New(time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
copyEnvelope := proto.Clone(envelope).(*rvboxv1.AgentEnvelope)
|
||||||
|
mutate(copyEnvelope.GetCommandDispatch())
|
||||||
|
if err := ValidateEnvelope(copyEnvelope, DefaultLimits(), rvboxv1.Platform_PLATFORM_LINUX); err == nil {
|
||||||
|
t.Fatal("invalid dispatch accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputChunkBoundedDecompression_HP_PROTO_05(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const limit = 64 << 10
|
||||||
|
raw := bytes.Repeat([]byte("compressible-data"), 100)
|
||||||
|
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
compressed := encoder.EncodeAll(raw, nil)
|
||||||
|
encoder.Close()
|
||||||
|
chunk := &rvboxv1.OutputChunk{Stream: rvboxv1.StreamKind_STREAM_STDOUT, Compression: rvboxv1.Compression_COMPRESSION_ZSTD, Data: compressed, UncompressedSize: uint64(len(raw)), CompressedSize: uint64(len(compressed))}
|
||||||
|
decoded, err := DecodeOutputChunk(chunk, limit)
|
||||||
|
if err != nil || !bytes.Equal(decoded, raw) {
|
||||||
|
t.Fatalf("DecodeOutputChunk() = (%d bytes, %v)", len(decoded), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
none := &rvboxv1.OutputChunk{Stream: rvboxv1.StreamKind_STREAM_STDERR, Compression: rvboxv1.Compression_COMPRESSION_NONE, Data: []byte("x"), UncompressedSize: 1, CompressedSize: 1}
|
||||||
|
if decoded, err := DecodeOutputChunk(none, limit); err != nil || string(decoded) != "x" {
|
||||||
|
t.Fatalf("uncompressed chunk = (%q, %v)", decoded, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, invalid := range []*rvboxv1.OutputChunk{
|
||||||
|
nil,
|
||||||
|
{Stream: rvboxv1.StreamKind_STREAM_KIND_UNSPECIFIED, Compression: rvboxv1.Compression_COMPRESSION_NONE},
|
||||||
|
{Stream: rvboxv1.StreamKind_STREAM_STDOUT, Compression: rvboxv1.Compression_COMPRESSION_NONE, Data: []byte("x"), UncompressedSize: 2, CompressedSize: 1},
|
||||||
|
{Stream: rvboxv1.StreamKind_STREAM_STDOUT, Compression: rvboxv1.Compression_COMPRESSION_ZSTD, Data: []byte("bad"), UncompressedSize: 1, CompressedSize: 3},
|
||||||
|
} {
|
||||||
|
if _, err := DecodeOutputChunk(invalid, limit); !errors.Is(err, ErrInvalidOutputChunk) {
|
||||||
|
t.Errorf("invalid chunk error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bombRaw := bytes.Repeat([]byte("b"), limit+1)
|
||||||
|
encoder, _ = zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
|
||||||
|
bomb := encoder.EncodeAll(bombRaw, nil)
|
||||||
|
encoder.Close()
|
||||||
|
bombChunk := &rvboxv1.OutputChunk{Stream: rvboxv1.StreamKind_STREAM_STDOUT, Compression: rvboxv1.Compression_COMPRESSION_ZSTD, Data: bomb, UncompressedSize: limit, CompressedSize: uint64(len(bomb))}
|
||||||
|
if _, err := DecodeOutputChunk(bombChunk, limit); !errors.Is(err, ErrInvalidOutputChunk) {
|
||||||
|
t.Fatalf("decompression bomb error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandEventPayloadValidation_BH_OUTFLOW_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
event := &rvboxv1.CommandEvent{IssueUuid: testIssueUUID, EventSeq: 1, ObservedAt: fixedTimestamp(), Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle_COMMAND_RUNNING, CommandRevision: 1}}}
|
||||||
|
envelope := &rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_CommandEvent{CommandEvent: event}}
|
||||||
|
if err := ValidateEnvelope(envelope, DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS); err != nil {
|
||||||
|
t.Fatalf("valid event: %v", err)
|
||||||
|
}
|
||||||
|
conflicting := proto.Clone(envelope).(*rvboxv1.AgentEnvelope)
|
||||||
|
conflicting.GetCommandEvent().GetLifecycle().Detail = strings.Repeat("x", int(DefaultLimits().MaxDetailBytes)+1)
|
||||||
|
if err := ValidateEnvelope(conflicting, DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("oversized lifecycle detail accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconciliationRowsAndCapacity_BH_SES_01(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limits := DefaultLimits()
|
||||||
|
request := &rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileRequest{ReconcileRequest: &rvboxv1.ReconcileRequest{Targets: []*rvboxv1.ReconcileTarget{{IssueUuid: testIssueUUID, LastServerEventSeq: 1, CommandRevision: 1, ImmutableRequestSha256: make([]byte, 32)}}}}}
|
||||||
|
if err := ValidateEnvelope(request, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err != nil {
|
||||||
|
t.Fatalf("valid reconcile request: %v", err)
|
||||||
|
}
|
||||||
|
duplicate := proto.Clone(request).(*rvboxv1.AgentEnvelope)
|
||||||
|
duplicate.GetReconcileRequest().Targets = append(duplicate.GetReconcileRequest().Targets, proto.Clone(duplicate.GetReconcileRequest().Targets[0]).(*rvboxv1.ReconcileTarget))
|
||||||
|
if err := ValidateEnvelope(duplicate, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("duplicate reconcile target accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := &rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileSnapshot{ReconcileSnapshot: &rvboxv1.ReconcileSnapshot{RetainedCommands: []*rvboxv1.ReconcileCommandState{{IssueUuid: testIssueUUID, Lifecycle: rvboxv1.CommandLifecycle_COMMAND_RUNNING, LastClientEventSeq: 1, CommandRevision: 1, ImmutableRequestSha256: make([]byte, 32)}}}}}
|
||||||
|
if err := ValidateEnvelope(snapshot, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err != nil {
|
||||||
|
t.Fatalf("valid snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{TerminateLocalIssueUuids: []string{testIssueUUID}, DiscardLocalTerminalIssueUuids: []string{testIssueUUID}}}}
|
||||||
|
if err := ValidateEnvelope(result, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("same UUID in both reconcile result actions accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
capacity := &rvboxv1.AgentEnvelope{SessionId: "session", SessionGeneration: 1, Payload: &rvboxv1.AgentEnvelope_ClientCapacity{ClientCapacity: &rvboxv1.ClientCapacity{RunningCommands: 2, MaxRunningCommands: 1, MaxQueuedCommands: 1}}}
|
||||||
|
if err := ValidateEnvelope(capacity, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err == nil {
|
||||||
|
t.Fatal("capacity overflow accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHello() *rvboxv1.ClientHello {
|
||||||
|
return &rvboxv1.ClientHello{ClientId: "host-01", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\ProgramData\RVBox\work`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: "instance-opaque", MaxRunningCommands: 16, MaxQueuedCommands: 100, SentAt: fixedTimestamp()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCommandSpec() *rvboxv1.ExecutionSpec {
|
||||||
|
return &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_SH, EnvOverrides: map[string]string{"LANG": "C"}, ExecutionProfiles: []rvboxv1.ExecutionProfile{rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM, rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_MEDIUM}, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "echo ok"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedTimestamp() *timestamppb.Timestamp {
|
||||||
|
return timestamppb.New(time.Unix(1_700_000_000, 0).UTC())
|
||||||
|
}
|
||||||
+44
-2
@@ -52,7 +52,17 @@ tests = ["internal/domain/lifecycle_test.go:TestLaunchBarrier_HP_LAUNCH_01"]
|
|||||||
id = "HP-PROTO-01"
|
id = "HP-PROTO-01"
|
||||||
layer = "unit"
|
layer = "unit"
|
||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/domain/sequence_test.go:TestOutputTruncationRanges_HP_PROTO_01"]
|
tests = [
|
||||||
|
"internal/domain/sequence_test.go:TestOutputTruncationRanges_HP_PROTO_01",
|
||||||
|
"internal/agentproto/validate_test.go:TestDecodeEnvelopeBoundaries_HP_PROTO_01",
|
||||||
|
"internal/agentproto/validate_test.go:TestExecutionSpecValidation_HP_PROTO_01",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "HP-PROTO-05"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/agentproto/validate_test.go:TestOutputChunkBoundedDecompression_HP_PROTO_05"]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-PROTO-09"
|
id = "HP-PROTO-09"
|
||||||
@@ -75,9 +85,41 @@ layer = "unit"
|
|||||||
status = "implemented"
|
status = "implemented"
|
||||||
tests = ["internal/domain/errors_test.go:TestWindowsPrelaunchErrorCodes_HP_WINCTX_01"]
|
tests = ["internal/domain/errors_test.go:TestWindowsPrelaunchErrorCodes_HP_WINCTX_01"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-SES-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = [
|
||||||
|
"internal/agentproto/validate_test.go:TestEnvelopeSessionFencingShape_BH_SES_01",
|
||||||
|
"internal/agentproto/validate_test.go:TestReconciliationRowsAndCapacity_BH_SES_01",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-IDEM-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/agentproto/validate_test.go:TestCommandDispatchUUIDRevisionAndTimestamp_BH_IDEM_01"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-SCRIPT-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/agentproto/validate_test.go:TestScriptDescriptorHostileNames_BH_SCRIPT_01"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-LAUNCH-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/agentproto/validate_test.go:TestWindowsEnvironmentCaseCollision_BH_LAUNCH_01"]
|
||||||
|
|
||||||
|
[[requirements]]
|
||||||
|
id = "BH-OUTFLOW-01"
|
||||||
|
layer = "unit"
|
||||||
|
status = "implemented"
|
||||||
|
tests = ["internal/agentproto/validate_test.go:TestCommandEventPayloadValidation_BH_OUTFLOW_01"]
|
||||||
|
|
||||||
[[requirements]]
|
[[requirements]]
|
||||||
id = "HP-WINCTX-02"
|
id = "HP-WINCTX-02"
|
||||||
layer = "integration"
|
layer = "integration"
|
||||||
status = "blocked_native_windows"
|
status = "blocked_native_windows"
|
||||||
tests = []
|
tests = []
|
||||||
|
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ type coverageRequirement struct {
|
|||||||
Tests []string `toml:"tests"`
|
Tests []string `toml:"tests"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var coverageIDPattern = regexp.MustCompile(`^(HP|ERR|RACE|CRASH|SEC|BOUND|REC)-[A-Z0-9]+-[0-9]{2}$`)
|
var coverageIDPattern = regexp.MustCompile(`^(HP|BH|ERR|RACE|CRASH|SEC|BOUND|REC)-[A-Z0-9]+-[0-9]{2}$`)
|
||||||
|
|
||||||
func validateCoverageInventory(path string) error {
|
func validateCoverageInventory(path string) error {
|
||||||
absolutePath, err := filepath.Abs(path)
|
absolutePath, err := filepath.Abs(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user