Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735d990901 | ||
|
|
9c27a1ebf5 | ||
|
|
079440f7a9 | ||
|
|
7a03644a9d | ||
|
|
0723c10d9a | ||
|
|
519c04e18f | ||
|
|
86a95952b6 | ||
|
|
09f40eb9c7 | ||
|
|
111092becb | ||
|
|
7947ebcc75 | ||
|
|
5cc2d120e7 | ||
|
|
72b3c54ed9 | ||
|
|
67510b65b4 | ||
|
|
cb94f4ad5d | ||
|
|
dc2cbdf4d7 | ||
|
|
48ee082c0b | ||
|
|
12ad2a4daa | ||
|
|
93246c14bf | ||
|
|
e764b0d96d | ||
|
|
a1d68d33e5 | ||
|
|
e5324998d0 | ||
|
|
f981823909 | ||
|
|
bfffea5d2d | ||
|
|
57c5310e67 | ||
|
|
6264e9c2dc | ||
|
|
68608a76a2 | ||
|
|
31f5501f86 |
@@ -16,7 +16,10 @@ linked, or embedded:
|
||||
|
||||
- Apollo `adc5c5a0bd80831ce495434bb16aee2cd4175fb8`, GPLv3:
|
||||
`src/rtsp.cpp`, `src/stream.cpp`, `src/audio.cpp`, `src/audio.h`,
|
||||
`src/nvhttp.cpp`, `LICENSE`, and `NOTICE`.
|
||||
`src/nvhttp.cpp`, `src/input.cpp`, `LICENSE`, and `NOTICE`.
|
||||
- Apollo's moonlight-common-c pin
|
||||
`c999436858471dfefa7617af3b7dc03ec1644ce4`, GPLv3: `src/Input.h`,
|
||||
`src/InputStream.c`, and `LICENSE.txt`.
|
||||
- Moonlight Qt `c0c4d6056569bba40ac4458a3c225c05ff86df6d` with common-c
|
||||
pin `2ea47752c3051d72a64bcca190024e8b354fa1ef`, GPLv3:
|
||||
`src/ControlStream.c`, `src/Video.h`, `src/RtpAudioQueue.h`,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package gatewaytls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Material struct {
|
||||
ServerTLS *tls.Config
|
||||
ControlTLS *tls.Config
|
||||
CertificateIdentity string
|
||||
}
|
||||
|
||||
func Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity string) (Material, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load gateway certificate: %w", err)
|
||||
}
|
||||
if len(certificate.Certificate) == 0 {
|
||||
return Material{}, errors.New("gateway certificate chain is empty")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
|
||||
if err != nil {
|
||||
return Material{}, errors.New("parse gateway leaf certificate")
|
||||
}
|
||||
certificate.Leaf = leaf
|
||||
if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
|
||||
return Material{}, errors.New("gateway leaf certificate is not currently valid")
|
||||
}
|
||||
if !hasUsage(leaf, x509.ExtKeyUsageServerAuth) || !hasUsage(leaf, x509.ExtKeyUsageClientAuth) {
|
||||
return Material{}, errors.New("gateway leaf certificate requires ServerAuth and ClientAuth")
|
||||
}
|
||||
if !gatewayURIAllowed(leaf.URIs, gatewayID) {
|
||||
return Material{}, errors.New("gateway leaf certificate URI identity mismatch")
|
||||
}
|
||||
if !validPublicDNSName(publicIdentity) || !slices.Contains(leaf.DNSNames, publicIdentity) || leaf.VerifyHostname(publicIdentity) != nil {
|
||||
return Material{}, errors.New("gateway public identity must match a DNS SAN")
|
||||
}
|
||||
streamingCAs, err := loadCAPool(streamingCAFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load streaming CA: %w", err)
|
||||
}
|
||||
controlRoots, err := loadControlRootPool(controlCAFile)
|
||||
if err != nil {
|
||||
return Material{}, fmt.Errorf("load control root: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(leaf.Raw)
|
||||
return Material{
|
||||
ServerTLS: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: streamingCAs,
|
||||
},
|
||||
ControlTLS: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: controlRoots,
|
||||
},
|
||||
CertificateIdentity: "sha256:" + hex.EncodeToString(digest[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadCAPool(path string) (*x509.CertPool, error) {
|
||||
encoded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
count := 0
|
||||
for len(bytes.TrimSpace(encoded)) > 0 {
|
||||
block, rest := pem.Decode(encoded)
|
||||
if block == nil {
|
||||
return nil, errors.New("CA PEM contains invalid data")
|
||||
}
|
||||
encoded = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("CA PEM contains a non-certificate block")
|
||||
}
|
||||
certificate, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil || !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
return nil, errors.New("CA PEM contains a non-CA certificate")
|
||||
}
|
||||
pool.AddCert(certificate)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, errors.New("CA PEM contains no certificate")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func loadControlRootPool(path string) (*x509.CertPool, error) {
|
||||
encoded, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var certificates []*x509.Certificate
|
||||
for len(bytes.TrimSpace(encoded)) > 0 {
|
||||
block, rest := pem.Decode(encoded)
|
||||
if block == nil {
|
||||
return nil, errors.New("control trust PEM contains invalid data")
|
||||
}
|
||||
encoded = rest
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("control trust PEM contains a non-certificate block")
|
||||
}
|
||||
certificate, parseErr := x509.ParseCertificate(block.Bytes)
|
||||
if parseErr != nil {
|
||||
return nil, errors.New("control trust PEM contains an invalid certificate")
|
||||
}
|
||||
certificates = append(certificates, certificate)
|
||||
}
|
||||
if len(certificates) == 0 {
|
||||
return nil, errors.New("control trust PEM contains no certificate")
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
allCAs := true
|
||||
for _, certificate := range certificates {
|
||||
if !certificate.IsCA || certificate.KeyUsage&x509.KeyUsageCertSign == 0 {
|
||||
allCAs = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allCAs {
|
||||
for _, certificate := range certificates {
|
||||
pool.AddCert(certificate)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
leaf := certificates[0]
|
||||
if len(certificates) != 1 || leaf.IsCA || !hasUsage(leaf, x509.ExtKeyUsageServerAuth) {
|
||||
return nil, errors.New("control trust PEM must contain CAs or one ServerAuth leaf")
|
||||
}
|
||||
pool.AddCert(leaf)
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func hasUsage(certificate *x509.Certificate, wanted x509.ExtKeyUsage) bool {
|
||||
for _, usage := range certificate.ExtKeyUsage {
|
||||
if usage == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gatewayURIAllowed(uris []*url.URL, gatewayID string) bool {
|
||||
if !validUUID(gatewayID) {
|
||||
return false
|
||||
}
|
||||
prefix := "/gateway/" + gatewayID + "/credential/"
|
||||
for _, uri := range uris {
|
||||
if uri == nil || uri.Scheme != "spiffe" || uri.Host != "versevdi" || uri.User != nil || uri.Opaque != "" || uri.RawPath != "" ||
|
||||
uri.RawQuery != "" || uri.ForceQuery || uri.Fragment != "" || uri.RawFragment != "" || !strings.HasPrefix(uri.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
if validUUID(strings.TrimPrefix(uri.Path, prefix)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validPublicDNSName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 253 || net.ParseIP(name) != nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(name, ".") {
|
||||
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, character := range []byte(label) {
|
||||
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 || value != strings.ToLower(value) || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(strings.ReplaceAll(value, "-", ""))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, b := range decoded {
|
||||
if b != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package gatewaytls
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testGatewayID = "7d0d1308-5e50-45ef-984c-4508ca899f5a"
|
||||
testCredentialID = "ba4d93f8-d692-4210-97db-5a561bfb32d3"
|
||||
)
|
||||
|
||||
type testCA struct {
|
||||
certificate *x509.Certificate
|
||||
key *ecdsa.PrivateKey
|
||||
der []byte
|
||||
}
|
||||
|
||||
func TestLoadSeparatesTrustPoolsAndDerivesGatewayIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(leafDER)
|
||||
if material.CertificateIdentity != "sha256:"+hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("certificate identity = %q", material.CertificateIdentity)
|
||||
}
|
||||
if material.ServerTLS.MinVersion != tls.VersionTLS13 || material.ControlTLS.MinVersion != tls.VersionTLS13 || material.ServerTLS.ClientAuth != tls.RequireAndVerifyClientCert ||
|
||||
material.ServerTLS.ClientCAs == nil || material.ControlTLS.RootCAs == nil ||
|
||||
len(material.ServerTLS.Certificates) != 1 || len(material.ControlTLS.Certificates) != 1 {
|
||||
t.Fatalf("TLS material = server:%#v control:%#v", material.ServerTLS, material.ControlTLS)
|
||||
}
|
||||
if got := material.ServerTLS.ClientCAs.Subjects(); len(got) != 1 || string(got[0]) != string(streamingCA.certificate.RawSubject) {
|
||||
t.Fatalf("streaming ClientCAs = %x", got)
|
||||
}
|
||||
if got := material.ControlTLS.RootCAs.Subjects(); len(got) != 1 || string(got[0]) != string(controlCA.certificate.RawSubject) {
|
||||
t.Fatalf("control RootCAs = %x", got)
|
||||
}
|
||||
if string(material.ServerTLS.Certificates[0].Certificate[0]) != string(material.ControlTLS.Certificates[0].Certificate[0]) {
|
||||
t.Fatal("server and control TLS did not use the same gateway leaf")
|
||||
}
|
||||
|
||||
trustedControlLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
parsedTrusted, _ := x509.ParseCertificate(trustedControlLeaf)
|
||||
if _, err := parsedTrusted.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err != nil {
|
||||
t.Fatalf("control CA did not verify control server: %v", err)
|
||||
}
|
||||
wrongControlLeaf, _ := newLeaf(t, streamingCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
|
||||
parsedWrong, _ := x509.ParseCertificate(wrongControlLeaf)
|
||||
if _, err := parsedWrong.Verify(x509.VerifyOptions{Roots: material.ControlTLS.RootCAs, DNSName: "control.example"}); err == nil {
|
||||
t.Fatal("control RootCAs trusted the streaming CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustsExactSelfSignedControlLeaf(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
controlLeaf, controlKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1"))
|
||||
controlFile := writeCertificate(t, "control-leaf.pem", controlLeaf)
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if material.ControlTLS.MinVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("ControlTLS.MinVersion = %d, want TLS 1.3", material.ControlTLS.MinVersion)
|
||||
}
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(identityCA.certificate)
|
||||
trusted := newControlServer(t, controlLeaf, controlKey, clientCAs)
|
||||
defer trusted.Close()
|
||||
response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("pinned self-signed control leaf handshake: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("trusted control response status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
wrongLeaf, wrongKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.1"))
|
||||
wrong := newControlServer(t, wrongLeaf, wrongKey, clientCAs)
|
||||
defer wrong.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(wrong.URL); err == nil {
|
||||
t.Fatal("different self-signed leaf with the same SAN completed handshake")
|
||||
}
|
||||
|
||||
mismatchedLeaf, mismatchedKey := newSelfSignedServerLeaf(t, net.ParseIP("127.0.0.2"))
|
||||
mismatchedFile := writeCertificate(t, "mismatched-control-leaf.pem", mismatchedLeaf)
|
||||
mismatchedMaterial, err := Load(certFile, keyFile, streamingFile, mismatchedFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() mismatched control leaf error = %v", err)
|
||||
}
|
||||
mismatched := newControlServer(t, mismatchedLeaf, mismatchedKey, clientCAs)
|
||||
defer mismatched.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: mismatchedMaterial.ControlTLS}}).Get(mismatched.URL); err == nil {
|
||||
t.Fatal("control leaf without the request hostname/SAN completed handshake")
|
||||
}
|
||||
|
||||
if _, err := Load(certFile, keyFile, controlFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted a non-CA leaf as streaming trust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTrustsExactCAIssuedControlLeaf(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
activeLeaf, activeKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
controlFile := writeCertificate(t, "control-leaf.pem", activeLeaf)
|
||||
material, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
clientCAs := x509.NewCertPool()
|
||||
clientCAs.AddCert(identityCA.certificate)
|
||||
trusted := newControlServer(t, activeLeaf, activeKey, clientCAs, controlCA.der)
|
||||
defer trusted.Close()
|
||||
response, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(trusted.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("pinned CA-issued control leaf handshake: %v", err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.TLS == nil || response.TLS.Version != tls.VersionTLS13 {
|
||||
t.Fatalf("pinned control leaf TLS version = %v, want TLS 1.3", response.TLS)
|
||||
}
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("trusted control response status = %d", response.StatusCode)
|
||||
}
|
||||
|
||||
siblingLeaf, siblingKey := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
sibling := newControlServer(t, siblingLeaf, siblingKey, clientCAs, controlCA.der)
|
||||
defer sibling.Close()
|
||||
if _, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: material.ControlTLS}}).Get(sibling.URL); err == nil {
|
||||
t.Fatal("sibling control leaf from the same issuer and SAN completed handshake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsControlLeafBundlesAndNonServerAuth(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
gatewayLeaf, gatewayKey := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, gatewayLeaf, identityCA.der, gatewayKey)
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
|
||||
serverLeaf, _ := newCAIssuedControlLeaf(t, controlCA, net.ParseIP("127.0.0.1"))
|
||||
nonServerLeaf, _ := newLeaf(t, controlCA, "control.example", nil, []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
certificates [][]byte
|
||||
}{
|
||||
{"leaf and issuer chain", [][]byte{serverLeaf, controlCA.der}},
|
||||
{"non ServerAuth leaf", [][]byte{nonServerLeaf}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
controlFile := writeCertificateBundle(t, "control.pem", test.certificates...)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted invalid control trust material")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsWrongPublicNameURIAndLeafUsage(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uriGatewayID string
|
||||
publicIdentity string
|
||||
leafDNSName string
|
||||
usages []x509.ExtKeyUsage
|
||||
}{
|
||||
{"wrong public name", testGatewayID, "other.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"noncanonical public identity", testGatewayID, " gateway.example ", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"wildcard is not an exact public identity", testGatewayID, "gateway.example", "*.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"wrong URI gateway", "c1f5d25a-47c6-44ed-b9be-e0e8e87d4ae2", "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}},
|
||||
{"missing client auth", testGatewayID, "gateway.example", "gateway.example", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+test.uriGatewayID+"/credential/"+testCredentialID)
|
||||
leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, test.usages)
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil {
|
||||
t.Fatal("Load() accepted invalid gateway certificate identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsNonCanonicalPublicDNSIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
tooLongLabel := strings.Repeat("a", 64) + ".example"
|
||||
tooLongName := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 62)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
leafDNSName string
|
||||
publicIdentity string
|
||||
}{
|
||||
{"uppercase exact SAN", "Gateway.example", "Gateway.example"},
|
||||
{"empty label", "gateway..example", "gateway..example"},
|
||||
{"leading hyphen", "-gateway.example", "-gateway.example"},
|
||||
{"trailing hyphen", "gateway-.example", "gateway-.example"},
|
||||
{"wildcard", "*.example", "*.example"},
|
||||
{"label exceeds 63 bytes", tooLongLabel, tooLongLabel},
|
||||
{"name exceeds 253 bytes", tooLongName, tooLongName},
|
||||
{"IP address", "127.0.0.1", "127.0.0.1"},
|
||||
{"port", "gateway.example:443", "gateway.example:443"},
|
||||
{"path", "gateway.example/path", "gateway.example/path"},
|
||||
{"whitespace", "gateway .example", "gateway .example"},
|
||||
{"trailing dot", "gateway.example.", "gateway.example."},
|
||||
{"Unicode", "gateway.example", "gäteway.example"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
leafDER, key := newLeaf(t, identityCA, test.leafDNSName, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, test.publicIdentity); err == nil {
|
||||
t.Fatal("Load() accepted a noncanonical public DNS identity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAcceptsCanonicalPublicDNSIdentity(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+testGatewayID+"/credential/"+testCredentialID)
|
||||
|
||||
for _, publicIdentity := range []string{"gateway.example", "gateway"} {
|
||||
t.Run(publicIdentity, func(t *testing.T) {
|
||||
leafDER, key := newLeaf(t, identityCA, publicIdentity, []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, publicIdentity); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsLegacyAndNoncanonicalGatewayURIs(t *testing.T) {
|
||||
streamingCA := newTestCA(t, "streaming-ca")
|
||||
controlCA := newTestCA(t, "control-ca")
|
||||
identityCA := newTestCA(t, "identity-ca")
|
||||
streamingFile := writeCertificate(t, "streaming-ca.pem", streamingCA.der)
|
||||
controlFile := writeCertificate(t, "control-ca.pem", controlCA.der)
|
||||
canonical := "spiffe://versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
uri string
|
||||
}{
|
||||
{"legacy URN", "urn:versevdi:gateway:" + testGatewayID + ":credential:" + testCredentialID},
|
||||
{"opaque URI", "spiffe:gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
{"leading path slash", "spiffe://versevdi//gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
{"trailing path slash", canonical + "/"},
|
||||
{"percent encoded credential", "spiffe://versevdi/gateway/" + testGatewayID + "/credential/%62a4d93f8-d692-4210-97db-5a561bfb32d3"},
|
||||
{"force query", canonical + "?"},
|
||||
{"query", canonical + "?version=1"},
|
||||
{"fragment", canonical + "#fragment"},
|
||||
{"user", "spiffe://gateway@versevdi/gateway/" + testGatewayID + "/credential/" + testCredentialID},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
uri := mustURL(t, test.uri)
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, testGatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted a legacy or noncanonical gateway URI")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("uppercase UUIDs", func(t *testing.T) {
|
||||
gatewayID := strings.ToUpper(testGatewayID)
|
||||
uri := mustURL(t, "spiffe://versevdi/gateway/"+gatewayID+"/credential/"+strings.ToUpper(testCredentialID))
|
||||
leafDER, key := newLeaf(t, identityCA, "gateway.example", []*url.URL{uri}, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth})
|
||||
certFile, keyFile := writeKeyPair(t, leafDER, identityCA.der, key)
|
||||
if _, err := Load(certFile, keyFile, streamingFile, controlFile, gatewayID, "gateway.example"); err == nil {
|
||||
t.Fatal("Load() accepted noncanonical uppercase UUIDs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newTestCA(t *testing.T, commonName string) testCA {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: commonName},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certificate, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return testCA{certificate: certificate, key: key, der: der}
|
||||
}
|
||||
|
||||
func newLeaf(t *testing.T, ca testCA, dnsName string, uris []*url.URL, usages []x509.ExtKeyUsage) ([]byte, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: dnsName}, DNSNames: []string{dnsName}, URIs: uris,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: usages,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newSelfSignedServerLeaf(t *testing.T, ip net.IP) ([]byte, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newCAIssuedControlLeaf(t *testing.T, ca testCA, ip net.IP) ([]byte, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
publicKey, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "control.example"}, IPAddresses: []net.IP{ip},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
|
||||
BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.certificate, publicKey, ca.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, key
|
||||
}
|
||||
|
||||
func newControlServer(t *testing.T, leaf []byte, key any, clientCAs *x509.CertPool, chain ...[]byte) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.TLS == nil || len(request.TLS.PeerCertificates) == 0 {
|
||||
http.Error(response, "mTLS required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
certificates := append([][]byte{leaf}, chain...)
|
||||
server.TLS = &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{{Certificate: certificates, PrivateKey: key}},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs,
|
||||
}
|
||||
server.StartTLS()
|
||||
return server
|
||||
}
|
||||
|
||||
func writeKeyPair(t *testing.T, leafDER, issuerDER []byte, key ed25519.PrivateKey) (string, string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
certFile := filepath.Join(directory, "gateway.pem")
|
||||
keyFile := filepath.Join(directory, "gateway-key.pem")
|
||||
certificatePEM := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuerDER})...)
|
||||
privateDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(certFile, certificatePEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
func writeCertificate(t *testing.T, name string, der []byte) string {
|
||||
return writeCertificateBundle(t, name, der)
|
||||
}
|
||||
|
||||
func writeCertificateBundle(t *testing.T, name string, certificates ...[]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
var encoded []byte
|
||||
for _, certificate := range certificates {
|
||||
encoded = append(encoded, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate})...)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, value string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
+12
-29
@@ -2,14 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -17,6 +14,7 @@ import (
|
||||
|
||||
"net/http"
|
||||
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/cmd/internal/gatewaytls"
|
||||
"git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane/gateway"
|
||||
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
|
||||
)
|
||||
@@ -28,20 +26,20 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var listen, advertiseAddress, controlPlane, certFile, keyFile, clientCAFile string
|
||||
var gatewayID, instanceIdentity, certificateIdentity, publicIdentity string
|
||||
var listen, advertiseAddress, controlPlane, certFile, keyFile, streamingCAFile, controlCAFile string
|
||||
var gatewayID, instanceIdentity, publicIdentity string
|
||||
flag.StringVar(&listen, "listen", "0.0.0.0:443", "gateway QUIC listen address")
|
||||
flag.StringVar(&advertiseAddress, "advertise-address", "", "client-visible gateway address host:port")
|
||||
flag.StringVar(&controlPlane, "control-plane", "", "Connection Server HTTPS base URL")
|
||||
flag.StringVar(&certFile, "cert", "", "gateway certificate PEM")
|
||||
flag.StringVar(&keyFile, "key", "", "gateway private key PEM")
|
||||
flag.StringVar(&clientCAFile, "client-ca", "", "Connection Server/client CA PEM")
|
||||
flag.StringVar(&streamingCAFile, "streaming-ca", "", "CA PEM for QUIC streaming clients")
|
||||
flag.StringVar(&controlCAFile, "control-ca", "", "CA bundle or exact Server leaf PEM")
|
||||
flag.StringVar(&gatewayID, "gateway-id", "", "stable gateway identifier")
|
||||
flag.StringVar(&instanceIdentity, "instance-identity", "", "gateway instance identity")
|
||||
flag.StringVar(&certificateIdentity, "certificate-identity", "", "gateway certificate identity")
|
||||
flag.StringVar(&publicIdentity, "public-identity", "gateway", "gateway public identity")
|
||||
flag.StringVar(&publicIdentity, "public-identity", "", "gateway DNS identity from the certificate SAN")
|
||||
flag.Parse()
|
||||
for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "client-ca": clientCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "certificate-identity": certificateIdentity} {
|
||||
for name, value := range map[string]string{"control-plane": controlPlane, "advertise-address": advertiseAddress, "cert": certFile, "key": keyFile, "streaming-ca": streamingCAFile, "control-ca": controlCAFile, "gateway-id": gatewayID, "instance-identity": instanceIdentity, "public-identity": publicIdentity} {
|
||||
if value == "" {
|
||||
return fmt.Errorf("-%s is required", name)
|
||||
}
|
||||
@@ -49,19 +47,20 @@ func run() error {
|
||||
if err := validateAdvertisedAddress(advertiseAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
serverTLS, clientTLS, err := loadTLS(certFile, keyFile, clientCAFile)
|
||||
tlsMaterial, err := gatewaytls.Load(certFile, keyFile, streamingCAFile, controlCAFile, gatewayID, publicIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: clientTLS}
|
||||
transport := &http.Transport{TLSClientConfig: tlsMaterial.ControlTLS}
|
||||
controlPlaneClient := gateway.NewControlPlaneClient(controlPlane, &http.Client{Transport: transport, Timeout: 5 * time.Second})
|
||||
provider := gateway.NewApolloAdapter(gateway.NewNativeApolloBackend(), gateway.ProviderIdentity{})
|
||||
capabilities := gateway.DefaultCapabilities()
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: serverTLS, GatewayID: gatewayID, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000})
|
||||
features := gateway.DefaultFeatures()
|
||||
server, err := gateway.NewServer(gateway.ServerConfig{ListenAddress: listen, TLSConfig: tlsMaterial.ServerTLS, GatewayID: gatewayID, Features: features, Capabilities: capabilities, ProviderCapabilities: capabilities, Admission: controlPlaneClient, ProviderStateReporter: controlPlaneClient, ClipboardAuditReporter: controlPlaneClient, Provider: provider, PacerKbps: 100000})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: certificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: []string{"quic-tls13", "datagram.media", "apollo"}, Capabilities: capabilities}
|
||||
registration := protocol.GatewayRegistration{Version: "1", GatewayID: gatewayID, InstanceIdentity: instanceIdentity, CertificateIdentity: tlsMaterial.CertificateIdentity, PublicIdentity: publicIdentity, Address: advertiseAddress, ProviderIdentity: "server-derived", ProtocolMinVersion: 1, ProtocolMaxVersion: 1, ConnectionCapacity: 8, BandwidthCapacityKbps: 100000, Features: features, Capabilities: capabilities}
|
||||
if _, err := controlPlaneClient.Register(context.Background(), registration); err != nil {
|
||||
_ = server.Close()
|
||||
return err
|
||||
@@ -182,22 +181,6 @@ func providerStateName(value uint64) string {
|
||||
}
|
||||
}
|
||||
|
||||
func loadTLS(certFile, keyFile, clientCAFile string) (*tls.Config, *tls.Config, error) {
|
||||
certificate, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
caBytes, err := os.ReadFile(clientCAFile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
clientCAs := x509.NewCertPool()
|
||||
if !clientCAs.AppendCertsFromPEM(caBytes) {
|
||||
return nil, nil, errors.New("client CA PEM contains no certificate")
|
||||
}
|
||||
return &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs}, &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{certificate}, RootCAs: clientCAs}, nil
|
||||
}
|
||||
|
||||
func parseProviderIdentity(value string) (gateway.ProviderIdentity, error) {
|
||||
uniqueID, fingerprint, ok := strings.Cut(value, "#")
|
||||
if !ok || uniqueID == "" || fingerprint == "" {
|
||||
|
||||
Generated
+701
@@ -0,0 +1,701 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.51.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e608df10a8a5f3c45a2ad4801f93083620b4f3a816551e5e407cdfcd6690f56"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "versevdi-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"getrandom 0.4.3",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "versevdi-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-only"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "=0.22.1"
|
||||
getrandom = "=0.4.3"
|
||||
quinn = { version = "=0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] }
|
||||
rustls = { version = "=0.23.43", default-features = false, features = ["std", "ring"] }
|
||||
rustls-pemfile = "=2.2.0"
|
||||
serde = { version = "=1.0.229", features = ["derive"] }
|
||||
serde_json = "=1.0.151"
|
||||
tokio = { version = "=1.51.4", features = ["rt-multi-thread", "sync", "time", "net"] }
|
||||
|
||||
[[test]]
|
||||
name = "protocol_fixtures"
|
||||
path = "tests/protocol_fixtures.rs"
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
incremental = false
|
||||
@@ -0,0 +1,4 @@
|
||||
module VerseVDICore {
|
||||
header "versevdi_core.h"
|
||||
export *
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#ifndef VERSEVDI_CORE_H
|
||||
#define VERSEVDI_CORE_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define VERSE_CORE_ABI_VERSION_1 UINT32_C(1)
|
||||
|
||||
typedef uint32_t verse_status_t;
|
||||
|
||||
#define VERSE_STATUS_OK UINT32_C(0)
|
||||
#define VERSE_STATUS_INVALID_ARGUMENT UINT32_C(1)
|
||||
#define VERSE_STATUS_INVALID_STATE UINT32_C(2)
|
||||
#define VERSE_STATUS_UNSUPPORTED_ABI UINT32_C(3)
|
||||
#define VERSE_STATUS_AUTHORITY_REJECTED UINT32_C(4)
|
||||
#define VERSE_STATUS_TLS UINT32_C(5)
|
||||
#define VERSE_STATUS_TRANSPORT UINT32_C(6)
|
||||
#define VERSE_STATUS_PROTOCOL UINT32_C(7)
|
||||
#define VERSE_STATUS_EXPIRED UINT32_C(8)
|
||||
#define VERSE_STATUS_QUEUE_FULL UINT32_C(9)
|
||||
#define VERSE_STATUS_CANCELLED UINT32_C(10)
|
||||
#define VERSE_STATUS_REENTRANT UINT32_C(11)
|
||||
#define VERSE_STATUS_BUSY UINT32_C(12)
|
||||
#define VERSE_STATUS_INTERNAL UINT32_C(13)
|
||||
|
||||
#define VERSE_STATE_CONNECTING UINT32_C(1)
|
||||
#define VERSE_STATE_CONNECTED UINT32_C(2)
|
||||
#define VERSE_STATE_CANCELLED UINT32_C(3)
|
||||
|
||||
#define VERSE_INPUT_KEYBOARD UINT32_C(1)
|
||||
#define VERSE_INPUT_MOUSE_BUTTON UINT32_C(2)
|
||||
#define VERSE_INPUT_RELATIVE_MOUSE UINT32_C(3)
|
||||
#define VERSE_INPUT_TEXT UINT32_C(4)
|
||||
#define VERSE_INPUT_CONTROLLER UINT32_C(5)
|
||||
#define VERSE_INPUT_ABSOLUTE_MOUSE UINT32_C(6)
|
||||
#define VERSE_INPUT_SCROLL UINT32_C(7)
|
||||
|
||||
typedef struct verse_core verse_core_t;
|
||||
|
||||
/* data may be NULL only when length is zero. The view never transfers ownership. */
|
||||
typedef struct verse_bytes_view {
|
||||
const uint8_t *data;
|
||||
size_t length;
|
||||
} verse_bytes_view_t;
|
||||
|
||||
typedef struct verse_state_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t state;
|
||||
uint32_t reason;
|
||||
} verse_state_event_v1_t;
|
||||
|
||||
typedef struct verse_error_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t code;
|
||||
uint32_t retryable;
|
||||
uint32_t phase;
|
||||
uint32_t reserved;
|
||||
} verse_error_event_v1_t;
|
||||
|
||||
typedef struct verse_stats_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint64_t dropped_callbacks;
|
||||
uint64_t dropped_media_units;
|
||||
uint64_t dropped_input_events;
|
||||
} verse_stats_event_v1_t;
|
||||
|
||||
typedef struct verse_media_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t channel;
|
||||
uint32_t sequence;
|
||||
uint64_t timestamp_ms;
|
||||
verse_bytes_view_t encoded_unit;
|
||||
} verse_media_event_v1_t;
|
||||
|
||||
typedef struct verse_control_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t kind;
|
||||
uint32_t reserved;
|
||||
verse_bytes_view_t payload;
|
||||
} verse_control_event_v1_t;
|
||||
|
||||
/*
|
||||
* Signers run synchronously inline on the thread invoking the API call.
|
||||
* transcript/tls_message is borrowed only for the callback; signature_out is
|
||||
* exactly 64 writable bytes. Admission may return OK, AUTHORITY_REJECTED,
|
||||
* CANCELLED, or INTERNAL. TLS may return OK, TLS, CANCELLED, or INTERNAL. Any
|
||||
* other value is normalized to INTERNAL.
|
||||
*/
|
||||
typedef verse_status_t (*verse_sign_admission_v1_fn)(
|
||||
void *signer_context,
|
||||
verse_bytes_view_t transcript,
|
||||
uint8_t signature_out[64]);
|
||||
typedef verse_status_t (*verse_sign_tls_ed25519_v1_fn)(
|
||||
void *signer_context,
|
||||
verse_bytes_view_t tls_message,
|
||||
uint8_t signature_out[64]);
|
||||
/*
|
||||
* Event callbacks run asynchronously on one core-owned worker thread and are
|
||||
* serialized with one another. Each event and nested byte view is borrowed only
|
||||
* for its callback and must not be retained.
|
||||
*/
|
||||
typedef void (*verse_state_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_state_event_v1_t *event);
|
||||
typedef void (*verse_error_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_error_event_v1_t *event);
|
||||
typedef void (*verse_stats_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_stats_event_v1_t *event);
|
||||
typedef void (*verse_media_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_media_event_v1_t *event);
|
||||
typedef void (*verse_control_event_v1_fn)(
|
||||
void *context,
|
||||
const verse_control_event_v1_t *event);
|
||||
|
||||
/*
|
||||
* create copies this table. context and every non-NULL callback must remain valid
|
||||
* until destroy succeeds; BUSY does not end that lifetime.
|
||||
*/
|
||||
typedef struct verse_core_config_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
void *context;
|
||||
verse_sign_admission_v1_fn sign_admission;
|
||||
verse_sign_tls_ed25519_v1_fn sign_tls_ed25519;
|
||||
verse_state_event_v1_fn on_state;
|
||||
verse_error_event_v1_fn on_error;
|
||||
verse_stats_event_v1_fn on_stats;
|
||||
verse_media_event_v1_fn on_media;
|
||||
verse_control_event_v1_fn on_control;
|
||||
} verse_core_config_v1_t;
|
||||
|
||||
typedef struct verse_connect_request_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
verse_bytes_view_t manifest_json;
|
||||
verse_bytes_view_t tunnel_credential_json;
|
||||
} verse_connect_request_v1_t;
|
||||
|
||||
/* values are kind-specific signed fields; every unused field and flags must be zero. */
|
||||
typedef struct verse_input_event_v1 {
|
||||
uint32_t struct_size;
|
||||
uint32_t abi_version;
|
||||
uint32_t kind;
|
||||
uint32_t flags;
|
||||
int32_t values[12];
|
||||
} verse_input_event_v1_t;
|
||||
|
||||
uint32_t verse_core_abi_version(void);
|
||||
/*
|
||||
* During a signer callback, every API below except verse_core_abi_version returns
|
||||
* REENTRANT for every handle. During an event callback, only cancel on that event's
|
||||
* originating handle is allowed; all other calls and cross-handle cancel return
|
||||
* REENTRANT. Connect copies both byte inputs before returning, so callers may
|
||||
* mutate or release their buffers afterward.
|
||||
*/
|
||||
verse_status_t verse_core_create_v1(
|
||||
const verse_core_config_v1_t *config,
|
||||
verse_core_t **out_core);
|
||||
verse_status_t verse_core_connect_v1(
|
||||
verse_core_t *core,
|
||||
const verse_connect_request_v1_t *request);
|
||||
verse_status_t verse_core_send_input_v1(
|
||||
verse_core_t *core,
|
||||
const verse_input_event_v1_t *event);
|
||||
verse_status_t verse_core_request_idr_v1(verse_core_t *core);
|
||||
/* Idempotent and nonblocking; it does not wait for an internal state lock. */
|
||||
verse_status_t verse_core_cancel_v1(verse_core_t *core);
|
||||
/*
|
||||
* OK suppresses all later callbacks, releases session resources, and invalidates
|
||||
* core. Calls admitted before destruction are accounted safely, but no API call
|
||||
* may begin after OK. BUSY retains core, context, and callback ownership and
|
||||
* requires a later retry.
|
||||
*/
|
||||
verse_status_t verse_core_destroy_v1(verse_core_t *core, uint32_t timeout_ms);
|
||||
|
||||
#if defined(__APPLE__) && defined(__aarch64__)
|
||||
_Static_assert(sizeof(verse_bytes_view_t) == 16, "verse_bytes_view_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_core_config_v1_t) == 72, "verse_core_config_v1_t arm64 layout");
|
||||
_Static_assert(offsetof(verse_core_config_v1_t, sign_admission) == 16, "config callback offset");
|
||||
_Static_assert(sizeof(verse_connect_request_v1_t) == 40, "verse_connect_request_v1_t arm64 layout");
|
||||
_Static_assert(offsetof(verse_connect_request_v1_t, manifest_json) == 8, "request view offset");
|
||||
_Static_assert(sizeof(verse_input_event_v1_t) == 64, "verse_input_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_state_event_v1_t) == 16, "verse_state_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_error_event_v1_t) == 24, "verse_error_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_stats_event_v1_t) == 32, "verse_stats_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_media_event_v1_t) == 40, "verse_media_event_v1_t arm64 layout");
|
||||
_Static_assert(sizeof(verse_control_event_v1_t) == 32, "verse_control_event_v1_t arm64 layout");
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-apple-darwin"]
|
||||
profile = "minimal"
|
||||
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 022
|
||||
PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
export PATH
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 --output ABSOLUTE_DIR --target-dir ABSOLUTE_DIR" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
output=
|
||||
target_dir=
|
||||
while test "$#" -gt 0; do
|
||||
case "$1" in
|
||||
--output)
|
||||
test "$#" -ge 2 || usage
|
||||
output=$2
|
||||
shift 2
|
||||
;;
|
||||
--target-dir)
|
||||
test "$#" -ge 2 || usage
|
||||
target_dir=$2
|
||||
shift 2
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
test -n "$output" || usage
|
||||
test -n "$target_dir" || usage
|
||||
ROOT=$(CDPATH= cd -P -- "$(dirname "$0")/../.." && pwd -P)
|
||||
CORE="$ROOT/core"
|
||||
|
||||
resolve_new_path() {
|
||||
requested=$1
|
||||
label=$2
|
||||
case "$requested" in
|
||||
/*) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
if test -e "$requested" || test -L "$requested"; then
|
||||
echo "$label must not exist: $requested" >&2
|
||||
exit 2
|
||||
fi
|
||||
parent=$(dirname -- "$requested")
|
||||
name=$(basename -- "$requested")
|
||||
case "$name" in
|
||||
''|.|..) usage ;;
|
||||
esac
|
||||
physical_parent=$(CDPATH= cd -P -- "$parent" 2>/dev/null && pwd -P) || {
|
||||
echo "$label parent must already exist: $parent" >&2
|
||||
exit 2
|
||||
}
|
||||
candidate="$physical_parent/$name"
|
||||
case "$candidate" in
|
||||
"$ROOT"|"$ROOT"/*)
|
||||
echo "$label must be outside the repository: $requested" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
output=$(resolve_new_path "$output" output)
|
||||
target_dir=$(resolve_new_path "$target_dir" "target directory")
|
||||
case "$output/:$target_dir/" in
|
||||
"$target_dir/"*:*|*:"$output/"*)
|
||||
echo "output and target directory must be separate" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir "$output"
|
||||
if ! mkdir "$target_dir"; then
|
||||
rmdir "$output"
|
||||
exit 2
|
||||
fi
|
||||
output=$(CDPATH= cd -P -- "$output" && pwd -P)
|
||||
target_dir=$(CDPATH= cd -P -- "$target_dir" && pwd -P)
|
||||
for reserved_path in "$output" "$target_dir"; do
|
||||
case "$reserved_path" in
|
||||
"$ROOT"|"$ROOT"/*)
|
||||
echo "reserved packaging path resolved inside the repository: $reserved_path" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
case "$output/:$target_dir/" in
|
||||
"$target_dir/"*:*|*:"$output/"*)
|
||||
echo "reserved output and target directory must be separate" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
test "$(/usr/bin/uname -s)" = Darwin || {
|
||||
echo "XCFramework packaging requires macOS" >&2
|
||||
exit 2
|
||||
}
|
||||
test "$(/usr/bin/uname -m)" = arm64 || {
|
||||
echo "XCFramework packaging requires an arm64 host" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
user_record=$(/usr/bin/dscacheutil -q user -a name "$(/usr/bin/id -un)")
|
||||
trusted_home=$(printf '%s\n' "$user_record" | awk '$1 == "dir:" { print $2; exit }')
|
||||
rustup="$trusted_home/.cargo/bin/rustup"
|
||||
test -x "$rustup" || {
|
||||
echo "rustup not found at trusted user path" >&2
|
||||
exit 2
|
||||
}
|
||||
cargo_bin=$(/usr/bin/env -i \
|
||||
HOME="$trusted_home" \
|
||||
PATH="$PATH" \
|
||||
RUSTUP_HOME="$trusted_home/.rustup" \
|
||||
"$rustup" which --toolchain 1.97.1 cargo)
|
||||
rustc_bin=$(/usr/bin/env -i \
|
||||
HOME="$trusted_home" \
|
||||
PATH="$PATH" \
|
||||
RUSTUP_HOME="$trusted_home/.rustup" \
|
||||
"$rustup" which --toolchain 1.97.1 rustc)
|
||||
rustc_version=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$rustc_bin" --version)
|
||||
cargo_version=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$cargo_bin" --version)
|
||||
test "$rustc_version" = 'rustc 1.97.1 (8bab26f4f 2026-07-14)' || {
|
||||
echo "unexpected rustc identity: $rustc_version" >&2
|
||||
exit 2
|
||||
}
|
||||
test "$cargo_version" = 'cargo 1.97.1 (c980f4866 2026-06-30)' || {
|
||||
echo "unexpected cargo identity: $cargo_version" >&2
|
||||
exit 2
|
||||
}
|
||||
xcodebuild=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --find xcodebuild)
|
||||
xcode_version=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$xcodebuild" -version)
|
||||
test "$xcode_version" = 'Xcode 26.6
|
||||
Build version 17F113' || {
|
||||
echo "unexpected Xcode identity: $xcode_version" >&2
|
||||
exit 2
|
||||
}
|
||||
clang=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --find clang)
|
||||
ar=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --find ar)
|
||||
clang_identity=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" "$clang" --version)
|
||||
clang_version=$(printf '%s\n' "$clang_identity" | sed -n '1p')
|
||||
sdk=$(/usr/bin/env -i PATH="$PATH" /usr/bin/xcrun --sdk macosx --show-sdk-path)
|
||||
|
||||
config_dir=$CORE
|
||||
while :; do
|
||||
if test -e "$config_dir/.cargo/config" || test -e "$config_dir/.cargo/config.toml"; then
|
||||
echo "Cargo config is not permitted in the packaging path: $config_dir/.cargo" >&2
|
||||
exit 2
|
||||
fi
|
||||
test "$config_dir" = / && break
|
||||
config_dir=$(dirname "$config_dir")
|
||||
done
|
||||
|
||||
source_commit=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" \
|
||||
/usr/bin/git -C "$ROOT" rev-parse HEAD)
|
||||
source_epoch=$(/usr/bin/env -i HOME="$trusted_home" PATH="$PATH" \
|
||||
/usr/bin/git -C "$ROOT" show -s --format=%ct HEAD)
|
||||
rustflags="--remap-path-prefix=$ROOT=. --remap-path-prefix=$target_dir=/cargo-target"
|
||||
cflags="-fdebug-prefix-map=$ROOT=. -ffile-prefix-map=$ROOT=. -fdebug-prefix-map=$target_dir=/cargo-target -ffile-prefix-map=$target_dir=/cargo-target"
|
||||
cargo_home="$target_dir/cargo-home"
|
||||
build_tmp="$target_dir/tmp"
|
||||
mkdir "$cargo_home" "$build_tmp"
|
||||
for cache in registry git; do
|
||||
if test -e "$trusted_home/.cargo/$cache"; then
|
||||
ln -s "$trusted_home/.cargo/$cache" "$cargo_home/$cache"
|
||||
fi
|
||||
done
|
||||
(
|
||||
cd "$CORE"
|
||||
/usr/bin/env -i \
|
||||
AR="$ar" \
|
||||
AR_aarch64_apple_darwin="$ar" \
|
||||
CARGO_HOME="$cargo_home" \
|
||||
CARGO_INCREMENTAL=0 \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
|
||||
CARGO_PROFILE_RELEASE_INCREMENTAL=false \
|
||||
CARGO_PROFILE_RELEASE_LTO=fat \
|
||||
CARGO_PROFILE_RELEASE_PANIC=abort \
|
||||
CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER="$clang" \
|
||||
CARGO_TARGET_DIR="$target_dir" \
|
||||
CC="$clang" \
|
||||
CC_aarch64_apple_darwin="$clang" \
|
||||
CFLAGS="$cflags" \
|
||||
CFLAGS_aarch64_apple_darwin="$cflags" \
|
||||
HOME="$trusted_home" \
|
||||
MACOSX_DEPLOYMENT_TARGET=14.0 \
|
||||
PATH="$PATH" \
|
||||
RUSTC="$rustc_bin" \
|
||||
RUSTFLAGS="$rustflags" \
|
||||
SDKROOT="$sdk" \
|
||||
SOURCE_DATE_EPOCH="$source_epoch" \
|
||||
TMPDIR="$build_tmp" \
|
||||
ZERO_AR_DATE=1 \
|
||||
"$cargo_bin" build \
|
||||
--target aarch64-apple-darwin \
|
||||
--release \
|
||||
--frozen
|
||||
)
|
||||
|
||||
headers="$target_dir/xcframework-headers"
|
||||
mkdir -p "$headers" "$output"
|
||||
cp "$ROOT/core/include/versevdi_core.h" "$headers/"
|
||||
cp "$ROOT/core/include/module.modulemap" "$headers/"
|
||||
|
||||
/usr/bin/env -i \
|
||||
HOME="$trusted_home" \
|
||||
PATH="$PATH" \
|
||||
TMPDIR="$build_tmp" \
|
||||
"$xcodebuild" -create-xcframework \
|
||||
-library "$target_dir/aarch64-apple-darwin/release/libversevdi_core.a" \
|
||||
-headers "$headers" \
|
||||
-output "$output/VerseVDICore.xcframework"
|
||||
|
||||
timestamp=$(date -r "$source_epoch" +%Y%m%d%H%M.%S)
|
||||
find "$output/VerseVDICore.xcframework" -exec touch -h -t "$timestamp" {} +
|
||||
|
||||
{
|
||||
printf 'rustc=%s\n' "$rustc_version"
|
||||
printf 'cargo=%s\n' "$cargo_version"
|
||||
printf 'cargo_path=%s\n' "$cargo_bin"
|
||||
printf 'rustc_path=%s\n' "$rustc_bin"
|
||||
printf 'xcode=%s\n' "$xcode_version"
|
||||
printf 'clang=%s\n' "$clang_version"
|
||||
printf 'sdk=%s\n' "$sdk"
|
||||
printf 'cargo_config=isolated-home-and-no-project-config\n'
|
||||
printf 'target=aarch64-apple-darwin\n'
|
||||
printf 'deployment_target=14.0\n'
|
||||
printf 'source_commit=%s\n' "$source_commit"
|
||||
printf 'source_epoch=%s\n' "$source_epoch"
|
||||
printf 'rustflags=--remap-path-prefix=<repository>=. --remap-path-prefix=<target>=/cargo-target\n'
|
||||
printf 'cflags=-fdebug-prefix-map/-ffile-prefix-map for <repository> and <target>\n'
|
||||
} >"$output/build-environment.txt"
|
||||
touch -t "$timestamp" "$output/build-environment.txt" "$output"
|
||||
+1800
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
use std::fmt;
|
||||
|
||||
/// Stable, provider-free failures returned by the safe core.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CoreError {
|
||||
InvalidArgument,
|
||||
Internal,
|
||||
AuthorityRejected,
|
||||
Tls,
|
||||
Transport,
|
||||
Protocol,
|
||||
Expired,
|
||||
QueueFull,
|
||||
Cancelled,
|
||||
Truncated,
|
||||
UnsupportedVersion,
|
||||
UnknownChannel,
|
||||
Fragment,
|
||||
FragmentLimit,
|
||||
Length,
|
||||
LengthMismatch,
|
||||
Magic,
|
||||
Kind,
|
||||
Reserved,
|
||||
Utf8,
|
||||
Field,
|
||||
Direction,
|
||||
Type,
|
||||
UnsupportedFeature,
|
||||
ConflictingDuplicate,
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
/// Returns a stable, provider-free machine code.
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidArgument => "invalid_argument",
|
||||
Self::Internal => "internal",
|
||||
Self::AuthorityRejected => "authority_rejected",
|
||||
Self::Tls => "tls",
|
||||
Self::Transport => "transport",
|
||||
Self::Protocol => "protocol",
|
||||
Self::Expired => "expired",
|
||||
Self::QueueFull => "queue_full",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Truncated => "truncated",
|
||||
Self::UnsupportedVersion => "unsupported_version",
|
||||
Self::UnknownChannel => "unknown_channel",
|
||||
Self::Fragment => "fragment",
|
||||
Self::FragmentLimit => "fragment_limit",
|
||||
Self::Length => "length",
|
||||
Self::LengthMismatch => "length_mismatch",
|
||||
Self::Magic => "magic",
|
||||
Self::Kind => "kind",
|
||||
Self::Reserved => "reserved",
|
||||
Self::Utf8 => "utf8",
|
||||
Self::Field => "field",
|
||||
Self::Direction => "direction",
|
||||
Self::Type => "type",
|
||||
Self::UnsupportedFeature => "unsupported_feature",
|
||||
Self::ConflictingDuplicate => "conflicting_duplicate",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CoreError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.code())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CoreError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||
@@ -0,0 +1,441 @@
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
const INPUT_HEADER: usize = 6;
|
||||
const FEEDBACK_HEADER: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ControllerState {
|
||||
pub controller: u8,
|
||||
pub active_mask: u16,
|
||||
pub button_flags: u16,
|
||||
pub left_trigger: u8,
|
||||
pub right_trigger: u8,
|
||||
pub left_x: i16,
|
||||
pub left_y: i16,
|
||||
pub right_x: i16,
|
||||
pub right_y: i16,
|
||||
pub extra_button_flags: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum InputEvent {
|
||||
Keyboard {
|
||||
pressed: bool,
|
||||
modifiers: u8,
|
||||
scancode: u16,
|
||||
},
|
||||
MouseButton {
|
||||
pressed: bool,
|
||||
button: u8,
|
||||
},
|
||||
RelativeMouse {
|
||||
delta_x: i16,
|
||||
delta_y: i16,
|
||||
},
|
||||
Text(char),
|
||||
Controller(ControllerState),
|
||||
AbsoluteMouse {
|
||||
x: u16,
|
||||
y: u16,
|
||||
viewport_width: u16,
|
||||
viewport_height: u16,
|
||||
},
|
||||
Scroll {
|
||||
vertical_delta: i16,
|
||||
horizontal_delta: i16,
|
||||
},
|
||||
}
|
||||
|
||||
fn feature(features: &[&str], wanted: &str) -> Result<()> {
|
||||
features
|
||||
.contains(&wanted)
|
||||
.then_some(())
|
||||
.ok_or(CoreError::UnsupportedFeature)
|
||||
}
|
||||
|
||||
fn state(value: u8) -> Result<bool> {
|
||||
match value {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(CoreError::Field),
|
||||
}
|
||||
}
|
||||
|
||||
fn i16_at(bytes: &[u8], offset: usize) -> i16 {
|
||||
i16::from_be_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], offset: usize) -> u16 {
|
||||
u16::from_be_bytes([bytes[offset], bytes[offset + 1]])
|
||||
}
|
||||
|
||||
/// Decodes one bounded VGI1 input envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for malformed bytes or a missing negotiated feature.
|
||||
pub fn decode_input(bytes: &[u8], features: &[&str]) -> Result<InputEvent> {
|
||||
if bytes.len() < INPUT_HEADER {
|
||||
return Err(CoreError::Truncated);
|
||||
}
|
||||
if &bytes[..4] != b"VGI1" {
|
||||
return Err(CoreError::Magic);
|
||||
}
|
||||
let body_length = usize::from(bytes[5]);
|
||||
if bytes.len() != INPUT_HEADER + body_length {
|
||||
return Err(CoreError::Length);
|
||||
}
|
||||
let body = &bytes[INPUT_HEADER..];
|
||||
match bytes[4] {
|
||||
0x01 if body.len() == 4 => {
|
||||
let scancode = u16_at(body, 2);
|
||||
if scancode == 0 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(InputEvent::Keyboard {
|
||||
pressed: state(body[0])?,
|
||||
modifiers: body[1],
|
||||
scancode,
|
||||
})
|
||||
}
|
||||
0x02 if body.len() == 3 => {
|
||||
if !(1..=5).contains(&body[1]) {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
if body[2] != 0 {
|
||||
return Err(CoreError::Reserved);
|
||||
}
|
||||
Ok(InputEvent::MouseButton {
|
||||
pressed: state(body[0])?,
|
||||
button: body[1],
|
||||
})
|
||||
}
|
||||
0x03 if body.len() == 4 => Ok(InputEvent::RelativeMouse {
|
||||
delta_x: i16_at(body, 0),
|
||||
delta_y: i16_at(body, 2),
|
||||
}),
|
||||
0x04 if (1..=4).contains(&body.len()) => {
|
||||
let text = std::str::from_utf8(body).map_err(|_| CoreError::Utf8)?;
|
||||
let mut chars = text.chars();
|
||||
let value = chars.next().ok_or(CoreError::Utf8)?;
|
||||
if chars.next().is_some() {
|
||||
return Err(CoreError::Utf8);
|
||||
}
|
||||
Ok(InputEvent::Text(value))
|
||||
}
|
||||
0x05 if body.len() == 17 => {
|
||||
if body[0] > 15 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(InputEvent::Controller(ControllerState {
|
||||
controller: body[0],
|
||||
active_mask: u16_at(body, 1),
|
||||
button_flags: u16_at(body, 3),
|
||||
left_trigger: body[5],
|
||||
right_trigger: body[6],
|
||||
left_x: i16_at(body, 7),
|
||||
left_y: i16_at(body, 9),
|
||||
right_x: i16_at(body, 11),
|
||||
right_y: i16_at(body, 13),
|
||||
extra_button_flags: u16_at(body, 15),
|
||||
}))
|
||||
}
|
||||
0x06 if body.len() == 8 => {
|
||||
feature(features, "input.absolute.v1")?;
|
||||
let x = u16_at(body, 0);
|
||||
let y = u16_at(body, 2);
|
||||
let viewport_width = u16_at(body, 4);
|
||||
let viewport_height = u16_at(body, 6);
|
||||
if viewport_width == 0
|
||||
|| viewport_height == 0
|
||||
|| x >= viewport_width
|
||||
|| y >= viewport_height
|
||||
{
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(InputEvent::AbsoluteMouse {
|
||||
x,
|
||||
y,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
})
|
||||
}
|
||||
0x07 if body.len() == 4 => {
|
||||
feature(features, "input.scroll.v1")?;
|
||||
Ok(InputEvent::Scroll {
|
||||
vertical_delta: i16_at(body, 0),
|
||||
horizontal_delta: i16_at(body, 2),
|
||||
})
|
||||
}
|
||||
0x01..=0x07 => Err(CoreError::Length),
|
||||
_ => Err(CoreError::Kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_i16(output: &mut Vec<u8>, value: i16) {
|
||||
output.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn push_u16(output: &mut Vec<u8>, value: u16) {
|
||||
output.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Encodes one bounded VGI1 input envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for invalid fields or a missing negotiated feature.
|
||||
pub fn encode_input(event: &InputEvent, features: &[&str]) -> Result<Vec<u8>> {
|
||||
let (kind, body) = match event {
|
||||
InputEvent::Keyboard {
|
||||
pressed,
|
||||
modifiers,
|
||||
scancode,
|
||||
} => {
|
||||
if *scancode == 0 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let mut body = vec![u8::from(*pressed), *modifiers];
|
||||
push_u16(&mut body, *scancode);
|
||||
(0x01, body)
|
||||
}
|
||||
InputEvent::MouseButton { pressed, button } => {
|
||||
if !(1..=5).contains(button) {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
(0x02, vec![u8::from(*pressed), *button, 0])
|
||||
}
|
||||
InputEvent::RelativeMouse { delta_x, delta_y } => {
|
||||
let mut body = Vec::with_capacity(4);
|
||||
push_i16(&mut body, *delta_x);
|
||||
push_i16(&mut body, *delta_y);
|
||||
(0x03, body)
|
||||
}
|
||||
InputEvent::Text(value) => {
|
||||
let mut bytes = [0_u8; 4];
|
||||
(0x04, value.encode_utf8(&mut bytes).as_bytes().to_vec())
|
||||
}
|
||||
InputEvent::Controller(controller) => {
|
||||
if controller.controller > 15 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let mut body = vec![controller.controller];
|
||||
push_u16(&mut body, controller.active_mask);
|
||||
push_u16(&mut body, controller.button_flags);
|
||||
body.extend_from_slice(&[controller.left_trigger, controller.right_trigger]);
|
||||
push_i16(&mut body, controller.left_x);
|
||||
push_i16(&mut body, controller.left_y);
|
||||
push_i16(&mut body, controller.right_x);
|
||||
push_i16(&mut body, controller.right_y);
|
||||
push_u16(&mut body, controller.extra_button_flags);
|
||||
(0x05, body)
|
||||
}
|
||||
InputEvent::AbsoluteMouse {
|
||||
x,
|
||||
y,
|
||||
viewport_width,
|
||||
viewport_height,
|
||||
} => {
|
||||
feature(features, "input.absolute.v1")?;
|
||||
if *viewport_width == 0
|
||||
|| *viewport_height == 0
|
||||
|| x >= viewport_width
|
||||
|| y >= viewport_height
|
||||
{
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let mut body = Vec::with_capacity(8);
|
||||
push_u16(&mut body, *x);
|
||||
push_u16(&mut body, *y);
|
||||
push_u16(&mut body, *viewport_width);
|
||||
push_u16(&mut body, *viewport_height);
|
||||
(0x06, body)
|
||||
}
|
||||
InputEvent::Scroll {
|
||||
vertical_delta,
|
||||
horizontal_delta,
|
||||
} => {
|
||||
feature(features, "input.scroll.v1")?;
|
||||
let mut body = Vec::with_capacity(4);
|
||||
push_i16(&mut body, *vertical_delta);
|
||||
push_i16(&mut body, *horizontal_delta);
|
||||
(0x07, body)
|
||||
}
|
||||
};
|
||||
let mut output = Vec::with_capacity(INPUT_HEADER + body.len());
|
||||
output.extend_from_slice(b"VGI1");
|
||||
output.push(kind);
|
||||
output.push(u8::try_from(body.len()).map_err(|_| CoreError::Length)?);
|
||||
output.extend_from_slice(&body);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FecStatus {
|
||||
pub frame_index: u32,
|
||||
pub highest_received_sequence: u16,
|
||||
pub next_contiguous_sequence: u16,
|
||||
pub missing_before_highest: u16,
|
||||
pub total_data_packets: u16,
|
||||
pub total_parity_packets: u16,
|
||||
pub received_data_packets: u16,
|
||||
pub received_parity_packets: u16,
|
||||
pub fec_percentage: u8,
|
||||
pub multi_fec_block_index: u8,
|
||||
pub multi_fec_block_count: u8,
|
||||
}
|
||||
|
||||
impl FecStatus {
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.total_data_packets == 0
|
||||
|| self.received_data_packets > self.total_data_packets
|
||||
|| self.received_parity_packets > self.total_parity_packets
|
||||
|| self.fec_percentage > 100
|
||||
|| self.multi_fec_block_count == 0
|
||||
|| self.multi_fec_block_index >= self.multi_fec_block_count
|
||||
{
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum FeedbackEvent {
|
||||
IdrRequest,
|
||||
Fec(FecStatus),
|
||||
TerminalReceipt,
|
||||
Termination {
|
||||
exit_code: u32,
|
||||
},
|
||||
Rumble {
|
||||
controller: u8,
|
||||
low_frequency: u16,
|
||||
high_frequency: u16,
|
||||
},
|
||||
Hdr {
|
||||
enabled: bool,
|
||||
},
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], offset: usize) -> u32 {
|
||||
u32::from_be_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
])
|
||||
}
|
||||
|
||||
/// Decodes one bounded VGF1 feedback envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for malformed bytes, direction, type, or size.
|
||||
pub fn decode_feedback(bytes: &[u8]) -> Result<FeedbackEvent> {
|
||||
if bytes.len() < FEEDBACK_HEADER {
|
||||
return Err(CoreError::Truncated);
|
||||
}
|
||||
if &bytes[..4] != b"VGF1" {
|
||||
return Err(CoreError::Magic);
|
||||
}
|
||||
let direction = bytes[4];
|
||||
if direction > 1 {
|
||||
return Err(CoreError::Direction);
|
||||
}
|
||||
let body_length = usize::from(u16_at(bytes, 6));
|
||||
if bytes.len() != FEEDBACK_HEADER + body_length {
|
||||
return Err(CoreError::Length);
|
||||
}
|
||||
let body = &bytes[FEEDBACK_HEADER..];
|
||||
match (direction, bytes[5]) {
|
||||
(0, 0x01) if body.is_empty() => Ok(FeedbackEvent::IdrRequest),
|
||||
(0, 0x02) if body.len() == 21 => {
|
||||
let status = FecStatus {
|
||||
frame_index: u32_at(body, 0),
|
||||
highest_received_sequence: u16_at(body, 4),
|
||||
next_contiguous_sequence: u16_at(body, 6),
|
||||
missing_before_highest: u16_at(body, 8),
|
||||
total_data_packets: u16_at(body, 10),
|
||||
total_parity_packets: u16_at(body, 12),
|
||||
received_data_packets: u16_at(body, 14),
|
||||
received_parity_packets: u16_at(body, 16),
|
||||
fec_percentage: body[18],
|
||||
multi_fec_block_index: body[19],
|
||||
multi_fec_block_count: body[20],
|
||||
};
|
||||
status.validate()?;
|
||||
Ok(FeedbackEvent::Fec(status))
|
||||
}
|
||||
(0, 0x03) if body.is_empty() => Ok(FeedbackEvent::TerminalReceipt),
|
||||
(1, 0x10) if body.len() == 4 => Ok(FeedbackEvent::Termination {
|
||||
exit_code: u32_at(body, 0),
|
||||
}),
|
||||
(1, 0x11) if body.len() == 5 => Ok(FeedbackEvent::Rumble {
|
||||
controller: body[0],
|
||||
low_frequency: u16_at(body, 1),
|
||||
high_frequency: u16_at(body, 3),
|
||||
}),
|
||||
(1, 0x12) if body.len() == 1 && body[0] <= 1 => Ok(FeedbackEvent::Hdr {
|
||||
enabled: body[0] == 1,
|
||||
}),
|
||||
(1, 0x13) if body.is_empty() => Ok(FeedbackEvent::Disconnected),
|
||||
(0, 0x10..=0x13) | (1, 0x01..=0x03) => Err(CoreError::Direction),
|
||||
(0, 0x01..=0x03) | (1, 0x10..=0x13) => Err(CoreError::Length),
|
||||
_ => Err(CoreError::Type),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes one bounded VGF1 feedback envelope.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error if the body size is not representable.
|
||||
pub fn encode_feedback(event: &FeedbackEvent) -> Result<Vec<u8>> {
|
||||
let (direction, kind, body) = match event {
|
||||
FeedbackEvent::IdrRequest => (0, 0x01, Vec::new()),
|
||||
FeedbackEvent::Fec(status) => {
|
||||
status.validate()?;
|
||||
let mut body = Vec::with_capacity(21);
|
||||
body.extend_from_slice(&status.frame_index.to_be_bytes());
|
||||
push_u16(&mut body, status.highest_received_sequence);
|
||||
push_u16(&mut body, status.next_contiguous_sequence);
|
||||
push_u16(&mut body, status.missing_before_highest);
|
||||
push_u16(&mut body, status.total_data_packets);
|
||||
push_u16(&mut body, status.total_parity_packets);
|
||||
push_u16(&mut body, status.received_data_packets);
|
||||
push_u16(&mut body, status.received_parity_packets);
|
||||
body.extend_from_slice(&[
|
||||
status.fec_percentage,
|
||||
status.multi_fec_block_index,
|
||||
status.multi_fec_block_count,
|
||||
]);
|
||||
(0, 0x02, body)
|
||||
}
|
||||
FeedbackEvent::TerminalReceipt => (0, 0x03, Vec::new()),
|
||||
FeedbackEvent::Termination { exit_code } => (1, 0x10, exit_code.to_be_bytes().to_vec()),
|
||||
FeedbackEvent::Rumble {
|
||||
controller,
|
||||
low_frequency,
|
||||
high_frequency,
|
||||
} => {
|
||||
let mut body = vec![*controller];
|
||||
push_u16(&mut body, *low_frequency);
|
||||
push_u16(&mut body, *high_frequency);
|
||||
(1, 0x11, body)
|
||||
}
|
||||
FeedbackEvent::Hdr { enabled } => (1, 0x12, vec![u8::from(*enabled)]),
|
||||
FeedbackEvent::Disconnected => (1, 0x13, Vec::new()),
|
||||
};
|
||||
let mut output = Vec::with_capacity(FEEDBACK_HEADER + body.len());
|
||||
output.extend_from_slice(b"VGF1");
|
||||
output.extend_from_slice(&[direction, kind]);
|
||||
output.extend_from_slice(
|
||||
&u16::try_from(body.len())
|
||||
.map_err(|_| CoreError::Length)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
output.extend_from_slice(&body);
|
||||
Ok(output)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![forbid(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
//! Provider-free wire codecs and bounded session primitives for `VerseVDI` clients.
|
||||
//!
|
||||
//! ```
|
||||
//! use versevdi_core::media::MediaFragment;
|
||||
//!
|
||||
//! let fragment = MediaFragment::new_video(1, 2, 0, 1, vec![1, 2, 3])?;
|
||||
//! let encoded = fragment.encode()?;
|
||||
//! assert_eq!(MediaFragment::decode(&encoded)?, fragment);
|
||||
//! # Ok::<(), versevdi_core::error::CoreError>(())
|
||||
//! ```
|
||||
|
||||
pub mod error;
|
||||
pub mod input;
|
||||
pub mod media;
|
||||
pub mod session;
|
||||
pub mod transport;
|
||||
pub mod wire;
|
||||
|
||||
mod tls;
|
||||
|
||||
#[allow(unsafe_code)]
|
||||
mod abi;
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
pub const DATAGRAM_HEADER_BYTES: usize = 23;
|
||||
pub const MAX_DATAGRAM_BYTES: usize = 1_200;
|
||||
pub const MAX_FRAGMENT_PAYLOAD_BYTES: usize = 1_177;
|
||||
pub const MAX_FRAGMENT_COUNT: u16 = 891;
|
||||
pub const MAX_COMPLETE_UNIT_BYTES: usize = 1_048_576;
|
||||
const MAX_INCOMPLETE_UNITS: usize = 4;
|
||||
const EXPIRY_MILLISECONDS: u64 = 250;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MediaChannel {
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
impl MediaChannel {
|
||||
const fn wire(self) -> u8 {
|
||||
match self {
|
||||
Self::Video => 10,
|
||||
Self::Audio => 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_wire(value: u8) -> Result<Self> {
|
||||
match value {
|
||||
10 => Ok(Self::Video),
|
||||
11 => Ok(Self::Audio),
|
||||
_ => Err(CoreError::UnknownChannel),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MediaFragment {
|
||||
pub channel: MediaChannel,
|
||||
pub sequence: u32,
|
||||
pub timestamp_ms: u64,
|
||||
pub fragment_index: u16,
|
||||
pub fragment_count: u16,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MediaFragment {
|
||||
/// Creates a validated video fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error when fragment or payload bounds are invalid.
|
||||
pub fn new_video(
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
fragment_index: u16,
|
||||
fragment_count: u16,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Self> {
|
||||
let fragment = Self {
|
||||
channel: MediaChannel::Video,
|
||||
sequence,
|
||||
timestamp_ms,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload,
|
||||
};
|
||||
fragment.validate()?;
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
/// Creates a validated audio fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error when fragment or payload bounds are invalid.
|
||||
pub fn new_audio(
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
fragment_index: u16,
|
||||
fragment_count: u16,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Self> {
|
||||
let fragment = Self {
|
||||
channel: MediaChannel::Audio,
|
||||
sequence,
|
||||
timestamp_ms,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload,
|
||||
};
|
||||
fragment.validate()?;
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.fragment_count == 0 || self.fragment_index >= self.fragment_count {
|
||||
return Err(CoreError::Fragment);
|
||||
}
|
||||
if self.fragment_count > MAX_FRAGMENT_COUNT {
|
||||
return Err(CoreError::FragmentLimit);
|
||||
}
|
||||
if self.payload.len() > MAX_FRAGMENT_PAYLOAD_BYTES {
|
||||
return Err(CoreError::LengthMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes one datagram-v2 fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for malformed or out-of-bound bytes.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
if bytes.len() < DATAGRAM_HEADER_BYTES {
|
||||
return Err(CoreError::Truncated);
|
||||
}
|
||||
if &bytes[..2] != b"VD" {
|
||||
return Err(CoreError::Magic);
|
||||
}
|
||||
if bytes[2] != 2 {
|
||||
return Err(CoreError::UnsupportedVersion);
|
||||
}
|
||||
let channel = MediaChannel::from_wire(bytes[3])?;
|
||||
if bytes[4] != 0 {
|
||||
return Err(CoreError::Field);
|
||||
}
|
||||
let sequence = u32::from_be_bytes([bytes[5], bytes[6], bytes[7], bytes[8]]);
|
||||
let timestamp_ms = u64::from_be_bytes([
|
||||
bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16],
|
||||
]);
|
||||
let fragment_index = u16::from_be_bytes([bytes[17], bytes[18]]);
|
||||
let fragment_count = u16::from_be_bytes([bytes[19], bytes[20]]);
|
||||
let payload_length = usize::from(u16::from_be_bytes([bytes[21], bytes[22]]));
|
||||
if bytes.len() != DATAGRAM_HEADER_BYTES + payload_length || bytes.len() > MAX_DATAGRAM_BYTES
|
||||
{
|
||||
return Err(CoreError::LengthMismatch);
|
||||
}
|
||||
let fragment = Self {
|
||||
channel,
|
||||
sequence,
|
||||
timestamp_ms,
|
||||
fragment_index,
|
||||
fragment_count,
|
||||
payload: bytes[DATAGRAM_HEADER_BYTES..].to_vec(),
|
||||
};
|
||||
fragment.validate()?;
|
||||
Ok(fragment)
|
||||
}
|
||||
|
||||
/// Encodes one datagram-v2 fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error when fragment or payload bounds are invalid.
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
self.validate()?;
|
||||
let mut output = Vec::with_capacity(DATAGRAM_HEADER_BYTES + self.payload.len());
|
||||
output.extend_from_slice(b"VD");
|
||||
output.extend_from_slice(&[2, self.channel.wire(), 0]);
|
||||
output.extend_from_slice(&self.sequence.to_be_bytes());
|
||||
output.extend_from_slice(&self.timestamp_ms.to_be_bytes());
|
||||
output.extend_from_slice(&self.fragment_index.to_be_bytes());
|
||||
output.extend_from_slice(&self.fragment_count.to_be_bytes());
|
||||
output.extend_from_slice(
|
||||
&u16::try_from(self.payload.len())
|
||||
.map_err(|_| CoreError::LengthMismatch)?
|
||||
.to_be_bytes(),
|
||||
);
|
||||
output.extend_from_slice(&self.payload);
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EncodedUnit {
|
||||
pub channel: MediaChannel,
|
||||
pub sequence: u32,
|
||||
pub timestamp_ms: u64,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct IncompleteUnit {
|
||||
channel: MediaChannel,
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
fragment_count: u16,
|
||||
started_at_ms: u64,
|
||||
total_bytes: usize,
|
||||
fragments: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Reassembler {
|
||||
incomplete: VecDeque<IncompleteUnit>,
|
||||
evicted_units: u64,
|
||||
expired_units: u64,
|
||||
}
|
||||
|
||||
impl Reassembler {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds a fragment and returns a complete encoded unit when all fragments arrive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable protocol error for conflicting fragments or size-bound violations.
|
||||
pub fn push(&mut self, fragment: MediaFragment, now_ms: u64) -> Result<Option<EncodedUnit>> {
|
||||
fragment.validate()?;
|
||||
self.expire(now_ms);
|
||||
|
||||
let position = self.incomplete.iter().position(|unit| {
|
||||
unit.channel == fragment.channel && unit.sequence == fragment.sequence
|
||||
});
|
||||
let position = if let Some(position) = position {
|
||||
position
|
||||
} else {
|
||||
if self.incomplete.len() == MAX_INCOMPLETE_UNITS {
|
||||
self.incomplete.pop_front();
|
||||
self.evicted_units += 1;
|
||||
}
|
||||
self.incomplete.push_back(IncompleteUnit {
|
||||
channel: fragment.channel,
|
||||
sequence: fragment.sequence,
|
||||
timestamp_ms: fragment.timestamp_ms,
|
||||
fragment_count: fragment.fragment_count,
|
||||
started_at_ms: now_ms,
|
||||
total_bytes: 0,
|
||||
fragments: vec![None; usize::from(fragment.fragment_count)],
|
||||
});
|
||||
self.incomplete.len() - 1
|
||||
};
|
||||
|
||||
let unit = &mut self.incomplete[position];
|
||||
if unit.timestamp_ms != fragment.timestamp_ms
|
||||
|| unit.fragment_count != fragment.fragment_count
|
||||
{
|
||||
self.incomplete.remove(position);
|
||||
return Err(CoreError::ConflictingDuplicate);
|
||||
}
|
||||
let index = usize::from(fragment.fragment_index);
|
||||
if let Some(existing) = &unit.fragments[index] {
|
||||
if existing == &fragment.payload {
|
||||
return Ok(None);
|
||||
}
|
||||
self.incomplete.remove(position);
|
||||
return Err(CoreError::ConflictingDuplicate);
|
||||
}
|
||||
let total_bytes = unit
|
||||
.total_bytes
|
||||
.checked_add(fragment.payload.len())
|
||||
.ok_or(CoreError::FragmentLimit)?;
|
||||
if total_bytes > MAX_COMPLETE_UNIT_BYTES {
|
||||
self.incomplete.remove(position);
|
||||
return Err(CoreError::FragmentLimit);
|
||||
}
|
||||
unit.total_bytes = total_bytes;
|
||||
unit.fragments[index] = Some(fragment.payload);
|
||||
if unit.fragments.iter().any(Option::is_none) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let complete = self
|
||||
.incomplete
|
||||
.remove(position)
|
||||
.ok_or(CoreError::InvalidArgument)?;
|
||||
let mut payload = Vec::with_capacity(complete.total_bytes);
|
||||
for bytes in complete.fragments {
|
||||
payload.extend(bytes.ok_or(CoreError::InvalidArgument)?);
|
||||
}
|
||||
Ok(Some(EncodedUnit {
|
||||
channel: complete.channel,
|
||||
sequence: complete.sequence,
|
||||
timestamp_ms: complete.timestamp_ms,
|
||||
payload,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn expire(&mut self, now_ms: u64) {
|
||||
let before = self.incomplete.len();
|
||||
self.incomplete
|
||||
.retain(|unit| now_ms.saturating_sub(unit.started_at_ms) < EXPIRY_MILLISECONDS);
|
||||
self.expired_units += u64::try_from(before - self.incomplete.len()).unwrap_or(u64::MAX);
|
||||
}
|
||||
|
||||
pub(crate) fn next_expiry_ms(&self) -> Option<u64> {
|
||||
self.incomplete
|
||||
.front()
|
||||
.map(|unit| unit.started_at_ms.saturating_add(EXPIRY_MILLISECONDS))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn incomplete_units(&self) -> usize {
|
||||
self.incomplete.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn evicted_units(&self) -> u64 {
|
||||
self.evicted_units
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn expired_units(&self) -> u64 {
|
||||
self.expired_units
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::input::decode_input;
|
||||
use crate::media::EncodedUnit;
|
||||
|
||||
pub const INPUT_QUEUE_CAPACITY: usize = 64;
|
||||
pub const CONTROL_QUEUE_CAPACITY: usize = 64;
|
||||
const MEDIA_QUEUE_CAPACITY: usize = 4;
|
||||
const MAX_CONTROL_BYTES: usize = 128 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SessionStats {
|
||||
pub dropped_media_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionCore {
|
||||
input: VecDeque<Vec<u8>>,
|
||||
control: VecDeque<Vec<u8>>,
|
||||
media: VecDeque<EncodedUnit>,
|
||||
cancelled: bool,
|
||||
stats: SessionStats,
|
||||
}
|
||||
|
||||
impl SessionCore {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Validates and enqueues one VGI1 envelope without blocking.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a stable VGI1 parse error, `queue_full`, or `cancelled`.
|
||||
pub fn enqueue_input(&mut self, bytes: Vec<u8>, features: &[&str]) -> Result<()> {
|
||||
if self.cancelled {
|
||||
return Err(CoreError::Cancelled);
|
||||
}
|
||||
decode_input(&bytes, features)?;
|
||||
if self.input.len() == INPUT_QUEUE_CAPACITY {
|
||||
return Err(CoreError::QueueFull);
|
||||
}
|
||||
self.input.push_back(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the oldest queued VGI1 envelope.
|
||||
#[must_use]
|
||||
pub fn dequeue_input(&mut self) -> Option<Vec<u8>> {
|
||||
self.input.pop_front()
|
||||
}
|
||||
|
||||
/// Enqueues one bounded reliable control body without blocking.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument`, `queue_full`, or `cancelled`.
|
||||
pub fn enqueue_control(&mut self, bytes: Vec<u8>) -> Result<()> {
|
||||
if self.cancelled {
|
||||
return Err(CoreError::Cancelled);
|
||||
}
|
||||
if bytes.len() > MAX_CONTROL_BYTES {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
if self.control.len() == CONTROL_QUEUE_CAPACITY {
|
||||
return Err(CoreError::QueueFull);
|
||||
}
|
||||
self.control.push_back(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the oldest queued reliable control body.
|
||||
#[must_use]
|
||||
pub fn dequeue_control(&mut self) -> Option<Vec<u8>> {
|
||||
self.control.pop_front()
|
||||
}
|
||||
|
||||
/// Enqueues one bounded complete encoded media unit.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for an oversized unit or `cancelled` after cancellation.
|
||||
pub fn enqueue_media(&mut self, unit: EncodedUnit) -> Result<()> {
|
||||
if unit.payload.len() > crate::media::MAX_COMPLETE_UNIT_BYTES {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
if self.cancelled {
|
||||
self.stats.dropped_media_units += 1;
|
||||
return Err(CoreError::Cancelled);
|
||||
}
|
||||
if self.media.len() == MEDIA_QUEUE_CAPACITY {
|
||||
let position = self
|
||||
.media
|
||||
.iter()
|
||||
.position(|queued| queued.channel == unit.channel);
|
||||
if let Some(position) = position {
|
||||
self.media.remove(position);
|
||||
} else {
|
||||
self.stats.dropped_media_units += 1;
|
||||
return Ok(());
|
||||
}
|
||||
self.stats.dropped_media_units += 1;
|
||||
}
|
||||
self.media.push_back(unit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pop_media(&mut self) -> Option<EncodedUnit> {
|
||||
self.media.pop_front()
|
||||
}
|
||||
|
||||
pub fn cancel(&mut self) {
|
||||
self.cancelled = true;
|
||||
self.input.clear();
|
||||
self.control.clear();
|
||||
self.media.clear();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_cancelled(&self) -> bool {
|
||||
self.cancelled
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn stats(&self) -> SessionStats {
|
||||
self.stats
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
struct InProcessSession {
|
||||
incoming: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl InProcessSession {
|
||||
fn push_incoming(&mut self, bytes: Vec<u8>) {
|
||||
self.incoming.push_back(bytes);
|
||||
}
|
||||
|
||||
fn pop_incoming(&mut self) -> Option<Vec<u8>> {
|
||||
self.incoming.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InProcessSession, SessionCore, CONTROL_QUEUE_CAPACITY, INPUT_QUEUE_CAPACITY};
|
||||
use crate::error::CoreError;
|
||||
use crate::media::{EncodedUnit, MediaChannel};
|
||||
|
||||
#[test]
|
||||
fn queues_are_bounded_and_cancellation_is_idempotent() {
|
||||
let mut session = SessionCore::new();
|
||||
assert_eq!(
|
||||
session.enqueue_input(vec![0; 24], &[]),
|
||||
Err(CoreError::Magic)
|
||||
);
|
||||
let input = b"VGI1\x01\x04\x01\x00\x00\x1e".to_vec();
|
||||
for _ in 0..INPUT_QUEUE_CAPACITY {
|
||||
session
|
||||
.enqueue_input(input.clone(), &[])
|
||||
.expect("within bound");
|
||||
}
|
||||
assert_eq!(session.enqueue_input(input, &[]), Err(CoreError::QueueFull));
|
||||
|
||||
for value in 0..CONTROL_QUEUE_CAPACITY {
|
||||
session
|
||||
.enqueue_control(vec![u8::try_from(value).expect("capacity fits u8")])
|
||||
.expect("within bound");
|
||||
}
|
||||
assert_eq!(session.enqueue_control(vec![0]), Err(CoreError::QueueFull));
|
||||
let mut oversized_control = SessionCore::new();
|
||||
assert_eq!(
|
||||
oversized_control.enqueue_control(vec![0; 128 * 1024 + 1]),
|
||||
Err(CoreError::InvalidArgument)
|
||||
);
|
||||
|
||||
session
|
||||
.enqueue_media(EncodedUnit {
|
||||
channel: MediaChannel::Audio,
|
||||
sequence: 1,
|
||||
timestamp_ms: 1,
|
||||
payload: vec![1],
|
||||
})
|
||||
.expect("bounded media");
|
||||
|
||||
session.cancel();
|
||||
session.cancel();
|
||||
assert!(session.is_cancelled());
|
||||
assert_eq!(
|
||||
session.enqueue_input(vec![0], &[]),
|
||||
Err(CoreError::Cancelled)
|
||||
);
|
||||
assert!(session.pop_media().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_queue_evicts_oldest_same_channel_at_four_units() {
|
||||
let mut session = SessionCore::new();
|
||||
assert_eq!(
|
||||
session.enqueue_media(EncodedUnit {
|
||||
channel: MediaChannel::Video,
|
||||
sequence: 99,
|
||||
timestamp_ms: 0,
|
||||
payload: vec![0; 1_048_577],
|
||||
}),
|
||||
Err(CoreError::InvalidArgument)
|
||||
);
|
||||
for sequence in 0..5 {
|
||||
session
|
||||
.enqueue_media(EncodedUnit {
|
||||
channel: MediaChannel::Video,
|
||||
sequence,
|
||||
timestamp_ms: u64::from(sequence),
|
||||
payload: vec![u8::try_from(sequence).expect("test sequence fits u8")],
|
||||
})
|
||||
.expect("bounded media");
|
||||
}
|
||||
assert_eq!(session.stats().dropped_media_units, 1);
|
||||
assert_eq!(session.pop_media().expect("media").sequence, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_in_process_session_preserves_byte_order() {
|
||||
let mut transport = InProcessSession::default();
|
||||
transport.push_incoming(vec![1, 2]);
|
||||
transport.push_incoming(vec![3]);
|
||||
assert_eq!(transport.pop_incoming(), Some(vec![1, 2]));
|
||||
assert_eq!(transport.pop_incoming(), Some(vec![3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_queue_rejects_malformed_vgi1_at_the_boundary() {
|
||||
let mut session = SessionCore::new();
|
||||
assert_eq!(
|
||||
session.enqueue_input(vec![0], &[]),
|
||||
Err(CoreError::Truncated)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_queue_honors_negotiated_vgi1_features() {
|
||||
let mut session = SessionCore::new();
|
||||
let absolute = b"VGI1\x06\x08\x00\x01\x00\x01\x00\x02\x00\x02".to_vec();
|
||||
assert_eq!(
|
||||
session.enqueue_input(absolute.clone(), &[]),
|
||||
Err(CoreError::UnsupportedFeature)
|
||||
);
|
||||
session
|
||||
.enqueue_input(absolute.clone(), &["input.absolute.v1"])
|
||||
.expect("negotiated absolute input");
|
||||
assert_eq!(session.dequeue_input(), Some(absolute));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_and_control_queues_drain_in_order() {
|
||||
let mut session = SessionCore::new();
|
||||
let first = b"VGI1\x01\x04\x01\x00\x00\x1e".to_vec();
|
||||
let second = b"VGI1\x01\x04\x00\x00\x00\x1e".to_vec();
|
||||
session
|
||||
.enqueue_input(first.clone(), &[])
|
||||
.expect("valid input");
|
||||
session
|
||||
.enqueue_input(second.clone(), &[])
|
||||
.expect("valid input");
|
||||
session.enqueue_control(vec![1]).expect("valid control");
|
||||
session.enqueue_control(vec![2]).expect("valid control");
|
||||
|
||||
assert_eq!(session.dequeue_input(), Some(first));
|
||||
assert_eq!(session.dequeue_input(), Some(second));
|
||||
assert_eq!(session.dequeue_input(), None);
|
||||
assert_eq!(session.dequeue_control(), Some(vec![1]));
|
||||
assert_eq!(session.dequeue_control(), Some(vec![2]));
|
||||
assert_eq!(session.dequeue_control(), None);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
use std::fmt;
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rustls::client::ResolvesClientCert;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
use rustls::sign::{CertifiedKey, Signer, SigningKey};
|
||||
use rustls::{ClientConfig, RootCertStore, SignatureAlgorithm, SignatureScheme};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::wire::NativeTunnelCredential;
|
||||
|
||||
pub(crate) type SignCallback = dyn Fn(&[u8]) -> Result<[u8; 64]> + Send + Sync;
|
||||
|
||||
pub(crate) struct CallbackSigningKey {
|
||||
callback: Arc<Mutex<Option<Arc<SignCallback>>>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CallbackSigningKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("CallbackSigningKey")
|
||||
}
|
||||
}
|
||||
|
||||
impl CallbackSigningKey {
|
||||
pub(crate) fn new(callback: Arc<SignCallback>) -> Self {
|
||||
Self {
|
||||
callback: Arc::new(Mutex::new(Some(callback))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKey for CallbackSigningKey {
|
||||
fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option<Box<dyn Signer>> {
|
||||
offered
|
||||
.contains(&SignatureScheme::ED25519)
|
||||
.then(|| Box::new(CallbackSigner(Arc::clone(&self.callback))) as Box<dyn Signer>)
|
||||
}
|
||||
|
||||
fn algorithm(&self) -> SignatureAlgorithm {
|
||||
SignatureAlgorithm::ED25519
|
||||
}
|
||||
}
|
||||
|
||||
struct CallbackSigner(Arc<Mutex<Option<Arc<SignCallback>>>>);
|
||||
|
||||
impl fmt::Debug for CallbackSigner {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("CallbackSigner")
|
||||
}
|
||||
}
|
||||
|
||||
impl Signer for CallbackSigner {
|
||||
fn sign(&self, message: &[u8]) -> std::result::Result<Vec<u8>, rustls::Error> {
|
||||
let callback = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| rustls::Error::General("client signing state failed".to_owned()))?
|
||||
.take()
|
||||
.ok_or_else(|| rustls::Error::General("client signer already used".to_owned()))?;
|
||||
callback(message)
|
||||
.map(|signature| signature.to_vec())
|
||||
.map_err(|_| rustls::Error::General("client signing failed".to_owned()))
|
||||
}
|
||||
|
||||
fn scheme(&self) -> SignatureScheme {
|
||||
SignatureScheme::ED25519
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ClientIdentity(Arc<CertifiedKey>);
|
||||
|
||||
impl ResolvesClientCert for ClientIdentity {
|
||||
fn resolve(
|
||||
&self,
|
||||
_root_hint_subjects: &[&[u8]],
|
||||
sigschemes: &[SignatureScheme],
|
||||
) -> Option<Arc<CertifiedKey>> {
|
||||
sigschemes
|
||||
.contains(&SignatureScheme::ED25519)
|
||||
.then(|| Arc::clone(&self.0))
|
||||
}
|
||||
|
||||
fn has_certs(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn client_config(
|
||||
credential: &NativeTunnelCredential,
|
||||
callback: Arc<SignCallback>,
|
||||
) -> Result<ClientConfig> {
|
||||
let certificate_chain = rustls_pemfile::certs(&mut Cursor::new(
|
||||
credential.certificate_chain_pem().as_bytes(),
|
||||
))
|
||||
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
|
||||
.map_err(|_| CoreError::Tls)?;
|
||||
if certificate_chain.is_empty() {
|
||||
return Err(CoreError::Tls);
|
||||
}
|
||||
let mut roots = RootCertStore::empty();
|
||||
let trust_bundle =
|
||||
rustls_pemfile::certs(&mut Cursor::new(credential.trust_bundle_pem().as_bytes()))
|
||||
.collect::<std::result::Result<Vec<CertificateDer<'static>>, _>>()
|
||||
.map_err(|_| CoreError::Tls)?;
|
||||
if trust_bundle.is_empty() || roots.add_parsable_certificates(trust_bundle).1 != 0 {
|
||||
return Err(CoreError::Tls);
|
||||
}
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut config = ClientConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|_| CoreError::Tls)?
|
||||
.with_root_certificates(roots)
|
||||
.with_client_cert_resolver(Arc::new(ClientIdentity(Arc::new(CertifiedKey::new(
|
||||
certificate_chain,
|
||||
Arc::new(CallbackSigningKey::new(callback)),
|
||||
)))));
|
||||
config.alpn_protocols = vec![b"versevdi-gateway-v1".to_vec()];
|
||||
config.enable_early_data = false;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct DropMarker(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropMarker {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_callback_is_consumed_and_released_after_one_signature() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let marker = DropMarker(Arc::clone(&dropped));
|
||||
let callback: Arc<SignCallback> = Arc::new(move |_| {
|
||||
let _ = ▮
|
||||
Ok([7; 64])
|
||||
});
|
||||
let key = CallbackSigningKey::new(callback);
|
||||
let signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("ED25519 signer");
|
||||
let second_signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("second ED25519 signer");
|
||||
drop(key);
|
||||
|
||||
assert_eq!(
|
||||
signer.sign(b"handshake").expect("first signature"),
|
||||
vec![7; 64]
|
||||
);
|
||||
assert!(dropped.load(Ordering::SeqCst), "callback remained retained");
|
||||
assert!(
|
||||
second_signer.sign(b"second request").is_err(),
|
||||
"signer was reusable"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,803 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
const MAX_MANIFEST_JSON_BYTES: usize = 128 * 1024;
|
||||
const MAX_CREDENTIAL_JSON_BYTES: usize = 196 * 1024;
|
||||
const MAX_ADMISSION_JSON_BYTES: usize = 16 * 1024;
|
||||
|
||||
fn decode_strict<T: DeserializeOwned>(bytes: &[u8], maximum: usize) -> Result<T> {
|
||||
if bytes.is_empty() || bytes.len() > maximum {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
serde_json::from_slice(bytes).map_err(|_| CoreError::InvalidArgument)
|
||||
}
|
||||
|
||||
fn bounded(value: &str, minimum: usize, maximum: usize) -> bool {
|
||||
(minimum..=maximum).contains(&value.len())
|
||||
}
|
||||
|
||||
const fn base64url_value(value: u8) -> Option<u8> {
|
||||
match value {
|
||||
b'A'..=b'Z' => Some(value - b'A'),
|
||||
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||
b'-' => Some(62),
|
||||
b'_' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn base64_value(value: u8) -> Option<u8> {
|
||||
match value {
|
||||
b'A'..=b'Z' => Some(value - b'A'),
|
||||
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_base64(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
|
||||
return false;
|
||||
}
|
||||
let data_length = bytes
|
||||
.iter()
|
||||
.position(|byte| *byte == b'=')
|
||||
.unwrap_or(bytes.len());
|
||||
let padding = bytes.len() - data_length;
|
||||
if data_length == 0
|
||||
|| padding > 2
|
||||
|| !bytes[..data_length]
|
||||
.iter()
|
||||
.all(|byte| base64_value(*byte).is_some())
|
||||
|| !bytes[data_length..].iter().all(|byte| *byte == b'=')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match padding {
|
||||
0 => true,
|
||||
1 => base64_value(bytes[data_length - 1]).is_some_and(|value| value.trailing_zeros() >= 2),
|
||||
2 => base64_value(bytes[data_length - 1]).is_some_and(|value| value.trailing_zeros() >= 4),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn certificate_only_pem(value: &str) -> bool {
|
||||
let mut lines = value.lines().peekable();
|
||||
let mut blocks = 0_u32;
|
||||
loop {
|
||||
while lines.next_if(|line| line.trim().is_empty()).is_some() {}
|
||||
let Some(begin) = lines.next() else {
|
||||
return blocks > 0;
|
||||
};
|
||||
if begin != "-----BEGIN CERTIFICATE-----" {
|
||||
return false;
|
||||
}
|
||||
blocks += 1;
|
||||
let mut body = String::new();
|
||||
let mut complete = false;
|
||||
for line in lines.by_ref() {
|
||||
if line == "-----END CERTIFICATE-----" {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
if line.is_empty() || line.trim() != line {
|
||||
return false;
|
||||
}
|
||||
body.push_str(line);
|
||||
}
|
||||
if !complete || !valid_base64(&body) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_base64url_decoded_len(value: &str) -> Option<usize> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) {
|
||||
return None;
|
||||
}
|
||||
let remainder_bytes = match bytes.len() % 4 {
|
||||
0 => 0,
|
||||
2 if base64url_value(*bytes.last()?)?.trailing_zeros() >= 4 => 1,
|
||||
3 if base64url_value(*bytes.last()?)?.trailing_zeros() >= 2 => 2,
|
||||
_ => return None,
|
||||
};
|
||||
bytes
|
||||
.len()
|
||||
.checked_div(4)?
|
||||
.checked_mul(3)?
|
||||
.checked_add(remainder_bytes)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
struct Timestamp {
|
||||
year: u16,
|
||||
month: u8,
|
||||
day: u8,
|
||||
hour: u8,
|
||||
minute: u8,
|
||||
second: u8,
|
||||
nanosecond: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn now_utc() -> Result<String> {
|
||||
system_time_utc(SystemTime::now())
|
||||
}
|
||||
|
||||
fn system_time_utc(now: SystemTime) -> Result<String> {
|
||||
let seconds = now
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| CoreError::InvalidArgument)?
|
||||
.as_secs();
|
||||
let days = seconds / 86_400;
|
||||
let day_seconds = seconds % 86_400;
|
||||
let shifted = days
|
||||
.checked_add(719_468)
|
||||
.ok_or(CoreError::InvalidArgument)?;
|
||||
let era = shifted / 146_097;
|
||||
let day_of_era = shifted % 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = if month_prime < 10 {
|
||||
month_prime + 3
|
||||
} else {
|
||||
month_prime - 9
|
||||
};
|
||||
if month <= 2 {
|
||||
year += 1;
|
||||
}
|
||||
if year > 9_999 {
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
let hour = day_seconds / 3_600;
|
||||
let minute = (day_seconds % 3_600) / 60;
|
||||
let second = day_seconds % 60;
|
||||
Ok(format!(
|
||||
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
|
||||
))
|
||||
}
|
||||
|
||||
fn timestamp(value: &str, exact_seconds: bool) -> Option<Timestamp> {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() < 20
|
||||
|| bytes.len() > 30
|
||||
|| bytes[4] != b'-'
|
||||
|| bytes[7] != b'-'
|
||||
|| bytes[10] != b'T'
|
||||
|| bytes[13] != b':'
|
||||
|| bytes[16] != b':'
|
||||
|| *bytes.last()? != b'Z'
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let digits = |start: usize, end: usize| {
|
||||
bytes
|
||||
.get(start..end)?
|
||||
.iter()
|
||||
.try_fold(0_u32, |number, byte| {
|
||||
byte.is_ascii_digit()
|
||||
.then_some(number * 10 + u32::from(*byte - b'0'))
|
||||
})
|
||||
};
|
||||
let year = u16::try_from(digits(0, 4)?).ok()?;
|
||||
let month = u8::try_from(digits(5, 7)?).ok()?;
|
||||
let day = u8::try_from(digits(8, 10)?).ok()?;
|
||||
let hour = u8::try_from(digits(11, 13)?).ok()?;
|
||||
let minute = u8::try_from(digits(14, 16)?).ok()?;
|
||||
let second = u8::try_from(digits(17, 19)?).ok()?;
|
||||
if hour > 23 || minute > 59 || second > 59 {
|
||||
return None;
|
||||
}
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let maximum_day = match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
_ => return None,
|
||||
};
|
||||
if day == 0 || day > maximum_day {
|
||||
return None;
|
||||
}
|
||||
let nanosecond = if bytes.len() == 20 {
|
||||
0
|
||||
} else {
|
||||
if exact_seconds || bytes[19] != b'.' {
|
||||
return None;
|
||||
}
|
||||
let fraction = bytes.get(20..bytes.len() - 1)?;
|
||||
if fraction.is_empty()
|
||||
|| fraction.len() > 9
|
||||
|| !fraction.iter().all(u8::is_ascii_digit)
|
||||
|| *fraction.last()? == b'0'
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut value = fraction
|
||||
.iter()
|
||||
.fold(0_u32, |number, byte| number * 10 + u32::from(*byte - b'0'));
|
||||
for _ in fraction.len()..9 {
|
||||
value *= 10;
|
||||
}
|
||||
value
|
||||
};
|
||||
Some(Timestamp {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
nanosecond,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_dns_name(value: &str) -> bool {
|
||||
bounded(value, 1, 253)
|
||||
&& value.parse::<std::net::IpAddr>().is_err()
|
||||
&& !uuid_shaped(value)
|
||||
&& value.split('.').all(|label| {
|
||||
bounded(label, 1, 63)
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
})
|
||||
}
|
||||
|
||||
fn uuid_shaped(value: &str) -> bool {
|
||||
value.len() == 36
|
||||
&& value.bytes().enumerate().all(|(index, byte)| match index {
|
||||
8 | 13 | 18 | 23 => byte == b'-',
|
||||
_ => byte.is_ascii_hexdigit(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CapabilityProfile {
|
||||
transport: String,
|
||||
framing: String,
|
||||
media: String,
|
||||
audio: String,
|
||||
source_rate_control: String,
|
||||
client_decode: Vec<String>,
|
||||
}
|
||||
|
||||
impl CapabilityProfile {
|
||||
/// Creates and validates an RC5 capability profile.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` when a field violates RC5 bounds or registry values.
|
||||
pub fn new(
|
||||
transport: &str,
|
||||
framing: &str,
|
||||
media: &str,
|
||||
audio: &str,
|
||||
source_rate_control: &str,
|
||||
client_decode: Vec<String>,
|
||||
) -> Result<Self> {
|
||||
let profile = Self {
|
||||
transport: transport.to_owned(),
|
||||
framing: framing.to_owned(),
|
||||
media: media.to_owned(),
|
||||
audio: audio.to_owned(),
|
||||
source_rate_control: source_rate_control.to_owned(),
|
||||
client_decode,
|
||||
};
|
||||
profile.validate()?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if !bounded(&self.transport, 1, 64)
|
||||
|| !matches!(self.framing.as_str(), "datagram-v1" | "datagram-v2")
|
||||
|| !bounded(&self.media, 1, 64)
|
||||
|| !bounded(&self.audio, 1, 64)
|
||||
|| !bounded(&self.source_rate_control, 1, 64)
|
||||
|| !(1..=2).contains(&self.client_decode.len())
|
||||
|| self
|
||||
.client_decode
|
||||
.iter()
|
||||
.any(|value| !matches!(value.as_str(), "h264-opus" | "hevc-opus"))
|
||||
|| self.client_decode.len()
|
||||
!= self
|
||||
.client_decode
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_subset_of(&self, offered: &Self) -> bool {
|
||||
self.transport == offered.transport
|
||||
&& self.framing == offered.framing
|
||||
&& self.media == offered.media
|
||||
&& self.audio == offered.audio
|
||||
&& self.source_rate_control == offered.source_rate_control
|
||||
&& self
|
||||
.client_decode
|
||||
.iter()
|
||||
.all(|codec| offered.client_decode.contains(codec))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestGateway {
|
||||
id: String,
|
||||
addresses: Vec<String>,
|
||||
public_identity: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestTunnel {
|
||||
versions: Vec<String>,
|
||||
features: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_field_names)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestBounds {
|
||||
minimum_kbps: u64,
|
||||
target_kbps: u64,
|
||||
maximum_kbps: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DisplayMode {
|
||||
resolution_width: u16,
|
||||
resolution_height: u16,
|
||||
fps: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ManifestProfile {
|
||||
id: String,
|
||||
bounds: ManifestBounds,
|
||||
display_mode: Option<DisplayMode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct GrantReference {
|
||||
opaque_value: String,
|
||||
expires_at: String,
|
||||
audience: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConnectionManifest {
|
||||
version: String,
|
||||
purpose: String,
|
||||
session_id: String,
|
||||
reconnect_sequence: u64,
|
||||
gateway: ManifestGateway,
|
||||
tunnel: ManifestTunnel,
|
||||
profile: ManifestProfile,
|
||||
grant: GrantReference,
|
||||
correlation_id: String,
|
||||
}
|
||||
|
||||
impl ConnectionManifest {
|
||||
/// Strictly decodes and validates an RC5 connection manifest.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, duplicate, trailing, unknown, or invalid data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let manifest: Self = decode_strict(bytes, MAX_MANIFEST_JSON_BYTES)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
let bounds = &self.profile.bounds;
|
||||
let valid_display = self.profile.display_mode.as_ref().is_none_or(|mode| {
|
||||
(320..=16_384).contains(&mode.resolution_width)
|
||||
&& (200..=8_640).contains(&mode.resolution_height)
|
||||
&& (1..=240).contains(&mode.fps)
|
||||
});
|
||||
if self.version != "1"
|
||||
|| !matches!(self.purpose.as_str(), "launch" | "reconnect")
|
||||
|| !bounded(&self.session_id, 1, 128)
|
||||
|| !bounded(&self.gateway.id, 1, 128)
|
||||
|| !(1..=4).contains(&self.gateway.addresses.len())
|
||||
|| self
|
||||
.gateway
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|address| !bounded(address, 1, 256))
|
||||
|| !valid_dns_name(&self.gateway.public_identity)
|
||||
|| self.gateway.public_identity == self.gateway.id
|
||||
|| !(1..=4).contains(&self.tunnel.versions.len())
|
||||
|| self
|
||||
.tunnel
|
||||
.versions
|
||||
.iter()
|
||||
.any(|version| !bounded(version, 1, 64))
|
||||
|| self.tunnel.features.len() > 32
|
||||
|| self
|
||||
.tunnel
|
||||
.features
|
||||
.iter()
|
||||
.any(|feature| !bounded(feature, 1, 64))
|
||||
|| !bounded(&self.profile.id, 1, 128)
|
||||
|| !(1..=100_000_000).contains(&bounds.minimum_kbps)
|
||||
|| !(1..=100_000_000).contains(&bounds.target_kbps)
|
||||
|| !(1..=100_000_000).contains(&bounds.maximum_kbps)
|
||||
|| bounds.minimum_kbps > bounds.target_kbps
|
||||
|| bounds.target_kbps > bounds.maximum_kbps
|
||||
|| !valid_display
|
||||
|| !bounded(&self.grant.opaque_value, 43, 256)
|
||||
|| timestamp(&self.grant.expires_at, false).is_none()
|
||||
|| !bounded(&self.grant.audience, 1, 128)
|
||||
|| !bounded(&self.correlation_id, 1, 128)
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks expiry and the supported tunnel binding at a supplied UTC instant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for unsupported input or `expired` for an expired grant.
|
||||
pub fn validate_at(&self, now_utc: &str) -> Result<()> {
|
||||
let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?;
|
||||
if timestamp(&self.grant.expires_at, false).ok_or(CoreError::InvalidArgument)? <= now {
|
||||
return Err(CoreError::Expired);
|
||||
}
|
||||
if !self
|
||||
.tunnel
|
||||
.versions
|
||||
.iter()
|
||||
.any(|value| value == "verse-gateway-v1/1")
|
||||
|| !self
|
||||
.tunnel
|
||||
.features
|
||||
.iter()
|
||||
.any(|value| value == "control.v1")
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn addresses(&self) -> &[String] {
|
||||
&self.gateway.addresses
|
||||
}
|
||||
|
||||
pub(crate) fn public_identity(&self) -> &str {
|
||||
&self.gateway.public_identity
|
||||
}
|
||||
|
||||
pub(crate) fn features(&self) -> &[String] {
|
||||
&self.tunnel.features
|
||||
}
|
||||
|
||||
pub(crate) fn admission(
|
||||
&self,
|
||||
client_nonce: String,
|
||||
device_signature: String,
|
||||
capabilities: CapabilityProfile,
|
||||
) -> Result<TunnelAdmissionRequest> {
|
||||
let request = TunnelAdmissionRequest {
|
||||
version: "1".to_owned(),
|
||||
session_id: self.session_id.clone(),
|
||||
gateway_id: self.gateway.id.clone(),
|
||||
audience: self.grant.audience.clone(),
|
||||
grant: self.grant.opaque_value.clone(),
|
||||
reconnect_sequence: self.reconnect_sequence,
|
||||
client_nonce,
|
||||
device_signature,
|
||||
capabilities,
|
||||
};
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NativeTunnelCredential {
|
||||
client_device_id: String,
|
||||
device_key_id: String,
|
||||
certificate_chain_pem: String,
|
||||
trust_bundle_pem: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl NativeTunnelCredential {
|
||||
/// Strictly decodes and validates an RC5 native tunnel credential.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, unknown, or out-of-bound data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let credential: Self = decode_strict(bytes, MAX_CREDENTIAL_JSON_BYTES)?;
|
||||
if !bounded(&credential.client_device_id, 1, 128)
|
||||
|| !bounded(&credential.device_key_id, 1, 128)
|
||||
|| !bounded(&credential.certificate_chain_pem, 1, 65_536)
|
||||
|| !bounded(&credential.trust_bundle_pem, 1, 65_536)
|
||||
|| !certificate_only_pem(&credential.certificate_chain_pem)
|
||||
|| !certificate_only_pem(&credential.trust_bundle_pem)
|
||||
|| timestamp(&credential.expires_at, false).is_none()
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// Checks credential expiry at a supplied UTC instant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for an invalid instant or `expired` after expiry.
|
||||
pub fn validate_at(&self, now_utc: &str) -> Result<()> {
|
||||
let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?;
|
||||
if timestamp(&self.expires_at, false).ok_or(CoreError::InvalidArgument)? <= now {
|
||||
return Err(CoreError::Expired);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn certificate_chain_pem(&self) -> &str {
|
||||
&self.certificate_chain_pem
|
||||
}
|
||||
|
||||
pub(crate) fn trust_bundle_pem(&self) -> &str {
|
||||
&self.trust_bundle_pem
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TunnelAdmissionRequest {
|
||||
version: String,
|
||||
session_id: String,
|
||||
gateway_id: String,
|
||||
audience: String,
|
||||
grant: String,
|
||||
reconnect_sequence: u64,
|
||||
client_nonce: String,
|
||||
device_signature: String,
|
||||
capabilities: CapabilityProfile,
|
||||
}
|
||||
|
||||
impl TunnelAdmissionRequest {
|
||||
/// Strictly decodes and validates an RC5 tunnel admission request.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, unknown, or out-of-bound data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let request: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?;
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<()> {
|
||||
if self.version != "1"
|
||||
|| !bounded(&self.session_id, 1, 128)
|
||||
|| !bounded(&self.gateway_id, 1, 128)
|
||||
|| !bounded(&self.audience, 1, 256)
|
||||
|| !bounded(&self.grant, 43, 256)
|
||||
|| !bounded(&self.client_nonce, 16, 128)
|
||||
|| self.device_signature.len() != 86
|
||||
|| !matches!(raw_base64url_decoded_len(&self.client_nonce), Some(12..=96))
|
||||
|| raw_base64url_decoded_len(&self.device_signature) != Some(64)
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
self.capabilities.validate()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn admission_transcript(&self) -> Vec<u8> {
|
||||
let reconnect_sequence = self.reconnect_sequence.to_string();
|
||||
let decode_count = self.capabilities.client_decode.len().to_string();
|
||||
let mut fields = vec![
|
||||
self.session_id.as_str(),
|
||||
self.gateway_id.as_str(),
|
||||
self.audience.as_str(),
|
||||
self.grant.as_str(),
|
||||
reconnect_sequence.as_str(),
|
||||
self.client_nonce.as_str(),
|
||||
self.capabilities.transport.as_str(),
|
||||
self.capabilities.framing.as_str(),
|
||||
self.capabilities.media.as_str(),
|
||||
self.capabilities.audio.as_str(),
|
||||
self.capabilities.source_rate_control.as_str(),
|
||||
decode_count.as_str(),
|
||||
];
|
||||
fields.extend(self.capabilities.client_decode.iter().map(String::as_str));
|
||||
let mut transcript = String::from("versevdi/tunnel-admission/v1");
|
||||
for field in fields {
|
||||
transcript.push_str(&field.len().to_string());
|
||||
transcript.push(':');
|
||||
transcript.push_str(field);
|
||||
}
|
||||
transcript.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientSessionAuthority {
|
||||
version: String,
|
||||
session_id: String,
|
||||
gateway_id: String,
|
||||
audience: String,
|
||||
reconnect_sequence: u64,
|
||||
expires_at: String,
|
||||
capabilities: CapabilityProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StableError {
|
||||
version: String,
|
||||
code: String,
|
||||
message: String,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct DecodedStableError {
|
||||
pub(crate) error: CoreError,
|
||||
pub(crate) code: String,
|
||||
pub(crate) retryable: bool,
|
||||
}
|
||||
|
||||
impl ClientSessionAuthority {
|
||||
/// Strictly decodes and validates a provider-free RC5 client authority.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for malformed, unknown, provider-shaped, or invalid data.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self> {
|
||||
let authority: Self = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?;
|
||||
if authority.version != "1"
|
||||
|| !bounded(&authority.session_id, 1, 128)
|
||||
|| !bounded(&authority.gateway_id, 1, 128)
|
||||
|| !bounded(&authority.audience, 1, 256)
|
||||
|| timestamp(&authority.expires_at, true).is_none()
|
||||
{
|
||||
return Err(CoreError::InvalidArgument);
|
||||
}
|
||||
authority.capabilities.validate()?;
|
||||
Ok(authority)
|
||||
}
|
||||
|
||||
/// Checks session, gateway, audience, reconnect, expiry, and capability bindings.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `invalid_argument` for an invalid clock value or `authority_rejected` on mismatch.
|
||||
pub fn validate_binding(
|
||||
&self,
|
||||
manifest: &ConnectionManifest,
|
||||
offered: &CapabilityProfile,
|
||||
now_utc: &str,
|
||||
) -> Result<()> {
|
||||
let now = timestamp(now_utc, false).ok_or(CoreError::InvalidArgument)?;
|
||||
let expires = timestamp(&self.expires_at, true).ok_or(CoreError::AuthorityRejected)?;
|
||||
let grant_expires =
|
||||
timestamp(&manifest.grant.expires_at, false).ok_or(CoreError::AuthorityRejected)?;
|
||||
if self.session_id != manifest.session_id
|
||||
|| self.gateway_id != manifest.gateway.id
|
||||
|| self.audience != manifest.grant.audience
|
||||
|| self.reconnect_sequence != manifest.reconnect_sequence
|
||||
|| expires <= now
|
||||
|| expires > grant_expires
|
||||
|| !self.capabilities.is_subset_of(offered)
|
||||
{
|
||||
return Err(CoreError::AuthorityRejected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_stable_error(bytes: &[u8]) -> Result<DecodedStableError> {
|
||||
let stable: StableError = decode_strict(bytes, MAX_ADMISSION_JSON_BYTES)?;
|
||||
if stable.version != "1" || !bounded(&stable.code, 1, 128) || !bounded(&stable.message, 1, 512)
|
||||
{
|
||||
return Err(CoreError::Protocol);
|
||||
}
|
||||
let error = match stable.code.as_str() {
|
||||
"expired_grant" => CoreError::Expired,
|
||||
"admission_rejected"
|
||||
| "gateway_draining"
|
||||
| "invalid_authority"
|
||||
| "no_capability_overlap"
|
||||
| "wrong_gateway"
|
||||
| "provider_work_unavailable"
|
||||
| "clipboard_audit_unavailable" => CoreError::AuthorityRejected,
|
||||
"provider_identity_rejected"
|
||||
| "provider_malformed"
|
||||
| "provider_timeout"
|
||||
| "provider_unavailable"
|
||||
| "provider_state_unavailable" => CoreError::Transport,
|
||||
"invalid_hello" => CoreError::Protocol,
|
||||
_ => return Err(CoreError::Protocol),
|
||||
};
|
||||
Ok(DecodedStableError {
|
||||
error,
|
||||
code: stable.code,
|
||||
retryable: stable.retryable,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stable_error_tests {
|
||||
use super::{decode_stable_error, system_time_utc};
|
||||
use crate::error::CoreError;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn system_clock_conversion_is_exact_at_epoch_and_leap_day() {
|
||||
assert_eq!(
|
||||
system_time_utc(UNIX_EPOCH).as_deref(),
|
||||
Ok("1970-01-01T00:00:00Z")
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_utc(UNIX_EPOCH + Duration::from_secs(1_709_251_199)).as_deref(),
|
||||
Ok("2024-02-29T23:59:59Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_error_uses_exact_rc5_bounds_and_preserves_retryability() {
|
||||
let message = "m".repeat(512);
|
||||
let bytes = serde_json::to_vec(&serde_json::json!({
|
||||
"version": "1",
|
||||
"code": "gateway_draining",
|
||||
"message": message,
|
||||
"retryable": true,
|
||||
}))
|
||||
.expect("encode stable error");
|
||||
let decoded = decode_stable_error(&bytes).expect("RC5 stable error");
|
||||
assert_eq!(decoded.error, CoreError::AuthorityRejected);
|
||||
assert!(decoded.retryable);
|
||||
|
||||
for invalid in [
|
||||
serde_json::json!({"version":"1","code":"gateway_draining","message":"","retryable":true}),
|
||||
serde_json::json!({"version":"1","code":"c".repeat(129),"message":"m","retryable":true}),
|
||||
serde_json::json!({"version":"1","code":"gateway_draining","message":"m".repeat(513),"retryable":true}),
|
||||
serde_json::json!({"version":"1","code":"unknown","message":"m","retryable":true}),
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_stable_error(&serde_json::to_vec(&invalid).expect("encode invalid")).err(),
|
||||
Some(CoreError::Protocol)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
#![allow(
|
||||
unsafe_code,
|
||||
clippy::borrow_as_ptr,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::items_after_statements,
|
||||
clashing_extern_declarations
|
||||
)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::c_void;
|
||||
use std::mem::{offset_of, size_of};
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustls::sign::SigningKey;
|
||||
use rustls::SignatureScheme;
|
||||
|
||||
#[path = "gateway_oracle.rs"]
|
||||
mod gateway_oracle;
|
||||
|
||||
thread_local! {
|
||||
static CORE_CONTEXTS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new());
|
||||
static ORACLES: RefCell<HashMap<usize, gateway_oracle::Oracle>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
const ABI_V1: u32 = 1;
|
||||
const OK: u32 = 0;
|
||||
const INVALID_ARGUMENT: u32 = 1;
|
||||
const INVALID_STATE: u32 = 2;
|
||||
const UNSUPPORTED_ABI: u32 = 3;
|
||||
const AUTHORITY_REJECTED: u32 = 4;
|
||||
const TLS: u32 = 5;
|
||||
const QUEUE_FULL: u32 = 9;
|
||||
const CANCELLED: u32 = 10;
|
||||
const REENTRANT: u32 = 11;
|
||||
const BUSY: u32 = 12;
|
||||
const INTERNAL: u32 = 13;
|
||||
|
||||
const STATE_CONNECTING: u32 = 1;
|
||||
const STATE_CONNECTED: u32 = 2;
|
||||
const STATE_CANCELLED: u32 = 3;
|
||||
const INPUT_KEYBOARD: u32 = 1;
|
||||
|
||||
#[repr(C)]
|
||||
struct Core {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct BytesView {
|
||||
data: *const u8,
|
||||
length: usize,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct StateEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
state: u32,
|
||||
reason: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ErrorEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
code: u32,
|
||||
retryable: u32,
|
||||
phase: u32,
|
||||
reserved: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct StatsEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
dropped_callbacks: u64,
|
||||
dropped_media_units: u64,
|
||||
dropped_input_events: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct MediaEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
channel: u32,
|
||||
sequence: u32,
|
||||
timestamp_ms: u64,
|
||||
encoded_unit: BytesView,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ControlEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
kind: u32,
|
||||
reserved: u32,
|
||||
payload: BytesView,
|
||||
}
|
||||
|
||||
type SignFn = unsafe extern "C" fn(*mut c_void, BytesView, *mut u8) -> u32;
|
||||
type StateFn = unsafe extern "C" fn(*mut c_void, *const StateEvent);
|
||||
type ErrorFn = unsafe extern "C" fn(*mut c_void, *const ErrorEvent);
|
||||
type StatsFn = unsafe extern "C" fn(*mut c_void, *const StatsEvent);
|
||||
type MediaFn = unsafe extern "C" fn(*mut c_void, *const MediaEvent);
|
||||
type ControlFn = unsafe extern "C" fn(*mut c_void, *const ControlEvent);
|
||||
|
||||
#[repr(C)]
|
||||
struct Config {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
context: *mut c_void,
|
||||
sign_admission: Option<SignFn>,
|
||||
sign_tls_ed25519: Option<SignFn>,
|
||||
on_state: Option<StateFn>,
|
||||
on_error: Option<ErrorFn>,
|
||||
on_stats: Option<StatsFn>,
|
||||
on_media: Option<MediaFn>,
|
||||
on_control: Option<ControlFn>,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ConnectRequest {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
manifest_json: BytesView,
|
||||
tunnel_credential_json: BytesView,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct InputEvent {
|
||||
struct_size: u32,
|
||||
abi_version: u32,
|
||||
kind: u32,
|
||||
flags: u32,
|
||||
values: [i32; 12],
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn verse_core_abi_version() -> u32;
|
||||
fn verse_core_create_v1(config: *const Config, out_core: *mut *mut Core) -> u32;
|
||||
fn verse_core_connect_v1(core: *mut Core, request: *const ConnectRequest) -> u32;
|
||||
fn verse_core_send_input_v1(core: *mut Core, event: *const InputEvent) -> u32;
|
||||
fn verse_core_request_idr_v1(core: *mut Core) -> u32;
|
||||
fn verse_core_cancel_v1(core: *mut Core) -> u32;
|
||||
fn verse_core_destroy_v1(core: *mut Core, timeout_ms: u32) -> u32;
|
||||
}
|
||||
|
||||
const MANIFEST: &[u8] = br#"{
|
||||
"version":"1","purpose":"launch","session_id":"session","reconnect_sequence":0,
|
||||
"gateway":{"id":"gateway","addresses":["gateway.test:443"],"public_identity":"gateway.test"},
|
||||
"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1","input.absolute.v1","input.scroll.v1"]},
|
||||
"profile":{"id":"standard","bounds":{"minimum_kbps":1000,"target_kbps":5000,"maximum_kbps":10000},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60}},
|
||||
"grant":{"opaque_value":"ggggggggggggggggggggggggggggggggggggggggggg","expires_at":"2099-01-01T00:00:00Z","audience":"audience"},
|
||||
"correlation_id":"correlation"
|
||||
}"#;
|
||||
const CREDENTIAL: &[u8] = br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","trust_bundle_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","expires_at":"2099-01-01T00:00:00Z"}"#;
|
||||
|
||||
struct Context {
|
||||
core: AtomicUsize,
|
||||
admission_calls: AtomicUsize,
|
||||
tls_calls: AtomicUsize,
|
||||
admission_status: AtomicU32,
|
||||
tls_status: AtomicU32,
|
||||
admission_input: Mutex<Vec<u8>>,
|
||||
tls_input: Mutex<Vec<u8>>,
|
||||
states: Mutex<Vec<u32>>,
|
||||
wake: Condvar,
|
||||
reentry_cancel: AtomicU32,
|
||||
reentry_send: AtomicU32,
|
||||
reentry_destroy: AtomicU32,
|
||||
block_callbacks: AtomicBool,
|
||||
release_callbacks: AtomicBool,
|
||||
callback_active: AtomicUsize,
|
||||
callback_max: AtomicUsize,
|
||||
cancel_on_connecting: AtomicBool,
|
||||
reentry_target: AtomicUsize,
|
||||
reentry_results: Mutex<Vec<u32>>,
|
||||
admission_key: Mutex<Option<Arc<dyn SigningKey>>>,
|
||||
tls_key: Mutex<Option<Arc<dyn SigningKey>>>,
|
||||
}
|
||||
|
||||
impl Default for Context {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
core: AtomicUsize::new(0),
|
||||
admission_calls: AtomicUsize::new(0),
|
||||
tls_calls: AtomicUsize::new(0),
|
||||
admission_status: AtomicU32::new(OK),
|
||||
tls_status: AtomicU32::new(OK),
|
||||
admission_input: Mutex::new(Vec::new()),
|
||||
tls_input: Mutex::new(Vec::new()),
|
||||
states: Mutex::new(Vec::new()),
|
||||
wake: Condvar::new(),
|
||||
reentry_cancel: AtomicU32::new(u32::MAX),
|
||||
reentry_send: AtomicU32::new(u32::MAX),
|
||||
reentry_destroy: AtomicU32::new(u32::MAX),
|
||||
block_callbacks: AtomicBool::new(false),
|
||||
release_callbacks: AtomicBool::new(false),
|
||||
callback_active: AtomicUsize::new(0),
|
||||
callback_max: AtomicUsize::new(0),
|
||||
cancel_on_connecting: AtomicBool::new(false),
|
||||
reentry_target: AtomicUsize::new(0),
|
||||
reentry_results: Mutex::new(Vec::new()),
|
||||
admission_key: Mutex::new(None),
|
||||
tls_key: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn context<'a>(raw: *mut c_void) -> &'a Context {
|
||||
// Test invariant: every callback receives the live Box<Context> supplied at create.
|
||||
unsafe { &*raw.cast::<Context>() }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission(raw: *mut c_void, input: BytesView, output: *mut u8) -> u32 {
|
||||
// Test invariant: ABI promises input is readable for input.length during this callback.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(input.data, input.length) };
|
||||
let ctx = unsafe { context(raw) };
|
||||
ctx.admission_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*ctx.admission_input.lock().expect("admission lock") = bytes.to_vec();
|
||||
let status = ctx.admission_status.load(Ordering::SeqCst);
|
||||
if status != OK {
|
||||
return status;
|
||||
}
|
||||
let key = ctx.admission_key.lock().expect("admission key");
|
||||
let Some(key) = key.as_ref() else {
|
||||
return INTERNAL;
|
||||
};
|
||||
let signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("Ed25519 admission signer");
|
||||
let signature = signer.sign(bytes).expect("admission signature");
|
||||
assert_eq!(signature.len(), 64);
|
||||
// Test invariant: ABI promises a writable 64-byte Rust-owned signature buffer.
|
||||
unsafe { ptr::copy_nonoverlapping(signature.as_ptr(), output, signature.len()) };
|
||||
OK
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_tls(raw: *mut c_void, input: BytesView, output: *mut u8) -> u32 {
|
||||
// Test invariant: ABI promises input is readable for input.length during this callback.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(input.data, input.length) };
|
||||
let ctx = unsafe { context(raw) };
|
||||
ctx.tls_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*ctx.tls_input.lock().expect("tls lock") = bytes.to_vec();
|
||||
let status = ctx.tls_status.load(Ordering::SeqCst);
|
||||
if status != OK {
|
||||
return status;
|
||||
}
|
||||
let key = ctx.tls_key.lock().expect("TLS key");
|
||||
let Some(key) = key.as_ref() else {
|
||||
return INTERNAL;
|
||||
};
|
||||
let signer = key
|
||||
.choose_scheme(&[SignatureScheme::ED25519])
|
||||
.expect("Ed25519 TLS signer");
|
||||
let signature = signer.sign(bytes).expect("TLS signature");
|
||||
assert_eq!(signature.len(), 64);
|
||||
// Test invariant: ABI promises a writable 64-byte Rust-owned signature buffer.
|
||||
unsafe { ptr::copy_nonoverlapping(signature.as_ptr(), output, signature.len()) };
|
||||
OK
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission_probes_global_reentry(
|
||||
raw: *mut c_void,
|
||||
input: BytesView,
|
||||
output: *mut u8,
|
||||
) -> u32 {
|
||||
let ctx = unsafe { context(raw) };
|
||||
let target = ctx.reentry_target.load(Ordering::SeqCst) as *mut Core;
|
||||
let results = [
|
||||
unsafe { verse_core_abi_version() },
|
||||
unsafe { verse_core_create_v1(ptr::null(), ptr::null_mut()) },
|
||||
unsafe { verse_core_connect_v1(target, ptr::null()) },
|
||||
unsafe { verse_core_send_input_v1(target, ptr::null()) },
|
||||
unsafe { verse_core_request_idr_v1(target) },
|
||||
unsafe { verse_core_cancel_v1(target) },
|
||||
unsafe { verse_core_destroy_v1(target, 0) },
|
||||
];
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("signer reentry results")
|
||||
.extend(results);
|
||||
unsafe { sign_admission(raw, input, output) }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_state_probes_global_reentry(raw: *mut c_void, event: *const StateEvent) {
|
||||
let ctx = unsafe { context(raw) };
|
||||
// Test invariant: ABI promises a readable state record for the callback duration.
|
||||
let state = unsafe { (*event).state };
|
||||
ctx.states.lock().expect("states lock").push(state);
|
||||
ctx.wake.notify_all();
|
||||
if state != STATE_CONNECTED {
|
||||
return;
|
||||
}
|
||||
let origin = ctx.core.load(Ordering::SeqCst) as *mut Core;
|
||||
let other = ctx.reentry_target.load(Ordering::SeqCst) as *mut Core;
|
||||
let results = [
|
||||
unsafe { verse_core_abi_version() },
|
||||
unsafe { verse_core_create_v1(ptr::null(), ptr::null_mut()) },
|
||||
unsafe { verse_core_connect_v1(origin, ptr::null()) },
|
||||
unsafe { verse_core_send_input_v1(origin, ptr::null()) },
|
||||
unsafe { verse_core_request_idr_v1(origin) },
|
||||
unsafe { verse_core_cancel_v1(other) },
|
||||
unsafe { verse_core_destroy_v1(origin, 0) },
|
||||
unsafe { verse_core_cancel_v1(origin) },
|
||||
];
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("event reentry results")
|
||||
.extend(results);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_state(raw: *mut c_void, event: *const StateEvent) {
|
||||
let ctx = unsafe { context(raw) };
|
||||
let active = ctx.callback_active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
ctx.callback_max.fetch_max(active, Ordering::SeqCst);
|
||||
// Test invariant: ABI promises a readable state record for the callback duration.
|
||||
let state = unsafe { (*event).state };
|
||||
ctx.states.lock().expect("states lock").push(state);
|
||||
ctx.wake.notify_all();
|
||||
|
||||
if state == STATE_CONNECTING && ctx.cancel_on_connecting.load(Ordering::SeqCst) {
|
||||
let core = ctx.core.load(Ordering::SeqCst) as *mut Core;
|
||||
ctx.reentry_cancel
|
||||
.store(unsafe { verse_core_cancel_v1(core) }, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if ctx.block_callbacks.load(Ordering::SeqCst) && !ctx.release_callbacks.load(Ordering::SeqCst) {
|
||||
let mut states = ctx.states.lock().expect("states lock");
|
||||
while !ctx.release_callbacks.load(Ordering::SeqCst) {
|
||||
states = ctx.wake.wait(states).expect("callback wait");
|
||||
}
|
||||
}
|
||||
|
||||
if state == STATE_CONNECTED && ctx.reentry_cancel.load(Ordering::SeqCst) == u32::MAX {
|
||||
let core = ctx.core.load(Ordering::SeqCst) as *mut Core;
|
||||
// Test invariant: the stored handle is live until this callback and its destroy complete.
|
||||
ctx.reentry_cancel
|
||||
.store(unsafe { verse_core_cancel_v1(core) }, Ordering::SeqCst);
|
||||
let event = keyboard_event();
|
||||
// Test invariant: event and handle remain valid for the synchronous call.
|
||||
ctx.reentry_send.store(
|
||||
unsafe { verse_core_send_input_v1(core, &event) },
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
// Test invariant: the callback intentionally probes the documented reentry rejection.
|
||||
ctx.reentry_destroy
|
||||
.store(unsafe { verse_core_destroy_v1(core, 1) }, Ordering::SeqCst);
|
||||
}
|
||||
ctx.callback_active.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission_reenters(
|
||||
raw: *mut c_void,
|
||||
input: BytesView,
|
||||
output: *mut u8,
|
||||
) -> u32 {
|
||||
let ctx = unsafe { context(raw) };
|
||||
ctx.reentry_cancel.store(
|
||||
unsafe { verse_core_cancel_v1(ctx.core.load(Ordering::SeqCst) as *mut Core) },
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
unsafe { sign_admission(raw, input, output) }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn sign_admission_waits_for_cancel(
|
||||
raw: *mut c_void,
|
||||
input: BytesView,
|
||||
output: *mut u8,
|
||||
) -> u32 {
|
||||
let ctx = unsafe { context(raw) };
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while ctx.reentry_cancel.load(Ordering::SeqCst) == u32::MAX {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"connecting callback did not cancel"
|
||||
);
|
||||
thread::yield_now();
|
||||
}
|
||||
unsafe { sign_admission(raw, input, output) }
|
||||
}
|
||||
|
||||
fn config(ctx: &mut Context) -> Config {
|
||||
Config {
|
||||
struct_size: size_of::<Config>() as u32,
|
||||
abi_version: ABI_V1,
|
||||
context: ptr::from_mut(ctx).cast(),
|
||||
sign_admission: Some(sign_admission),
|
||||
sign_tls_ed25519: Some(sign_tls),
|
||||
on_state: Some(on_state),
|
||||
on_error: None,
|
||||
on_stats: None,
|
||||
on_media: None,
|
||||
on_control: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(manifest: &[u8], credential: &[u8]) -> ConnectRequest {
|
||||
ConnectRequest {
|
||||
struct_size: size_of::<ConnectRequest>() as u32,
|
||||
abi_version: ABI_V1,
|
||||
manifest_json: BytesView {
|
||||
data: manifest.as_ptr(),
|
||||
length: manifest.len(),
|
||||
},
|
||||
tunnel_credential_json: BytesView {
|
||||
data: credential.as_ptr(),
|
||||
length: credential.len(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard_event() -> InputEvent {
|
||||
let mut values = [0; 12];
|
||||
values[0] = 1;
|
||||
values[2] = 30;
|
||||
InputEvent {
|
||||
struct_size: size_of::<InputEvent>() as u32,
|
||||
abi_version: ABI_V1,
|
||||
kind: INPUT_KEYBOARD,
|
||||
flags: 0,
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
fn create(ctx: &mut Context) -> *mut Core {
|
||||
let mut core = ptr::null_mut();
|
||||
let config = config(ctx);
|
||||
// Test invariant: config/out pointers remain valid for the synchronous create call.
|
||||
assert_eq!(unsafe { verse_core_create_v1(&config, &mut core) }, OK);
|
||||
assert!(!core.is_null());
|
||||
register(core, ctx);
|
||||
core
|
||||
}
|
||||
|
||||
fn register(core: *mut Core, ctx: &mut Context) {
|
||||
ctx.core.store(core as usize, Ordering::SeqCst);
|
||||
CORE_CONTEXTS.with(|contexts| {
|
||||
contexts
|
||||
.borrow_mut()
|
||||
.insert(core as usize, ptr::from_mut(ctx) as usize);
|
||||
});
|
||||
}
|
||||
|
||||
fn connect(core: *mut Core, manifest: &[u8], credential: &[u8]) -> u32 {
|
||||
connect_mode(core, "", manifest, credential)
|
||||
}
|
||||
|
||||
fn connect_mode(core: *mut Core, mode: &str, manifest: &[u8], credential: &[u8]) -> u32 {
|
||||
connect_with_oracle(core, mode, |oracle| {
|
||||
let manifest = if manifest == MANIFEST {
|
||||
oracle.ready.manifest.as_bytes()
|
||||
} else {
|
||||
manifest
|
||||
};
|
||||
let credential = if credential == CREDENTIAL {
|
||||
oracle.ready.credential.as_bytes()
|
||||
} else {
|
||||
credential
|
||||
};
|
||||
let request = request(manifest, credential);
|
||||
// Test invariant: request and backing byte slices remain valid for the synchronous call.
|
||||
unsafe { verse_core_connect_v1(core, &request) }
|
||||
})
|
||||
}
|
||||
|
||||
fn connect_with_oracle(
|
||||
core: *mut Core,
|
||||
mode: &str,
|
||||
action: impl FnOnce(&gateway_oracle::Oracle) -> u32,
|
||||
) -> u32 {
|
||||
let oracle = gateway_oracle::Oracle::start(mode);
|
||||
CORE_CONTEXTS.with(|contexts| {
|
||||
let raw = *contexts
|
||||
.borrow()
|
||||
.get(&(core as usize))
|
||||
.expect("registered ABI context");
|
||||
// Test invariant: context outlives the core and is removed only after destroy succeeds.
|
||||
let ctx = unsafe { &*(raw as *const Context) };
|
||||
*ctx.admission_key.lock().expect("admission key") =
|
||||
Some(gateway_oracle::test_key(&oracle.ready.admission_key));
|
||||
*ctx.tls_key.lock().expect("TLS key") =
|
||||
Some(gateway_oracle::test_key(&oracle.ready.client_key));
|
||||
});
|
||||
let status = action(&oracle);
|
||||
if status == OK {
|
||||
ORACLES.with(|oracles| {
|
||||
oracles.borrow_mut().insert(core as usize, oracle);
|
||||
});
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn wait_for(ctx: &Context, predicate: impl Fn(&[u32]) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut states = ctx.states.lock().expect("states lock");
|
||||
while !predicate(&states) {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
assert!(
|
||||
!remaining.is_zero(),
|
||||
"callback deadline exceeded: {states:?}"
|
||||
);
|
||||
(states, _) = ctx
|
||||
.wake
|
||||
.wait_timeout(states, remaining)
|
||||
.expect("callback wait");
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy(core: *mut Core) -> u32 {
|
||||
// Test invariant: caller retains the handle until destroy reports success.
|
||||
let status = unsafe { verse_core_destroy_v1(core, 2_000) };
|
||||
if status == OK {
|
||||
ORACLES.with(|oracles| {
|
||||
oracles.borrow_mut().remove(&(core as usize));
|
||||
});
|
||||
CORE_CONTEXTS.with(|contexts| {
|
||||
contexts.borrow_mut().remove(&(core as usize));
|
||||
});
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm64_c_layout_is_exact_and_version_is_fixed() {
|
||||
assert_eq!(versevdi_core::session::INPUT_QUEUE_CAPACITY, 64);
|
||||
assert_eq!(size_of::<BytesView>(), 16);
|
||||
assert_eq!(size_of::<Config>(), 72);
|
||||
assert_eq!(offset_of!(Config, sign_admission), 16);
|
||||
assert_eq!(size_of::<ConnectRequest>(), 40);
|
||||
assert_eq!(offset_of!(ConnectRequest, manifest_json), 8);
|
||||
assert_eq!(size_of::<InputEvent>(), 64);
|
||||
assert_eq!(size_of::<StateEvent>(), 16);
|
||||
assert_eq!(size_of::<ErrorEvent>(), 24);
|
||||
assert_eq!(size_of::<StatsEvent>(), 32);
|
||||
assert_eq!(size_of::<MediaEvent>(), 40);
|
||||
assert_eq!(size_of::<ControlEvent>(), 32);
|
||||
// Test invariant: no pointer arguments are involved.
|
||||
assert_eq!(unsafe { verse_core_abi_version() }, ABI_V1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_validates_prefix_callbacks_output_and_trailing_bytes() {
|
||||
let mut ctx = Context::default();
|
||||
let mut core = ptr::dangling_mut::<Core>();
|
||||
let mut cfg = config(&mut ctx);
|
||||
|
||||
cfg.struct_size = 7;
|
||||
// Test invariant: config/out are readable/writable for this call.
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&cfg, &mut core) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
assert!(core.is_null());
|
||||
|
||||
cfg = config(&mut ctx);
|
||||
cfg.abi_version = 2;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&cfg, &mut core) },
|
||||
UNSUPPORTED_ABI
|
||||
);
|
||||
assert!(core.is_null());
|
||||
|
||||
cfg = config(&mut ctx);
|
||||
cfg.sign_tls_ed25519 = None;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&cfg, &mut core) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
assert!(core.is_null());
|
||||
|
||||
#[repr(C)]
|
||||
struct Extended {
|
||||
base: Config,
|
||||
ignored: [u8; 32],
|
||||
}
|
||||
let extended = Extended {
|
||||
base: config(&mut ctx),
|
||||
ignored: [0xEE; 32],
|
||||
};
|
||||
let mut extended = extended;
|
||||
extended.base.struct_size = size_of::<Extended>() as u32;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_create_v1(&extended.base, &mut core) },
|
||||
OK
|
||||
);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_copies_inputs_and_calls_purpose_specific_signers_once() {
|
||||
let mut ctx = Context::default();
|
||||
let core = create(&mut ctx);
|
||||
let mut manifest = MANIFEST.to_vec();
|
||||
let mut credential = CREDENTIAL.to_vec();
|
||||
assert_eq!(connect(core, &manifest, &credential), OK);
|
||||
manifest.fill(b'x');
|
||||
credential.fill(b'y');
|
||||
|
||||
assert_eq!(ctx.admission_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ctx.tls_calls.load(Ordering::SeqCst), 1);
|
||||
let admission = ctx.admission_input.lock().expect("admission");
|
||||
let tls = ctx.tls_input.lock().expect("tls");
|
||||
assert!(admission.starts_with(b"versevdi/tunnel-admission/v1"));
|
||||
assert!(!tls.is_empty());
|
||||
assert_ne!(&*admission, &*tls);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_slices_reject_short_unsupported_null_and_oversized_inputs() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
let mut req = request(MANIFEST, CREDENTIAL);
|
||||
|
||||
req.struct_size = 7;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
req.struct_size = size_of::<ConnectRequest>() as u32;
|
||||
req.abi_version = 2;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
UNSUPPORTED_ABI
|
||||
);
|
||||
req.abi_version = ABI_V1;
|
||||
req.manifest_json = BytesView {
|
||||
data: ptr::null(),
|
||||
length: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
req.manifest_json = BytesView {
|
||||
data: ptr::null(),
|
||||
length: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
req.manifest_json.length = 1_048_577;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_connect_v1(core, &req) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
|
||||
let mut input = keyboard_event();
|
||||
input.struct_size = 7;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &input) },
|
||||
INVALID_ARGUMENT
|
||||
);
|
||||
input.struct_size = size_of::<InputEvent>() as u32;
|
||||
input.abi_version = 2;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &input) },
|
||||
UNSUPPORTED_ABI
|
||||
);
|
||||
|
||||
#[repr(C)]
|
||||
struct ExtendedRequest {
|
||||
base: ConnectRequest,
|
||||
ignored: [u8; 24],
|
||||
}
|
||||
assert_eq!(
|
||||
connect_with_oracle(core, "", |oracle| {
|
||||
let mut trailing_request = ExtendedRequest {
|
||||
base: request(
|
||||
oracle.ready.manifest.as_bytes(),
|
||||
oracle.ready.credential.as_bytes(),
|
||||
),
|
||||
ignored: [0xEE; 24],
|
||||
};
|
||||
trailing_request.base.struct_size = size_of::<ExtendedRequest>() as u32;
|
||||
unsafe { verse_core_connect_v1(core, &trailing_request.base) }
|
||||
}),
|
||||
OK
|
||||
);
|
||||
|
||||
#[repr(C)]
|
||||
struct ExtendedInput {
|
||||
base: InputEvent,
|
||||
ignored: [u8; 24],
|
||||
}
|
||||
let mut trailing_input = ExtendedInput {
|
||||
base: keyboard_event(),
|
||||
ignored: [0xEE; 24],
|
||||
};
|
||||
trailing_input.base.struct_size = size_of::<ExtendedInput>() as u32;
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &trailing_input.base) },
|
||||
OK
|
||||
);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_order_is_serial_and_only_cancel_is_reentrant() {
|
||||
let mut ctx = Context::default();
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
wait_for(&ctx, |states| states.contains(&STATE_CANCELLED));
|
||||
|
||||
assert_eq!(
|
||||
ctx.states.lock().expect("states").as_slice(),
|
||||
[STATE_CONNECTING, STATE_CONNECTED, STATE_CANCELLED]
|
||||
);
|
||||
assert_eq!(ctx.reentry_cancel.load(Ordering::SeqCst), OK);
|
||||
assert_eq!(ctx.reentry_send.load(Ordering::SeqCst), REENTRANT);
|
||||
assert_eq!(ctx.reentry_destroy.load(Ordering::SeqCst), REENTRANT);
|
||||
assert_eq!(ctx.callback_max.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_callbacks_cannot_reenter_even_cancel() {
|
||||
let mut ctx = Context::default();
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.sign_admission = Some(sign_admission_reenters);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(ctx.reentry_cancel.load(Ordering::SeqCst), REENTRANT);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_callback_rejects_every_stateful_api_across_handles() {
|
||||
let mut other_ctx = Context::default();
|
||||
other_ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let other = create(&mut other_ctx);
|
||||
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.reentry_target.store(other as usize, Ordering::SeqCst);
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.sign_admission = Some(sign_admission_probes_global_reentry);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("signer results")
|
||||
.as_slice(),
|
||||
[ABI_V1, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT]
|
||||
);
|
||||
|
||||
assert_eq!(destroy(core), OK);
|
||||
assert_eq!(connect(other, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(destroy(other), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_callback_allows_only_originating_handle_cancel() {
|
||||
let mut other_ctx = Context::default();
|
||||
other_ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let other = create(&mut other_ctx);
|
||||
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_target.store(other as usize, Ordering::SeqCst);
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.on_state = Some(on_state_probes_global_reentry);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
wait_for(&ctx, |states| states.contains(&STATE_CANCELLED));
|
||||
assert_eq!(
|
||||
ctx.reentry_results
|
||||
.lock()
|
||||
.expect("event results")
|
||||
.as_slice(),
|
||||
[ABI_V1, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT, REENTRANT, OK]
|
||||
);
|
||||
|
||||
assert_eq!(destroy(core), OK);
|
||||
assert_eq!(connect(other, MANIFEST, CREDENTIAL), OK);
|
||||
assert_eq!(destroy(other), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_statuses_are_purpose_specific_and_unknown_values_are_internal() {
|
||||
for (admission, expected) in [
|
||||
(OK, OK),
|
||||
(AUTHORITY_REJECTED, AUTHORITY_REJECTED),
|
||||
(CANCELLED, CANCELLED),
|
||||
(INTERNAL, INTERNAL),
|
||||
(TLS, INTERNAL),
|
||||
(u32::MAX, INTERNAL),
|
||||
] {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.admission_status.store(admission, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), expected);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
for (tls, expected) in [
|
||||
(OK, OK),
|
||||
(TLS, TLS),
|
||||
(CANCELLED, CANCELLED),
|
||||
(INTERNAL, INTERNAL),
|
||||
(AUTHORITY_REJECTED, INTERNAL),
|
||||
(u32::MAX, INTERNAL),
|
||||
] {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.tls_status.store(tls, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), expected);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_during_connect_preserves_state_order_and_stops_before_tls_signing() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.cancel_on_connecting.store(true, Ordering::SeqCst);
|
||||
let mut cfg = config(&mut ctx);
|
||||
cfg.sign_admission = Some(sign_admission_waits_for_cancel);
|
||||
let mut core = ptr::null_mut();
|
||||
assert_eq!(unsafe { verse_core_create_v1(&cfg, &mut core) }, OK);
|
||||
register(core, &mut ctx);
|
||||
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), CANCELLED);
|
||||
wait_for(&ctx, |states| states.contains(&STATE_CANCELLED));
|
||||
assert_eq!(
|
||||
ctx.states.lock().expect("states").as_slice(),
|
||||
[STATE_CONNECTING, STATE_CANCELLED]
|
||||
);
|
||||
assert_eq!(ctx.tls_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_input_is_nonblocking_bounded_and_cancel_is_idempotent() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect_mode(core, "slow-input", MANIFEST, CREDENTIAL), OK);
|
||||
let event = keyboard_event();
|
||||
let started = Instant::now();
|
||||
let mut sent = 0;
|
||||
let saturated = loop {
|
||||
match unsafe { verse_core_send_input_v1(core, &event) } {
|
||||
OK => sent += 1,
|
||||
QUEUE_FULL => break true,
|
||||
status => panic!("unexpected input status {status}"),
|
||||
}
|
||||
if sent == 10_000 || started.elapsed() > Duration::from_secs(1) {
|
||||
break false;
|
||||
}
|
||||
};
|
||||
assert!(saturated, "real slow consumer did not expose bounded queue");
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
assert_eq!(unsafe { verse_core_cancel_v1(core) }, OK);
|
||||
assert_eq!(unsafe { verse_core_cancel_v1(core) }, OK);
|
||||
assert_eq!(unsafe { verse_core_send_input_v1(core, &event) }, CANCELLED);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroy_timeout_keeps_ownership_suppresses_late_callbacks_and_allows_retry() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
ctx.block_callbacks.store(true, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), OK);
|
||||
wait_for(&ctx, |states| !states.is_empty());
|
||||
|
||||
assert_eq!(unsafe { verse_core_destroy_v1(core, 1) }, BUSY);
|
||||
let count_at_timeout = ctx.states.lock().expect("states").len();
|
||||
ctx.release_callbacks.store(true, Ordering::SeqCst);
|
||||
ctx.wake.notify_all();
|
||||
assert_eq!(destroy(core), OK);
|
||||
assert_eq!(ctx.states.lock().expect("states").len(), count_at_timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preconnect_and_postcancel_state_checks_are_stable() {
|
||||
let mut ctx = Context::default();
|
||||
ctx.reentry_cancel.store(OK, Ordering::SeqCst);
|
||||
let core = create(&mut ctx);
|
||||
let event = keyboard_event();
|
||||
assert_eq!(
|
||||
unsafe { verse_core_send_input_v1(core, &event) },
|
||||
INVALID_STATE
|
||||
);
|
||||
assert_eq!(unsafe { verse_core_request_idr_v1(core) }, INVALID_STATE);
|
||||
assert_eq!(unsafe { verse_core_cancel_v1(core) }, OK);
|
||||
assert_eq!(connect(core, MANIFEST, CREDENTIAL), CANCELLED);
|
||||
assert_eq!(destroy(core), OK);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "versevdi_core.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char MANIFEST[] =
|
||||
"{\"version\":\"1\",\"purpose\":\"launch\",\"session_id\":\"session\","
|
||||
"\"reconnect_sequence\":0,\"gateway\":{\"id\":\"gateway\",\"addresses\":["
|
||||
"\"127.0.0.1:9\"],\"public_identity\":\"gateway.test\"},\"tunnel\":{"
|
||||
"\"versions\":[\"verse-gateway-v1/1\"],\"features\":[\"control.v1\"]},"
|
||||
"\"profile\":{\"id\":\"standard\",\"bounds\":{\"minimum_kbps\":1000,"
|
||||
"\"target_kbps\":5000,\"maximum_kbps\":10000},\"display_mode\":null},"
|
||||
"\"grant\":{\"opaque_value\":\"ggggggggggggggggggggggggggggggggggggggggggg\","
|
||||
"\"expires_at\":\"2099-01-01T00:00:00Z\",\"audience\":\"audience\"},"
|
||||
"\"correlation_id\":\"correlation\"}";
|
||||
static const char CREDENTIAL[] =
|
||||
"{\"client_device_id\":\"device\",\"device_key_id\":\"key\","
|
||||
"\"certificate_chain_pem\":\"-----BEGIN CERTIFICATE-----\\nAQID\\n-----END CERTIFICATE-----\","
|
||||
"\"trust_bundle_pem\":\"-----BEGIN CERTIFICATE-----\\nAQID\\n-----END CERTIFICATE-----\","
|
||||
"\"expires_at\":\"2099-01-01T00:00:00Z\"}";
|
||||
|
||||
typedef struct smoke_context {
|
||||
atomic_uint admission_calls;
|
||||
atomic_uint tls_calls;
|
||||
} smoke_context_t;
|
||||
|
||||
static verse_status_t reject_admission(
|
||||
void *raw,
|
||||
verse_bytes_view_t input,
|
||||
uint8_t signature[64]) {
|
||||
smoke_context_t *context = raw;
|
||||
(void)input;
|
||||
(void)signature;
|
||||
atomic_fetch_add(&context->admission_calls, 1U);
|
||||
return VERSE_STATUS_INTERNAL;
|
||||
}
|
||||
|
||||
static verse_status_t reject_tls(
|
||||
void *raw,
|
||||
verse_bytes_view_t input,
|
||||
uint8_t signature[64]) {
|
||||
smoke_context_t *context = raw;
|
||||
(void)input;
|
||||
(void)signature;
|
||||
atomic_fetch_add(&context->tls_calls, 1U);
|
||||
return VERSE_STATUS_INTERNAL;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
smoke_context_t context = {0};
|
||||
verse_core_config_v1_t config = {
|
||||
.struct_size = sizeof(config),
|
||||
.abi_version = VERSE_CORE_ABI_VERSION_1,
|
||||
.context = &context,
|
||||
.sign_admission = reject_admission,
|
||||
.sign_tls_ed25519 = reject_tls,
|
||||
};
|
||||
verse_core_t *core = NULL;
|
||||
|
||||
assert(verse_core_abi_version() == VERSE_CORE_ABI_VERSION_1);
|
||||
assert(verse_core_create_v1(NULL, &core) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
assert(verse_core_create_v1(&config, NULL) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
config.struct_size = 8U;
|
||||
assert(verse_core_create_v1(&config, &core) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
config.struct_size = sizeof(config);
|
||||
config.abi_version = VERSE_CORE_ABI_VERSION_1 + 1U;
|
||||
assert(verse_core_create_v1(&config, &core) == VERSE_STATUS_UNSUPPORTED_ABI);
|
||||
config.abi_version = VERSE_CORE_ABI_VERSION_1;
|
||||
assert(verse_core_create_v1(&config, &core) == VERSE_STATUS_OK);
|
||||
assert(core != NULL);
|
||||
|
||||
verse_input_event_v1_t input = {
|
||||
.struct_size = sizeof(input),
|
||||
.abi_version = VERSE_CORE_ABI_VERSION_1,
|
||||
.kind = VERSE_INPUT_KEYBOARD,
|
||||
.values = {1, 0, 30},
|
||||
};
|
||||
assert(verse_core_send_input_v1(core, &input) == VERSE_STATUS_INVALID_STATE);
|
||||
assert(verse_core_request_idr_v1(core) == VERSE_STATUS_INVALID_STATE);
|
||||
|
||||
char manifest[sizeof(MANIFEST)];
|
||||
char credential[sizeof(CREDENTIAL)];
|
||||
memcpy(manifest, MANIFEST, sizeof(manifest));
|
||||
memcpy(credential, CREDENTIAL, sizeof(credential));
|
||||
verse_connect_request_v1_t request = {
|
||||
.struct_size = sizeof(request),
|
||||
.abi_version = VERSE_CORE_ABI_VERSION_1,
|
||||
.manifest_json = {(const uint8_t *)manifest, sizeof(manifest) - 1U},
|
||||
.tunnel_credential_json = {(const uint8_t *)credential, sizeof(credential) - 1U},
|
||||
};
|
||||
assert(verse_core_connect_v1(NULL, &request) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
request.struct_size = 8U;
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
request.struct_size = sizeof(request);
|
||||
request.abi_version = VERSE_CORE_ABI_VERSION_1 + 1U;
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_UNSUPPORTED_ABI);
|
||||
request.abi_version = VERSE_CORE_ABI_VERSION_1;
|
||||
request.manifest_json.data = NULL;
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_INVALID_ARGUMENT);
|
||||
request.manifest_json.data = (const uint8_t *)manifest;
|
||||
|
||||
assert(verse_core_connect_v1(core, &request) == VERSE_STATUS_TLS);
|
||||
memset(manifest, 0, sizeof(manifest));
|
||||
memset(credential, 0, sizeof(credential));
|
||||
assert(verse_core_send_input_v1(core, &input) == VERSE_STATUS_CANCELLED);
|
||||
assert(verse_core_request_idr_v1(core) == VERSE_STATUS_CANCELLED);
|
||||
assert(verse_core_cancel_v1(core) == VERSE_STATUS_OK);
|
||||
assert(verse_core_cancel_v1(core) == VERSE_STATUS_OK);
|
||||
assert(atomic_load(&context.admission_calls) == 0U);
|
||||
assert(atomic_load(&context.tls_calls) == 0U);
|
||||
assert(verse_core_destroy_v1(core, 2000U) == VERSE_STATUS_OK);
|
||||
assert(atomic_load(&context.admission_calls) == 0U);
|
||||
assert(atomic_load(&context.tls_calls) == 0U);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# Protocol fixture provenance
|
||||
|
||||
- Repository: `git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol`
|
||||
- Commit: `4693102b3ccbb81aeb1144c1a3b0884ee682bfa3`
|
||||
- Tag: `v1.0.0-phase3d-macos-rc.5`
|
||||
- Schema SHA-256: `b2353c12269304289b4e872f27cc370ae61b958dea90d9fb7b6ab8afd7d37248`
|
||||
- Fixture corpus SHA-256: `6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30`
|
||||
|
||||
Copied byte-for-byte from `fixtures/conformance/tunnel-v1.tsv`,
|
||||
`fixtures/conformance/datagram-v2.tsv`,
|
||||
`fixtures/conformance/gateway-input-feedback-v1.tsv`, and
|
||||
`fixtures/manifest.json`. RC4 is superseded and is not an authority for these
|
||||
fixtures.
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
id version kind input expected
|
||||
v2-valid-video-single 2 datagram hex=5644020a00000000010000000000000002000000010003010203 valid
|
||||
v2-valid-video-last-fragment 2 datagram hex=5644020a00000000010000000000000002037a037b0000 valid
|
||||
v2-invalid-short 2 datagram hex=564402 invalid:truncated
|
||||
v2-invalid-version 2 datagram hex=5644030a00000000010000000000000002000000010000 invalid:unsupported_version
|
||||
v2-invalid-channel 2 datagram hex=5644020d00000000010000000000000002000000010000 invalid:unknown_channel
|
||||
v2-invalid-fragment-zero 2 datagram hex=5644020a00000000010000000000000002000000000000 invalid:fragment
|
||||
v2-invalid-fragment-index 2 datagram hex=5644020a00000000010000000000000002000100010000 invalid:fragment
|
||||
v2-invalid-fragment-count-limit 2 datagram hex=5644020a000000000100000000000000020000037c0000 invalid:fragment_limit
|
||||
v2-invalid-length 2 datagram hex=5644020a00000000010000000000000002000000010001 invalid:length_mismatch
|
||||
|
@@ -0,0 +1,34 @@
|
||||
id version kind input expected
|
||||
valid-keyboard-press 1 gateway_input hex=5647493101040102001e valid
|
||||
valid-keyboard-release 1 gateway_input hex=5647493101040000001e valid
|
||||
valid-mouse-button 1 gateway_input hex=564749310203010100 valid
|
||||
valid-mouse-release 1 gateway_input hex=564749310203000100 valid
|
||||
valid-relative-mouse 1 gateway_input hex=564749310304fffe0003 valid
|
||||
valid-utf8-scalar 1 gateway_input hex=564749310403e29883 valid
|
||||
valid-controller 1 gateway_input hex=5647493105110200030004ffff00010002000300040005 valid
|
||||
valid-controller-release 1 gateway_input hex=5647493105110200000000000000000000000000000000 valid
|
||||
valid-absolute-mouse 1 gateway_input hex=56474931060804d202370a0005a0 valid
|
||||
valid-scroll 1 gateway_input hex=564749310704ff880078 valid
|
||||
valid-idr 1 gateway_feedback hex=5647463100010000 valid
|
||||
valid-fec 1 gateway_feedback hex=56474631000200150000002a000500030002000a000200080002140001 valid
|
||||
valid-terminal-receipt 1 gateway_feedback hex=5647463100030000 valid
|
||||
valid-termination 1 gateway_feedback hex=564746310110000400000001 valid
|
||||
valid-rumble 1 gateway_feedback hex=56474631011100050112345678 valid
|
||||
valid-hdr 1 gateway_feedback hex=564746310112000101 valid
|
||||
invalid-input-magic 1 gateway_input hex=494e503101040102001e invalid:magic
|
||||
invalid-input-kind 1 gateway_input hex=564749317f00 invalid:kind
|
||||
invalid-input-reserved 1 gateway_input hex=564749310203010101 invalid:reserved
|
||||
invalid-input-utf8 1 gateway_input hex=564749310402c328 invalid:utf8
|
||||
invalid-input-length 1 gateway_input hex=564749310104010200 invalid:length
|
||||
invalid-absolute-zero-viewport 1 gateway_input hex=56474931060800000000000005a0 invalid:field
|
||||
invalid-absolute-x-out-of-range 1 gateway_input hex=5647493106080a0000000a0005a0 invalid:field
|
||||
invalid-absolute-y-out-of-range 1 gateway_input hex=564749310608000005a00a0005a0 invalid:field
|
||||
invalid-absolute-length 1 gateway_input hex=56474931060700000000010001 invalid:length
|
||||
invalid-scroll-length 1 gateway_input hex=5647493107020000 invalid:length
|
||||
invalid-feedback-direction 1 gateway_feedback hex=5647463101020000 invalid:direction
|
||||
invalid-terminal-receipt-direction 1 gateway_feedback hex=5647463101030000 invalid:direction
|
||||
invalid-terminal-receipt-body 1 gateway_feedback hex=5647463100030001ff invalid:length
|
||||
invalid-terminal-receipt-truncated 1 gateway_feedback hex=56474631000300 invalid:truncated
|
||||
invalid-terminal-receipt-length 1 gateway_feedback hex=5647463100030001 invalid:length
|
||||
invalid-feedback-type 1 gateway_feedback hex=5647463100040000 invalid:type
|
||||
invalid-feedback-length 1 gateway_feedback hex=5647463101100003000000 invalid:length
|
||||
|
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"algorithm": "sha256(path\\0bytes\\0 sorted by path)",
|
||||
"files": [
|
||||
"fixtures/conformance/control-v1.tsv",
|
||||
"fixtures/conformance/datagram-v1.tsv",
|
||||
"fixtures/conformance/datagram-v2.tsv",
|
||||
"fixtures/conformance/device-proof-v1.tsv",
|
||||
"fixtures/conformance/events-v1.tsv",
|
||||
"fixtures/conformance/gateway-clipboard-audit-v1.tsv",
|
||||
"fixtures/conformance/gateway-clipboard-v1.tsv",
|
||||
"fixtures/conformance/gateway-input-feedback-v1.tsv",
|
||||
"fixtures/conformance/tunnel-v1.tsv"
|
||||
],
|
||||
"corpus_sha256": "6d2ce3a855b2fa45733a5f7b5b4c2e68448cceed5dfbca535ec81fe8cf230b30"
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
id version kind input expected
|
||||
tunnel-current 2 tunnel offered=2;feature=control.v2 valid
|
||||
tunnel-n-minus-1 1 tunnel offered=1;feature=control.v1 valid
|
||||
tunnel-n-minus-2 0 tunnel offered=0;feature=control.v1 valid
|
||||
tunnel-display-request 2 tunnel offered=2;feature=display.request.v1 valid
|
||||
tunnel-absolute-input 2 tunnel offered=2;feature=input.absolute.v1 valid
|
||||
tunnel-scroll-input 2 tunnel offered=2;feature=input.scroll.v1 valid
|
||||
tunnel-unsupported 2 tunnel offered=3;feature=control.v2 invalid:unsupported_version
|
||||
tunnel-no-control 2 tunnel offered=2;feature=media.video invalid:unsupported_feature
|
||||
|
File diff suppressed because it is too large
Load Diff
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)
|
||||
BUILDER="$ROOT/core/scripts/build-xcframework.sh"
|
||||
EXPECTED_EXPORTS='_verse_core_abi_version
|
||||
_verse_core_cancel_v1
|
||||
_verse_core_connect_v1
|
||||
_verse_core_create_v1
|
||||
_verse_core_destroy_v1
|
||||
_verse_core_request_idr_v1
|
||||
_verse_core_send_input_v1'
|
||||
|
||||
test "$(uname -s)" = Darwin
|
||||
test "$(uname -m)" = arm64
|
||||
test -x "$BUILDER"
|
||||
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/versevdi-core-package.XXXXXX")
|
||||
trap 'rm -rf "$WORK"' EXIT HUP INT TERM
|
||||
|
||||
expect_path_rejected() {
|
||||
error=$1
|
||||
shift
|
||||
if "$BUILDER" "$@" >"$WORK/path.stdout" 2>"$WORK/path.stderr"; then
|
||||
echo "unsafe packaging path was accepted" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F "$error" "$WORK/path.stderr" >/dev/null
|
||||
}
|
||||
|
||||
mkdir "$WORK/parents" "$WORK/physical-parent"
|
||||
mkdir "$WORK/existing-output"
|
||||
expect_path_rejected "must not exist" \
|
||||
--output "$WORK/existing-output" --target-dir "$WORK/parents/existing-target"
|
||||
ln -s "$WORK/physical-parent" "$WORK/parent-alias"
|
||||
ln -s "$WORK/physical-parent" "$WORK/second-parent-alias"
|
||||
ln -s "$WORK/missing" "$WORK/dangling-output"
|
||||
expect_path_rejected "must not exist" \
|
||||
--output "$WORK/dangling-output" --target-dir "$WORK/parents/dangling-target"
|
||||
expect_path_rejected "must be separate" \
|
||||
--output "$WORK/parents/same" --target-dir "$WORK/parents/same"
|
||||
expect_path_rejected "must be separate" \
|
||||
--output "$WORK/parent-alias/aliased-same" \
|
||||
--target-dir "$WORK/second-parent-alias/aliased-same"
|
||||
ln -s "$ROOT" "$WORK/repository-alias"
|
||||
expect_path_rejected "outside the repository" \
|
||||
--output "$WORK/repository-alias/core/forbidden-output" \
|
||||
--target-dir "$WORK/parents/repository-alias-target"
|
||||
|
||||
(umask 022 && "$BUILDER" --output "$WORK/one" --target-dir "$WORK/target-one")
|
||||
mkdir "$WORK/hostile-bin"
|
||||
mkdir -p "$WORK/hostile-cargo-home" "$WORK/hostile-home/.cargo"
|
||||
ln -s /usr/bin/false "$WORK/hostile-bin/cargo"
|
||||
ln -s /usr/bin/false "$WORK/hostile-bin/rustup"
|
||||
ln -s /usr/bin/false "$WORK/hostile-bin/xcodebuild"
|
||||
cat >"$WORK/hostile-cargo-home/config.toml" <<'EOF'
|
||||
[build]
|
||||
rustc-wrapper = "/usr/bin/false"
|
||||
EOF
|
||||
cat >"$WORK/hostile-home/.cargo/config.toml" <<'EOF'
|
||||
[build]
|
||||
rustc = "/usr/bin/false"
|
||||
EOF
|
||||
(
|
||||
umask 077
|
||||
env \
|
||||
AR=/usr/bin/false \
|
||||
CARGO_BUILD_RUSTC=/usr/bin/false \
|
||||
CARGO_BUILD_RUSTC_WRAPPER=/usr/bin/false \
|
||||
CARGO_ENCODED_RUSTFLAGS=--cfghostile \
|
||||
CARGO_HOME="$WORK/hostile-cargo-home" \
|
||||
CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/false \
|
||||
CC=/usr/bin/false \
|
||||
DEVELOPER_DIR="$WORK/hostile-xcode" \
|
||||
HOME="$WORK/hostile-home" \
|
||||
PATH="$WORK/hostile-bin:/usr/bin:/bin" \
|
||||
RUSTC=/usr/bin/false \
|
||||
RUSTC_WRAPPER=/usr/bin/false \
|
||||
RUSTFLAGS=--cfg=hostile \
|
||||
RUSTUP_HOME="$WORK/hostile-rustup-home" \
|
||||
RUSTUP_TOOLCHAIN=bogus \
|
||||
"$BUILDER" \
|
||||
--output "$WORK/parent-alias/two" \
|
||||
--target-dir "$WORK/parents/target-two"
|
||||
)
|
||||
|
||||
framework_one="$WORK/one/VerseVDICore.xcframework"
|
||||
framework_two="$WORK/physical-parent/two/VerseVDICore.xcframework"
|
||||
library_one=$(find "$framework_one" -type f -name libversevdi_core.a -print)
|
||||
library_two=$(find "$framework_two" -type f -name libversevdi_core.a -print)
|
||||
test "$(printf '%s\n' "$library_one" | grep -c .)" -eq 1
|
||||
test "$(printf '%s\n' "$library_two" | grep -c .)" -eq 1
|
||||
|
||||
platform=$(/usr/libexec/PlistBuddy -c 'Print :AvailableLibraries:0:SupportedPlatform' "$framework_one/Info.plist")
|
||||
architecture=$(/usr/libexec/PlistBuddy -c 'Print :AvailableLibraries:0:SupportedArchitectures:0' "$framework_one/Info.plist")
|
||||
available_libraries=$(/usr/libexec/PlistBuddy -c 'Print :AvailableLibraries' "$framework_one/Info.plist")
|
||||
test "$platform" = macos
|
||||
test "$architecture" = arm64
|
||||
test "$(printf '%s\n' "$available_libraries" | grep -c 'Dict {')" -eq 1
|
||||
library_archs=$(xcrun lipo -archs "$library_one")
|
||||
library_identity=$(file "$library_one")
|
||||
test "$library_archs" = arm64
|
||||
printf '%s\n' "$library_identity" | grep -F 'current ar archive' >/dev/null
|
||||
|
||||
consumer="$WORK/consumer"
|
||||
link_consumer() {
|
||||
output=$1
|
||||
library=$2
|
||||
xcrun clang \
|
||||
-arch arm64 \
|
||||
-mmacosx-version-min=14.0 \
|
||||
-std=c11 \
|
||||
-Wall -Wextra -Werror -Wpedantic \
|
||||
-fmodules \
|
||||
-fmodules-cache-path="$WORK/module-cache" \
|
||||
-I"$(dirname "$library_one")/Headers" \
|
||||
"$ROOT/core/tests/ffi/abi_smoke.c" \
|
||||
-Wl,-force_load,"$library" \
|
||||
-framework Security \
|
||||
-framework SystemConfiguration \
|
||||
-framework CoreFoundation \
|
||||
-lresolv \
|
||||
-o "$output"
|
||||
}
|
||||
|
||||
link_consumer "$consumer" "$library_one"
|
||||
consumer_archs=$(xcrun lipo -archs "$consumer")
|
||||
consumer_identity=$(file "$consumer")
|
||||
test "$consumer_archs" = arm64
|
||||
printf '%s\n' "$consumer_identity" | grep -F 'Mach-O 64-bit executable arm64' >/dev/null
|
||||
|
||||
symbols=$(xcrun nm -gjU "$consumer")
|
||||
actual_exports=$(printf '%s\n' "$symbols" | grep '^_verse_core_' | LC_ALL=C sort -u)
|
||||
test "$actual_exports" = "$EXPECTED_EXPORTS"
|
||||
|
||||
cat >"$WORK/unexpected.c" <<'EOF'
|
||||
void verse_core_unexpected_v1(void) {}
|
||||
EOF
|
||||
xcrun clang -arch arm64 -mmacosx-version-min=14.0 -c "$WORK/unexpected.c" -o "$WORK/unexpected.o"
|
||||
cp "$library_one" "$WORK/libunexpected.a"
|
||||
ZERO_AR_DATE=1 xcrun ar -r "$WORK/libunexpected.a" "$WORK/unexpected.o"
|
||||
link_consumer "$WORK/unexpected-consumer" "$WORK/libunexpected.a"
|
||||
unexpected_symbols=$(xcrun nm -gjU "$WORK/unexpected-consumer")
|
||||
if test "$(printf '%s\n' "$unexpected_symbols" | grep '^_verse_core_' | LC_ALL=C sort -u)" = "$EXPECTED_EXPORTS"; then
|
||||
echo "force-loaded export inspection missed an unreferenced ABI symbol" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dependencies=$(xcrun otool -L "$consumer")
|
||||
unexpected_dependencies=$(printf '%s\n' "$dependencies" | tail -n +2 | awk '{print $1}' | grep -Ev '^(/usr/lib/(libSystem\.B|libresolv\.9)\.dylib|/System/Library/Frameworks/(CoreFoundation|Security|SystemConfiguration)\.framework/Versions/A/[^/]+)$' || true)
|
||||
test -z "$unexpected_dependencies"
|
||||
|
||||
"$consumer"
|
||||
i=0
|
||||
pids=
|
||||
while test "$i" -lt 32; do
|
||||
"$consumer" &
|
||||
pids="$pids $!"
|
||||
i=$((i + 1))
|
||||
done
|
||||
for pid in $pids; do
|
||||
wait "$pid"
|
||||
done
|
||||
|
||||
canonical_tree() {
|
||||
(
|
||||
cd "$1"
|
||||
find . -print >"$WORK/tree.entries"
|
||||
LC_ALL=C sort "$WORK/tree.entries" >"$WORK/tree.sorted"
|
||||
while IFS= read -r path; do
|
||||
metadata=$(stat -f '%HT|%Sp' "$path")
|
||||
case "$metadata" in
|
||||
'Regular File|'*) digest=$(sha256_file "$path") ;;
|
||||
'Symbolic Link|'*) digest=$(readlink "$path") ;;
|
||||
*) digest=- ;;
|
||||
esac
|
||||
printf '%s|%s|%s\n' "$metadata" "$digest" "$path"
|
||||
done <"$WORK/tree.sorted"
|
||||
)
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
checksum=$(shasum -a 256 "$1")
|
||||
set -- $checksum
|
||||
printf '%s\n' "$1"
|
||||
}
|
||||
|
||||
canonical_tree "$framework_one" >"$WORK/one.tree"
|
||||
canonical_tree "$framework_two" >"$WORK/two.tree"
|
||||
cmp "$WORK/one.tree" "$WORK/two.tree"
|
||||
canonical_tree "$WORK/one" >"$WORK/one.output-tree"
|
||||
canonical_tree "$WORK/physical-parent/two" >"$WORK/two.output-tree"
|
||||
cmp "$WORK/one.output-tree" "$WORK/two.output-tree"
|
||||
for directory in \
|
||||
"$WORK/one" \
|
||||
"$WORK/target-one" \
|
||||
"$framework_one" \
|
||||
"$WORK/physical-parent/two" \
|
||||
"$WORK/parents/target-two" \
|
||||
"$framework_two"; do
|
||||
test "$(stat -f '%Sp' "$directory")" = drwxr-xr-x
|
||||
done
|
||||
test "$(sha256_file "$library_one")" = "$(sha256_file "$library_two")"
|
||||
|
||||
source_epoch=$(git -C "$ROOT" show -s --format=%ct HEAD)
|
||||
find "$WORK/one" "$WORK/physical-parent/two" -exec stat -f '%m' {} \; >"$WORK/mtimes"
|
||||
while IFS= read -r epoch; do
|
||||
test "$epoch" = "$source_epoch"
|
||||
done <"$WORK/mtimes"
|
||||
|
||||
expected_rustc='rustc 1.97.1 (8bab26f4f 2026-07-14)'
|
||||
expected_cargo='cargo 1.97.1 (c980f4866 2026-06-30)'
|
||||
expected_xcode='Xcode 26.6
|
||||
Build version 17F113'
|
||||
grep -Fx "rustc=$expected_rustc" "$WORK/one/build-environment.txt" >/dev/null
|
||||
grep -Fx "cargo=$expected_cargo" "$WORK/one/build-environment.txt" >/dev/null
|
||||
test "$(sed -n 's/^xcode=//p; /^Build version /p' "$WORK/one/build-environment.txt")" = "$expected_xcode"
|
||||
cmp "$WORK/one/build-environment.txt" "$WORK/physical-parent/two/build-environment.txt"
|
||||
|
||||
strings "$library_one" >"$WORK/library.strings"
|
||||
if grep -F "$ROOT" "$WORK/library.strings" >/dev/null; then
|
||||
echo "repository path leaked into static archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -F "$WORK/target-one" "$WORK/library.strings" >/dev/null; then
|
||||
echo "target directory path leaked into static archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive_members=$(xcrun ar -tv "$library_one")
|
||||
printf '%s\n' "$archive_members" | awk '$7 != "1970" { exit 1 }'
|
||||
|
||||
printf 'framework_sha256=%s\n' "$(sha256_file "$WORK/one.tree")"
|
||||
printf 'library_sha256=%s\n' "$(sha256_file "$library_one")"
|
||||
@@ -0,0 +1,651 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use versevdi_core::input::{
|
||||
decode_feedback, decode_input, encode_feedback, encode_input, FecStatus, FeedbackEvent,
|
||||
};
|
||||
use versevdi_core::media::{MediaFragment, Reassembler};
|
||||
use versevdi_core::wire::{
|
||||
CapabilityProfile, ClientSessionAuthority, ConnectionManifest, NativeTunnelCredential,
|
||||
TunnelAdmissionRequest,
|
||||
};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_protocol_rc5_fixtures_have_immutable_hashes() {
|
||||
let cases = [
|
||||
(
|
||||
"tunnel-v1.tsv",
|
||||
"31a884800031c17844b9a8789702cf0d3639838ccfeb819c2bc3f0a5462ac5df",
|
||||
),
|
||||
(
|
||||
"datagram-v2.tsv",
|
||||
"65b9f6f018af624033562a78aae0b685df331c5e857d5f3d5d415aefa9d5b97b",
|
||||
),
|
||||
(
|
||||
"gateway-input-feedback-v1.tsv",
|
||||
"91b4dc1eb756476637ea7deebcb297256cb56d8e9c3d635088511af6b8914bda",
|
||||
),
|
||||
(
|
||||
"manifest.json",
|
||||
"76c33b33864d85f7d4a3761798eab52ffadbbe5fc313cf4e6e03369cfdc8df9a",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, expected) in cases {
|
||||
let output = Command::new("shasum")
|
||||
.args(["-a", "256"])
|
||||
.arg(fixture(name))
|
||||
.output()
|
||||
.expect("shasum must be installed for fixture verification");
|
||||
assert!(output.status.success(), "shasum failed for {name}");
|
||||
let actual = String::from_utf8(output.stdout).expect("shasum output is UTF-8");
|
||||
assert_eq!(&actual[..64], expected, "fixture drifted: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_manifest() -> &'static [u8] {
|
||||
br#"{
|
||||
"version":"1","purpose":"launch","session_id":"session","reconnect_sequence":0,
|
||||
"gateway":{"id":"gateway","addresses":["gateway.test:443"],"public_identity":"gateway.test"},
|
||||
"tunnel":{"versions":["verse-gateway-v1/1"],"features":["control.v1","input.absolute.v1","input.scroll.v1"]},
|
||||
"profile":{"id":"standard","bounds":{"minimum_kbps":1000,"target_kbps":5000,"maximum_kbps":10000},"display_mode":{"resolution_width":1920,"resolution_height":1080,"fps":60}},
|
||||
"grant":{"opaque_value":"ggggggggggggggggggggggggggggggggggggggggggg","expires_at":"2099-01-01T00:00:00Z","audience":"audience"},
|
||||
"correlation_id":"correlation"
|
||||
}"#
|
||||
}
|
||||
|
||||
fn capabilities() -> CapabilityProfile {
|
||||
CapabilityProfile::new(
|
||||
"quic-tls13",
|
||||
"datagram-v2",
|
||||
"encoded",
|
||||
"encoded",
|
||||
"server",
|
||||
vec!["h264-opus".to_owned(), "hevc-opus".to_owned()],
|
||||
)
|
||||
.expect("literal capability profile is valid")
|
||||
}
|
||||
|
||||
const VALID_CERTIFICATE_PEM: &str = "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----";
|
||||
|
||||
fn credential_json(certificate_chain_pem: &str, trust_bundle_pem: &str) -> Vec<u8> {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"client_device_id": "device",
|
||||
"device_key_id": "key",
|
||||
"certificate_chain_pem": certificate_chain_pem,
|
||||
"trust_bundle_pem": trust_bundle_pem,
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
}))
|
||||
.expect("literal credential is JSON-encodable")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() {
|
||||
assert!(ConnectionManifest::decode(valid_manifest()).is_ok());
|
||||
assert!(
|
||||
ConnectionManifest::decode(br#"{"version":"1","version":"1","purpose":"launch"}"#).is_err()
|
||||
);
|
||||
|
||||
let trailing = [valid_manifest(), b" {}"].concat();
|
||||
assert!(ConnectionManifest::decode(&trailing).is_err());
|
||||
|
||||
let unknown = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replacen(
|
||||
"\"correlation_id\"",
|
||||
"\"unknown\":true,\"correlation_id\"",
|
||||
1,
|
||||
);
|
||||
assert!(ConnectionManifest::decode(unknown.as_bytes()).is_err());
|
||||
|
||||
let provider = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replacen(
|
||||
"\"addresses\"",
|
||||
"\"providerIdentity\":\"hidden\",\"addresses\"",
|
||||
1,
|
||||
);
|
||||
assert!(ConnectionManifest::decode(provider.as_bytes()).is_err());
|
||||
|
||||
let authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#;
|
||||
let authority_trailing = [authority.as_slice(), b" {}"].concat();
|
||||
assert!(ClientSessionAuthority::decode(&authority_trailing).is_err());
|
||||
let authority_duplicate = String::from_utf8(authority.to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replacen(
|
||||
"\"session_id\":",
|
||||
"\"session_id\":\"duplicate\",\"session_id\":",
|
||||
1,
|
||||
);
|
||||
assert!(ClientSessionAuthority::decode(authority_duplicate.as_bytes()).is_err());
|
||||
|
||||
let admission_provider = format!(
|
||||
r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","provider_identity":"forbidden","capabilities":{{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#,
|
||||
"g".repeat(43),
|
||||
"n".repeat(16),
|
||||
"A".repeat(86),
|
||||
);
|
||||
assert!(TunnelAdmissionRequest::decode(admission_provider.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_public_identity_requires_dns_sni_not_ip_or_uuid() {
|
||||
for invalid_identity in ["127.0.0.1", "::1", "550e8400-e29b-41d4-a716-446655440000"] {
|
||||
let manifest = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replace("gateway.test\"}", &format!("{invalid_identity}\"}}"));
|
||||
assert!(
|
||||
ConnectionManifest::decode(manifest.as_bytes()).is_err(),
|
||||
"non-DNS SNI accepted: {invalid_identity}"
|
||||
);
|
||||
}
|
||||
|
||||
let identity_equals_dns_shaped_gateway_id = String::from_utf8(valid_manifest().to_vec())
|
||||
.expect("fixture is UTF-8")
|
||||
.replace("\"id\":\"gateway\"", "\"id\":\"gateway.test\"");
|
||||
assert!(
|
||||
ConnectionManifest::decode(identity_equals_dns_shaped_gateway_id.as_bytes()).is_err(),
|
||||
"public SNI identity matched the logical gateway id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() {
|
||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||
manifest
|
||||
.validate_at("2026-08-12T00:00:00Z")
|
||||
.expect("unexpired manifest");
|
||||
|
||||
let credential = NativeTunnelCredential::decode(&credential_json(
|
||||
VALID_CERTIFICATE_PEM,
|
||||
VALID_CERTIFICATE_PEM,
|
||||
))
|
||||
.expect("valid credential");
|
||||
credential
|
||||
.validate_at("2026-08-12T00:00:00Z")
|
||||
.expect("unexpired credential");
|
||||
assert!(NativeTunnelCredential::decode(
|
||||
br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","trust_bundle_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","client_private_key_pem":"forbidden","expires_at":"2099-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let authority = ClientSessionAuthority::decode(
|
||||
br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#,
|
||||
)
|
||||
.expect("valid client-safe authority");
|
||||
authority
|
||||
.validate_binding(&manifest, &capabilities(), "2026-08-12T00:00:00Z")
|
||||
.expect("authority is bound and is a capability subset");
|
||||
|
||||
let provider_authority = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"provider_profile":"apollo"}"#;
|
||||
assert!(ClientSessionAuthority::decode(provider_authority).is_err());
|
||||
let provider_route = br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]},"stream_host":"provider.invalid"}"#;
|
||||
assert!(ClientSessionAuthority::decode(provider_route).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_authority_directly_rejects_every_manifest_binding_mismatch() {
|
||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||
let offered = CapabilityProfile::new(
|
||||
"quic-tls13",
|
||||
"datagram-v2",
|
||||
"encoded",
|
||||
"encoded",
|
||||
"server",
|
||||
vec!["h264-opus".to_owned()],
|
||||
)
|
||||
.expect("offered capabilities");
|
||||
let valid = serde_json::json!({
|
||||
"version": "1",
|
||||
"session_id": "session",
|
||||
"gateway_id": "gateway",
|
||||
"audience": "audience",
|
||||
"reconnect_sequence": 0,
|
||||
"expires_at": "2098-01-01T00:00:00Z",
|
||||
"capabilities": {
|
||||
"transport": "quic-tls13",
|
||||
"framing": "datagram-v2",
|
||||
"media": "encoded",
|
||||
"audio": "encoded",
|
||||
"source_rate_control": "server",
|
||||
"client_decode": ["h264-opus"],
|
||||
},
|
||||
});
|
||||
for case in [
|
||||
"session",
|
||||
"gateway",
|
||||
"audience",
|
||||
"reconnect",
|
||||
"expired",
|
||||
"beyond-grant",
|
||||
"capability",
|
||||
] {
|
||||
let mut value = valid.clone();
|
||||
match case {
|
||||
"session" => value["session_id"] = "other".into(),
|
||||
"gateway" => value["gateway_id"] = "other".into(),
|
||||
"audience" => value["audience"] = "other".into(),
|
||||
"reconnect" => value["reconnect_sequence"] = 1.into(),
|
||||
"expired" => value["expires_at"] = "2026-08-12T00:00:00Z".into(),
|
||||
"beyond-grant" => value["expires_at"] = "2100-01-01T00:00:00Z".into(),
|
||||
"capability" => {
|
||||
value["capabilities"]["client_decode"] =
|
||||
serde_json::json!(["h264-opus", "hevc-opus"]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let authority =
|
||||
ClientSessionAuthority::decode(&serde_json::to_vec(&value).expect("encode authority"))
|
||||
.expect("structurally valid authority");
|
||||
assert_eq!(
|
||||
authority.validate_binding(&manifest, &offered, "2026-08-12T00:00:00Z"),
|
||||
Err(versevdi_core::error::CoreError::AuthorityRejected),
|
||||
"binding mismatch accepted: {case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_private_key_pem_in_certificate_fields() {
|
||||
for field in ["certificate_chain_pem", "trust_bundle_pem"] {
|
||||
let private_key = "-----BEGIN PRIVATE KEY-----\nAQID\n-----END PRIVATE KEY-----";
|
||||
let credential = if field == "certificate_chain_pem" {
|
||||
credential_json(private_key, VALID_CERTIFICATE_PEM)
|
||||
} else {
|
||||
credential_json(VALID_CERTIFICATE_PEM, private_key)
|
||||
};
|
||||
assert!(
|
||||
NativeTunnelCredential::decode(&credential).is_err(),
|
||||
"private key armor accepted in {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_accepts_one_or_more_certificate_blocks() {
|
||||
let two_certificates = format!("{VALID_CERTIFICATE_PEM}\n\n{VALID_CERTIFICATE_PEM}\n");
|
||||
assert!(NativeTunnelCredential::decode(&credential_json(
|
||||
&two_certificates,
|
||||
VALID_CERTIFICATE_PEM,
|
||||
))
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
fn assert_credential_pem_rejected(invalid_values: &[&str]) {
|
||||
for invalid in invalid_values {
|
||||
assert!(
|
||||
NativeTunnelCredential::decode(&credential_json(invalid, VALID_CERTIFICATE_PEM))
|
||||
.is_err(),
|
||||
"invalid certificate chain accepted"
|
||||
);
|
||||
assert!(
|
||||
NativeTunnelCredential::decode(&credential_json(VALID_CERTIFICATE_PEM, invalid))
|
||||
.is_err(),
|
||||
"invalid trust bundle accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_bare_certificate_text() {
|
||||
assert_credential_pem_rejected(&["certificate"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_non_certificate_pem_labels() {
|
||||
assert_credential_pem_rejected(&["-----BEGIN PUBLIC KEY-----\nAQID\n-----END PUBLIC KEY-----"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_malformed_or_incomplete_certificate_armor() {
|
||||
assert_credential_pem_rejected(&[
|
||||
"-----BEGIN CERTIFICATE-----\nAQID",
|
||||
"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----",
|
||||
"-----BEGIN CERTIFICATE-----\nAQI\n-----END CERTIFICATE-----",
|
||||
"-----BEGIN CERTIFICATE-----\nAQJ=\n-----END CERTIFICATE-----",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_junk_between_or_after_certificate_blocks() {
|
||||
let between = format!("{VALID_CERTIFICATE_PEM}\njunk\n{VALID_CERTIFICATE_PEM}");
|
||||
let after = format!("{VALID_CERTIFICATE_PEM}\njunk");
|
||||
assert_credential_pem_rejected(&[&between, &after]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_tunnel_credential_rejects_empty_certificate_blocks() {
|
||||
assert_credential_pem_rejected(&["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_mismatch_and_capability_escalation_are_rejected() {
|
||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||
assert!(manifest.validate_at("2100-01-01T00:00:00Z").is_err());
|
||||
let credential = NativeTunnelCredential::decode(&credential_json(
|
||||
VALID_CERTIFICATE_PEM,
|
||||
VALID_CERTIFICATE_PEM,
|
||||
))
|
||||
.expect("valid credential");
|
||||
assert!(credential.validate_at("2099-01-01T00:00:00Z").is_err());
|
||||
|
||||
let mismatched = ClientSessionAuthority::decode(
|
||||
br#"{"version":"1","session_id":"other","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}"#,
|
||||
)
|
||||
.expect("shape is valid");
|
||||
assert!(mismatched
|
||||
.validate_binding(&manifest, &capabilities(), "2026-08-12T00:00:00Z")
|
||||
.is_err());
|
||||
|
||||
let escalated = ClientSessionAuthority::decode(
|
||||
br#"{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","reconnect_sequence":0,"expires_at":"2098-01-01T00:00:00Z","capabilities":{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus","hevc-opus"]}}"#,
|
||||
)
|
||||
.expect("shape is valid");
|
||||
let h264_only = CapabilityProfile::new(
|
||||
"quic-tls13",
|
||||
"datagram-v2",
|
||||
"encoded",
|
||||
"encoded",
|
||||
"server",
|
||||
vec!["h264-opus".to_owned()],
|
||||
)
|
||||
.expect("literal capability profile");
|
||||
assert!(escalated
|
||||
.validate_binding(&manifest, &h264_only, "2026-08-12T00:00:00Z")
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_transcript_matches_rc5_literal() {
|
||||
let request = TunnelAdmissionRequest::decode(
|
||||
format!(
|
||||
r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","capabilities":{{"transport":"quic-tls13","framing":"datagram-v1","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#,
|
||||
"g".repeat(43),
|
||||
"n".repeat(16),
|
||||
"A".repeat(86),
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("valid admission request");
|
||||
|
||||
assert_eq!(
|
||||
request.admission_transcript(),
|
||||
format!(
|
||||
"versevdi/tunnel-admission/v17:session7:gateway8:audience43:{}1:016:{}10:quic-tls1311:datagram-v17:encoded7:encoded6:server1:19:h264-opus",
|
||||
"g".repeat(43),
|
||||
"n".repeat(16),
|
||||
)
|
||||
.into_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_non_raw_base64url_nonce_and_signature() {
|
||||
for (nonce, signature) in [
|
||||
("!".repeat(16), "A".repeat(86)),
|
||||
("A".repeat(17), "A".repeat(86)),
|
||||
("A".repeat(16), "!".repeat(86)),
|
||||
("A".repeat(16), format!("{}B", "A".repeat(85))),
|
||||
] {
|
||||
let request = format!(
|
||||
r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","capabilities":{{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#,
|
||||
"g".repeat(43),
|
||||
nonce,
|
||||
signature,
|
||||
);
|
||||
assert!(TunnelAdmissionRequest::decode(request.as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_hex(value: &str) -> Vec<u8> {
|
||||
value
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.map(|pair| {
|
||||
let text = std::str::from_utf8(pair).expect("fixture hex is ASCII");
|
||||
u8::from_str_radix(text, 16).expect("fixture hex is valid")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fixture_rows(name: &str) -> impl Iterator<Item = Vec<&'static str>> {
|
||||
let data = match name {
|
||||
"datagram-v2.tsv" => include_str!("fixtures/datagram-v2.tsv"),
|
||||
"gateway-input-feedback-v1.tsv" => {
|
||||
include_str!("fixtures/gateway-input-feedback-v1.tsv")
|
||||
}
|
||||
_ => unreachable!("known fixture"),
|
||||
};
|
||||
data.lines()
|
||||
.skip(1)
|
||||
.map(|line| line.split('\t').collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_v2_codec_matches_all_rc5_conformance_rows() {
|
||||
for row in fixture_rows("datagram-v2.tsv") {
|
||||
let bytes = decode_hex(row[3].strip_prefix("hex=").expect("hex fixture"));
|
||||
match row[4] {
|
||||
"valid" => {
|
||||
let fragment = MediaFragment::decode(&bytes).expect(row[0]);
|
||||
assert_eq!(fragment.encode().expect(row[0]), bytes, "{}", row[0]);
|
||||
}
|
||||
error => assert_eq!(
|
||||
MediaFragment::decode(&bytes).expect_err(row[0]).code(),
|
||||
error.strip_prefix("invalid:").expect("invalid fixture"),
|
||||
"{}",
|
||||
row[0]
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vgi1_and_vgf1_codecs_match_all_rc5_conformance_rows() {
|
||||
let features = ["input.absolute.v1", "input.scroll.v1"];
|
||||
for row in fixture_rows("gateway-input-feedback-v1.tsv") {
|
||||
let bytes = decode_hex(row[3].strip_prefix("hex=").expect("hex fixture"));
|
||||
let result = match row[2] {
|
||||
"gateway_input" => {
|
||||
decode_input(&bytes, &features).and_then(|event| encode_input(&event, &features))
|
||||
}
|
||||
"gateway_feedback" => decode_feedback(&bytes).and_then(|event| encode_feedback(&event)),
|
||||
_ => unreachable!("known fixture kind"),
|
||||
};
|
||||
match row[4] {
|
||||
"valid" => assert_eq!(result.expect(row[0]), bytes, "{}", row[0]),
|
||||
error => assert_eq!(
|
||||
result.expect_err(row[0]).code(),
|
||||
error.strip_prefix("invalid:").expect("invalid fixture"),
|
||||
"{}",
|
||||
row[0]
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_v2_reassembly_is_bounded_reordered_and_duplicate_safe() {
|
||||
let first = MediaFragment::new_video(7, 42, 0, 2, b"hello".to_vec()).expect("fragment");
|
||||
let second = MediaFragment::new_video(7, 42, 1, 2, b" world".to_vec()).expect("fragment");
|
||||
let mut reassembler = Reassembler::new();
|
||||
|
||||
assert!(reassembler
|
||||
.push(second.clone(), 10)
|
||||
.expect("second")
|
||||
.is_none());
|
||||
assert!(reassembler.push(second, 11).expect("duplicate").is_none());
|
||||
let unit = reassembler
|
||||
.push(first, 12)
|
||||
.expect("first")
|
||||
.expect("complete unit");
|
||||
assert_eq!(unit.payload, b"hello world");
|
||||
assert_eq!(reassembler.incomplete_units(), 0);
|
||||
|
||||
for sequence in 0..5 {
|
||||
let fragment = MediaFragment::new_video(sequence, 1, 0, 2, vec![0]).expect("fragment");
|
||||
assert!(reassembler.push(fragment, 20 + u64::from(sequence)).is_ok());
|
||||
}
|
||||
assert_eq!(reassembler.incomplete_units(), 4);
|
||||
assert_eq!(reassembler.evicted_units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_v2_boundary_loop_covers_payload_fragment_count_and_audio() {
|
||||
for payload_length in [0, 1, 1_177] {
|
||||
let fragment = MediaFragment::new_audio(
|
||||
u32::try_from(payload_length).expect("small"),
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
vec![0; payload_length],
|
||||
)
|
||||
.expect("boundary is valid");
|
||||
assert_eq!(
|
||||
MediaFragment::decode(&fragment.encode().expect("encode")).expect("decode"),
|
||||
fragment
|
||||
);
|
||||
}
|
||||
assert!(MediaFragment::new_audio(1, 1, 0, 1, vec![0; 1_178]).is_err());
|
||||
assert!(MediaFragment::new_audio(1, 1, 0, 0, Vec::new()).is_err());
|
||||
assert!(MediaFragment::new_audio(1, 1, 0, 892, Vec::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembly_discards_conflicts_and_expires_after_250_ms() {
|
||||
let mut reassembler = Reassembler::new();
|
||||
let first = MediaFragment::new_video(1, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(first, 0).expect("first");
|
||||
let conflict = MediaFragment::new_video(1, 1, 0, 2, vec![2]).expect("fragment");
|
||||
assert!(reassembler.push(conflict, 1).is_err());
|
||||
assert_eq!(reassembler.incomplete_units(), 0);
|
||||
|
||||
let expiring = MediaFragment::new_video(2, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(expiring, 10).expect("first");
|
||||
let next = MediaFragment::new_video(3, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(next, 261).expect("expiry sweep");
|
||||
assert_eq!(reassembler.expired_units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembly_expires_at_exactly_250_ms() {
|
||||
let mut reassembler = Reassembler::new();
|
||||
let expiring = MediaFragment::new_video(2, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(expiring, 10).expect("first");
|
||||
let next = MediaFragment::new_video(3, 1, 0, 2, vec![1]).expect("fragment");
|
||||
reassembler.push(next, 260).expect("expiry sweep");
|
||||
assert_eq!(reassembler.expired_units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassembly_rejects_a_complete_unit_above_one_mebibyte() {
|
||||
let mut reassembler = Reassembler::new();
|
||||
for index in 0..891_u16 {
|
||||
let fragment = MediaFragment::new_video(9, 1, index, 891, vec![0; 1_177])
|
||||
.expect("individual fragment is bounded");
|
||||
let result = reassembler.push(fragment, 0);
|
||||
if index < 890 {
|
||||
assert!(result.expect("within aggregate bound").is_none());
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(reassembler.incomplete_units(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_features_and_feedback_booleans_fail_closed() {
|
||||
assert_eq!(
|
||||
decode_input(&decode_hex("56474931060804d202370a0005a0"), &[])
|
||||
.expect_err("absolute feature is required")
|
||||
.code(),
|
||||
"unsupported_feature"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_feedback(&decode_hex("564746310112000102"))
|
||||
.expect_err("HDR is boolean")
|
||||
.code(),
|
||||
"length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_gateway_feedback_is_provider_free_control() {
|
||||
assert_eq!(
|
||||
decode_feedback(&decode_hex("5647463101130000")).expect("disconnected feedback"),
|
||||
FeedbackEvent::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fec_feedback_enforces_rc5_go_field_invariants_on_decode_and_encode() {
|
||||
for invalid in [
|
||||
"56474631000200150000002a0005000300020000000200080002140001",
|
||||
"56474631000200150000002a000500030002000a0002000b0002140001",
|
||||
"56474631000200150000002a000500030002000a000200080003140001",
|
||||
"56474631000200150000002a000500030002000a000200080002650001",
|
||||
"56474631000200150000002a000500030002000a000200080002140000",
|
||||
"56474631000200150000002a000500030002000a000200080002140101",
|
||||
] {
|
||||
assert_eq!(
|
||||
decode_feedback(&decode_hex(invalid))
|
||||
.expect_err("invalid FEC status")
|
||||
.code(),
|
||||
"field"
|
||||
);
|
||||
}
|
||||
|
||||
let valid = FecStatus {
|
||||
frame_index: 42,
|
||||
highest_received_sequence: 5,
|
||||
next_contiguous_sequence: 3,
|
||||
missing_before_highest: 2,
|
||||
total_data_packets: 10,
|
||||
total_parity_packets: 2,
|
||||
received_data_packets: 8,
|
||||
received_parity_packets: 2,
|
||||
fec_percentage: 20,
|
||||
multi_fec_block_index: 0,
|
||||
multi_fec_block_count: 1,
|
||||
};
|
||||
let invalid = [
|
||||
FecStatus {
|
||||
total_data_packets: 0,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
received_data_packets: 11,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
received_parity_packets: 3,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
fec_percentage: 101,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
multi_fec_block_count: 0,
|
||||
..valid.clone()
|
||||
},
|
||||
FecStatus {
|
||||
multi_fec_block_index: 1,
|
||||
..valid
|
||||
},
|
||||
];
|
||||
for status in invalid {
|
||||
assert_eq!(
|
||||
encode_feedback(&FeedbackEvent::Fec(status))
|
||||
.expect_err("invalid FEC status")
|
||||
.code(),
|
||||
"field"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -328,13 +329,52 @@ func TestApolloControlWireVectorAndTagFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApolloKeyboardInputWireVector(t *testing.T) {
|
||||
packet, err := encodeApolloInputEvent(InputEvent{Device: "keyboard", Code: 30, Pressed: true, Payload: []byte{2}})
|
||||
packets, err := encodeApolloInputEvent(InputEvent{Device: "keyboard", Code: 30, Pressed: true, Payload: []byte{2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const expected = "0000000a03000000001e00020000"
|
||||
if string(packet.payload) != string(mustDecodeHex(t, expected)) {
|
||||
t.Fatalf("keyboard packet = %x, want %s", packet.payload, expected)
|
||||
if len(packets) != 1 || string(packets[0].payload) != string(mustDecodeHex(t, expected)) {
|
||||
t.Fatalf("keyboard packets = %#v, want %s", packets, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApolloAbsoluteAndScrollInputWireVectors(t *testing.T) {
|
||||
// Independently implemented from the approved Apollo adc5c5a0 input.cpp
|
||||
// consumer and its moonlight-common-c c999436 Input.h/InputStream.c pin.
|
||||
absolute, err := encodeApolloInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0x04, 0xd2, 0x02, 0x37, 0x0a, 0x00, 0x05, 0xa0}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const absoluteExpected = "0000000e0500000004d20237000009ff059f"
|
||||
if len(absolute) != 1 || absolute[0].channel != apolloChannelMouse || string(absolute[0].payload) != string(mustDecodeHex(t, absoluteExpected)) {
|
||||
t.Fatalf("absolute packets = %#v, want %s", absolute, absoluteExpected)
|
||||
}
|
||||
|
||||
scroll, err := encodeApolloInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0x00, 0x78}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const verticalExpected = "0000000a0a000000ff88ff880000"
|
||||
const horizontalExpected = "00000006010000550078"
|
||||
if len(scroll) != 2 || scroll[0].channel != apolloChannelMouse || scroll[1].channel != apolloChannelMouse ||
|
||||
string(scroll[0].payload) != string(mustDecodeHex(t, verticalExpected)) || string(scroll[1].payload) != string(mustDecodeHex(t, horizontalExpected)) {
|
||||
t.Fatalf("scroll packets = %#v", scroll)
|
||||
}
|
||||
|
||||
zero, err := encodeApolloInputEvent(InputEvent{Device: "mouse-scroll", Payload: make([]byte, 4)})
|
||||
if err != nil || len(zero) != 0 {
|
||||
t.Fatalf("zero scroll packets = %#v, %v", zero, err)
|
||||
}
|
||||
|
||||
for _, payload := range [][]byte{
|
||||
{0, 0, 0, 0, 0, 1, 0, 2},
|
||||
{0, 0, 0, 0, 0x80, 0, 0, 2},
|
||||
{0, 0, 0, 0, 0, 2, 0x80, 0},
|
||||
} {
|
||||
if _, err := encodeApolloInputEvent(InputEvent{Device: "mouse-absolute", Payload: payload}); !errors.Is(err, ErrInputMalformed) {
|
||||
t.Fatalf("Apollo accepted unrepresentable absolute payload %x: %v", payload, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+48
-12
@@ -19,11 +19,11 @@ type apolloInputPacket struct {
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
|
||||
func encodeApolloInputEvent(event InputEvent) ([]apolloInputPacket, error) {
|
||||
switch event.Device {
|
||||
case "keyboard":
|
||||
if event.Code < 0 || event.Code > 0xffff || len(event.Payload) > 1 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 14)
|
||||
binary.BigEndian.PutUint32(packet[:4], 10)
|
||||
@@ -36,10 +36,10 @@ func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
|
||||
if len(event.Payload) == 1 {
|
||||
packet[11] = event.Payload[0]
|
||||
}
|
||||
return apolloInputPacket{channel: apolloChannelKeyboard, payload: packet}, nil
|
||||
return []apolloInputPacket{{channel: apolloChannelKeyboard, payload: packet}}, nil
|
||||
case "mouse-button":
|
||||
if event.Code < 1 || event.Code > 8 || len(event.Payload) != 0 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 9)
|
||||
binary.BigEndian.PutUint32(packet[:4], 5)
|
||||
@@ -49,28 +49,28 @@ func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
|
||||
}
|
||||
binary.LittleEndian.PutUint32(packet[4:8], magic)
|
||||
packet[8] = byte(event.Code)
|
||||
return apolloInputPacket{channel: apolloChannelMouse, payload: packet}, nil
|
||||
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
|
||||
case "mouse-relative":
|
||||
if event.Pressed || len(event.Payload) != 4 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(packet[:4], 8)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 7)
|
||||
copy(packet[8:], event.Payload)
|
||||
return apolloInputPacket{channel: apolloChannelMouse, payload: packet}, nil
|
||||
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
|
||||
case "utf8":
|
||||
if event.Pressed || len(event.Payload) == 0 || len(event.Payload) > utf8.UTFMax || !utf8.Valid(event.Payload) || utf8.RuneCount(event.Payload) != 1 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 8+len(event.Payload))
|
||||
binary.BigEndian.PutUint32(packet[:4], uint32(4+len(event.Payload)))
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 0x17)
|
||||
copy(packet[8:], event.Payload)
|
||||
return apolloInputPacket{channel: apolloChannelUTF8, payload: packet}, nil
|
||||
return []apolloInputPacket{{channel: apolloChannelUTF8, payload: packet}}, nil
|
||||
case "controller":
|
||||
if event.Code < 0 || event.Code > 15 || len(event.Payload) != 16 {
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 34)
|
||||
binary.BigEndian.PutUint32(packet[:4], 30)
|
||||
@@ -83,8 +83,44 @@ func encodeApolloInputEvent(event InputEvent) (apolloInputPacket, error) {
|
||||
binary.LittleEndian.PutUint16(packet[28:30], 0x9c)
|
||||
copy(packet[30:32], event.Payload[14:16])
|
||||
binary.LittleEndian.PutUint16(packet[32:34], 0x55)
|
||||
return apolloInputPacket{channel: apolloChannelGamepad + uint8(event.Code), payload: packet}, nil
|
||||
return []apolloInputPacket{{channel: apolloChannelGamepad + uint8(event.Code), payload: packet}}, nil
|
||||
case "mouse-absolute":
|
||||
if event.Pressed || event.Code != 0 || !validAbsolutePayload(event.Payload) {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
width, height := binary.BigEndian.Uint16(event.Payload[4:6]), binary.BigEndian.Uint16(event.Payload[6:8])
|
||||
if width < 2 || height < 2 || width > 0x7fff || height > 0x7fff {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packet := make([]byte, 18)
|
||||
binary.BigEndian.PutUint32(packet[:4], 14)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 5)
|
||||
copy(packet[8:12], event.Payload[:4])
|
||||
binary.BigEndian.PutUint16(packet[14:16], width-1)
|
||||
binary.BigEndian.PutUint16(packet[16:18], height-1)
|
||||
return []apolloInputPacket{{channel: apolloChannelMouse, payload: packet}}, nil
|
||||
case "mouse-scroll":
|
||||
if event.Pressed || event.Code != 0 || len(event.Payload) != 4 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
packets := make([]apolloInputPacket, 0, 2)
|
||||
if event.Payload[0] != 0 || event.Payload[1] != 0 {
|
||||
packet := make([]byte, 14)
|
||||
binary.BigEndian.PutUint32(packet[:4], 10)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 10)
|
||||
copy(packet[8:10], event.Payload[:2])
|
||||
copy(packet[10:12], event.Payload[:2])
|
||||
packets = append(packets, apolloInputPacket{channel: apolloChannelMouse, payload: packet})
|
||||
}
|
||||
if event.Payload[2] != 0 || event.Payload[3] != 0 {
|
||||
packet := make([]byte, 10)
|
||||
binary.BigEndian.PutUint32(packet[:4], 6)
|
||||
binary.LittleEndian.PutUint32(packet[4:8], 0x55000001)
|
||||
copy(packet[8:10], event.Payload[2:4])
|
||||
packets = append(packets, apolloInputPacket{channel: apolloChannelMouse, payload: packet})
|
||||
}
|
||||
return packets, nil
|
||||
default:
|
||||
return apolloInputPacket{}, ErrInputMalformed
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,15 +454,17 @@ func (s *nativeApolloSession) Audio() <-chan ProviderMedia { return s.audio }
|
||||
func (s *nativeApolloSession) Events() <-chan ProviderEvent { return s.events }
|
||||
|
||||
func (s *nativeApolloSession) Input(ctx context.Context, event InputEvent) error {
|
||||
packet, err := encodeApolloInputEvent(event)
|
||||
packets, err := encodeApolloInputEvent(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.writeApolloControl(packet.channel, true, apolloControlTypeInput, packet.payload); err != nil {
|
||||
return err
|
||||
for _, packet := range packets {
|
||||
if err := s.writeApolloControl(packet.channel, true, apolloControlTypeInput, packet.payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if event.Device == "keyboard" || event.Device == "mouse-button" || event.Device == "controller" {
|
||||
key := fmt.Sprintf("%s:%d", event.Device, event.Code)
|
||||
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
|
||||
var ErrNoCapabilityOverlap = errors.New("no capability overlap")
|
||||
|
||||
func DefaultFeatures() []string {
|
||||
return []string{"quic-tls13", "datagram.media", "apollo", "display.request.v1", "input.absolute.v1", "input.scroll.v1"}
|
||||
}
|
||||
|
||||
func DefaultCapabilities() protocol.CapabilityProfile {
|
||||
return protocol.CapabilityProfile{
|
||||
Transport: "quic-tls13",
|
||||
|
||||
+472
-11
@@ -12,7 +12,9 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
@@ -67,6 +69,10 @@ func FuzzDecodeFrame(f *testing.F) {
|
||||
func FuzzDecodeInputEvent(f *testing.F) {
|
||||
seed, _ := EncodeInputEvent(InputEvent{Sequence: 1, Device: "keyboard", Code: 7, Pressed: true})
|
||||
f.Add(seed)
|
||||
absolute, _ := EncodeInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0, 1, 0, 1, 0, 2, 0, 2}})
|
||||
f.Add(absolute)
|
||||
scroll, _ := EncodeInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0, 0x78}})
|
||||
f.Add(scroll)
|
||||
f.Add([]byte("VGI1"))
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
_, _ = DecodeInputEvent(data)
|
||||
@@ -88,6 +94,107 @@ func TestInputEventUsesFixedProtocolVGI1Vector(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputEventUsesFixedAbsoluteAndScrollVectors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event InputEvent
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "absolute",
|
||||
event: InputEvent{Device: "mouse-absolute", Payload: []byte{0x04, 0xd2, 0x02, 0x37, 0x0a, 0x00, 0x05, 0xa0}},
|
||||
expected: "56474931060804d202370a0005a0",
|
||||
},
|
||||
{
|
||||
name: "scroll",
|
||||
event: InputEvent{Device: "mouse-scroll", Payload: []byte{0xff, 0x88, 0x00, 0x78}},
|
||||
expected: "564749310704ff880078",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
encoded, err := EncodeInputEvent(test.event)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hex.EncodeToString(encoded) != test.expected {
|
||||
t.Fatalf("EncodeInputEvent() = %x, want %s", encoded, test.expected)
|
||||
}
|
||||
decoded, err := DecodeInputEvent(encoded)
|
||||
if err != nil || decoded.Device != test.event.Device || decoded.Code != 0 || decoded.Pressed || !bytes.Equal(decoded.Payload, test.event.Payload) {
|
||||
t.Fatalf("DecodeInputEvent() = %#v, %v", decoded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputEventRejectsMalformedAbsoluteAndScroll(t *testing.T) {
|
||||
for _, event := range []InputEvent{
|
||||
{Device: "mouse-absolute", Pressed: true, Payload: make([]byte, 8)},
|
||||
{Device: "mouse-absolute", Payload: []byte{0, 0, 0, 0, 0, 0, 0, 1}},
|
||||
{Device: "mouse-absolute", Payload: []byte{0, 2, 0, 0, 0, 2, 0, 1}},
|
||||
{Device: "mouse-absolute", Payload: []byte{0, 0, 0, 1, 0, 2, 0, 1}},
|
||||
{Device: "mouse-scroll", Pressed: true, Payload: make([]byte, 4)},
|
||||
{Device: "mouse-scroll", Payload: make([]byte, 3)},
|
||||
} {
|
||||
if _, err := EncodeInputEvent(event); !errors.Is(err, ErrInputMalformed) {
|
||||
t.Fatalf("EncodeInputEvent(%#v) = %v", event, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{
|
||||
"56474931060800000000000005a0",
|
||||
"5647493106080a0000000a0005a0",
|
||||
"564749310608000005a00a0005a0",
|
||||
"56474931060700000000010001",
|
||||
"5647493107020000",
|
||||
} {
|
||||
raw, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecodeInputEvent(raw); !errors.Is(err, ErrInputMalformed) {
|
||||
t.Fatalf("DecodeInputEvent(%s) = %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRejectsUnadvertisedInputBeforeProviderTranslation(t *testing.T) {
|
||||
provider := &fakeSession{
|
||||
state: protocol.ProviderState{State: ProviderStateReady},
|
||||
pressed: make(map[string]struct{}),
|
||||
}
|
||||
session := &gatewaySession{
|
||||
server: &Server{config: ServerConfig{}},
|
||||
provider: provider,
|
||||
ctx: context.Background(),
|
||||
pressed: make(map[string]struct{}),
|
||||
}
|
||||
absolute, err := EncodeInputEvent(InputEvent{Device: "mouse-absolute", Payload: []byte{0, 1, 0, 1, 0, 2, 0, 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := session.handleInput(absolute, 1); !errors.Is(err, ErrInputMalformed) {
|
||||
t.Fatalf("unadvertised absolute input = %v", err)
|
||||
}
|
||||
if len(provider.inputs) != 0 {
|
||||
t.Fatalf("unadvertised absolute input reached provider: %#v", provider.inputs)
|
||||
}
|
||||
session.server.config.Features = []string{"input.absolute.v1"}
|
||||
if err := session.handleInput(absolute, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scroll, err := EncodeInputEvent(InputEvent{Device: "mouse-scroll", Payload: []byte{0, 1, 0, 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := session.handleInput(scroll, 3); !errors.Is(err, ErrInputMalformed) {
|
||||
t.Fatalf("unadvertised scroll input = %v", err)
|
||||
}
|
||||
if len(provider.inputs) != 1 || len(session.pressed) != 0 {
|
||||
t.Fatalf("provider inputs=%#v pressed=%#v", provider.inputs, session.pressed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFeedbackUsesFixedProtocolVGFVector(t *testing.T) {
|
||||
feedback := Feedback{Sequence: 9, Kind: FeedbackFEC, Payload: []byte{0, 0, 0, 42, 0, 5, 0, 3, 0, 2, 0, 10, 0, 2, 0, 8, 0, 2, 20, 0, 1}}
|
||||
encoded, err := EncodeClientFeedback(feedback)
|
||||
@@ -370,6 +477,293 @@ func TestAdmissionQUICMTLSRelayAndCleanup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableErrorFramePrecedesConnectionTeardown(t *testing.T) {
|
||||
for _, retryable := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("retryable=%t", retryable), func(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
clientTLS.NextProtos = []string{"versevdi-gateway-v1"}
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
authority := protocol.SessionAuthority{
|
||||
Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||
ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(),
|
||||
ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key(),
|
||||
}
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: &oneTimeAdmission{authority: authority, released: make(chan struct{})}, Provider: fake})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retryable {
|
||||
server.BeginDrain()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
connection, err := quic.DialAddr(context.Background(), server.Addr().String(), clientTLS, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := connection.OpenStreamSync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gatewayID := authority.GatewayID
|
||||
if !retryable {
|
||||
gatewayID = "wrong-gateway"
|
||||
}
|
||||
request := protocol.TunnelAdmissionRequest{Version: "1", SessionID: authority.SessionID, GatewayID: gatewayID, Audience: authority.Audience, Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities()}
|
||||
payload, err := protocol.EncodeTunnelAdmissionRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeWire(stream, payload, defaultHelloLimit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := readWire(stream, defaultHelloLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("stable response lost before connection teardown: %v", err)
|
||||
}
|
||||
stable, err := protocol.DecodeStableError(response)
|
||||
if err != nil || stable.Retryable != retryable {
|
||||
t.Fatalf("stable response = %#v, %v", stable, err)
|
||||
}
|
||||
select {
|
||||
case <-connection.Context().Done():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server retained rejected connection")
|
||||
}
|
||||
_ = server.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableErrorDrainOutlivesExpiredHandlerContext(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
clientTLS.NextProtos = []string{"versevdi-gateway-v1"}
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
|
||||
admission := AdmissionFunc(func(ctx context.Context, _ protocol.TunnelAdmissionRequest) (protocol.SessionAuthority, error) {
|
||||
<-ctx.Done()
|
||||
return protocol.SessionAuthority{}, ctx.Err()
|
||||
})
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: admission, Provider: fake})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.helloTimeout = 20 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
connection, err := quic.DialAddr(context.Background(), server.Addr().String(), clientTLS, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := connection.OpenStreamSync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := protocol.TunnelAdmissionRequest{Version: "1", SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience, Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities()}
|
||||
payload, err := protocol.EncodeTunnelAdmissionRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeWire(stream, payload, defaultHelloLimit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := readWire(stream, defaultHelloLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("stable response lost with expired handler context: %v", err)
|
||||
}
|
||||
if stable, err := protocol.DecodeStableError(response); err != nil || stable.Code != "admission_rejected" {
|
||||
t.Fatalf("stable response = %#v, %v", stable, err)
|
||||
}
|
||||
_ = connection.CloseWithError(applicationError, "done")
|
||||
cancel()
|
||||
_ = server.Close()
|
||||
}
|
||||
|
||||
func TestServerCloseInterruptsPartialHelloClient(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
clientTLS.NextProtos = []string{"versevdi-gateway-v1"}
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: &oneTimeAdmission{authority: authority, released: make(chan struct{})}, Provider: fake})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
serveDone := make(chan error, 1)
|
||||
go func() { serveDone <- server.Serve(ctx) }()
|
||||
connection, err := quic.DialAddr(context.Background(), server.Addr().String(), clientTLS, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := connection.OpenStreamSync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stream.Write([]byte{0, 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed := make(chan error, 1)
|
||||
go func() { closed <- server.Close() }()
|
||||
select {
|
||||
case err := <-closed:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Server.Close blocked on partial hello")
|
||||
}
|
||||
select {
|
||||
case <-connection.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("partial hello connection remained open")
|
||||
}
|
||||
cancel()
|
||||
if err := <-serveDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloUsesOneAbsoluteDeadlineAgainstTrickle(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
clientTLS.NextProtos = []string{"versevdi-gateway-v1"}
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: &oneTimeAdmission{authority: authority, released: make(chan struct{})}, Provider: fake})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.helloTimeout = 40 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
connection, err := quic.DialAddr(context.Background(), server.Addr().String(), clientTLS, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := connection.OpenStreamSync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stream.Write([]byte{0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := time.Now()
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
_, _ = stream.Write([]byte{0})
|
||||
response, err := readWire(stream, defaultHelloLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("absolute hello deadline did not produce a stable error: %v", err)
|
||||
}
|
||||
stable, err := protocol.DecodeStableError(response)
|
||||
if err != nil || stable.Code != "invalid_hello" {
|
||||
t.Fatalf("stable response = %#v, %v", stable, err)
|
||||
}
|
||||
if time.Since(started) > 250*time.Millisecond {
|
||||
t.Fatal("trickling hello reset or escaped its absolute deadline")
|
||||
}
|
||||
_ = connection.CloseWithError(applicationError, "done")
|
||||
_ = server.Close()
|
||||
}
|
||||
|
||||
func TestHelloDeadlineIsClearedAfterAdmission(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
|
||||
admission := &oneTimeAdmission{authority: authority, released: make(chan struct{}), disableClipboard: true}
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: admission, Provider: fake})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.helloTimeout = 40 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
request := protocol.TunnelAdmissionRequest{Version: "1", SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience, Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities()}
|
||||
client, err := Dial(context.Background(), server.Addr().String(), clientTLS, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
select {
|
||||
case <-client.connection.Context().Done():
|
||||
t.Fatal("hello deadline leaked into the admitted session")
|
||||
default:
|
||||
}
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
}
|
||||
|
||||
func TestStableErrorNeverLeaksInternalProviderDetails(t *testing.T) {
|
||||
var wire bytes.Buffer
|
||||
if err := writeStableError(&wire, "provider_unavailable", errors.New("https://provider.invalid/launch?rikey=secret-sentinel"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := readWire(&wire, defaultHelloLimit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stable, err := protocol.DecodeStableError(response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(stable.Message, "provider.invalid") || strings.Contains(stable.Message, "secret-sentinel") {
|
||||
t.Fatalf("stable error leaked internal details: %q", stable.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAdmissionProviderFailureIsTerminalEvenWhenReleaseFails(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
clientTLS.NextProtos = []string{"versevdi-gateway-v1"}
|
||||
fake := NewFakeApollo(FakeApolloConfig{Now: time.Now()})
|
||||
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Audience: "versevdi-gateway", ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
|
||||
admission := &oneTimeAdmission{authority: authority, released: make(chan struct{}), disableClipboard: true, releaseErr: errors.New("release failed")}
|
||||
provider := providerStartFunc(func(context.Context, LaunchRequest) (ProviderSession, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
})
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: authority.GatewayID, Admission: admission, Provider: provider})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { _ = server.Serve(ctx) }()
|
||||
connection, err := quic.DialAddr(context.Background(), server.Addr().String(), clientTLS, &quic.Config{EnableDatagrams: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream, err := connection.OpenStreamSync(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := protocol.TunnelAdmissionRequest{Version: "1", SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience, Grant: strings.Repeat("g", 64), ClientNonce: "nonce-0000000001", DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities()}
|
||||
payload, err := protocol.EncodeTunnelAdmissionRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeWire(stream, payload, defaultHelloLimit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := readWire(stream, defaultHelloLimit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stable, err := protocol.DecodeStableError(response)
|
||||
if err != nil || stable.Code != "provider_timeout" || stable.Retryable {
|
||||
t.Fatalf("post-admission stable response = %#v, %v", stable, err)
|
||||
}
|
||||
select {
|
||||
case <-admission.released:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("post-admission failure did not attempt release")
|
||||
}
|
||||
if got := admission.releases.Load(); got != 1 {
|
||||
t.Fatalf("release attempts = %d, want 1", got)
|
||||
}
|
||||
_ = connection.CloseWithError(applicationError, "done")
|
||||
_ = server.Close()
|
||||
}
|
||||
|
||||
func TestGatewayTelemetrySeparatesQueueProcessingAndPacing(t *testing.T) {
|
||||
serverTLS, clientTLS := testTLS(t)
|
||||
session := &fakeSession{
|
||||
@@ -581,6 +975,14 @@ func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absolute, err := EncodeInputEvent(InputEvent{Sequence: 10, Device: "mouse-absolute", Payload: []byte{0, 1, 0, 1, 0, 2, 0, 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scroll, err := EncodeInputEvent(InputEvent{Sequence: 11, Device: "mouse-scroll", Payload: []byte{0, 1, 0, 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feedback, err := EncodeClientFeedback(Feedback{Sequence: 8, Kind: FeedbackIDR})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -593,6 +995,8 @@ func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
|
||||
testChannelFrame("input.sequenced.v1", 7, input),
|
||||
testChannelFrame("control.ack.v1", 8, feedback),
|
||||
testChannelFrame("clipboard.text.v1", 9, clipboard),
|
||||
testChannelFrame("input.sequenced.v1", 10, absolute),
|
||||
testChannelFrame("input.sequenced.v1", 11, scroll),
|
||||
} {
|
||||
encoded, encodeErr := protocol.EncodeChannelFrame(frame)
|
||||
if encodeErr != nil {
|
||||
@@ -617,7 +1021,7 @@ func TestRegisteredChannelFramesTraversePublicTransport(t *testing.T) {
|
||||
inputs := append([]InputEvent(nil), h.session.inputs...)
|
||||
feedbacks := append([]Feedback(nil), h.session.feedback...)
|
||||
h.session.mu.Unlock()
|
||||
if len(inputs) == 1 && inputs[0].Sequence == 7 && len(feedbacks) == 1 && feedbacks[0].Sequence == 8 && feedbacks[0].Kind == FeedbackIDR {
|
||||
if len(inputs) == 3 && inputs[0].Sequence == 7 && inputs[1].Sequence == 10 && inputs[1].Device == "mouse-absolute" && inputs[2].Sequence == 11 && inputs[2].Device == "mouse-scroll" && len(feedbacks) == 1 && feedbacks[0].Sequence == 8 && feedbacks[0].Kind == FeedbackIDR {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
@@ -854,10 +1258,61 @@ func newNativeGatewayLifecycleHarness(t *testing.T, sessionID string) nativeGate
|
||||
return nativeGatewayLifecycleHarness{native: native, key: key, client: client, admission: admission, reporter: reporter}
|
||||
}
|
||||
|
||||
func TestGatewayEgressReturnsProviderFreeClientAuthority(t *testing.T) {
|
||||
h := newNativeGatewayLifecycleHarness(t, "session-client-authority")
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(h.client.authorityRaw, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"version": "1",
|
||||
"session_id": "session-client-authority",
|
||||
"gateway_id": "gateway-1",
|
||||
"audience": "versevdi-gateway",
|
||||
"reconnect_sequence": float64(0),
|
||||
"expires_at": h.admission.authority.ExpiresAt,
|
||||
"capabilities": map[string]any{
|
||||
"transport": "quic-tls13",
|
||||
"framing": "datagram-v2",
|
||||
"media": "encoded",
|
||||
"audio": "encoded",
|
||||
"source_rate_control": "server",
|
||||
"client_decode": []any{"h264-opus"},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("raw client authority = %s", h.client.authorityRaw)
|
||||
}
|
||||
if err := h.client.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.waitReleased(t)
|
||||
if !reflect.DeepEqual(h.admission.releaseAuthority, h.admission.authority) {
|
||||
t.Fatalf("release authority = %#v, want provider-bearing %#v", h.admission.releaseAuthority, h.admission.authority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSessionAuthorityRejectsProviderBearingRC3(t *testing.T) {
|
||||
var _ protocol.ClientSessionAuthority = Client{}.Authority
|
||||
raw, err := protocol.EncodeSessionAuthority(protocol.SessionAuthority{
|
||||
Version: "1", SessionID: "session-rc3", GatewayID: "gateway-1", Audience: "versevdi-gateway",
|
||||
ExpiresAt: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(),
|
||||
ProviderProfile: ProviderProfileApollo, ProviderIdentity: "apollo-fixture-1#sha256:fixture-apollo-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := protocol.DecodeClientSessionAuthority(raw); err == nil {
|
||||
t.Fatalf("accepted provider-bearing RC3 authority: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
type independentGatewayClient struct {
|
||||
connection *quic.Conn
|
||||
control *quic.Stream
|
||||
media independentMediaReassembler
|
||||
connection *quic.Conn
|
||||
control *quic.Stream
|
||||
authority protocol.ClientSessionAuthority
|
||||
authorityRaw []byte
|
||||
media independentMediaReassembler
|
||||
}
|
||||
|
||||
func dialIndependentGateway(ctx context.Context, address string, tlsConfig *tls.Config, request protocol.TunnelAdmissionRequest) (*independentGatewayClient, error) {
|
||||
@@ -879,17 +1334,20 @@ func dialIndependentGateway(ctx context.Context, address string, tlsConfig *tls.
|
||||
if err == nil {
|
||||
encoded, err = independentReadWire(stream, defaultHelloLimit)
|
||||
}
|
||||
var authority protocol.ClientSessionAuthority
|
||||
if err == nil {
|
||||
_, err = protocol.DecodeSessionAuthority(encoded)
|
||||
authority, err = protocol.DecodeClientSessionAuthority(encoded)
|
||||
}
|
||||
if err != nil {
|
||||
_ = connection.CloseWithError(applicationError, "independent client admission failed")
|
||||
return nil, err
|
||||
}
|
||||
return &independentGatewayClient{
|
||||
connection: connection,
|
||||
control: stream,
|
||||
media: independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)},
|
||||
connection: connection,
|
||||
control: stream,
|
||||
authority: authority,
|
||||
authorityRaw: encoded,
|
||||
media: independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1320,7 +1778,7 @@ func newGatewayTransportHarnessWithClipboard(t *testing.T, clipboardEnabled bool
|
||||
authority := protocol.SessionAuthority{Version: "1", SessionID: "session-transport", GatewayID: "gateway-1", Audience: "versevdi-gateway", ReconnectSequence: 0, ExpiresAt: time.Now().Add(5 * time.Second).UTC().Format(time.RFC3339Nano), Capabilities: DefaultCapabilities(), ProviderProfile: ProviderProfileApollo, ProviderIdentity: fake.config.Identity.Key()}
|
||||
admission := &oneTimeAdmission{authority: authority, released: make(chan struct{}), disableClipboard: !clipboardEnabled}
|
||||
reporter := &recordingProviderStateReporter{}
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: "gateway-1", Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(), Admission: admission, ProviderStateReporter: reporter, ClipboardAuditReporter: reporter, Provider: fake})
|
||||
server, err := NewServer(ServerConfig{ListenAddress: "127.0.0.1:0", TLSConfig: serverTLS, GatewayID: "gateway-1", Features: DefaultFeatures(), Capabilities: DefaultCapabilities(), ProviderCapabilities: DefaultCapabilities(), Admission: admission, ProviderStateReporter: reporter, ClipboardAuditReporter: reporter, Provider: fake})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1414,11 +1872,13 @@ func testTLS(t *testing.T) (*tls.Config, *tls.Config) {
|
||||
type oneTimeAdmission struct {
|
||||
used atomic.Bool
|
||||
authority protocol.SessionAuthority
|
||||
releaseAuthority protocol.SessionAuthority
|
||||
releases atomic.Int64
|
||||
released chan struct{}
|
||||
streamPolicy protocol.ProviderStreamPolicy
|
||||
providerWork *protocol.ProviderSessionWork
|
||||
disableClipboard bool
|
||||
releaseErr error
|
||||
}
|
||||
|
||||
type recordingProviderStateReporter struct {
|
||||
@@ -1488,11 +1948,12 @@ func (a *oneTimeAdmission) ProviderWork(_ context.Context, authority protocol.Se
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *oneTimeAdmission) Release(context.Context, protocol.SessionAuthority) error {
|
||||
func (a *oneTimeAdmission) Release(_ context.Context, authority protocol.SessionAuthority) error {
|
||||
if a.releases.Add(1) == 1 {
|
||||
a.releaseAuthority = authority
|
||||
close(a.released)
|
||||
}
|
||||
return nil
|
||||
return a.releaseErr
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
|
||||
@@ -15,6 +15,8 @@ const (
|
||||
gatewayInputRelative = 3
|
||||
gatewayInputUTF8 = 4
|
||||
gatewayInputController = 5
|
||||
gatewayInputAbsolute = 6
|
||||
gatewayInputScroll = 7
|
||||
)
|
||||
|
||||
func EncodeInputEvent(event InputEvent) ([]byte, error) {
|
||||
@@ -76,6 +78,24 @@ func EncodeInputEvent(event InputEvent) ([]byte, error) {
|
||||
encoded[4], encoded[5], encoded[6] = gatewayInputController, 17, byte(event.Code)
|
||||
copy(encoded[7:], event.Payload)
|
||||
return encoded, nil
|
||||
case "mouse-absolute":
|
||||
if event.Pressed || event.Code != 0 || !validAbsolutePayload(event.Payload) {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+8)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5] = gatewayInputAbsolute, 8
|
||||
copy(encoded[6:], event.Payload)
|
||||
return encoded, nil
|
||||
case "mouse-scroll":
|
||||
if event.Pressed || event.Code != 0 || len(event.Payload) != 4 {
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
encoded := make([]byte, gatewayInputHeaderSize+4)
|
||||
copy(encoded, "VGI1")
|
||||
encoded[4], encoded[5] = gatewayInputScroll, 4
|
||||
copy(encoded[6:], event.Payload)
|
||||
return encoded, nil
|
||||
default:
|
||||
return nil, ErrInputMalformed
|
||||
}
|
||||
@@ -117,11 +137,30 @@ func DecodeInputEvent(data []byte) (InputEvent, error) {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "controller", Code: int32(body[0]), Pressed: active != 0, Payload: payload}, nil
|
||||
case gatewayInputAbsolute:
|
||||
if !validAbsolutePayload(body) {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "mouse-absolute", Payload: append([]byte(nil), body...)}, nil
|
||||
case gatewayInputScroll:
|
||||
if len(body) != 4 {
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
return InputEvent{Device: "mouse-scroll", Payload: append([]byte(nil), body...)}, nil
|
||||
default:
|
||||
return InputEvent{}, ErrInputMalformed
|
||||
}
|
||||
}
|
||||
|
||||
func validAbsolutePayload(payload []byte) bool {
|
||||
if len(payload) != 8 {
|
||||
return false
|
||||
}
|
||||
x, y := binary.BigEndian.Uint16(payload[:2]), binary.BigEndian.Uint16(payload[2:4])
|
||||
width, height := binary.BigEndian.Uint16(payload[4:6]), binary.BigEndian.Uint16(payload[6:8])
|
||||
return width != 0 && height != 0 && x < width && y < height
|
||||
}
|
||||
|
||||
func anyNonzero(data []byte) bool {
|
||||
for _, value := range data {
|
||||
if value != 0 {
|
||||
|
||||
+119
-40
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
defaultHelloLimit = 16 * 1024
|
||||
defaultHelloTimeout = 10 * time.Second
|
||||
defaultControlLimit = 128 * 1024
|
||||
clientControlBacklog = 64
|
||||
terminalAckTimeout = 2 * time.Second
|
||||
@@ -68,6 +69,7 @@ type ServerConfig struct {
|
||||
TLSConfig *tls.Config
|
||||
QUICConfig *quic.Config
|
||||
GatewayID string
|
||||
Features []string
|
||||
Capabilities protocol.CapabilityProfile
|
||||
ProviderCapabilities protocol.CapabilityProfile
|
||||
Admission Admission
|
||||
@@ -87,16 +89,18 @@ type mediaTimingObservation struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
listener *quic.Listener
|
||||
config ServerConfig
|
||||
metrics *Metrics
|
||||
pacer *fairPacer
|
||||
mu sync.Mutex
|
||||
sessions map[*gatewaySession]struct{}
|
||||
draining atomic.Bool
|
||||
closed atomic.Bool
|
||||
closeOnce sync.Once
|
||||
workers sync.WaitGroup
|
||||
listener *quic.Listener
|
||||
config ServerConfig
|
||||
metrics *Metrics
|
||||
pacer *fairPacer
|
||||
mu sync.Mutex
|
||||
sessions map[*gatewaySession]struct{}
|
||||
connections map[*quic.Conn]struct{}
|
||||
helloTimeout time.Duration
|
||||
draining atomic.Bool
|
||||
closed atomic.Bool
|
||||
closeOnce sync.Once
|
||||
workers sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewServer(config ServerConfig) (*Server, error) {
|
||||
@@ -137,7 +141,7 @@ func NewServer(config ServerConfig) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{listener: listener, config: config, metrics: &Metrics{}, pacer: newFairPacer(config.PacerKbps), sessions: make(map[*gatewaySession]struct{})}, nil
|
||||
return &Server{listener: listener, config: config, metrics: &Metrics{}, pacer: newFairPacer(config.PacerKbps), sessions: make(map[*gatewaySession]struct{}), connections: make(map[*quic.Conn]struct{}), helloTimeout: defaultHelloTimeout}, nil
|
||||
}
|
||||
|
||||
func validateServerTLS(config *tls.Config) error {
|
||||
@@ -173,9 +177,22 @@ func (s *Server) Serve(ctx context.Context) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.closed.Load() {
|
||||
s.mu.Unlock()
|
||||
_ = connection.CloseWithError(applicationError, "server closed")
|
||||
continue
|
||||
}
|
||||
s.connections[connection] = struct{}{}
|
||||
s.workers.Add(1)
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
defer s.workers.Done()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.connections, connection)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
s.handleConnection(ctx, connection)
|
||||
}()
|
||||
}
|
||||
@@ -185,13 +202,24 @@ func (s *Server) Close() error {
|
||||
var err error
|
||||
s.closeOnce.Do(func() {
|
||||
s.BeginDrain()
|
||||
s.closed.Store(true)
|
||||
err = s.listener.Close()
|
||||
s.mu.Lock()
|
||||
s.closed.Store(true)
|
||||
sessions := make([]*gatewaySession, 0, len(s.sessions))
|
||||
for session := range s.sessions {
|
||||
session.cancel()
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
connections := make([]*quic.Conn, 0, len(s.connections))
|
||||
for connection := range s.connections {
|
||||
connections = append(connections, connection)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
err = s.listener.Close()
|
||||
for _, session := range sessions {
|
||||
session.cancel()
|
||||
}
|
||||
for _, connection := range connections {
|
||||
_ = connection.CloseWithError(applicationError, "server closed")
|
||||
}
|
||||
})
|
||||
s.workers.Wait()
|
||||
return err
|
||||
@@ -199,80 +227,98 @@ func (s *Server) Close() error {
|
||||
|
||||
func (s *Server) handleConnection(parent context.Context, connection *quic.Conn) {
|
||||
defer connection.CloseWithError(applicationError, "connection closed")
|
||||
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||
helloDeadline := time.Now().Add(s.helloTimeout)
|
||||
ctx, cancel := context.WithDeadline(parent, helloDeadline)
|
||||
defer cancel()
|
||||
stream, err := connection.AcceptStream(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := stream.SetDeadline(helloDeadline); err != nil {
|
||||
return
|
||||
}
|
||||
writeError := func(code string, err error, retryable bool) {
|
||||
responseDeadline := time.Now().Add(time.Second)
|
||||
if stream.SetDeadline(responseDeadline) != nil {
|
||||
return
|
||||
}
|
||||
if writeStableError(stream, code, err, retryable) == nil && stream.Close() == nil {
|
||||
responseCtx, responseCancel := context.WithDeadline(context.Background(), responseDeadline)
|
||||
defer responseCancel()
|
||||
select {
|
||||
case <-connection.Context().Done():
|
||||
case <-responseCtx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
requestBytes, err := readWire(stream, defaultHelloLimit)
|
||||
if err != nil {
|
||||
_ = writeStableError(stream, "invalid_hello", err, false)
|
||||
writeError("invalid_hello", err, false)
|
||||
return
|
||||
}
|
||||
request, err := protocol.DecodeTunnelAdmissionRequest(requestBytes)
|
||||
if err != nil {
|
||||
_ = writeStableError(stream, "invalid_hello", err, false)
|
||||
writeError("invalid_hello", err, false)
|
||||
return
|
||||
}
|
||||
if s.Draining() {
|
||||
_ = writeStableError(stream, "gateway_draining", ErrGatewayDraining, true)
|
||||
writeError("gateway_draining", ErrGatewayDraining, true)
|
||||
return
|
||||
}
|
||||
if request.GatewayID != s.config.GatewayID {
|
||||
_ = writeStableError(stream, "wrong_gateway", ErrAdmissionRejected, false)
|
||||
writeError("wrong_gateway", ErrAdmissionRejected, false)
|
||||
return
|
||||
}
|
||||
authority, err := s.config.Admission.Admit(ctx, request)
|
||||
if err != nil {
|
||||
s.metrics.AdmissionRejects.Add(1)
|
||||
_ = writeStableError(stream, stableAdmissionCode(err), err, errors.Is(err, context.DeadlineExceeded))
|
||||
writeError(stableAdmissionCode(err), err, false)
|
||||
return
|
||||
}
|
||||
if s.Draining() {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "gateway_draining", ErrGatewayDraining, true)
|
||||
writeError("gateway_draining", ErrGatewayDraining, false)
|
||||
return
|
||||
}
|
||||
if err := s.validateAuthority(authority, request); err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "invalid_authority", err, false)
|
||||
writeError("invalid_authority", err, false)
|
||||
return
|
||||
}
|
||||
work, err := s.config.Admission.ProviderWork(ctx, authority)
|
||||
if err != nil || s.validateProviderWork(work, authority) != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "provider_work_unavailable", ErrAdmissionRejected, err != nil)
|
||||
writeError("provider_work_unavailable", ErrAdmissionRejected, false)
|
||||
return
|
||||
}
|
||||
selected, err := IntersectCapabilities(s.config.Capabilities, s.config.ProviderCapabilities, request.Capabilities, authority.Capabilities)
|
||||
if err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
s.metrics.AdmissionRejects.Add(1)
|
||||
_ = writeStableError(stream, "no_capability_overlap", err, false)
|
||||
writeError("no_capability_overlap", err, false)
|
||||
return
|
||||
}
|
||||
selected, err = selectApolloPolicyCapabilities(work.StreamPolicy, selected)
|
||||
if err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
s.metrics.AdmissionRejects.Add(1)
|
||||
_ = writeStableError(stream, "no_capability_overlap", err, false)
|
||||
writeError("no_capability_overlap", err, false)
|
||||
return
|
||||
}
|
||||
clipboard, err := newClipboardGate(work.ClipboardPolicy, time.Now)
|
||||
if err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "provider_work_unavailable", ErrAdmissionRejected, false)
|
||||
writeError("provider_work_unavailable", ErrAdmissionRejected, false)
|
||||
return
|
||||
}
|
||||
if (work.ClipboardPolicy.ClientToProviderEnabled || work.ClipboardPolicy.ProviderToClientEnabled) && s.config.ClipboardAuditReporter == nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "clipboard_audit_unavailable", ErrAdmissionRejected, true)
|
||||
writeError("clipboard_audit_unavailable", ErrAdmissionRejected, false)
|
||||
return
|
||||
}
|
||||
if err := s.reportProviderState(ctx, protocol.ProviderState{Version: "1", SessionID: request.SessionID, State: ProviderStateStarting, CleanupPending: false, Channels: []string{"video", "audio", "input", "feedback"}}); err != nil {
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "provider_state_unavailable", err, true)
|
||||
writeError("provider_state_unavailable", err, false)
|
||||
return
|
||||
}
|
||||
providerSession, err := s.config.Provider.Start(ctx, LaunchRequest{SessionID: request.SessionID, Capabilities: selected, ProviderProfile: authority.ProviderProfile, ProviderIdentity: work.ProviderIdentity, ProviderWork: work})
|
||||
@@ -280,19 +326,22 @@ func (s *Server) handleConnection(parent context.Context, connection *quic.Conn)
|
||||
s.metrics.ProviderErrors.Add(1)
|
||||
_ = s.reportProviderState(context.Background(), protocol.ProviderState{Version: "1", SessionID: request.SessionID, State: ProviderStateFailed, CleanupPending: false, Channels: []string{"video", "audio", "input", "feedback"}})
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, stableProviderCode(err), err, errors.Is(err, context.DeadlineExceeded))
|
||||
writeError(stableProviderCode(err), err, false)
|
||||
return
|
||||
}
|
||||
if err := s.reportProviderState(ctx, providerSession.State()); err != nil {
|
||||
_ = providerSession.ReleaseAll(context.Background())
|
||||
_ = providerSession.Terminate(context.Background())
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
_ = writeStableError(stream, "provider_state_unavailable", err, true)
|
||||
writeError("provider_state_unavailable", err, false)
|
||||
return
|
||||
}
|
||||
authority.Capabilities = selected
|
||||
authorityBytes, err := protocol.EncodeSessionAuthority(authority)
|
||||
if err != nil || writeWire(stream, authorityBytes, defaultHelloLimit) != nil {
|
||||
clientAuthority := protocol.ClientSessionAuthority{
|
||||
Version: authority.Version, SessionID: authority.SessionID, GatewayID: authority.GatewayID, Audience: authority.Audience,
|
||||
ReconnectSequence: authority.ReconnectSequence, ExpiresAt: authority.ExpiresAt, Capabilities: selected,
|
||||
}
|
||||
authorityBytes, err := protocol.EncodeClientSessionAuthority(clientAuthority)
|
||||
if err != nil || writeWire(stream, authorityBytes, defaultHelloLimit) != nil || stream.SetDeadline(time.Time{}) != nil {
|
||||
_ = providerSession.ReleaseAll(context.Background())
|
||||
_ = providerSession.Terminate(context.Background())
|
||||
_ = s.config.Admission.Release(context.Background(), authority)
|
||||
@@ -866,6 +915,16 @@ func (s *gatewaySession) handleInput(payload []byte, sequence uint32) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requiredFeature := ""
|
||||
switch event.Device {
|
||||
case "mouse-absolute":
|
||||
requiredFeature = "input.absolute.v1"
|
||||
case "mouse-scroll":
|
||||
requiredFeature = "input.scroll.v1"
|
||||
}
|
||||
if requiredFeature != "" && !slices.Contains(s.server.config.Features, requiredFeature) {
|
||||
return ErrInputMalformed
|
||||
}
|
||||
event.Sequence = sequence
|
||||
if err := s.provider.Input(s.ctx, event); err != nil {
|
||||
s.server.metrics.InputRejected.Add(1)
|
||||
@@ -929,11 +988,8 @@ func (s *gatewaySession) cleanup() {
|
||||
})
|
||||
}
|
||||
|
||||
func writeStableError(writer io.Writer, code string, err error, retryable bool) error {
|
||||
message := err.Error()
|
||||
if len(message) > 256 {
|
||||
message = message[:256]
|
||||
}
|
||||
func writeStableError(writer io.Writer, code string, _ error, retryable bool) error {
|
||||
message := stableErrorMessage(code)
|
||||
payload, encodeErr := protocol.EncodeStableError(protocol.StableError{Version: "1", Code: code, Message: message, Retryable: retryable})
|
||||
if encodeErr != nil {
|
||||
return encodeErr
|
||||
@@ -941,6 +997,29 @@ func writeStableError(writer io.Writer, code string, err error, retryable bool)
|
||||
return writeWire(writer, payload, defaultHelloLimit)
|
||||
}
|
||||
|
||||
func stableErrorMessage(code string) string {
|
||||
switch code {
|
||||
case "invalid_hello":
|
||||
return "invalid client hello"
|
||||
case "gateway_draining":
|
||||
return "gateway is draining"
|
||||
case "wrong_gateway":
|
||||
return "gateway does not match admission request"
|
||||
case "admission_rejected", "expired_grant":
|
||||
return "admission rejected"
|
||||
case "invalid_authority":
|
||||
return "invalid session authority"
|
||||
case "no_capability_overlap":
|
||||
return "no compatible capability"
|
||||
case "clipboard_audit_unavailable":
|
||||
return "clipboard audit unavailable"
|
||||
case "provider_work_unavailable", "provider_identity_rejected", "provider_malformed", "provider_timeout", "provider_unavailable", "provider_state_unavailable":
|
||||
return "provider unavailable"
|
||||
default:
|
||||
return "request failed"
|
||||
}
|
||||
}
|
||||
|
||||
func stableAdmissionCode(err error) string {
|
||||
if errors.Is(err, ErrGatewayDraining) {
|
||||
return "gateway_draining"
|
||||
@@ -999,7 +1078,7 @@ type Client struct {
|
||||
controlReadMu sync.Mutex
|
||||
controlWriteMu sync.Mutex
|
||||
pendingControl map[string][][]byte
|
||||
Authority protocol.SessionAuthority
|
||||
Authority protocol.ClientSessionAuthority
|
||||
}
|
||||
|
||||
func Dial(ctx context.Context, address string, tlsConfig *tls.Config, request protocol.TunnelAdmissionRequest) (*Client, error) {
|
||||
@@ -1034,7 +1113,7 @@ func Dial(ctx context.Context, address string, tlsConfig *tls.Config, request pr
|
||||
_ = connection.CloseWithError(applicationError, "no authority")
|
||||
return nil, err
|
||||
}
|
||||
authority, authorityErr := protocol.DecodeSessionAuthority(response)
|
||||
authority, authorityErr := protocol.DecodeClientSessionAuthority(response)
|
||||
if authorityErr != nil {
|
||||
stable, stableErr := protocol.DecodeStableError(response)
|
||||
if stableErr == nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ module git.sechmachine.io.vn/sechmachine/VerseVDI-Data-Plane
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.5
|
||||
github.com/quic-go/quic-go v0.61.0
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.9
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.9/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10 h1:9KcV44asmhURVQJ6NbuRXaoTf0UI7Zmx/b3b2/kfHlM=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3c-gateway-rc.10/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1 h1:uCH+etSQtzqfhhRP3dWdp6aIDTU55G7r1UEOAJfWsGw=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.1/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2 h1:UdkgLXQ2fWFCTGcMblZHXhvQbDx2wfTy4eHyEvav72U=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.2/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.3 h1:43EJHiKcbWdF0K1dvVznpWaUUWcvT5nogcGiSnOVu5s=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.3/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.5 h1:bJ3JeCm7dsQwWc7kMjcSgBrtATT1hi2xUSNdE73q4X4=
|
||||
git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol v1.0.0-phase3d-macos-rc.5/go.mod h1:7PhFIDhjtr20btWoEb2GqB+7dBpzJt43olrnHVutWoc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
||||
+7
-1
@@ -8,7 +8,7 @@ Because the bounded fixture uses loopback rather than a physical 1 Gbps link, v8
|
||||
|
||||
The retained v6 qualification run passed its then-current checks but is superseded because its tight-loop sender contradicted the pinned Apollo schedule. Private Linux runs 123 and 124 remain failed evidence. One local v8 sustained run passed on Darwin, but it is neither Linux proof nor normative Section 7 evidence.
|
||||
|
||||
The later Darwin non-sustained pre-CI invocation was not green and was not retried. Its 1440p120 profile delivered the exact 6,250,000 bytes in 120 frames plus all 6,483 source and warm-up shards with zero drops, but measured 46,973.13 kbps over an implied approximately 1.0644383 seconds and failed the 5% throughput gate. Private Linux full verification/artifact retention and the replacement v10 normative run remain open.
|
||||
The later Darwin non-sustained pre-CI invocation was not green and was not retried. Its 1440p120 profile delivered the exact 6,250,000 bytes in 120 frames plus all 6,483 source and warm-up shards with zero drops, but measured 46,973.13 kbps over an implied approximately 1.0644383 seconds and failed the 5% throughput gate. At that point, private Linux full verification/artifact retention and the replacement v10 normative run were still open.
|
||||
|
||||
Private Linux run 125 at the frozen v8 harness head is retained as failed evidence. Its exact 33-datagram gap between successful fixture writes and production `MediaIngress` equaled the Linux socket's 33 measured kernel UDP drops. The complete-frame queue, fair pacer, QUIC fragmentation, and public decoder were downstream and did not account for the loss.
|
||||
|
||||
@@ -28,6 +28,12 @@ Qualification v10 keeps the logical impairment CSV unchanged and adds only `impa
|
||||
|
||||
The independent parser's caller explicitly selects smoke or normative authority. Normative validation requires exactly 10,000 sent logical units and always enforces the ten-second convergence and 105% rolling-cap gates; retained `sent` data cannot weaken them. Both wire and fairness readers reject compressed input before hashing when it exceeds a writer-derived bound, feed gzip output through a bounded standard-library reader before CSV parsing, and bound fields, offsets, flows, and encoded lengths from the canonical row count, schema, time horizon, and writer values. Aggregate-only v9 data and malformed schema/hash/count/order/transition/length or oversized inputs remain rejected.
|
||||
|
||||
Private Linux run 129/job 483 and artifact 29 at `c0e362c0285d267f8af4087d07943822311f60e1` passed execution, artifact-byte, and at-least-30-elapsed-day retention checks. Its subsequent v9 a2 qualification remains runtime-passing but normative-raw-evidence-incomplete. Exact v10 source `ce3b3079837c5745f95a51c98eb6204363962ae7` then passed its one private Linux push attempt in run 130/job 484: `make verify`, the sustained gate, strict OpenSpec, deterministic artifact build/upload, and clean checkout all passed. The retained run log is SHA-256 `7e23011db2cb3118ca6cd940cfdc59ba4b536de39276d6b6a74c5862c745b0fa`. Artifact 30's ZIP is SHA-256 `59f938f9187255ad015fa5608b49909e89a065e4f5ba515b7450608b8048b1cc` at 12,957,227 bytes; its amd64 binary is `33ecd42cee0e01bc15aa87aa0610bfb165b2a2b379133110271c98d717584cc1` at 12,367,020 bytes, its arm64 binary is `4b6fc560e23fd8e283219f63ac4a33881863d12644676fb6e5a7d9fc4523182b` at 11,525,817 bytes, and its source-correct SPDX is `a1c2ae2bc4a8c0e0dea67ac53dbf8bb6135399cecb8d6fd153e9b59174ec35b9` at 8,090 bytes. The API recorded exactly 2,592,000 seconds of retention. The binaries remain unchanged because v10 changes retained test evidence only; the SPDX truthfully remains unscanned and unsigned.
|
||||
|
||||
Two subsequent ce3 elevated-execution preflights were denied before `CreateProcess`. No `go test` process started, no target was created, and neither event consumed a qualification attempt; they remain environment/approval history rather than runtime failures. The later owner-approved elevated direct command ran exactly once at ce3 with immutable Protocol RC10, exited zero, and passed `TestSection7Qualification` in 1,917.92 seconds (package 1,918.491 seconds). It produced mode-0750 `gateway-rc10-ce3b307` with 17 files totaling 4,179,714 bytes and a passed v10 manifest SHA-256 `53f1e24a3182887d496b745826af20c7b220a3a0cc2e1682baf1eb746f4c240d`.
|
||||
|
||||
Independent review recomputed 36,000/72,000/36,000 processing frames and 11.25 GB of exact payload, including payload digests, statistics, isolated resources, and all eight logical impairment files. It also recomputed the constrained wire artifact's 15,060 rows as 15,058 ordered 1,200/25-byte deliveries plus canonical 25%/50% transitions, including full-tail convergence and maxima. The fairness artifact retained 70,916 rows; stage-bounded recomputation reproduced Jain index `0.9999999546920958`, maximum share error `0.0003749275708101844`, and the two-second 25%/50% convergence/maxima. Identity, hashes, bounded grammar, and preservation checks found no material mismatch. These results remain deterministic fixture evidence only and do not prove live Apollo, the native macOS client, a physical firewall or route, a real encoder, multi-host operation, Phase 3C-C, vulnerability scanning, signing, deployment, or promotion.
|
||||
|
||||
The ingress repair follows reviewed behavior rather than copying implementation source:
|
||||
|
||||
- Apollo `adc5c5a0bd80831ce495434bb16aee2cd4175fb8`, GPL-3.0, `src/stream.cpp:1463-1474,1573-1627`, supplies the 80%-of-1-Gbps raw-block pacing, 64-KiB/64-packet batch cap, and cross-frame send schedule used by the fixture.
|
||||
+6
@@ -14,6 +14,12 @@ Private Linux run 128/job 482 was the single push-triggered attempt at exact sou
|
||||
|
||||
At exact source `c0e362c0285d267f8af4087d07943822311f60e1`, the first Section 7 attempt created `gateway-rc10-c0e362c` and stopped at the sandbox loopback-bind boundary, leaving that mode-0750 directory empty. The separately authorized escalated attempt `gateway-rc10-c0e362c-a2` then passed the v9 runtime gates and retained 16 files; its manifest has SHA-256 `61ea55140dfe6b37332de03879ac33206dd5d47763d09fb0fc2f65bb1f8e02b4`. Independent review denied normative acceptance because v9 retained only logical impairment rows and aggregate capacity summaries: it did not retain the raw public datagram timestamps/lengths or fairness transition offsets needed to recompute those summaries. Both attempts remain preserved without relabeling; a2 is runtime-passing but normative-raw-evidence-incomplete.
|
||||
|
||||
Private Linux run 129/job 483 and artifact 29 at `c0e362c0285d267f8af4087d07943822311f60e1` passed execution, artifact-byte, and at-least-30-elapsed-day retention checks. That Linux result remains valid, while the later v9 a2 qualification remains runtime-passing but normative-raw-evidence-incomplete. Neither classification is relabeled.
|
||||
|
||||
Immutable v10 source `ce3b3079837c5745f95a51c98eb6204363962ae7` passed its single private Linux push attempt, run 130/job 484, including `make verify`, the sustained gate, strict OpenSpec, deterministic build/upload, and the clean-checkout gate. Artifact 30 retained the unchanged amd64 and arm64 binaries plus a source-correct, unscanned, unsigned SPDX for exactly 2,592,000 API-recorded seconds. Two later elevated-execution preflights were denied before `CreateProcess`; neither created the target nor consumed a qualification attempt. The subsequent owner-approved direct command ran exactly once, exited zero, and wrote the 17-file v10 `gateway-rc10-ce3b307` bundle with passed manifest SHA-256 `53f1e24a3182887d496b745826af20c7b220a3a0cc2e1682baf1eb746f4c240d`.
|
||||
|
||||
Independent review recomputed all three processing profiles, 11.25 GB of payload, all eight logical impairment files, the 15,060-row constrained public-wire artifact, and the 70,916-row fairness artifact from the retained v10 evidence. It reproduced ordered 25%/50% transitions, full-tail impairment convergence and maxima, stage-bounded fairness convergence and maxima, Jain index `0.9999999546920958`, maximum share error `0.0003749275708101844`, and every bound manifest identity/hash/grammar value without a material mismatch. This closes only deterministic fixture evidence: live Apollo, the native macOS client, the physical firewall and route, a real encoder, multi-host operation, Phase 3C-C, scanning, signing, deployment, and promotion remain outside this result.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Generate deterministic variable-size encoded frame units at the named frame rates and target bitrates, including bounded keyframes.
|
||||
+2
@@ -59,6 +59,8 @@ The retained fairness manifest SHALL bind its 25% and 50% transition offsets to
|
||||
- **WHEN** a wire or fairness gzip exceeds its canonical compressed or decompressed limit or contains an overlong field, out-of-horizon offset, unknown flow, or out-of-range encoded length
|
||||
- **THEN** validation rejects it through the bounded standard-library reader before an unbounded CSV record can be allocated
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Retained private Linux candidate artifact
|
||||
A retained private Linux candidate artifact SHALL have API metadata whose `expires_at - created_at` interval is at least 30 elapsed days (2,592,000 seconds). Workflow intent, cleanup lag, and a local copy SHALL NOT substitute for the recorded API interval. A shorter interval SHALL fail the artifact-retention gate even when execution and artifact bytes pass. The workflow request MAY exceed 30 calendar days only to compensate for verified platform rounding; the acceptance threshold remains at least 30 elapsed days.
|
||||
|
||||
+5
-5
@@ -23,28 +23,28 @@
|
||||
- [x] 5.1 Retain the v6 qualification attempt and mark its passing result superseded by the tight-loop source defect
|
||||
- [x] 5.2 Implement v8 post-first-write, non-collapsing complete-frame UDP pacing with persistent cross-frame carry and verify the focused, race, short-resource, and cross-platform compile checks that passed
|
||||
- [x] 5.3 Preserve private Linux runs 123 and 124 as failed evidence, the passing local v8 Darwin sustained run as non-Linux and non-normative, and the un-retried failed Darwin non-sustained pre-CI invocation with its exact throughput evidence
|
||||
- [ ] 5.4 Run private Linux full verification and retain deterministic Linux artifacts for the frozen v10 evidence descendant
|
||||
- [ ] 5.5 Run one separately approved replacement v10 normative Section 7 qualification
|
||||
- [x] 5.4 Run private Linux full verification and retain deterministic Linux artifacts for the frozen v10 evidence descendant
|
||||
- [x] 5.5 Run one separately approved replacement v10 normative Section 7 qualification
|
||||
|
||||
## 6. Native video ingress remediation
|
||||
|
||||
- [x] 6.1 Preserve run 125 and reproduce its pre-decrypt shortfall with a public 662-shard blocked-AEAD regression
|
||||
- [x] 6.2 Add the video-only 2,195,456-byte socket-buffer request, fixed 2,048-slot drain, single processor, overflow accounting, and bounded cancellation tests
|
||||
- [ ] 6.3 Freeze the reviewed production repair through the still-open private Linux full-verification and artifact gate before any replacement normative run
|
||||
- [x] 6.3 Freeze the reviewed production repair through private Linux full verification and artifact retention before running the replacement normative qualification
|
||||
|
||||
## 7. Fair-pacer schedule-debt remediation
|
||||
|
||||
- [x] 7.1 Preserve the consumed failed `55afea72` v8 attempt and its two partial files without retry, relabeling, or modification
|
||||
- [x] 7.2 Reproduce repeated host-stall queue expiry through the public native path and add bounded one-flow/eight-flow debt, fairness, rolling-cap, and capacity-step regressions
|
||||
- [x] 7.3 Retain the 5 ms instantaneous ceiling, carry valid debt within the 250 ms queue horizon, and repay it at no more than 5% above nominal fair share
|
||||
- [ ] 7.4 Freeze and verify a new executable candidate on private Linux before any separately authorized replacement normative run
|
||||
- [x] 7.4 Freeze and verify a new executable candidate on private Linux before any separately authorized replacement normative run
|
||||
|
||||
## 8. Public-wire capacity measurement correction
|
||||
|
||||
- [x] 8.1 Preserve run 127/job 481 at `122080ab` as failed evidence with its exact log hash, no artifact, and no retry
|
||||
- [x] 8.2 Observe raw independent-client QUIC datagram lengths/times in-process and derive v9 capacity convergence and caps from configured wire rate while keeping logical payload observations separate; this did not persist sufficient raw evidence for independent proof
|
||||
- [x] 8.3 Preserve run 128/job 482 as passing Linux execution and artifact-byte evidence but retention-nonconforming, with no retry
|
||||
- [ ] 8.4 Freeze the 31-day-request descendant and prove a future private artifact records at least 30 elapsed days before tasks 5.4, 5.5, 6.3, or 7.4 can close
|
||||
- [x] 8.4 Freeze the 31-day-request descendant and prove artifact 30 records exactly 30 elapsed days before closing tasks 5.4, 5.5, 6.3, and 7.4
|
||||
|
||||
## 9. Retained public-wire evidence correction
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-10
|
||||
@@ -0,0 +1,182 @@
|
||||
## Context
|
||||
|
||||
Phase 3C freezes a gateway-only control/media baseline, but the Data Plane macOS target is
|
||||
still a template and the shared Protocol has no requested display mode, absolute pointer, or
|
||||
high-resolution scroll contract. The closed Server already owns broker persistence,
|
||||
allocation policy, manifest disclosure, and provider-work. The GPLv3 Data Plane owns the
|
||||
pure-Go gateway/Apollo translation plus the future Rust core and native client. The Protocol
|
||||
repository remains the only wire authority.
|
||||
|
||||
The implementation must preserve strict legacy JSON decoding, exact immutable Protocol pins,
|
||||
no direct client-to-provider path, encoded-media relay without gateway decode/transcode, and
|
||||
bounded release-all behavior. UI references are retained in the Planning repository as
|
||||
review evidence only.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Deliver one native Apple-Silicon SwiftUI/AppKit client for macOS 26 with macOS 15/14
|
||||
compatibility, built against a deterministic fake core before real streaming integration.
|
||||
- Carry a client-requested display mode through Server allocation to provider work, while the
|
||||
Server remains the only policy/clamp authority and both requested/effective values remain
|
||||
visible to the client.
|
||||
- Add provider-neutral absolute pointer and high-resolution scroll events without changing
|
||||
the gateway-only route or coupling the public contract to Apollo packet shapes.
|
||||
- Keep lifecycle, input release, local preview storage, secrets, media, and diagnostics
|
||||
bounded and testable at their trust boundaries.
|
||||
- Preserve a small, sized/versioned Rust C ABI and a single Swift unsafe bridge.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Remote apps, manual PC/direct provider connections, live display renegotiation,
|
||||
multi-monitor remote topology, HDR, microphone, file/folder redirection, image clipboard,
|
||||
macros/timed sequences, Intel, provider-specific UI, or a plugin framework.
|
||||
- Gateway decode, encode, transcode, render, cgo, native sidecar, insecure retry, or silent
|
||||
capability fallback.
|
||||
- Public release, signing credentials, deployment, promotion, or agent access to the owner's
|
||||
Apollo host. Live Apollo/SudoMaker evidence remains owner E2E.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Repository and compatibility ownership
|
||||
|
||||
- Protocol adds a reusable `DisplayMode` object and optional request/response fields while
|
||||
retaining control wire version 1. Generated Go represents optional referenced objects as
|
||||
pointers so `nil` is omitted; Rust/Swift use optionals. A legacy request receives the
|
||||
legacy response shape, while a display-aware request requires `display.request.v1` and a
|
||||
display-aware manifest contains the accepted value.
|
||||
- Protocol registers `display.request.v1`, `input.absolute.v1`, and `input.scroll.v1` and
|
||||
extends VGI1 only behind those negotiated features. Old gateways continue to reject
|
||||
unknown kinds; clients therefore never send an unadvertised kind.
|
||||
- Protobuf remains unchanged unless an observed consumer or verification gate proves that
|
||||
the JSON control route actually depends on it. This avoids an unrelated second contract.
|
||||
- A verified-unused immutable Phase 3D Protocol RC is frozen before Server/Data pins advance
|
||||
together. No sibling `replace` or local wire fork is committed.
|
||||
|
||||
### 2. Server-owned display decision
|
||||
|
||||
- Broker sessions gain nullable requested/effective width, height, and FPS columns plus the
|
||||
immutable policy-version identity needed to replay allocation/reconnect. Dedicated columns
|
||||
keep the decision queryable and avoid overloading the existing bandwidth policy JSON.
|
||||
- Request validation accepts width 320..16384, height 200..8640, and FPS 1..240. Requested
|
||||
mode participates in every idempotency identity, including waiting sessions.
|
||||
- Allocation selects the machine, resolves the immutable machine/pool/global policy, computes
|
||||
the accepted mode, and atomically persists the allocation and accepted values. Reconnect
|
||||
reuses the persisted result; it does not re-clamp against later policy.
|
||||
- Clamp is proportional and deterministic:
|
||||
`scale = min(policyWidth/requestWidth, policyHeight/requestHeight, 1)`;
|
||||
`effectiveWidth = floorToEven(requestWidth * scale)`;
|
||||
`effectiveHeight = floorToEven(requestHeight * scale)`;
|
||||
`effectiveFPS = min(requestedFPS, policyFPS)`.
|
||||
- Provider work uses the persisted effective dimensions/FPS and the selected immutable
|
||||
policy's codec/bitrate/audio. Legacy sessions retain today's exact policy dimensions.
|
||||
|
||||
### 3. Provider-neutral input
|
||||
|
||||
- VGI1 kind `0x06` has exactly eight body bytes: big-endian unsigned `x`, `y`, viewport
|
||||
width, and viewport height. Width/height must be nonzero; x < width and y < height.
|
||||
- VGI1 kind `0x07` has exactly four body bytes: big-endian signed 16-bit vertical and
|
||||
horizontal high-resolution scroll deltas.
|
||||
- Protocol docs, fixtures, Go/Rust/Swift classifiers, Data encode/decode, fuzzing, transport,
|
||||
and Apollo translation share the same vectors. Apollo packet details remain private to the
|
||||
adapter and must be justified by pinned reference source before implementation.
|
||||
- Absolute/scroll events do not enter the pressed-state ledger. Keys, buttons, and controllers
|
||||
continue to release through the existing ledger on every authority/lifecycle exit.
|
||||
|
||||
### 4. Native workspace, display discovery, and input ownership
|
||||
|
||||
- The client uses `NavigationSplitView`, native controls/materials, and standard macOS 26
|
||||
Liquid Glass APIs conditionally. macOS 15/14 retain identical hierarchy with native
|
||||
materials; no custom glass framework is introduced.
|
||||
- The main split workspace exposes All, Favorites, Desktops, and Pools. Settings opens from
|
||||
the app menu in a separate regular window, and a Connections menu owns resource commands;
|
||||
neither surface adds Apps, Add PC, manual endpoint, or direct-provider entry.
|
||||
- Desktop cards use a bounded adaptive `300...360 pt` width, `16:10` aspect ratio, and
|
||||
`16 pt` spacing. Cards grow continuously within natural column bands, including three
|
||||
columns; a new column appears only when every card can remain at least `300 pt` wide;
|
||||
cards clamp at `360 pt` and retain residual row width. Screenshot pixels are
|
||||
non-authoritative. Card actions remain accessible without hover, and no direct-connect
|
||||
button or endpoint entry is added.
|
||||
- Automatic mode reads physical pixels for the screen where the session window opens and
|
||||
uses its maximum FPS. A defensible notch-safe built-in mode is preferred; otherwise Full
|
||||
Native is disclosed. A display move offers a new-session reconnect and never mutates the
|
||||
active stream.
|
||||
- The renderer aspect-fits. Absolute coordinates originate only inside the rendered viewport;
|
||||
letterbox bars produce no pointer event.
|
||||
- Native `NSWindow` fullscreen preserves top-edge system UI in absolute mode. Relative mode
|
||||
hides/captures the cursor. Reserved mode-toggle and emergency-release chords are handled
|
||||
before mapping and never cross the core boundary. Mode switches release pressed input and
|
||||
clamp cursor restoration to the rendered viewport.
|
||||
|
||||
### 5. State, Rust core, and platform boundaries
|
||||
|
||||
- Swift actors own authentication, resources, brokerage, streaming, and settings; immutable
|
||||
projections reach `MainActor` views. One bridge owns every C pointer/callback/lifetime.
|
||||
- The Rust core owns tunnel negotiation, authenticated framing, packet reconstruction,
|
||||
encoded media delivery, input encoding, statistics, cancellation, and structured errors.
|
||||
Apple frameworks own decode/render/audio/device/input capture and product state.
|
||||
- The C ABI uses fixed-width values, explicit lengths, opaque handles, sized/versioned tables,
|
||||
a declared callback queue, caught panics, cancellation, and late-callback/destroy rules.
|
||||
- Fakes cover every external boundary before real core/media integration. No speculative
|
||||
adapter/factory/plugin layer is added beyond interfaces already needed for deterministic
|
||||
tests.
|
||||
|
||||
### 6. Preview and diagnostic privacy
|
||||
|
||||
- Stable desktop previews are downscaled to `656 x 410`, stored in the sandbox cache as mode
|
||||
`0600`, and written only after an active session ends cleanly. Failed launch retains the
|
||||
prior preview. Pool cards use generic art.
|
||||
- Logout or entitlement loss clears affected previews. Hide/Clear settings are explicit.
|
||||
Backup, telemetry, OSLog, crashes, support bundles, and default diagnostics exclude preview
|
||||
and raw media bytes.
|
||||
- Diagnostics are allowlisted structured state/correlation/performance summaries; tokens,
|
||||
grants, provider endpoints, media, input, clipboard, and Windows secrets never enter them.
|
||||
|
||||
### 7. Delivery and evidence
|
||||
|
||||
- Land capability-sized commits in order: Planning/OpenSpec, Protocol, Server, gateway input,
|
||||
fake-core native shell, Rust/core integration, platform media, then full qualification.
|
||||
- Every nontrivial boundary starts with a focused failing test/vector and ends with the
|
||||
narrow affected gate; one complete clean-source gate runs only after freeze.
|
||||
- macOS 26/15/14 native behavior, accessibility, lifecycle, privacy, and platform media require
|
||||
real target evidence. The owner later runs the only live Apollo/SudoMaker acceptance.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Strict legacy decoders reject additive response fields] -> Emit display fields only for a
|
||||
negotiated display-aware request; retain legacy shapes and cross-version fixtures.
|
||||
- [Requested dimensions exceed policy or change aspect] -> Apply one Server-owned
|
||||
proportional/even clamp, persist it atomically, and disclose both values.
|
||||
- [Absolute coordinates mismatch presentation] -> Normalize against the rendered viewport,
|
||||
reject letterbox coordinates, and test edge/scale/display cases.
|
||||
- [Relative capture strands local input] -> Keep an unremappable emergency release, release
|
||||
before every mode/authority transition, and test focus/sleep/cancel/revocation paths.
|
||||
- [Preview bytes leak user media] -> Minimize one local file, mode `0600`, clean-session-only
|
||||
writes, lifecycle clearing, and explicit exclusion/secret-canary tests.
|
||||
- [Apollo input semantics are guessed] -> Stop until the pinned provider source establishes
|
||||
the exact packet; do not encode provider assumptions into VGI.
|
||||
- [macOS API rendering differs by release/accessibility setting] -> Test deterministic
|
||||
geometry and state automatically; use bounded manual material review without snapshots of
|
||||
OS-owned pixels as the sole gate.
|
||||
- [The full client scope is large] -> Keep serial capability commits and stop at the first
|
||||
failed contract/environment gate rather than introducing fallbacks or partial claims.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Freeze Planning/OpenSpec and visual references.
|
||||
2. Implement/freeze an immutable Protocol RC with legacy and new feature fixtures.
|
||||
3. Apply the forward-only Server migration and update Server/Data Protocol pins together.
|
||||
4. Deploy no schema or runtime automatically; validate clean install/upgrade/legacy rows and
|
||||
gateway fixtures locally/private CI first.
|
||||
5. Build the native shell against fakes, then integrate the frozen core/gateway surfaces.
|
||||
6. Roll back application binaries only while schema compatibility permits; nullable additive
|
||||
fields preserve legacy row behavior. Never down-migrate or rewrite applied migrations.
|
||||
7. Keep signing, publication, deployment, promotion, and live owner E2E under separate
|
||||
authorization.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None at proposal time. Exact Apollo absolute-pointer/scroll packet vectors must be established
|
||||
from the approved pinned source before the adapter step; absence of that evidence is an
|
||||
implementation hard stop, not a design choice to guess.
|
||||
@@ -0,0 +1,69 @@
|
||||
## Why
|
||||
|
||||
Phase 3C has frozen the gateway and Connection Server image-engineering baseline, so the first
|
||||
native VerseVDI endpoint can now be built against an immutable gateway-only authority model.
|
||||
Phase 3D must add the user-facing macOS workspace, exact display negotiation, safe native
|
||||
input, and streaming core without exposing Apollo/provider details or weakening the existing
|
||||
control and transport boundaries.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Build the Apple-Silicon SwiftUI/AppKit client and platform-neutral Rust Streaming Core
|
||||
behind one sized/versioned C ABI (`P3D-001`–`P3D-031`).
|
||||
- Implement the reviewed native All/Favorites/Desktops/Pools workspace, bounded adaptive
|
||||
`300...360 pt` `16:10` preview cards with `16 pt` spacing and natural columns, search/sort,
|
||||
a Connections menu, Settings through the app menu in a separate regular window,
|
||||
accessibility, and privacy-bounded desktop previews (`P3D-032`, `P3D-037`). Screenshot
|
||||
pixels are behavioral evidence, not authoritative geometry constants.
|
||||
- Add feature-gated requested/effective display modes, Server-owned policy clamping,
|
||||
client display detection/disclosure, and explicit reconnect-on-display-change behavior
|
||||
(`P3D-033`–`P3D-034`).
|
||||
- Add provider-neutral absolute pointer and high-resolution scroll input while preserving
|
||||
relative input, release-all, reserved local escape chords, letterbox exclusion, and scoped
|
||||
keyboard mapping (`P3D-035`–`P3D-036`).
|
||||
- Keep authentication, manifests, media, input, and clipboard on the authenticated
|
||||
gateway-only route. The client receives no provider endpoint, provider credential, or
|
||||
Windows credential outside normal encrypted input.
|
||||
- Defer remote apps, manual PC/direct-provider connections, live display renegotiation,
|
||||
multi-monitor remote topology, HDR, microphone, file/folder redirection, image clipboard,
|
||||
macros, Intel, public release, and provider-specific client UI.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `macos-native-foundation`: Platform baseline, Swift/Rust/C ABI ownership, authentication,
|
||||
resource/broker authority, gateway-only manifest validation, and fake-core-first delivery.
|
||||
- `macos-media-presentation`: Registered media dispatch, hardware video presentation, audio,
|
||||
bounded queues, and observable quality without media persistence.
|
||||
- `macos-input-control`: Provider-neutral keyboard, pointer, scroll, controller, text
|
||||
clipboard, mappings, reserved local chords, and release-all behavior.
|
||||
- `macos-lifecycle-quality`: Interruption, reconnect, accessibility, privacy, diagnostics,
|
||||
packaging, rollback, uninstall, and candidate qualification behavior.
|
||||
- `macos-workspace`: Screenshot-backed workspace/card interactions, All/Favorites/Desktops/
|
||||
Pools navigation, separate Settings and Connections command surfaces, search/sort, and
|
||||
privacy-bounded previews.
|
||||
- `session-display-mode`: Feature-gated requested/effective display mode, Server policy
|
||||
clamping, client detection/disclosure, and owner Apollo IDD acceptance.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
None. Existing Phase 3C gateway capabilities remain unchanged; this change consumes and
|
||||
extends their separately owned Protocol surfaces without redefining their requirements.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Protocol repository:** additive strict JSON display objects, feature identifiers, VGI1
|
||||
absolute/scroll grammar, cross-language bindings, fixtures, and compatibility tests.
|
||||
- **Connection Server:** additive broker-session migration/query fields, request identity,
|
||||
allocation transaction/clamp, manifest disclosure, and provider-work projection.
|
||||
- **Data Plane:** Protocol pin update, Go gateway input validation/Apollo translation, Rust
|
||||
core/C ABI/XCFramework, SwiftUI/AppKit client, tests, documentation, and private build
|
||||
configuration.
|
||||
- **Provenance and license boundary:** Apollo, Moonlight, Microsoft Windows App, and Omnissa
|
||||
material remains reference evidence only. No proprietary Server or Planning content is
|
||||
copied into the GPLv3 Data Plane.
|
||||
- **Hard stops:** unknown provider packet semantics, incompatible legacy response behavior,
|
||||
direct-provider routing, secret/media/input persistence, unsupported platform safety, or
|
||||
a failed deterministic gate blocks the affected capability. Live Apollo/macOS/firewall
|
||||
interoperability remains `deferred-owner-e2e` until the owner runs the frozen candidate.
|
||||
@@ -0,0 +1,55 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: P3D-016 complete MVP input
|
||||
Input SHALL support keyboard, absolute and relative mouse, high-resolution scroll, and one standard controller with a pressed-state ledger and release-all on every authority/interruption boundary.
|
||||
|
||||
#### Scenario: Authority loss with pressed input
|
||||
- **WHEN** focus, network, session authority, sleep, cancellation, or termination changes while input is pressed
|
||||
- **THEN** release-all is sent/recorded once and no pressed state remains
|
||||
|
||||
### Requirement: P3D-017 Windows password is ordinary input
|
||||
Windows lock-screen credentials SHALL travel only as normal encrypted input events and SHALL never be collected, stored, autofilled, or separately injected.
|
||||
|
||||
#### Scenario: Lock-screen typing
|
||||
- **WHEN** the user types into a remote Windows credential field
|
||||
- **THEN** the client handles the keys like any remote input and retains no credential value
|
||||
|
||||
### Requirement: P3D-028 controller layouts
|
||||
Controller input SHALL support user-selectable Xbox, PlayStation, and Nintendo/Switch-style normalized layouts with explicit unsupported-capability behavior.
|
||||
|
||||
#### Scenario: Layout selection
|
||||
- **WHEN** the user changes controller layout
|
||||
- **THEN** subsequent normalized events use the selected mapping and unsupported controls are surfaced explicitly
|
||||
|
||||
### Requirement: P3D-029 bounded text clipboard
|
||||
The client SHALL expose clipboard enablement/direction and visible failure behavior, permit only bounded text, and reject files, file URLs, folders, binary data, and oversized content.
|
||||
|
||||
#### Scenario: File clipboard attempt
|
||||
- **WHEN** a local or remote clipboard advertises a file or file URL
|
||||
- **THEN** transfer is rejected without reading or persisting file content
|
||||
|
||||
### Requirement: P3D-035 native pointer modes and escape safety
|
||||
The client SHALL aspect-fit video, emit absolute pointer events only inside the rendered viewport, default to macOS-owned absolute cursor behavior, and provide session-only relative capture with reserved local control chords.
|
||||
|
||||
#### Scenario: Letterbox pointer
|
||||
- **WHEN** the absolute pointer is in a letterbox bar
|
||||
- **THEN** no remote absolute-pointer event is emitted
|
||||
|
||||
#### Scenario: Toggle relative mode
|
||||
- **WHEN** `Control-Option-Shift-M` is pressed during a session
|
||||
- **THEN** pressed input is released, the chord is not forwarded, capture switches, and the saved/restored cursor point is clamped inside the rendered viewport
|
||||
|
||||
#### Scenario: Emergency release
|
||||
- **WHEN** `Control-Option-Shift-Escape` is pressed in any input mode
|
||||
- **THEN** pressed input is released, absolute/local cursor ownership and controls return, and the chord is not forwarded
|
||||
|
||||
### Requirement: P3D-036 scoped keyboard mappings
|
||||
Keyboard mapping SHALL support physical/logical keys and simultaneous chords with Mac-to-Windows and language defaults, deterministic global/resource precedence and modifier order, conflicts, enablement, add/remove, and Restore Defaults; macros and reserved-chord remapping are prohibited.
|
||||
|
||||
#### Scenario: Conflicting resource mapping
|
||||
- **WHEN** an enabled resource mapping conflicts with a global mapping
|
||||
- **THEN** deterministic resource precedence applies and the conflict is visible/editable
|
||||
|
||||
#### Scenario: Reserved mapping attempt
|
||||
- **WHEN** a mapping targets either local reserved escape chord
|
||||
- **THEN** the mapping is rejected and the local chord remains intercepted
|
||||
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: P3D-018 explicit recovery states
|
||||
The client SHALL distinguish network interruption, reconnectable session, draining, provider cleanup failure, assignment unavailable, capacity queue, entitlement loss, and terminal end with safe actions.
|
||||
|
||||
#### Scenario: Reconnectable interruption
|
||||
- **WHEN** the control authority reports a reconnectable session after network loss
|
||||
- **THEN** the UI offers only a fresh authorized reconnect path and does not replay the old grant
|
||||
|
||||
### Requirement: P3D-019 deterministic lifecycle changes
|
||||
Sleep/wake, foreground/background, display/audio/controller/network changes, server/gateway restart, and core cancellation SHALL have deterministic tested behavior.
|
||||
|
||||
#### Scenario: Sleep during active stream
|
||||
- **WHEN** the Mac sleeps during an active stream
|
||||
- **THEN** input is released, owned tasks/resources stop, and wake reconciles authority before reconnect
|
||||
|
||||
### Requirement: P3D-021 accessible critical paths
|
||||
Authentication, resources, brokerage, session, errors, and settings SHALL support VoiceOver, keyboard navigation, visible focus, contrast, reduced motion, dynamic type where applicable, and non-color status cues.
|
||||
|
||||
#### Scenario: Keyboard-only connection
|
||||
- **WHEN** a user navigates the workspace without a pointer
|
||||
- **THEN** every critical action is reachable with visible focus and meaningful accessibility labels
|
||||
|
||||
### Requirement: P3D-022 redacted diagnostics
|
||||
Errors, telemetry, logs, crashes, and support artifacts SHALL exclude credentials, grants, provider endpoints, raw media/input, clipboard content, and Windows secrets while retaining bounded correlation identifiers.
|
||||
|
||||
#### Scenario: Synthetic secret canary
|
||||
- **WHEN** all diagnostics and support outputs are generated after injecting secret canaries
|
||||
- **THEN** the scanner finds no secret, media, input, clipboard, or provider-endpoint value
|
||||
|
||||
### Requirement: P3D-023 release platform safety
|
||||
Any distributed release SHALL be signed, notarized, hardened-runtime compatible, signature-verified for update, and rollback-tested.
|
||||
|
||||
#### Scenario: Tampered update
|
||||
- **WHEN** update metadata or the application artifact is modified after signing
|
||||
- **THEN** update verification fails before installation or execution
|
||||
|
||||
### Requirement: P3D-024 explicit uninstall policy
|
||||
Uninstall SHALL remove documented local application state according to explicit Keychain/cache policy and SHALL NOT silently delete server-side device authority.
|
||||
|
||||
#### Scenario: Offline uninstall
|
||||
- **WHEN** the app is removed while the Server is unreachable
|
||||
- **THEN** local state follows policy and server-side revocation is not falsely claimed
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: P3D-014 hardware video presentation
|
||||
Video SHALL use negotiated registered media identifiers, VideoToolbox hardware decode, and Metal presentation without unnecessary copies or persistence.
|
||||
|
||||
#### Scenario: Unsupported hardware decode
|
||||
- **WHEN** the negotiated profile cannot be decoded by supported VideoToolbox hardware
|
||||
- **THEN** the session fails explicitly without software fallback, remote transcode, or pixel logging
|
||||
|
||||
### Requirement: P3D-015 bounded audio playback
|
||||
Audio SHALL use a proven CoreAudio abstraction with bounded buffering, negotiated channel layout, drift handling, and measurable A/V synchronization.
|
||||
|
||||
#### Scenario: Audio device replacement
|
||||
- **WHEN** the default audio device changes or disappears during an active session
|
||||
- **THEN** bounded playback state is recreated or fails visibly without persisting streamed audio
|
||||
@@ -0,0 +1,134 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: P3D-001 Apple Silicon platform baseline
|
||||
The client SHALL target Apple Silicon with macOS 26 primary, macOS 15/14 supported, and a non-release macOS 27 compatibility lane when its SDK/runtime exists.
|
||||
|
||||
#### Scenario: Supported platform matrix
|
||||
- **WHEN** the frozen candidate is qualified
|
||||
- **THEN** exact native evidence exists for macOS 26, 15, and 14, while Intel is not claimed
|
||||
|
||||
### Requirement: P3D-002 isolated SwiftUI state
|
||||
The shell SHALL use SwiftUI with structured concurrency and isolated authentication, resource, broker, stream, and settings state.
|
||||
|
||||
#### Scenario: Concurrent state update
|
||||
- **WHEN** control and stream events arrive concurrently
|
||||
- **THEN** owning actors serialize mutation and views receive immutable main-actor projections
|
||||
|
||||
### Requirement: P3D-003 no former C++ core
|
||||
The implementation SHALL NOT restore the superseded C++ core packaging design.
|
||||
|
||||
#### Scenario: Core dependency audit
|
||||
- **WHEN** the native client dependency graph is inspected
|
||||
- **THEN** no VerseVDI C++ streaming core or wrapper is linked
|
||||
|
||||
### Requirement: P3D-004 stable C ABI
|
||||
The C ABI SHALL use sized versioned tables and define pointer ownership, lifetime, callback thread, cancellation, error, reentrancy, and destroy behavior.
|
||||
|
||||
#### Scenario: Older caller table
|
||||
- **WHEN** a caller supplies a supported older structure size
|
||||
- **THEN** the core reads only available fields and returns a deterministic compatibility result
|
||||
|
||||
### Requirement: P3D-005 Rust core
|
||||
The platform-neutral streaming core SHALL be Rust, not the superseded C++ implementation.
|
||||
|
||||
#### Scenario: Core artifact inspection
|
||||
- **WHEN** the XCFramework artifact is inventoried
|
||||
- **THEN** its exported VerseVDI surface is the reviewed C ABI backed by the pinned Rust core
|
||||
|
||||
### Requirement: P3D-006 Swift platform ownership
|
||||
Swift SHALL own HTTPS/WebSocket control, Keychain, navigation, accessibility, Apple decode/render/audio/input objects, signing, and update integration.
|
||||
|
||||
#### Scenario: Platform operation routing
|
||||
- **WHEN** a platform credential, window, decoder, audio device, or input operation occurs
|
||||
- **THEN** it is owned by Swift/AppKit/Apple frameworks and not by the Rust core
|
||||
|
||||
### Requirement: P3D-007 narrow credential storage
|
||||
Refresh credentials and device private keys SHALL use the narrowest practical Keychain accessibility; access tokens and gateway grants SHALL remain memory-only.
|
||||
|
||||
#### Scenario: Relaunch storage audit
|
||||
- **WHEN** the app terminates and relaunches
|
||||
- **THEN** no access token or gateway grant is recoverable from persistent client storage
|
||||
|
||||
### Requirement: P3D-008 authenticated device enrollment
|
||||
First use SHALL authenticate before device-key generation/registration and SHALL complete server challenge proof before launch authority.
|
||||
|
||||
#### Scenario: Unauthenticated enrollment attempt
|
||||
- **WHEN** device enrollment is requested without a current authenticated user session
|
||||
- **THEN** no key is registered and no launch authority is issued
|
||||
|
||||
### Requirement: P3D-009 no active LDAP mode
|
||||
The Phase 3D client SHALL NOT expose an active LDAP login mode before Phase 6.
|
||||
|
||||
#### Scenario: Login surface inspection
|
||||
- **WHEN** the Phase 3D login UI and control requests are exercised
|
||||
- **THEN** only local login is offered and no LDAP request is made
|
||||
|
||||
### Requirement: P3D-010 authorized resources only
|
||||
The UI SHALL show only assigned desktops and entitled pools, including unavailable assignment, `awaiting_desktop`, and capacity queue state.
|
||||
|
||||
#### Scenario: Cross-subject cached resource
|
||||
- **WHEN** stale local data references another subject's resource
|
||||
- **THEN** reconciliation removes or withholds it and no action is available
|
||||
|
||||
### Requirement: P3D-011 idempotent broker reconciliation
|
||||
The client SHALL request/cancel idempotently, apply events by sequence, resync gaps through REST, and reconcile current authority after relaunch.
|
||||
|
||||
#### Scenario: Event sequence gap
|
||||
- **WHEN** the next event sequence is not contiguous
|
||||
- **THEN** incremental application stops and REST reconciliation completes before further action
|
||||
|
||||
### Requirement: P3D-012 gateway-only manifest
|
||||
The client SHALL accept only a gateway-only manifest matching authenticated server, device, session, audience, expiry, and supported tunnel range.
|
||||
|
||||
#### Scenario: Wrong manifest context
|
||||
- **WHEN** any manifest binding or required tunnel capability is wrong
|
||||
- **THEN** the core rejects it before opening a media/input session
|
||||
|
||||
### Requirement: P3D-013 forbidden provider details
|
||||
The client SHALL reject and safely report any manifest containing a provider/VM endpoint or credential-like field.
|
||||
|
||||
#### Scenario: Provider field injection
|
||||
- **WHEN** a manifest contains a provider address or credential-shaped field
|
||||
- **THEN** launch fails closed and diagnostics contain no injected value
|
||||
|
||||
### Requirement: P3D-020 callback isolation
|
||||
Core callbacks SHALL transfer bounded immutable values to their owning actor and SHALL NOT mutate SwiftUI state directly.
|
||||
|
||||
#### Scenario: Callback storm
|
||||
- **WHEN** callbacks arrive rapidly from a non-main core thread
|
||||
- **THEN** bounded bridge delivery preserves order/ownership without direct view-state mutation
|
||||
|
||||
### Requirement: P3D-025 fake-core-first candidate
|
||||
The client SHALL pass fake-core UI behavior before real-core fixture integration and SHALL freeze a platform-qualified candidate before owner live Apollo acceptance.
|
||||
|
||||
#### Scenario: Live provider unavailable
|
||||
- **WHEN** deterministic implementation gates pass without owner Apollo access
|
||||
- **THEN** the candidate can reach engineering review with live interoperability recorded `deferred-owner-e2e`
|
||||
|
||||
### Requirement: P3D-026 XCFramework packaging
|
||||
The Rust core SHALL be packaged as a static library within an XCFramework behind one stable C ABI and thin Swift wrapper.
|
||||
|
||||
#### Scenario: Unsafe-call audit
|
||||
- **WHEN** Swift source is inspected
|
||||
- **THEN** only the designated bridge owns direct C ABI pointer and callback translation
|
||||
|
||||
### Requirement: P3D-027 core responsibility boundary
|
||||
The Rust core SHALL own tunnel negotiation, cryptography, reconstruction, encoded delivery, input encoding, policy updates, statistics, and core errors without owning product authorization or Apple UI/media devices.
|
||||
|
||||
#### Scenario: Responsibility audit
|
||||
- **WHEN** the core dependency/module graph is inspected
|
||||
- **THEN** no SwiftUI, Apple decoder/renderer/audio device, or product-auth implementation is linked into it
|
||||
|
||||
### Requirement: P3D-030 registered media dispatch
|
||||
Core and client SHALL dispatch framing, reconstruction, decode, and render from registered negotiated identifiers and SHALL fail stably on unsupported/stale combinations.
|
||||
|
||||
#### Scenario: Unsupported profile combination
|
||||
- **WHEN** framing and media identifiers have no supported explicit intersection
|
||||
- **THEN** the session fails before decoder allocation without guessing, fallback, or transcode
|
||||
|
||||
### Requirement: P3D-031 local authentication lifecycle
|
||||
The MVP SHALL support local login, rotating refresh, logout, device-revocation response, and generic anti-enumeration errors.
|
||||
|
||||
#### Scenario: Refresh-family revocation
|
||||
- **WHEN** the Server reports refresh reuse or device revocation
|
||||
- **THEN** local authority and active input/stream state are cleared and generic reauthentication is shown
|
||||
@@ -0,0 +1,27 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: P3D-032 native desktop workspace
|
||||
The app SHALL expose All, Favorites, Desktops, and Pools in a native split workspace without Apps, Add PC, manual endpoint, or direct-provider surfaces. Settings SHALL open from the app menu in a separate regular window, and a Connections menu SHALL expose applicable resource commands. Desktop cards SHALL use a bounded adaptive `300...360 pt` width, `16:10` aspect ratio, and `16 pt` spacing; grow continuously within natural column bands including three columns; add a column only when every card remains at least the minimum; clamp at the maximum with residual row width; and use full-bleed previews with subtle readable metadata treatment. Screenshot pixels SHALL be non-authoritative.
|
||||
|
||||
#### Scenario: Narrow and wide resize
|
||||
- **WHEN** the workspace moves between narrow, medium, and wide widths
|
||||
- **THEN** card width stays within `300...360 pt`, height preserves `16:10`, cards resize continuously within a column band, natural column transitions include three columns, and text/actions do not overflow
|
||||
|
||||
#### Scenario: Accessible card actions
|
||||
- **WHEN** a card receives hover or keyboard focus
|
||||
- **THEN** Favorite and settings controls become available without adding a connect button; double-click or Return connects
|
||||
|
||||
#### Scenario: Settings and Connections surfaces
|
||||
- **WHEN** the user invokes Settings or a Connections command
|
||||
- **THEN** Settings opens in its separate regular window and a resource command acts only on the currently visible selection; Return connects only a focused card
|
||||
|
||||
### Requirement: P3D-037 private favorites and previews
|
||||
Favorites, search, sort, filters, diagnostics, and previews SHALL reveal only current authorized resources. A stable desktop MAY retain one `656 x 410` mode-`0600` preview only after an active session ends cleanly; pool art is generic and preview bytes are excluded from backup/telemetry/log/crash/support data.
|
||||
|
||||
#### Scenario: Failed launch and clean end
|
||||
- **WHEN** a launch fails and a later active session ends cleanly
|
||||
- **THEN** the failed launch preserves the old preview and only the clean end atomically replaces it
|
||||
|
||||
#### Scenario: Entitlement loss
|
||||
- **WHEN** logout or reconciliation removes a desktop entitlement
|
||||
- **THEN** its preview is cleared and cannot appear through Favorites, search, cache, or diagnostics
|
||||
@@ -0,0 +1,27 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: P3D-033 requested and effective display mode
|
||||
The client SHALL offer global and desktop/pool overrides for Automatic, detected/common preset, Custom, and Full Native width/height/FPS. A display-aware request SHALL be feature-gated; the Server SHALL validate, include it in idempotency identity, resolve immutable policy, proportionally clamp to even dimensions and maximum FPS, persist requested/effective values atomically, and disclose both without provider details.
|
||||
|
||||
#### Scenario: Oversized custom request
|
||||
- **WHEN** a requested mode exceeds either policy dimension or FPS
|
||||
- **THEN** the Server applies one proportional scale, floors both dimensions to even pixels, caps FPS, persists the result, and returns the requested and accepted modes
|
||||
|
||||
#### Scenario: Legacy request
|
||||
- **WHEN** a client omits the feature and requested display object
|
||||
- **THEN** the Server preserves the legacy response shape and provider work uses the existing exact policy mode
|
||||
|
||||
#### Scenario: Same idempotency key with different mode
|
||||
- **WHEN** two requests reuse an idempotency key but contain different requested display modes
|
||||
- **THEN** the Server rejects the mismatch rather than returning or mutating the earlier session
|
||||
|
||||
### Requirement: P3D-034 native detection and reconnect semantics
|
||||
Automatic mode SHALL derive physical pixels and maximum FPS from the screen where the session window opens, prefer a defensible notch-safe built-in mode, otherwise disclose Full Native, and SHALL NOT mutate an active mode after a display move. Reconnect to match the new display SHALL end the prior session and use a new request/idempotency key. Owner E2E SHALL prove accepted values on Apollo/SudoMaker IDD through the gateway-only route.
|
||||
|
||||
#### Scenario: Move active session to another display
|
||||
- **WHEN** an Automatic session window moves to a display with different native capability
|
||||
- **THEN** the active mode remains unchanged and the user may explicitly reconnect with a newly detected request
|
||||
|
||||
#### Scenario: Exact owner virtual-display acceptance
|
||||
- **WHEN** the owner runs the frozen client, Server, gateway, and Apollo candidate
|
||||
- **THEN** retained evidence binds requested and accepted width/height/FPS to the actual SudoMaker virtual display without a direct client-provider route
|
||||
@@ -0,0 +1,69 @@
|
||||
## 1. Contract Freeze
|
||||
|
||||
- [x] 1.1 Add Protocol `DisplayMode`, negotiated display fields/features, VGI1 absolute/scroll grammar, strict valid/invalid cross-language fixtures, and optional-field omission regressions
|
||||
- [x] 1.2 Regenerate Go/Rust/Swift outputs twice, pass Protocol `make verify`, freeze a verified-unused immutable Phase 3D RC, and record its fixture/generated hashes
|
||||
- [x] 1.3 Update Server and Data Plane to the exact Protocol RC without a filesystem replacement and prove clean-cache module resolution before consumer implementation
|
||||
|
||||
## 2. Server Display Authority
|
||||
|
||||
- [x] 2.1 Add the forward-only nullable requested/effective display and policy-version migration plus clean-install/upgrade/schema/grant tests
|
||||
- [x] 2.2 Add strict request validation and requested-mode idempotency identity, including waiting-session mismatch regressions
|
||||
- [x] 2.3 Implement one proportional even-pixel/FPS clamp at allocation and atomically persist the immutable requested/effective decision
|
||||
- [x] 2.4 Disclose display-aware broker/manifest values only to negotiated clients, preserve legacy response shapes, and reuse the persisted mode on reconnect
|
||||
- [x] 2.5 Project persisted effective width/height/FPS through existing provider work with selected immutable codec/bitrate/audio and pass focused repository/E2E tests
|
||||
|
||||
## 3. Gateway Input Translation
|
||||
|
||||
- [x] 3.1 Add red VGI absolute/scroll encode/decode/bounds/fuzz/transport tests and prove unadvertised kinds fail before provider translation
|
||||
- [x] 3.2 Establish exact Apollo absolute-pointer and scroll vectors from the approved pinned source; hard-stop without that evidence
|
||||
- [x] 3.3 Implement the smallest provider-neutral VGI validation and Apollo adapter translation without adding pressed-state or direct-provider surfaces
|
||||
- [ ] 3.4 Pass focused input vectors/fuzz/transport/fake-provider tests, then one affected Data Plane `make verify`
|
||||
|
||||
## 4. Rust Core and Stable ABI
|
||||
|
||||
- [ ] 4.1 Pin Rust toolchain/dependencies and add minimal core modules with bounded fake transport, Protocol fixtures, cancellation, errors, queues, and redaction
|
||||
- [ ] 4.2 Specify and test the sized/versioned C ABI ownership, callback thread, panic, reentrancy, cancellation, late-callback, and destroy contracts before implementation
|
||||
- [ ] 4.3 Implement gateway-only manifest/QUIC/framing/media/input behavior against fixtures with no provider endpoint, decoder, renderer, transcode, or product-auth path
|
||||
- [ ] 4.4 Package deterministic Apple-Silicon static XCFramework output and one Swift bridge; pass Cargo format/Clippy/tests plus applicable fuzz/Miri/sanitizer and ABI stress gates
|
||||
|
||||
## 5. Native Foundation Against Fakes
|
||||
|
||||
- [ ] 5.1 Reconcile the Xcode project to macOS 14 deployment, Xcode 26.6/Swift 6.3 Swift-6 mode, Apple Silicon, and conditional macOS 26 APIs
|
||||
- [ ] 5.2 Implement isolated auth/resource/broker/stream/settings owners and deterministic control/event/core/credential/platform fakes with unit tests before views
|
||||
- [ ] 5.3 Implement local login, device proof, serialized rotating refresh, Keychain/memory-only policy, logout/revocation, and anti-enumeration tests
|
||||
- [ ] 5.4 Implement resource/broker/event-gap/relaunch/idempotency reconciliation and cross-subject fixture negatives
|
||||
|
||||
## 6. Workspace and Display UX
|
||||
|
||||
- [ ] 6.1 Build the screenshot-backed All/Favorites/Desktops/Pools workspace with bounded adaptive `300...360 pt` `16:10` cards, `16 pt` spacing, continuous within-band growth, natural minimum-triggered columns including three, maximum clamp/residual width, safe truncation, native focus/VoiceOver, Settings through the app menu in a separate regular window, a Connections menu, and no Apps/Add-PC/manual/direct endpoint surfaces; treat screenshot pixels as non-authoritative
|
||||
- [ ] 6.2 Add global and resource Automatic/preset/Custom/Full Native settings, physical-pixel/FPS detection, notch-safe disclosure, and requested/accepted projections with deterministic display fakes
|
||||
- [ ] 6.3 Add aspect-fit viewport math, letterbox pointer exclusion, display-move reconnect offer, and new-session/idempotency behavior tests
|
||||
- [ ] 6.4 Pass geometry/hierarchy/visibility/responsive/accessibility automation and bounded macOS 26/15/14 native-material review
|
||||
|
||||
## 7. Native Input and Preview Privacy
|
||||
|
||||
- [ ] 7.1 Implement absolute and relative pointer capture with native fullscreen, top-edge local controls, pressed-state release, bounded cursor restore, HUD, and unremappable local toggle/emergency chords
|
||||
- [ ] 7.2 Implement physical/logical and language-aware global/resource keyboard mappings, deterministic conflicts/precedence/modifier order, add/remove/enable/restore, and no-macro/reserved-chord negatives
|
||||
- [ ] 7.3 Implement normalized controller layouts and bounded text-only clipboard with file/binary/oversize/loop/permission negatives
|
||||
- [ ] 7.4 Implement atomic clean-session-only mode-`0600` desktop previews, generic pool art, hide/clear/logout/entitlement behavior, and backup/log/crash/support exclusion tests
|
||||
|
||||
## 8. Platform Media and Lifecycle
|
||||
|
||||
- [ ] 8.1 Implement registered VideoToolbox decode and Metal aspect-fit presentation with bounded latency-first queues, configuration/IDR/discontinuity/device-loss tests, and no pixel persistence
|
||||
- [ ] 8.2 Implement bounded CoreAudio playback/synchronization with device/format/sleep/cancel tests and retained timing summaries without audio persistence
|
||||
- [ ] 8.3 Implement deterministic sleep/wake, foreground/background, display/audio/controller/network change, server/gateway restart, cancellation, reconnect, and release-all behavior
|
||||
- [ ] 8.4 Complete accessible errors/actions, allowlisted diagnostics, privacy manifest, secret-canary support-bundle tests, and 100-cycle ownership/leak stress
|
||||
|
||||
## 9. Real-Core Integration
|
||||
|
||||
- [ ] 9.1 Replace fake-core session transport only at the existing client seam and pass the same state/UI/lifecycle suites unchanged
|
||||
- [ ] 9.2 Run shared Protocol manifest/framing/media/input fixtures through Go, Rust, Swift, gateway transport, and fake Apollo without payload or provider-detail leakage
|
||||
- [ ] 9.3 Pass fixture-backed gateway-only video/audio/input/reconnect/cancel/revocation integration and reject every direct-route/provider-field/downgrade case
|
||||
|
||||
## 10. Freeze and Qualification
|
||||
|
||||
- [ ] 10.1 Freeze exact source, Protocol/core artifacts, locks, settings, Xcode/toolchain, fake/provider fixtures, and environment; any change invalidates only its affected evidence plus the final gate
|
||||
- [ ] 10.2 Run the single final clean-source Protocol `make verify`, Server generated-SQL/OpenSpec/full serial Go suite, Data `make verify`/race/resource gates, Cargo gates, and Swift/XCTest/XCUITest/accessibility suites
|
||||
- [ ] 10.3 Qualify macOS 26/15/14 Apple Silicon platform behavior, lifecycle, performance/energy, privacy, uninstall/rollback preparation, and clean checkout; keep signing/publication/deployment/promotion separately authorized
|
||||
- [ ] 10.4 Retain versions, hashes, logs, inventories, redacted metrics, failures, and exact requirement mapping; archive this change only when canonical specs match
|
||||
- [ ] 10.5 Package but do not execute the owner E2E that binds Automatic/Custom requested and accepted width/height/FPS to the Apollo/SudoMaker virtual display through the gateway-only route; retain it as `deferred-owner-e2e`
|
||||
@@ -0,0 +1,38 @@
|
||||
# complete-encoded-frame-transport Specification
|
||||
|
||||
## Purpose
|
||||
Define complete encoded-frame transport and the bounded native media queues required to preserve frame bytes and boundaries through the gateway.
|
||||
## Requirements
|
||||
### Requirement: Production transport preserves complete encoded frames
|
||||
The gateway SHALL carry each recovered Apollo encoded frame as one Protocol datagram-v2 sequence, preserve exact bytes and frame boundaries through the production media queue, pacer, QUIC transport, and independent reassembler, and reject frames outside Protocol bounds before forwarding.
|
||||
|
||||
#### Scenario: Large source-shaped frame
|
||||
- **WHEN** Apollo UDP/FEC recovers a valid encoded frame above 18,864 bytes within the reviewed maximum
|
||||
- **THEN** the independent client receives one byte-identical frame with the same boundary
|
||||
|
||||
#### Scenario: Invalid fragment stream
|
||||
- **WHEN** fragments are oversized, inconsistent, conflicting duplicates, outside the reorder/state/time bounds, or claim an oversized frame
|
||||
- **THEN** the client emits no partial payload and bounded state is released
|
||||
|
||||
### Requirement: Native video queue has count byte and latency bounds
|
||||
The native provider video queue SHALL retain at most 16 complete frames, at
|
||||
most 4 MiB of encoded frame bytes, and no frame for more than 250 milliseconds.
|
||||
It SHALL replace the oldest entry when full, expire stale entries independently
|
||||
of queue activity, and increment truthful drop telemetry for every replacement
|
||||
or expiry. Cleanup and cancellation MUST stop expiry work and release all queued
|
||||
payload references.
|
||||
|
||||
#### Scenario: Sustained realistic frames
|
||||
- **WHEN** a provider produces realistic variable-size complete frames faster than a slow Verse reader can forward them
|
||||
- **THEN** retained entries, bytes, and age remain within the reviewed per-session limits and newer frames continue to progress
|
||||
|
||||
#### Scenario: Session cleanup
|
||||
- **WHEN** a session terminates, disconnects, or is cancelled with queued video
|
||||
- **THEN** queued frames are released, blocked readers wake, and no media crosses after quiescence
|
||||
|
||||
### Requirement: Other provider queues remain independently bounded
|
||||
Audio and provider event queues SHALL retain independent count and payload bounds and MUST NOT share the video byte budget.
|
||||
|
||||
#### Scenario: Video saturation
|
||||
- **WHEN** the video queue reaches its byte or age bound
|
||||
- **THEN** audio and terminal event delivery retain their existing independent bounded capacity
|
||||
@@ -5,49 +5,64 @@ Define the deterministic processing, impairment, pacing, and evidence boundaries
|
||||
for qualifying a frozen Phase 3C gateway candidate.
|
||||
## Requirements
|
||||
### Requirement: Fixed media processing qualification
|
||||
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted
|
||||
RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`,
|
||||
recovery/FEC, byte/count/latency-bounded production queues, the production fair
|
||||
pacer, Protocol complete-frame fragmentation, Verse framing/QUIC, and an
|
||||
independent bounded client reassembler for 1080p60 H.264 at 20 Mbps, 1440p120
|
||||
HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. The source fixture SHALL emit
|
||||
deterministic variable-size complete encoded frames at the named 60/120 FPS
|
||||
rate, preserve exact target bytes over each fixed interval, and include bounded
|
||||
larger keyframes without codec operation. After a recorded warm-up, the frozen
|
||||
candidate SHALL run each profile for ten wall-clock minutes, preserve every
|
||||
frame's bytes and boundary, retain every monotonic processing sample plus
|
||||
bounded provider-queue observations, and report frame count, frame rate,
|
||||
bitrate, min, median, p90, p95, p99, max, mean, standard deviation, and measured
|
||||
batched monotonic-clock overhead and method. Processing begins at complete
|
||||
provider-frame receipt and ends at QUIC handoff, excluding client transit and
|
||||
pacing. Queue delay SHALL measure provider-queue residence, processing SHALL
|
||||
measure gateway work before pacing, and pacing delay SHALL measure scheduler
|
||||
waiting. Native video queues SHALL retain at most 16 complete frames, 4 MiB,
|
||||
and 250 milliseconds; audio and event queues SHALL remain independently bounded
|
||||
at 16 units. CPU, heap, allocations, and goroutines SHALL be measured from the
|
||||
isolated gateway process only; CPU SHALL be actual OS user plus system
|
||||
consumption and MUST NOT include idle wall capacity or unrelated parent
|
||||
fixture/client work. Successive profiles SHALL use independent resource-counter
|
||||
baselines. Any bypass, payload or frame-boundary mutation, frame-rate/count
|
||||
mismatch, wall-duration violation, bitrate outside both lower and upper bounds,
|
||||
unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5
|
||||
ms SHALL fail.
|
||||
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, byte/count/latency-bounded production queues, the production fair pacer, Protocol complete-frame fragmentation, Verse framing/QUIC, and an independent bounded client reassembler for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. The source fixture SHALL emit deterministic variable-size complete encoded frame units at the named 60/120 FPS rate, preserve exact target bytes over each fixed interval, and include bounded larger keyframes without codec operation. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve every frame's bytes and boundary, retain every monotonic processing sample plus bounded provider-queue observations, and report frame count, frame rate, bitrate, count, min, median, p90, p95, p99, max, mean, standard deviation, measured batched monotonic-clock overhead and method, and observed bitrate. Processing begins at complete provider-frame receipt and ends at QUIC handoff, excluding client transit and pacing. Queue delay SHALL measure provider-queue residence, processing SHALL measure gateway work before pacing, and pacing delay SHALL measure scheduler waiting. CPU, heap, allocations, and goroutines SHALL be measured from the isolated gateway process only; CPU SHALL be actual OS user plus system consumption and MUST NOT include idle wall capacity or unrelated parent fixture/client work. Successive profiles SHALL use independent resource-counter baselines. Any bypass, payload or boundary mutation, frame-rate/count mismatch, wall-duration violation, bitrate outside both lower and upper bounds, unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5 ms SHALL fail.
|
||||
|
||||
Within each complete frame the source fixture SHALL reproduce pinned Apollo's source schedule by deriving packets per millisecond from the raw UDP block size at 80% of 1 Gbps, bounding each source batch to the smaller of 64 KiB or 64 packets, capturing the monotonic batch start immediately after the first successful shard write, scheduling the next batch no earlier than that start plus the current batch's raw-block serialization interval, carrying that schedule across frames, and making pacing waits context-cancellable. A delayed batch SHALL remain late rather than trigger an overdue catch-up burst.
|
||||
|
||||
Native Apollo video ingress SHALL request a 2,195,456-byte socket receive buffer before media ping or worker startup and SHALL drain the connected video socket into a fixed FIFO pool of exactly 2,048 slots before the existing single decrypt/FEC processor. Each slot and the saturation scratch buffer SHALL be `apolloMediaMaximumPacket + 1` bytes so oversized datagrams remain rejected. A full pool SHALL NOT stop socket draining: each successfully read accepted-size discard SHALL increment both media-ingress and media-drop telemetry without allocation, while oversized reads SHALL retain the existing rejection accounting. Socket closure SHALL cancel the video read, and video/audio channels SHALL close only after the audio reader, video drain, and video processor exit. Audio and control ingress SHALL remain unchanged.
|
||||
|
||||
The production fair pacer SHALL retain its 5 ms instantaneous catch-up ceiling. When a flow resumes later than that ceiling, it SHALL carry the remaining valid schedule debt only within the existing 250 ms provider-queue horizon and SHALL repay that debt using an interval no shorter than 20/21 of its nominal equal-tier fair-share interval. It SHALL return to the nominal interval when the debt is repaid. Simultaneous debt across eight equal-tier flows and the existing 25% and 50% capacity changes SHALL preserve the existing share-error contract and SHALL NOT exceed 105% of configured aggregate capacity in any rolling five-second window.
|
||||
|
||||
Capacity-step convergence and rolling-cap evidence SHALL use the monotonic receive time and encoded length of every raw public QUIC datagram observed immediately after the independent client's `ReceiveDatagram` returns and before decode or reassembly. For each reduction, target bytes per second and the five-second cap SHALL derive from the configured public-wire rate, `qualificationMediaPacerKbps(profile, reduction) * 1000 / 8`. Completed logical-payload observations SHALL remain separate and SHALL continue to measure payload integrity, loss, reorder, latency, throughput, and queue behavior. An impairment capacity step SHALL include every delivery observation at or after its recorded transition through constrained-run completion; a later transition SHALL NOT truncate the earlier step's retained tail. It SHALL anchor measurement windows at the first such public delivery, require four consecutive 250 ms windows between 90% and 105% of its target, converge within ten seconds, and remain at or below 105% in every rolling five-second window.
|
||||
|
||||
The constrained profile SHALL retain a bounded gzip CSV containing exactly two capacity-transition records and every observed raw public datagram delivery under one monotonic epoch captured before the constrained sequence. The schema SHALL distinguish transition and delivery records and SHALL contain `record_type`, `reduction_percent`, `transition_after_ns`, `received_after_ns`, and `encoded_bytes`; fields not applicable to a record type SHALL remain empty and SHALL be rejected when populated. The manifest SHALL bind the file name, SHA-256, compressed byte count, total row count, delivery row count, transition row count, monotonic timebase, exact 25% and 50% transition offsets, and each capacity summary's raw recomputation source. The row bound SHALL derive from the configured constrained-job and fragment bounds rather than a prior run's observed row count.
|
||||
|
||||
The retained fairness manifest SHALL bind its 25% and 50% transition offsets to the monotonic epoch of `fairness.csv.gz`. Fairness recomputation SHALL remain bounded to each separately collected `runQualificationFleetStage` capacity slice. An independent parser SHALL be able to reconstruct each stage and reproduce the rolling-five-second maximum, configured cap, and exact two-second fairness convergence from the retained raw files and manifest alone. The parser SHALL select normative versus smoke validation only from trusted caller input; normative validation SHALL require exactly 10,000 sent logical units and SHALL enforce the ten-second convergence and 105% rolling-cap gates unconditionally. Compressed, decompressed, row, field, offset, flow, and encoded-length limits SHALL derive from canonical writer schemas, configured row limits, and canonical run horizons and SHALL be enforced before CSV parsing can allocate an unbounded record. Missing raw-wire evidence; a wrong file hash, size, or count; duplicate or missing transitions; negative or nonmonotonic offsets; invalid encoded lengths; completed-logical-frame substitution; populated not-applicable fields; oversized input; or a summary mismatch SHALL fail qualification evidence acceptance.
|
||||
|
||||
#### Scenario: Healthy fixed profile
|
||||
- **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command
|
||||
- **THEN** the harness emits compressed raw frame/path and gateway-process
|
||||
resource samples plus a summary tied to the exact command, CPU scope,
|
||||
timing-overhead method, topology, source commit, immutable Protocol version,
|
||||
environment, and payload hash
|
||||
- **THEN** the harness emits compressed raw frame/path and gateway-process resource samples plus a summary tied to the exact command, CPU scope, timing-overhead method, topology, source commit, immutable Protocol version, environment, and payload hash
|
||||
|
||||
#### Scenario: Processing gate failure
|
||||
- **WHEN** any production path stage lacks a per-frame observation, stage
|
||||
accounting does not balance, payload or frame boundaries change, duration,
|
||||
frame-rate, frame-count, or bitrate bounds fail, measured p95 exceeds 5 ms,
|
||||
parent work changes gateway CPU, idle capacity is reported as consumed CPU, or
|
||||
timing overhead is absent
|
||||
- **WHEN** any production path stage lacks a per-frame observation, stage accounting does not balance, payload or frame boundaries change, duration, frame-rate, frame-count, or bitrate bounds fail, measured p95 exceeds 5 ms, parent work changes gateway CPU, idle capacity is reported as consumed CPU, or timing overhead is absent
|
||||
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate
|
||||
|
||||
#### Scenario: Source-shaped Apollo pacing is preserved
|
||||
- **WHEN** the fixture emits 1,072-byte encrypted video shards with 1,040-byte raw blocks for consecutive complete frames
|
||||
- **THEN** it uses 96 packets per millisecond, batches at most 63 shards, records each batch after its first successful shard write, starts each later batch no earlier than the prior batch's raw serialization interval, carries the schedule into the following frame, and emits no shard after a cancelled pacing wait
|
||||
|
||||
#### Scenario: Video crypto processing stalls
|
||||
- **WHEN** the first video AEAD operation is blocked while a 662-shard keyframe arrives
|
||||
- **THEN** all 662 successful connected-socket reads reach media-ingress accounting before processing resumes, and after release the exact complete frame traverses recovery, the bounded production queue, pacer, QUIC, and independent reassembly
|
||||
|
||||
#### Scenario: Video ingress pool saturates
|
||||
- **WHEN** all 2,048 fixed video slots are occupied
|
||||
- **THEN** accepted-size datagrams are deliberately discarded through the fixed scratch buffer and counted as ingress plus drops, oversized datagrams remain rejected, and cancellation closes every media worker without a race or leak
|
||||
|
||||
#### Scenario: Repeated media-loop host stalls
|
||||
- **WHEN** three approximately 95 ms scheduling debts are introduced at separated completed-public-frame barriers while source recovery continues
|
||||
- **THEN** the pacer limits instantaneous catch-up to 5 ms, repays each remaining debt at no more than 5% above nominal fair share, preserves every frame in exact order and bytes without provider or gateway drops, stays within the existing queue bounds, and closes cleanly on cancellation
|
||||
|
||||
#### Scenario: Capacity measurement crosses a short transition phase
|
||||
- **WHEN** a constrained 1080p flow carries nonzero bounded debt through the approximately 1.572-second 25% phase before the 50% transition
|
||||
- **THEN** convergence and rolling-cap checks use the observed 1,200-byte and 25-byte public datagrams against the configured wire targets, while the separately retained completed-payload observations cannot substitute for transport delivery timing
|
||||
|
||||
#### Scenario: Aggregate-only capacity evidence is retained
|
||||
- **WHEN** a qualification bundle contains logical-frame impairment rows and aggregate capacity summaries but omits raw public-wire rows or fairness transition offsets
|
||||
- **THEN** independent evidence validation rejects the bundle as incomplete even if its in-process runtime assertions passed
|
||||
|
||||
#### Scenario: Raw capacity evidence is independently recomputed
|
||||
- **WHEN** v10 validation reads the retained constrained wire CSV, fairness CSV, and manifest transitions
|
||||
- **THEN** it validates bounded schema, hashes, sizes, counts, monotonic offsets, encoded datagram lengths, and transition uniqueness, then exactly reproduces the full-after-transition impairment targets, four consecutive 250 ms convergence windows, every rolling-five-second maximum, and stage-bounded fairness two-second alignment
|
||||
|
||||
#### Scenario: Retained counts cannot weaken normative gates
|
||||
- **WHEN** a purported normative bundle retains a sent count other than 10,000 or retains an 11-second convergence or over-cap summary
|
||||
- **THEN** validation rejects it regardless of any retained field value, while explicitly selected smoke validation still requires exact raw-summary recomputation
|
||||
|
||||
#### Scenario: Retained CSV exceeds bounded evidence grammar
|
||||
- **WHEN** a wire or fairness gzip exceeds its canonical compressed or decompressed limit or contains an overlong field, out-of-horizon offset, unknown flow, or out-of-range encoded length
|
||||
- **THEN** validation rejects it through the bounded standard-library reader before an unbounded CSV record can be allocated
|
||||
|
||||
### Requirement: Bounded impairment qualification
|
||||
The harness SHALL run exactly the baseline, latency, jitter, loss, reorder, and constrained Section 7.2 profiles once by applying fixed-seed impairment at the source-shaped provider network boundary while traffic concurrently traverses the production gateway path. Baseline SHALL cover all three media profiles and the other profiles SHALL cover 1080p60. The harness MUST NOT serialize a complete provider-to-client traversal per source unit. Reorder-off profiles SHALL preserve source order through an ordered delay queue whose catch-up is limited to one media serialization interval; the fixed-seed applied-delay distribution and jitter observed after ordered traversal SHALL be reported separately. Loss-only traffic SHALL NOT gain implicit reorder. Reorder-on profiles SHALL inject and record only the fixed bounded reorder pattern. Each source unit SHALL have one attributable outcome across source emission, injected drop, native provider/FEC handling, bounded queue replacement, gateway forwarding, QUIC send/receive, and public-client delivery. Each artifact SHALL retain raw impairment and queue observations and record tool version, exact command/configuration, environment, candidate commit, immutable Protocol version, direction, queue discipline, topology, fixed seed, observed one-way latency, acknowledged Apollo ENet RTT, applied and observed jitter, injected and unexplained loss, reorder, throughput, drops, and capacity-step statistics.
|
||||
|
||||
@@ -76,3 +91,10 @@ Qualification artifacts SHALL contain no provider endpoint, credential, clipboar
|
||||
#### Scenario: Deterministic evidence publication
|
||||
- **WHEN** qualification completes
|
||||
- **THEN** the manifest labels fake-provider, path impairment, and local processing evidence separately and leaves live interoperability deferred-owner-e2e
|
||||
|
||||
### Requirement: Retained private Linux candidate artifact
|
||||
A retained private Linux candidate artifact SHALL have API metadata whose `expires_at - created_at` interval is at least 30 elapsed days (2,592,000 seconds). Workflow intent, cleanup lag, and a local copy SHALL NOT substitute for the recorded API interval. A shorter interval SHALL fail the artifact-retention gate even when execution and artifact bytes pass. The workflow request MAY exceed 30 calendar days only to compensate for verified platform rounding; the acceptance threshold remains at least 30 elapsed days.
|
||||
|
||||
#### Scenario: Platform rounding shortens retention
|
||||
- **WHEN** a private Linux candidate run passes execution and artifact-byte checks but its artifact API metadata records less than 2,592,000 seconds between creation and expiry
|
||||
- **THEN** the artifact-retention gate remains failed until a separately authorized candidate run records an interval of at least 2,592,000 seconds
|
||||
|
||||
Reference in New Issue
Block a user