chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maximumSimulatedFirmwareSize = 268435456
|
||||
|
||||
type simulatedDeviceCommand struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
type simulatedFirmwareManifest struct {
|
||||
ReleaseID string
|
||||
Version string
|
||||
Digest string
|
||||
Signature []byte
|
||||
SigningKeyID string
|
||||
DownloadPath string
|
||||
}
|
||||
|
||||
type firmwareExecutionOutcome string
|
||||
|
||||
const (
|
||||
firmwareCompleted firmwareExecutionOutcome = "completed"
|
||||
firmwareFailed firmwareExecutionOutcome = "failed"
|
||||
firmwareInterrupted firmwareExecutionOutcome = "interrupted"
|
||||
firmwareRebootRequired firmwareExecutionOutcome = "reboot_required"
|
||||
)
|
||||
|
||||
func (s *simulator) executeFirmwareCommand(ctx context.Context, command simulatedDeviceCommand) (firmwareExecutionOutcome, error) {
|
||||
manifest, err := parseSimulatedFirmwareManifest(command.Payload)
|
||||
if err != nil {
|
||||
if ackErr := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"result_code": "MANIFEST_INVALID", "error_message": err.Error()}); ackErr != nil {
|
||||
return firmwareFailed, ackErr
|
||||
}
|
||||
return firmwareFailed, nil
|
||||
}
|
||||
fault, err := simulatedFirmwareFault()
|
||||
if err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
delay, err := simulatorOptionalDuration("SIM_FIRMWARE_STEP_DELAY", 30*time.Second)
|
||||
if err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
abEnabled, err := simulatedABFirmwareEnabled()
|
||||
if err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if !abEnabled && isABFirmwareFault(fault) {
|
||||
return firmwareFailed, errors.New("A/B firmware fault injection requires SIM_FIRMWARE_AB_UPDATE=true")
|
||||
}
|
||||
if abEnabled {
|
||||
if handled, outcome, resumeErr := s.resumeABFirmwareCommand(ctx, command, manifest, fault, delay); handled || resumeErr != nil {
|
||||
return outcome, resumeErr
|
||||
}
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_STARTED"}); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if fault == "download_failed" {
|
||||
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_FAILED"})
|
||||
}
|
||||
content, err := s.downloadFirmware(ctx, manifest.DownloadPath)
|
||||
if err != nil {
|
||||
if ackErr := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 5, "result_code": "DOWNLOAD_FAILED", "error_message": err.Error()}); ackErr != nil {
|
||||
return firmwareFailed, ackErr
|
||||
}
|
||||
return firmwareFailed, nil
|
||||
}
|
||||
digestValue := sha256.Sum256(content)
|
||||
actualDigest := hex.EncodeToString(digestValue[:])
|
||||
if fault == "digest_mismatch" || actualDigest != manifest.Digest {
|
||||
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "DIGEST_MISMATCH", "expected_sha256": manifest.Digest, "actual_sha256": actualDigest, "bytes_downloaded": len(content)})
|
||||
}
|
||||
publicKey, err := simulatedFirmwareTrustedKey(manifest.SigningKeyID)
|
||||
if err != nil {
|
||||
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "SIGNING_KEY_UNTRUSTED", "signing_key_id": manifest.SigningKeyID, "error_message": err.Error()})
|
||||
}
|
||||
signedManifest := fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", env("SIM_DEVICE_MODEL", "simulated-terminal"), env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a"), manifest.Version, manifest.Digest, len(content))
|
||||
if fault == "signature_mismatch" || !ed25519.Verify(publicKey, []byte(signedManifest), manifest.Signature) {
|
||||
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "SIGNATURE_MISMATCH", "signing_key_id": manifest.SigningKeyID, "verified_sha256": actualDigest, "bytes_downloaded": len(content)})
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "downloading", "progress_percent": 100, "result_code": "ARTIFACT_VERIFIED", "verified_sha256": actualDigest, "verified_signing_key_id": manifest.SigningKeyID, "bytes_downloaded": len(content)}); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if fault == "interrupt_after_download" {
|
||||
return firmwareInterrupted, nil
|
||||
}
|
||||
if err := waitForContext(ctx, delay); err != nil {
|
||||
return firmwareInterrupted, err
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{"phase": "installing", "progress_percent": 80, "result_code": "INSTALL_STARTED"}); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if fault == "install_failed" {
|
||||
return firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "installing", "progress_percent": 80, "result_code": "INSTALL_FAILED"})
|
||||
}
|
||||
if fault == "interrupt_after_install" {
|
||||
return firmwareInterrupted, nil
|
||||
}
|
||||
if err := waitForContext(ctx, delay); err != nil {
|
||||
return firmwareInterrupted, err
|
||||
}
|
||||
if abEnabled {
|
||||
return s.stageABFirmwareCommand(ctx, command, manifest, fault)
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_OK", "installed_version": manifest.Version, "verified_sha256": actualDigest, "verified_signing_key_id": manifest.SigningKeyID}); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
return firmwareCompleted, nil
|
||||
}
|
||||
|
||||
func parseSimulatedFirmwareManifest(payload map[string]any) (simulatedFirmwareManifest, error) {
|
||||
digest := stringValue(payload, "sha256")
|
||||
signature, signatureErr := base64.StdEncoding.DecodeString(stringValue(payload, "signature"))
|
||||
manifest := simulatedFirmwareManifest{
|
||||
ReleaseID: stringValue(payload, "release_id"),
|
||||
Version: stringValue(payload, "version"),
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
SigningKeyID: stringValue(payload, "signing_key_id"),
|
||||
DownloadPath: stringValue(payload, "download_path"),
|
||||
}
|
||||
if manifest.ReleaseID == "" || manifest.Version == "" || manifest.SigningKeyID == "" || len(manifest.Digest) != sha256.Size*2 || signatureErr != nil || len(manifest.Signature) != ed25519.SignatureSize {
|
||||
return simulatedFirmwareManifest{}, errors.New("firmware command requires release_id, version, signing_key_id, a 64-character SHA-256 and a 64-byte Ed25519 signature")
|
||||
}
|
||||
if _, err := hex.DecodeString(manifest.Digest); err != nil {
|
||||
return simulatedFirmwareManifest{}, errors.New("firmware command SHA-256 is not lowercase hexadecimal")
|
||||
}
|
||||
if manifest.Digest != strings.ToLower(manifest.Digest) {
|
||||
return simulatedFirmwareManifest{}, errors.New("firmware command SHA-256 is not lowercase hexadecimal")
|
||||
}
|
||||
parsedPath, err := url.Parse(manifest.DownloadPath)
|
||||
if err != nil || parsedPath.IsAbs() || parsedPath.Host != "" || parsedPath.RawQuery != "" || parsedPath.Fragment != "" || strings.Contains(parsedPath.Path, "..") || !strings.HasPrefix(parsedPath.Path, "/device/v2/firmware-releases/") || !strings.HasSuffix(parsedPath.Path, "/download") {
|
||||
return simulatedFirmwareManifest{}, errors.New("firmware command download_path is outside the fixed device firmware boundary")
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func stringValue(value map[string]any, key string) string {
|
||||
item, _ := value[key].(string)
|
||||
return strings.TrimSpace(item)
|
||||
}
|
||||
|
||||
func simulatedFirmwareFault() (string, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_FAULT")))
|
||||
if value == "" || value == "none" {
|
||||
return "none", nil
|
||||
}
|
||||
switch value {
|
||||
case "download_failed", "digest_mismatch", "signature_mismatch", "install_failed", "interrupt_after_download", "interrupt_after_install", "power_loss_after_stage", "power_loss_during_boot", "boot_health_failed":
|
||||
return value, nil
|
||||
default:
|
||||
return "", fmt.Errorf("SIM_FIRMWARE_FAULT must be none, download_failed, digest_mismatch, signature_mismatch, install_failed, interrupt_after_download, interrupt_after_install, power_loss_after_stage, power_loss_during_boot or boot_health_failed")
|
||||
}
|
||||
}
|
||||
|
||||
func isABFirmwareFault(value string) bool {
|
||||
return value == "power_loss_after_stage" || value == "power_loss_during_boot" || value == "boot_health_failed"
|
||||
}
|
||||
|
||||
func simulatedFirmwareTrustedKey(keyID string) (ed25519.PublicKey, error) {
|
||||
raw := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_TRUSTED_KEYS"))
|
||||
if raw == "" {
|
||||
return nil, errors.New("SIM_FIRMWARE_TRUSTED_KEYS is empty")
|
||||
}
|
||||
var encoded map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &encoded); err != nil {
|
||||
return nil, errors.New("SIM_FIRMWARE_TRUSTED_KEYS must be a JSON object of key IDs to Base64 Ed25519 public keys")
|
||||
}
|
||||
value, exists := encoded[keyID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("signing key %s is not provisioned", keyID)
|
||||
}
|
||||
publicKey, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil || len(publicKey) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("signing key %s is not a 32-byte Base64 Ed25519 public key", keyID)
|
||||
}
|
||||
return ed25519.PublicKey(publicKey), nil
|
||||
}
|
||||
|
||||
func simulatorOptionalDuration(key string, maximum time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil || parsed < 0 || parsed > maximum {
|
||||
return 0, fmt.Errorf("%s must be a non-negative duration no greater than %s", key, maximum)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (s *simulator) ackDeviceCommand(ctx context.Context, commandID, status string, result map[string]any) error {
|
||||
body, err := json.Marshal(map[string]any{"status": status, "result_summary": result})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.request(ctx, http.MethodPost, "/device/v2/commands/"+commandID+"/ack", body, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *simulator) downloadFirmware(ctx context.Context, path string) ([]byte, error) {
|
||||
result, err := s.request(ctx, http.MethodGet, path, nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var address struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
if err := decodeData(result.Body, &address); err != nil || address.DownloadURL == "" {
|
||||
return nil, errors.New("firmware download response did not contain a private URL")
|
||||
}
|
||||
parsed, err := url.Parse(address.DownloadURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return nil, errors.New("firmware private download URL is invalid")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address.DownloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download firmware object: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("firmware object returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, maximumSimulatedFirmwareSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read firmware object: %w", err)
|
||||
}
|
||||
if len(content) == 0 || len(content) > maximumSimulatedFirmwareSize {
|
||||
return nil, fmt.Errorf("firmware object size %d is outside 1 to %d bytes", len(content), maximumSimulatedFirmwareSize)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
firmwareBootStaged = "STAGED"
|
||||
firmwareBootRebootPending = "REBOOT_PENDING"
|
||||
firmwareBootConfirmed = "CONFIRMED"
|
||||
firmwareBootRolledBack = "ROLLED_BACK"
|
||||
)
|
||||
|
||||
type simulatedFirmwareBootState struct {
|
||||
Phase string `json:"phase"`
|
||||
CommandID string `json:"command_id"`
|
||||
ReleaseID string `json:"release_id"`
|
||||
TargetVersion string `json:"target_version"`
|
||||
PreviousVersion string `json:"previous_version"`
|
||||
Digest string `json:"sha256"`
|
||||
ActiveSlot string `json:"active_slot"`
|
||||
PreviousSlot string `json:"previous_slot"`
|
||||
TargetSlot string `json:"target_slot"`
|
||||
FailedSlot string `json:"failed_slot,omitempty"`
|
||||
BootAttemptID string `json:"boot_attempt_id,omitempty"`
|
||||
TerminalResultCode string `json:"terminal_result_code,omitempty"`
|
||||
HealthDeadline time.Time `json:"health_deadline,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func simulatedABFirmwareEnabled() (bool, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_AB_UPDATE")))
|
||||
if value == "" || value == "false" {
|
||||
return false, nil
|
||||
}
|
||||
if value != "true" {
|
||||
return false, errors.New("SIM_FIRMWARE_AB_UPDATE must be true or false")
|
||||
}
|
||||
statePath := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_STATE_FILE"))
|
||||
if statePath == "" || !filepath.IsAbs(statePath) {
|
||||
return false, errors.New("SIM_FIRMWARE_STATE_FILE must be an absolute path when A/B firmware simulation is enabled")
|
||||
}
|
||||
evidencePath := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_EVIDENCE_FILE"))
|
||||
if evidencePath != "" && !filepath.IsAbs(evidencePath) {
|
||||
return false, errors.New("SIM_FIRMWARE_EVIDENCE_FILE must be an absolute path")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func simulatedFirmwareHealthWindow() (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_BOOT_HEALTH_WINDOW"))
|
||||
if value == "" {
|
||||
return 2 * time.Minute, nil
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil || duration < time.Second || duration > 30*time.Minute {
|
||||
return 0, errors.New("SIM_FIRMWARE_BOOT_HEALTH_WINDOW must be between 1s and 30m")
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func simulatedFirmwareInitialSlot() (string, error) {
|
||||
value := strings.ToUpper(env("SIM_FIRMWARE_ACTIVE_SLOT", "A"))
|
||||
if value != "A" && value != "B" {
|
||||
return "", errors.New("SIM_FIRMWARE_ACTIVE_SLOT must be A or B")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func inactiveFirmwareSlot(active string) string {
|
||||
if active == "A" {
|
||||
return "B"
|
||||
}
|
||||
return "A"
|
||||
}
|
||||
|
||||
func loadSimulatedFirmwareBootState() (*simulatedFirmwareBootState, error) {
|
||||
path := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_STATE_FILE"))
|
||||
content, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read simulated firmware state: %w", err)
|
||||
}
|
||||
var state simulatedFirmwareBootState
|
||||
decoder := json.NewDecoder(strings.NewReader(string(content)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&state); err != nil {
|
||||
return nil, fmt.Errorf("decode simulated firmware state: %w", err)
|
||||
}
|
||||
if state.ActiveSlot != "A" && state.ActiveSlot != "B" {
|
||||
return nil, errors.New("simulated firmware state has an invalid active slot")
|
||||
}
|
||||
switch state.Phase {
|
||||
case firmwareBootStaged, firmwareBootRebootPending, firmwareBootConfirmed, firmwareBootRolledBack:
|
||||
default:
|
||||
return nil, errors.New("simulated firmware state has an invalid phase")
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func saveSimulatedFirmwareBootState(state simulatedFirmwareBootState) error {
|
||||
path := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_STATE_FILE"))
|
||||
if !filepath.IsAbs(path) {
|
||||
return errors.New("SIM_FIRMWARE_STATE_FILE must be an absolute path")
|
||||
}
|
||||
state.UpdatedAt = time.Now().UTC()
|
||||
content, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open simulated firmware state: %w", err)
|
||||
}
|
||||
if _, err = file.Write(content); err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("write simulated firmware state: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close simulated firmware state: %w", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simulator) resumeABFirmwareCommand(ctx context.Context, command simulatedDeviceCommand, manifest simulatedFirmwareManifest, fault string, delay time.Duration) (bool, firmwareExecutionOutcome, error) {
|
||||
state, err := loadSimulatedFirmwareBootState()
|
||||
if err != nil || state == nil {
|
||||
return false, firmwareFailed, err
|
||||
}
|
||||
if state.Phase == firmwareBootConfirmed || state.Phase == firmwareBootRolledBack {
|
||||
if state.CommandID != command.ID {
|
||||
return false, firmwareCompleted, nil
|
||||
}
|
||||
if state.Phase == firmwareBootConfirmed {
|
||||
err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{
|
||||
"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_AB_BOOT_CONFIRMED", "installed_version": manifest.Version,
|
||||
"verified_sha256": manifest.Digest, "verified_signing_key_id": manifest.SigningKeyID, "active_slot": state.ActiveSlot, "previous_slot": state.PreviousSlot, "boot_attempt_id": state.BootAttemptID,
|
||||
})
|
||||
return true, firmwareCompleted, err
|
||||
}
|
||||
resultCode := state.TerminalResultCode
|
||||
if resultCode == "" {
|
||||
resultCode = "BOOT_HEALTH_FAILED_ROLLED_BACK"
|
||||
}
|
||||
err := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{
|
||||
"phase": "rolled_back", "progress_percent": 95, "result_code": resultCode, "rollback_applied": true,
|
||||
"active_slot": state.ActiveSlot, "failed_slot": state.FailedSlot, "rollback_version": state.TargetVersion, "boot_attempt_id": state.BootAttemptID,
|
||||
})
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if state.CommandID != command.ID || state.ReleaseID != manifest.ReleaseID || state.TargetVersion != manifest.Version || state.Digest != manifest.Digest {
|
||||
return true, firmwareFailed, s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{"phase": "rolled_back", "progress_percent": 0, "result_code": "LOCAL_UPDATE_STATE_CONFLICT", "rollback_applied": false})
|
||||
}
|
||||
if state.Phase == firmwareBootStaged {
|
||||
outcome, err := s.armSimulatedFirmwareBoot(ctx, command.ID, *state)
|
||||
return true, outcome, err
|
||||
}
|
||||
bootState := *state
|
||||
bootState.ActiveSlot = state.TargetSlot
|
||||
if err := recordSimulatedFirmwareEvidence(command, manifest, "boot_verifying", bootState, "BOOT_HEALTH_CHECK_STARTED"); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{
|
||||
"phase": "boot_verifying", "progress_percent": 95, "result_code": "BOOT_HEALTH_CHECK_STARTED",
|
||||
"active_slot": state.TargetSlot, "previous_slot": state.PreviousSlot, "boot_attempt_id": state.BootAttemptID, "boot_health_deadline": state.HealthDeadline,
|
||||
}); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if fault == "power_loss_during_boot" {
|
||||
if err := recordSimulatedFirmwareEvidence(command, manifest, "power_loss_during_boot", *state, "SIMULATED_POWER_LOSS"); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
return true, firmwareInterrupted, nil
|
||||
}
|
||||
rollbackCode := ""
|
||||
if fault == "boot_health_failed" {
|
||||
rollbackCode = "BOOT_HEALTH_FAILED_ROLLED_BACK"
|
||||
} else if !state.HealthDeadline.IsZero() && time.Now().After(state.HealthDeadline) {
|
||||
rollbackCode = "BOOT_HEALTH_TIMEOUT_ROLLED_BACK"
|
||||
}
|
||||
if rollbackCode != "" {
|
||||
failedSlot := state.TargetSlot
|
||||
state.Phase = firmwareBootRolledBack
|
||||
state.ActiveSlot = state.PreviousSlot
|
||||
state.FailedSlot = failedSlot
|
||||
state.TerminalResultCode = rollbackCode
|
||||
state.TargetSlot = ""
|
||||
state.TargetVersion = state.PreviousVersion
|
||||
if err := saveSimulatedFirmwareBootState(*state); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if err := recordSimulatedFirmwareEvidence(command, manifest, "rolled_back", *state, rollbackCode); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "FAILED", map[string]any{
|
||||
"phase": "rolled_back", "progress_percent": 95, "result_code": rollbackCode, "rollback_applied": true,
|
||||
"active_slot": state.ActiveSlot, "failed_slot": failedSlot, "rollback_version": state.PreviousVersion, "boot_attempt_id": state.BootAttemptID,
|
||||
}); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
return true, firmwareFailed, nil
|
||||
}
|
||||
if err := waitForContext(ctx, delay); err != nil {
|
||||
return true, firmwareInterrupted, err
|
||||
}
|
||||
confirmedSlot := state.TargetSlot
|
||||
state.Phase = firmwareBootConfirmed
|
||||
state.ActiveSlot = confirmedSlot
|
||||
state.TerminalResultCode = "SIMULATED_AB_BOOT_CONFIRMED"
|
||||
state.TargetSlot = ""
|
||||
state.PreviousVersion = state.TargetVersion
|
||||
if err := saveSimulatedFirmwareBootState(*state); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if err := recordSimulatedFirmwareEvidence(command, manifest, "confirmed", *state, "SIMULATED_AB_BOOT_CONFIRMED"); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{
|
||||
"phase": "confirmed", "progress_percent": 100, "result_code": "SIMULATED_AB_BOOT_CONFIRMED", "installed_version": manifest.Version,
|
||||
"verified_sha256": manifest.Digest, "verified_signing_key_id": manifest.SigningKeyID, "active_slot": confirmedSlot, "previous_slot": state.PreviousSlot, "boot_attempt_id": state.BootAttemptID,
|
||||
}); err != nil {
|
||||
return true, firmwareFailed, err
|
||||
}
|
||||
return true, firmwareCompleted, nil
|
||||
}
|
||||
|
||||
func (s *simulator) stageABFirmwareCommand(ctx context.Context, command simulatedDeviceCommand, manifest simulatedFirmwareManifest, fault string) (firmwareExecutionOutcome, error) {
|
||||
current, err := loadSimulatedFirmwareBootState()
|
||||
if err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
activeSlot, err := simulatedFirmwareInitialSlot()
|
||||
if err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
previousVersion := env("SIM_FIRMWARE_CURRENT_VERSION", "0.2.0-sim")
|
||||
if current != nil && (current.Phase == firmwareBootConfirmed || current.Phase == firmwareBootRolledBack) {
|
||||
activeSlot = current.ActiveSlot
|
||||
previousVersion = current.TargetVersion
|
||||
}
|
||||
state := simulatedFirmwareBootState{
|
||||
Phase: firmwareBootStaged, CommandID: command.ID, ReleaseID: manifest.ReleaseID, TargetVersion: manifest.Version,
|
||||
PreviousVersion: previousVersion, Digest: manifest.Digest, ActiveSlot: activeSlot, PreviousSlot: activeSlot, TargetSlot: inactiveFirmwareSlot(activeSlot),
|
||||
}
|
||||
if err := saveSimulatedFirmwareBootState(state); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if err := recordSimulatedFirmwareEvidence(command, manifest, "staging", state, "INACTIVE_SLOT_STAGED"); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "ACKNOWLEDGED", map[string]any{
|
||||
"phase": "staging", "progress_percent": 85, "result_code": "INACTIVE_SLOT_STAGED", "active_slot": state.ActiveSlot, "target_slot": state.TargetSlot,
|
||||
}); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if fault == "power_loss_after_stage" {
|
||||
if err := recordSimulatedFirmwareEvidence(command, manifest, "power_loss_after_stage", state, "SIMULATED_POWER_LOSS"); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
return firmwareInterrupted, nil
|
||||
}
|
||||
return s.armSimulatedFirmwareBoot(ctx, command.ID, state)
|
||||
}
|
||||
|
||||
func (s *simulator) armSimulatedFirmwareBoot(ctx context.Context, commandID string, state simulatedFirmwareBootState) (firmwareExecutionOutcome, error) {
|
||||
window, err := simulatedFirmwareHealthWindow()
|
||||
if err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
state.Phase = firmwareBootRebootPending
|
||||
state.BootAttemptID = nonce()
|
||||
state.HealthDeadline = time.Now().UTC().Add(window)
|
||||
if err := saveSimulatedFirmwareBootState(state); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if err := recordSimulatedFirmwareEvidence(simulatedDeviceCommand{ID: commandID}, simulatedFirmwareManifest{ReleaseID: state.ReleaseID, Version: state.TargetVersion, Digest: state.Digest}, "reboot_pending", state, "REBOOT_ARMED"); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, commandID, "ACKNOWLEDGED", map[string]any{
|
||||
"phase": "reboot_pending", "progress_percent": 90, "result_code": "REBOOT_ARMED", "active_slot": state.ActiveSlot,
|
||||
"target_slot": state.TargetSlot, "boot_attempt_id": state.BootAttemptID, "boot_health_deadline": state.HealthDeadline,
|
||||
}); err != nil {
|
||||
return firmwareFailed, err
|
||||
}
|
||||
return firmwareRebootRequired, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/firmwareconformance"
|
||||
)
|
||||
|
||||
func recordSimulatedFirmwareEvidence(command simulatedDeviceCommand, manifest simulatedFirmwareManifest, phase string, state simulatedFirmwareBootState, resultCode string) error {
|
||||
path := strings.TrimSpace(os.Getenv("SIM_FIRMWARE_EVIDENCE_FILE"))
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var evidence firmwareconformance.DeviceEvidence
|
||||
content, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
partitionDigest := strings.ToLower(env("SIM_PARTITION_TABLE_SHA256", firmwareconformance.SHA256([]byte(env("SIM_DEVICE_MODEL", "simulated-terminal")+":"+env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a")+":A/B"))))
|
||||
secureBoot, parseErr := simulatedEvidenceBoolean("SIM_SECURE_BOOT_ENABLED", false)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
evidence = firmwareconformance.DeviceEvidence{
|
||||
SchemaVersion: firmwareconformance.DeviceEvidenceSchema, EvidenceSource: "SIMULATOR",
|
||||
CandidateVersion: env("SIM_CANDIDATE_VERSION", "local-development"), BuildDigest: strings.ToLower(strings.TrimSpace(os.Getenv("SIM_BUILD_DIGEST"))),
|
||||
DeviceID: env("SIM_DEVICE_ID", "10000000-0000-0000-0000-000000000511"), DeviceModel: env("SIM_DEVICE_MODEL", "simulated-terminal"), HardwareRevision: env("SIM_DEVICE_HARDWARE_REVISION", "sim-rev-a"),
|
||||
ReleaseID: manifest.ReleaseID, CommandID: command.ID, Scenario: strings.ToUpper(strings.TrimSpace(os.Getenv("SIM_FIRMWARE_EVIDENCE_SCENARIO"))),
|
||||
BootloaderVersion: env("SIM_BOOTLOADER_VERSION", "sim-bootloader-1"), PartitionTableSHA256: partitionDigest, SecureBootEnabled: secureBoot,
|
||||
ArtifactSHA256: manifest.Digest, SigningKeyID: manifest.SigningKeyID,
|
||||
InitialSlot: state.PreviousSlot, TargetSlot: state.TargetSlot, FinalSlot: state.PreviousSlot,
|
||||
InitialVersion: state.PreviousVersion, TargetVersion: state.TargetVersion, FinalVersion: state.PreviousVersion,
|
||||
BootAttemptID: state.BootAttemptID, CollectedAt: now, Events: []firmwareconformance.DeviceEvent{},
|
||||
}
|
||||
if evidence.BuildDigest == "" || evidence.Scenario == "" {
|
||||
return errors.New("SIM_BUILD_DIGEST and SIM_FIRMWARE_EVIDENCE_SCENARIO are required when evidence capture is enabled")
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("read simulated firmware evidence: %w", err)
|
||||
} else {
|
||||
evidence, err = firmwareconformance.ParseDeviceEvidence(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if evidence.CommandID != command.ID || evidence.ReleaseID != manifest.ReleaseID {
|
||||
return errors.New("simulated firmware evidence belongs to a different command or release")
|
||||
}
|
||||
}
|
||||
if manifest.SigningKeyID != "" && evidence.SigningKeyID == "" {
|
||||
evidence.SigningKeyID = manifest.SigningKeyID
|
||||
}
|
||||
if state.BootAttemptID != "" {
|
||||
evidence.BootAttemptID = state.BootAttemptID
|
||||
}
|
||||
event := firmwareconformance.DeviceEvent{
|
||||
Phase: phase, OccurredAt: now, ActiveSlot: state.ActiveSlot, TargetSlot: state.TargetSlot, ResultCode: resultCode, BootAttemptID: state.BootAttemptID,
|
||||
}
|
||||
evidence.Events = append(evidence.Events, event)
|
||||
evidence.CollectedAt = now
|
||||
switch phase {
|
||||
case "confirmed":
|
||||
evidence.FinalSlot = state.ActiveSlot
|
||||
evidence.FinalVersion = manifest.Version
|
||||
case "rolled_back":
|
||||
evidence.FinalSlot = state.ActiveSlot
|
||||
evidence.FinalVersion = state.TargetVersion
|
||||
}
|
||||
encoded, err := json.MarshalIndent(evidence, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open simulated firmware evidence: %w", err)
|
||||
}
|
||||
if _, err = file.Write(encoded); err == nil {
|
||||
err = file.Sync()
|
||||
}
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("write simulated firmware evidence: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close simulated firmware evidence: %w", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func simulatedEvidenceBoolean(key string, fallback bool) (bool, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
if value == "true" {
|
||||
return true, nil
|
||||
}
|
||||
if value == "false" {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
)
|
||||
|
||||
type simulator struct {
|
||||
baseURL, deviceID string
|
||||
secret []byte
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type apiResult struct {
|
||||
Status int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type createdConversation struct {
|
||||
ID string `json:"conversation_id"`
|
||||
RTC struct {
|
||||
RoomID string `json:"room_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
} `json:"rtc"`
|
||||
}
|
||||
|
||||
type deviceConversationState struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
FailureCode string `json:"failure_code"`
|
||||
}
|
||||
|
||||
type uploadBatch struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Segments []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
UploadURL string `json:"upload_url"`
|
||||
ContentType string `json:"content_type"`
|
||||
LastErrorCode string `json:"last_error_code"`
|
||||
LocalDeleteAfter *time.Time `json:"local_delete_after"`
|
||||
} `json:"segments"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "模拟设备失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
secret := []byte(env("SIM_DEVICE_SECRET", "local-simulated-device-secret-32-bytes-minimum"))
|
||||
if len(secret) < 32 {
|
||||
return errors.New("SIM_DEVICE_SECRET must contain at least 32 characters")
|
||||
}
|
||||
sim := &simulator{
|
||||
baseURL: strings.TrimRight(env("SIM_API_URL", "http://localhost:58080"), "/"),
|
||||
deviceID: env("SIM_DEVICE_ID", "10000000-0000-0000-0000-000000000511"),
|
||||
secret: secret, client: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
scenarios := parseScenarios(env("SIM_SCENARIO", "bootstrap"))
|
||||
if scenarios["offline"] {
|
||||
if err := sim.offlineRetry(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if scenarios["bootstrap"] || scenarios["conversation"] || scenarios["all"] {
|
||||
if err := sim.bootstrap(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
conversationID := strings.TrimSpace(os.Getenv("SIM_CONVERSATION_ID"))
|
||||
if scenarios["conversation"] || scenarios["all"] {
|
||||
id, err := sim.conversation(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conversationID = id
|
||||
}
|
||||
if scenarios["transcript"] || scenarios["all"] {
|
||||
if conversationID == "" {
|
||||
return errors.New("transcript scenario requires conversation or SIM_CONVERSATION_ID")
|
||||
}
|
||||
if err := sim.transcript(ctx, conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if scenarios["telemetry"] || scenarios["all"] {
|
||||
if err := sim.telemetry(ctx, conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if scenarios["commands"] || scenarios["all"] {
|
||||
if err := sim.commands(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if scenarios["media"] {
|
||||
if conversationID == "" {
|
||||
return errors.New("media scenario requires SIM_CONVERSATION_ID")
|
||||
}
|
||||
if err := sim.media(ctx, conversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simulator) bootstrap(ctx context.Context) error {
|
||||
query := url.Values{
|
||||
"firmware_version": {env("SIM_FIRMWARE_VERSION", "0.2.0-sim")}, "board_rev": {"sim-rev-a"}, "rtc_sdk_version": {"mock-rtc-1"},
|
||||
"codec": {"opus"}, "sample_rate": {"16000"}, "network": {"simulated"},
|
||||
"device_time": {time.Now().UTC().Format(time.RFC3339)},
|
||||
}
|
||||
first, err := s.request(ctx, http.MethodGet, "/device/v2/bootstrap?"+query.Encode(), nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var bootstrap struct {
|
||||
Device struct {
|
||||
FirmwareCompatible bool `json:"firmware_compatible"`
|
||||
MinimumFirmwareVersion *string `json:"minimum_firmware_version"`
|
||||
} `json:"device"`
|
||||
}
|
||||
if err := decodeData(first.Body, &bootstrap); err != nil {
|
||||
return err
|
||||
}
|
||||
if !bootstrap.Device.FirmwareCompatible {
|
||||
minimum := "当前策略"
|
||||
if bootstrap.Device.MinimumFirmwareVersion != nil {
|
||||
minimum = *bootstrap.Device.MinimumFirmwareVersion
|
||||
}
|
||||
return fmt.Errorf("firmware update required before conversation; minimum=%s", minimum)
|
||||
}
|
||||
second, err := s.request(ctx, http.MethodGet, "/device/v2/config", nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("bootstrap HTTP %d; config HTTP %d\n", first.Status, second.Status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simulator) offlineRetry(ctx context.Context) error {
|
||||
offline := *s
|
||||
offline.baseURL = "http://127.0.0.1:1"
|
||||
offline.client = &http.Client{Timeout: 250 * time.Millisecond}
|
||||
if _, err := offline.request(ctx, http.MethodGet, "/device/v2/config", nil, ""); err == nil {
|
||||
return errors.New("offline simulation unexpectedly reached a server")
|
||||
}
|
||||
fmt.Println("offline request retained locally; reconnecting with a new nonce")
|
||||
_, err := s.request(ctx, http.MethodGet, "/device/v2/config", nil, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *simulator) conversation(ctx context.Context) (string, error) {
|
||||
createBody := jsonBody(map[string]any{
|
||||
"device_time": time.Now().UTC(), "monotonic_ms": 1000,
|
||||
"capabilities": map[string]any{
|
||||
"rtc": true, "audio_tracks": []string{"near", "far"}, "upload_resume": true,
|
||||
"rtc_credential_renewal": true,
|
||||
},
|
||||
"network": map[string]any{"type": "simulated", "rssi": -48}, "requested_goal": "open_conversation",
|
||||
})
|
||||
createKey := "sim-create-" + nonce()
|
||||
createdResponse, err := s.request(ctx, http.MethodPost, "/device/v2/conversations", createBody, createKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var created createdConversation
|
||||
if err := decodeData(createdResponse.Body, &created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
replay, err := s.request(ctx, http.MethodPost, "/device/v2/conversations", createBody, createKey)
|
||||
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
|
||||
return "", fmt.Errorf("create replay failed: %w", err)
|
||||
}
|
||||
activate := jsonBody(map[string]any{
|
||||
"joined_at": time.Now().UTC(), "room_id": created.RTC.RoomID, "rtc_user_id": created.RTC.UserID,
|
||||
"rtc_join_result": "success", "config_revision": 1, "connection_reference": "sim-device",
|
||||
})
|
||||
activationPath := "/device/v2/conversations/" + created.ID + "/activate"
|
||||
if _, err := s.request(ctx, http.MethodPost, activationPath, activate, "sim-activate-"+nonce()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.waitForConversationActive(ctx, created.ID, 15*time.Second); err != nil {
|
||||
return "", err
|
||||
}
|
||||
heartbeat := jsonBody(map[string]any{
|
||||
"sequence_no": 1, "device_time": time.Now().UTC(), "monotonic_ms": 2000, "conversation_status": "ACTIVE",
|
||||
"rtc": map[string]any{"joined": true, "uplink": true, "downlink": true}, "network": map[string]any{"type": "simulated", "rssi": -50},
|
||||
"firmware_version": "0.2.0-sim", "reported_config_revision": 1,
|
||||
"rtc_credential_expires_at": created.RTC.ExpiresAt,
|
||||
"recording_queue": map[string]any{"pending_segments": 2, "failed_segments": 0}, "command_results": []any{},
|
||||
})
|
||||
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/heartbeat", heartbeat, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stop := jsonBody(map[string]any{
|
||||
"reason": "simulated_hangup", "device_time": time.Now().UTC(), "last_heartbeat_sequence": 1,
|
||||
"rtc_left": true, "recording": map[string]any{"near_segments": 1, "far_segments": 1},
|
||||
})
|
||||
stopKey := "sim-stop-" + nonce()
|
||||
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/stop", stop, stopKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
replay, err = s.request(ctx, http.MethodPost, "/device/v2/conversations/"+created.ID+"/stop", stop, stopKey)
|
||||
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
|
||||
return "", fmt.Errorf("stop replay failed: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
fmt.Println("conversation and repeated requests completed:", created.ID)
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func (s *simulator) waitForConversationActive(ctx context.Context, conversationID string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
statusResponse, err := s.request(ctx, http.MethodGet, "/device/v2/conversations/"+conversationID, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var state deviceConversationState
|
||||
if err := decodeData(statusResponse.Body, &state); err != nil {
|
||||
return err
|
||||
}
|
||||
switch state.Action {
|
||||
case "continue":
|
||||
if state.Status != "ACTIVE" {
|
||||
return fmt.Errorf("conversation returned continue in unexpected state %s", state.Status)
|
||||
}
|
||||
return nil
|
||||
case "leave_room":
|
||||
code := state.FailureCode
|
||||
if code == "" {
|
||||
code = strings.ToLower(state.Status)
|
||||
}
|
||||
return fmt.Errorf("conversation cannot start; leave RTC room: %s", code)
|
||||
case "wait":
|
||||
default:
|
||||
return fmt.Errorf("conversation returned unsupported device action %q", state.Action)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return errors.New("provider did not activate the simulated conversation within 15 seconds")
|
||||
}
|
||||
if err := waitForContext(ctx, 500*time.Millisecond); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *simulator) transcript(ctx context.Context, conversationID string) error {
|
||||
now := time.Now().UTC()
|
||||
body := jsonBody(map[string]any{"turns": []any{map[string]any{
|
||||
"turn_key": "sim-final-" + nonce(), "sequence_no": 100, "speaker": "subject",
|
||||
"start_at": now.Add(-2 * time.Second), "end_at": now,
|
||||
"final_text": "这是模拟设备离线缓存后补交的最终字幕。", "interrupted": false,
|
||||
}}})
|
||||
key := "sim-transcript-" + nonce()
|
||||
if _, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/turns:batch", body, key); err != nil {
|
||||
return err
|
||||
}
|
||||
replay, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/turns:batch", body, key)
|
||||
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
|
||||
return fmt.Errorf("transcript replay failed: %w", err)
|
||||
}
|
||||
fmt.Println("transcript supplement and replay completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simulator) telemetry(ctx context.Context, conversationID string) error {
|
||||
sequence := time.Now().UnixNano()
|
||||
event := map[string]any{
|
||||
"event_key": "sim-status-" + strconv.FormatInt(sequence, 10), "sequence_no": sequence,
|
||||
"occurred_at": time.Now().UTC(), "event_type": "status",
|
||||
"status": map[string]any{
|
||||
"firmware_version": "0.2.0-sim", "reported_config_revision": 1,
|
||||
"network": map[string]any{"type": "simulated", "rssi": -52},
|
||||
"rtc": map[string]any{"joined": false, "uplink": false, "downlink": false},
|
||||
"recording_queue": map[string]any{"pending_segments": 0, "failed_segments": 0}, "conversation_status": "COMPLETED",
|
||||
},
|
||||
}
|
||||
if conversationID != "" {
|
||||
event["conversation_id"] = conversationID
|
||||
}
|
||||
body := jsonBody(map[string]any{"events": []any{event}})
|
||||
key := "sim-telemetry-" + nonce()
|
||||
if _, err := s.request(ctx, http.MethodPost, "/device/v2/telemetry:batch", body, key); err != nil {
|
||||
return err
|
||||
}
|
||||
replay, err := s.request(ctx, http.MethodPost, "/device/v2/telemetry:batch", body, key)
|
||||
if err != nil || replay.Header.Get("Idempotency-Replayed") != "true" {
|
||||
return fmt.Errorf("telemetry replay failed: %w", err)
|
||||
}
|
||||
fmt.Println("telemetry batch and replay completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simulator) commands(ctx context.Context) error {
|
||||
response, err := s.request(ctx, http.MethodGet, "/device/v2/commands?limit=20", nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data struct {
|
||||
Items []simulatedDeviceCommand `json:"items"`
|
||||
}
|
||||
if err := decodeData(response.Body, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
completed, failed := 0, 0
|
||||
for _, command := range data.Items {
|
||||
if command.Type == "firmware_update" {
|
||||
outcome, err := s.executeFirmwareCommand(ctx, command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch outcome {
|
||||
case firmwareCompleted:
|
||||
completed++
|
||||
case firmwareFailed:
|
||||
failed++
|
||||
case firmwareInterrupted:
|
||||
fmt.Printf("firmware command %s intentionally interrupted; server state retained for recovery\n", command.ID)
|
||||
return nil
|
||||
case firmwareRebootRequired:
|
||||
fmt.Printf("firmware command %s armed the inactive slot; restart the simulator to perform boot health confirmation\n", command.ID)
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.ackDeviceCommand(ctx, command.ID, "SUCCEEDED", map[string]any{"result_code": "SIMULATED_OK"}); err != nil {
|
||||
return err
|
||||
}
|
||||
completed++
|
||||
}
|
||||
fmt.Printf("processed %d pending commands: %d completed, %d simulated failures\n", len(data.Items), completed, failed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simulator) media(ctx context.Context, conversationID string) error {
|
||||
durationMS, err := strconv.Atoi(env("SIM_MEDIA_DURATION_MS", "100"))
|
||||
if err != nil || durationMS < 1 || durationMS > 600000 {
|
||||
return errors.New("SIM_MEDIA_DURATION_MS must be between 1 and 600000")
|
||||
}
|
||||
mediaPath := strings.TrimSpace(os.Getenv("SIM_MEDIA_FILE"))
|
||||
content := syntheticWAV(durationMS)
|
||||
if mediaPath != "" {
|
||||
content, err = os.ReadFile(mediaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read simulated media file: %w", err)
|
||||
}
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
manifest := jsonBody(map[string]any{"segments": []any{map[string]any{
|
||||
"track": env("SIM_MEDIA_TRACK", "near"), "sequence_no": int(time.Now().Unix() % 1000000000),
|
||||
"codec": "wav_pcm_s16le", "content_type": "audio/wav", "duration_ms": durationMS,
|
||||
"expected_size": len(content), "expected_digest": hex.EncodeToString(digest[:]),
|
||||
}}})
|
||||
prepared, err := s.request(ctx, http.MethodPost, "/device/v2/conversations/"+conversationID+"/upload-batches", manifest, "sim-media-"+nonce())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var batch uploadBatch
|
||||
if err := decodeData(prepared.Body, &batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(batch.Segments) != 1 || batch.Segments[0].UploadURL == "" {
|
||||
return errors.New("media preparation did not return one upload URL")
|
||||
}
|
||||
uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPut, batch.Segments[0].UploadURL, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadRequest.Header.Set("Content-Type", "audio/wav")
|
||||
uploadResponse, err := s.client.Do(uploadRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload simulated media: %w", err)
|
||||
}
|
||||
uploadResponse.Body.Close()
|
||||
if uploadResponse.StatusCode < 200 || uploadResponse.StatusCode >= 300 {
|
||||
return fmt.Errorf("upload simulated media returned HTTP %d", uploadResponse.StatusCode)
|
||||
}
|
||||
if _, err := s.request(ctx, http.MethodPost, "/device/v2/upload-batches/"+batch.ID+"/complete", []byte(`{}`), ""); err != nil {
|
||||
return err
|
||||
}
|
||||
verifyTimeout, err := simulatorDuration("SIM_MEDIA_VERIFY_TIMEOUT", 20*time.Second, 5*time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := s.waitForVerifiedMedia(ctx, batch.ID, verifyTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if verified.LocalDeleteAfter == nil {
|
||||
return errors.New("verified media segment did not return local_delete_after")
|
||||
}
|
||||
deleteWaitMax, err := simulatorDuration("SIM_MEDIA_DELETE_WAIT_MAX", 30*time.Second, 10*time.Minute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wait := time.Until(*verified.LocalDeleteAfter) + 100*time.Millisecond
|
||||
if wait > deleteWaitMax {
|
||||
fmt.Printf("media verified; local encrypted copy retained until %s (outside simulator wait window)\n", verified.LocalDeleteAfter.UTC().Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
if wait > 0 {
|
||||
if err := waitForContext(ctx, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// The simulator only releases its in-memory copy. SIM_MEDIA_FILE is an operator-owned
|
||||
// fixture and is intentionally never removed from disk.
|
||||
content = nil
|
||||
ack := jsonBody(map[string]any{"status": "DELETED"})
|
||||
if _, err := s.request(ctx, http.MethodPost, "/device/v2/media-segments/"+verified.ID+"/local-delete-ack", ack, "sim-local-delete-"+nonce()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("media verified and local encrypted copy deletion acknowledged:", batch.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type verifiedMediaSegment struct {
|
||||
ID string
|
||||
LocalDeleteAfter *time.Time
|
||||
}
|
||||
|
||||
func (s *simulator) waitForVerifiedMedia(ctx context.Context, batchID string, timeout time.Duration) (verifiedMediaSegment, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
response, err := s.request(ctx, http.MethodGet, "/device/v2/upload-batches/"+batchID, nil, "")
|
||||
if err != nil {
|
||||
return verifiedMediaSegment{}, err
|
||||
}
|
||||
var batch uploadBatch
|
||||
if err := decodeData(response.Body, &batch); err != nil {
|
||||
return verifiedMediaSegment{}, err
|
||||
}
|
||||
if batch.Status == "FAILED" {
|
||||
code := "SEGMENT_REJECTED"
|
||||
if len(batch.Segments) > 0 && batch.Segments[0].LastErrorCode != "" {
|
||||
code = batch.Segments[0].LastErrorCode
|
||||
}
|
||||
return verifiedMediaSegment{}, fmt.Errorf("media verification failed: %s", code)
|
||||
}
|
||||
if batch.Status == "COMPLETED" {
|
||||
for _, segment := range batch.Segments {
|
||||
if segment.Status == "VERIFIED" && segment.ID != "" {
|
||||
return verifiedMediaSegment{ID: segment.ID, LocalDeleteAfter: segment.LocalDeleteAfter}, nil
|
||||
}
|
||||
}
|
||||
return verifiedMediaSegment{}, errors.New("completed media batch has no verified segment")
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return verifiedMediaSegment{}, fmt.Errorf("media verification did not complete within %s", timeout)
|
||||
}
|
||||
if err := waitForContext(ctx, 300*time.Millisecond); err != nil {
|
||||
return verifiedMediaSegment{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func simulatorDuration(key string, fallback, maximum time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil || parsed <= 0 || parsed > maximum {
|
||||
return 0, fmt.Errorf("%s must be a positive duration no greater than %s", key, maximum)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func waitForContext(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *simulator) request(ctx context.Context, method, path string, body []byte, key string) (apiResult, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, method, s.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return apiResult{}, err
|
||||
}
|
||||
timestamp := time.Now().UTC().Format(time.RFC3339)
|
||||
requestNonce, err := newNonce()
|
||||
if err != nil {
|
||||
return apiResult{}, err
|
||||
}
|
||||
hash := sha256.Sum256(body)
|
||||
digest := hex.EncodeToString(hash[:])
|
||||
request.Header.Set(deviceauth.HeaderDeviceID, s.deviceID)
|
||||
request.Header.Set(deviceauth.HeaderTimestamp, timestamp)
|
||||
request.Header.Set(deviceauth.HeaderNonce, requestNonce)
|
||||
request.Header.Set(deviceauth.HeaderContentSHA256, digest)
|
||||
request.Header.Set(deviceauth.HeaderSignature, deviceauth.SignatureHeader(s.secret, method, request.URL.RequestURI(), timestamp, requestNonce, digest))
|
||||
if len(body) > 0 {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if key != "" {
|
||||
request.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil {
|
||||
return apiResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return apiResult{}, err
|
||||
}
|
||||
result := apiResult{Status: response.StatusCode, Header: response.Header.Clone(), Body: responseBody}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("%s returned HTTP %d: %s", request.URL.Path, response.StatusCode, responseBody)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func decodeData(body []byte, target any) error {
|
||||
var result envelope
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(result.Data, target)
|
||||
}
|
||||
|
||||
func parseScenarios(value string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
for _, item := range strings.Split(strings.ToLower(value), ",") {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
result[item] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func syntheticWAV(durationMS int) []byte {
|
||||
samples := 16000 * durationMS / 1000
|
||||
dataSize := samples * 2
|
||||
buffer := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
|
||||
buffer.WriteString("RIFF")
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint32(36+dataSize))
|
||||
buffer.WriteString("WAVEfmt ")
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint32(16))
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint16(1))
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint32(16000))
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint32(32000))
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint16(2))
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint16(16))
|
||||
buffer.WriteString("data")
|
||||
_ = binary.Write(buffer, binary.LittleEndian, uint32(dataSize))
|
||||
buffer.Write(make([]byte, dataSize))
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func jsonBody(value any) []byte {
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
func nonce() string {
|
||||
value, err := newNonce()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
func newNonce() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
func env(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
)
|
||||
|
||||
func TestSimulatorRequestSignsBodyAndReturnsEnvelope(t *testing.T) {
|
||||
secret := []byte("simulator-request-secret-at-least-32-bytes")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, _ := io.ReadAll(request.Body)
|
||||
digest := sha256.Sum256(body)
|
||||
if request.Header.Get(deviceauth.HeaderDeviceID) != "device-1" || request.Header.Get(deviceauth.HeaderContentSHA256) != hex.EncodeToString(digest[:]) || request.Header.Get(deviceauth.HeaderSignature) == "" {
|
||||
t.Errorf("invalid signed headers: %v", request.Header)
|
||||
}
|
||||
if request.Header.Get("Idempotency-Key") != "idem-1" {
|
||||
t.Errorf("idempotency key=%q", request.Header.Get("Idempotency-Key"))
|
||||
}
|
||||
writer.Header().Set("ETag", `"3"`)
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, `{"data":{"status":"ok"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: secret, client: server.Client()}
|
||||
result, err := sim.request(context.Background(), http.MethodPost, "/device/v2/test", []byte(`{"value":1}`), "idem-1")
|
||||
if err != nil || result.Status != http.StatusOK || result.Header.Get("ETag") != `"3"` {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
var payload struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decodeData(result.Body, &payload); err != nil || payload.Status != "ok" {
|
||||
t.Fatalf("payload=%+v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorHelpersAndValidation(t *testing.T) {
|
||||
scenarios := parseScenarios(" Bootstrap,TELEMETRY, bootstrap ")
|
||||
if !scenarios["bootstrap"] || !scenarios["telemetry"] || len(scenarios) != 2 {
|
||||
t.Fatalf("scenarios=%v", scenarios)
|
||||
}
|
||||
wav := syntheticWAV(100)
|
||||
if string(wav[:4]) != "RIFF" || string(wav[8:12]) != "WAVE" || binary.LittleEndian.Uint32(wav[40:44]) != uint32(len(wav)-44) {
|
||||
t.Fatalf("invalid WAV header: size=%d", len(wav))
|
||||
}
|
||||
if len(jsonBody(map[string]int{"value": 1})) == 0 {
|
||||
t.Fatal("json body is empty")
|
||||
}
|
||||
if value, err := newNonce(); err != nil || len(value) != 32 {
|
||||
t.Fatalf("nonce=%q err=%v", value, err)
|
||||
}
|
||||
t.Setenv("SIM_DEVICE_SECRET", "short")
|
||||
if err := run(context.Background()); err == nil || !strings.Contains(err.Error(), "at least 32") {
|
||||
t.Fatalf("short secret error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatedConversationIgnoresEngineMode(t *testing.T) {
|
||||
decode := func(mode string) createdConversation {
|
||||
var created createdConversation
|
||||
body := []byte(`{"data":{"conversation_id":"conversation-1","rtc":{"room_id":"room-1","user_id":"device-1","expires_at":"2026-08-23T12:05:00Z"},"session":{"engine_mode":"` + mode + `"}}}`)
|
||||
if err := decodeData(body, &created); err != nil {
|
||||
t.Fatalf("decode mode %q: %v", mode, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
for _, mode := range []string{"s2s_pure", "s2s_hybrid", "cascade", "future_mode"} {
|
||||
created := decode(mode)
|
||||
if created.ID != "conversation-1" || created.RTC.RoomID != "room-1" || created.RTC.UserID != "device-1" {
|
||||
t.Fatalf("mode %q changed terminal media inputs: %#v", mode, created)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorWaitForConversationState(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, response, wantError string
|
||||
}{
|
||||
{name: "active", response: `{"data":{"status":"ACTIVE","action":"continue"}}`},
|
||||
{name: "failed", response: `{"data":{"status":"FAILED","action":"leave_room","failure_code":"provider_start_rejected"}}`, wantError: "provider_start_rejected"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet || request.URL.Path != "/device/v2/conversations/conversation-1" {
|
||||
t.Errorf("unexpected request %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, test.response)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
|
||||
err := sim.waitForConversationActive(context.Background(), "conversation-1", time.Second)
|
||||
if test.wantError == "" && err != nil {
|
||||
t.Fatalf("wait failed: %v", err)
|
||||
}
|
||||
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
|
||||
t.Fatalf("error=%v want %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorHTTPAndMediaFailures(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusConflict)
|
||||
_, _ = io.WriteString(writer, `{"error":{"code":"CONFLICT"}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
|
||||
if _, err := sim.request(context.Background(), http.MethodGet, "/conflict", nil, ""); err == nil || !strings.Contains(err.Error(), "HTTP 409") {
|
||||
t.Fatalf("conflict error=%v", err)
|
||||
}
|
||||
t.Setenv("SIM_MEDIA_DURATION_MS", "0")
|
||||
if err := sim.media(context.Background(), "conversation-1"); err == nil || !strings.Contains(err.Error(), "between 1 and 600000") {
|
||||
t.Fatalf("media validation error=%v", err)
|
||||
}
|
||||
var target map[string]any
|
||||
if err := decodeData([]byte(`not-json`), &target); err == nil {
|
||||
t.Fatal("invalid envelope accepted")
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"data": "not-an-object"})
|
||||
if err := decodeData(encoded, &target); err == nil {
|
||||
t.Fatal("invalid data payload accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorMediaCompletesVerificationAndDeleteReceipt(t *testing.T) {
|
||||
deleteAcknowledged := false
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/conversations/conversation-1/upload-batches":
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"UPLOADING","segments":[{"id":"segment-1","upload_url":"`+server.URL+`/upload","content_type":"audio/wav"}]}}`)
|
||||
case request.Method == http.MethodPut && request.URL.Path == "/upload":
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/upload-batches/batch-1/complete":
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"VERIFYING","segments":[]}}`)
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/device/v2/upload-batches/batch-1":
|
||||
deleteAfter := time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano)
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"batch-1","status":"COMPLETED","segments":[{"id":"segment-1","status":"VERIFIED","local_delete_after":"`+deleteAfter+`"}]}}`)
|
||||
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/media-segments/segment-1/local-delete-ack":
|
||||
if request.Header.Get("Idempotency-Key") == "" {
|
||||
t.Error("local delete receipt has no idempotency key")
|
||||
}
|
||||
deleteAcknowledged = true
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"segment-1","status":"VERIFIED","local_delete_status":"DELETED"}}`)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Setenv("SIM_MEDIA_VERIFY_TIMEOUT", "2s")
|
||||
t.Setenv("SIM_MEDIA_DELETE_WAIT_MAX", "2s")
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
|
||||
if err := sim.media(context.Background(), "conversation-1"); err != nil {
|
||||
t.Fatalf("media flow error=%v", err)
|
||||
}
|
||||
if !deleteAcknowledged {
|
||||
t.Fatal("local delete receipt was not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorFirmwareCommandSuccess(t *testing.T) {
|
||||
content := []byte("signed simulated firmware image")
|
||||
payload := signedSimulatorFirmwarePayload(t, content, "2.3.1")
|
||||
acks := []map[string]any{}
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/device/v2/firmware-releases/release-1/download":
|
||||
_, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`)
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin":
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = writer.Write(content)
|
||||
case request.Method == http.MethodPost && request.URL.Path == "/device/v2/commands/command-1/ack":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode ack: %v", err)
|
||||
}
|
||||
acks = append(acks, body)
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"command-1"}}`)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
|
||||
command := simulatedDeviceCommand{ID: "command-1", Type: "firmware_update", Payload: payload}
|
||||
outcome, err := sim.executeFirmwareCommand(context.Background(), command)
|
||||
if err != nil || outcome != firmwareCompleted {
|
||||
t.Fatalf("outcome=%q err=%v", outcome, err)
|
||||
}
|
||||
if len(acks) != 4 || acks[0]["status"] != "ACKNOWLEDGED" || acks[1]["status"] != "ACKNOWLEDGED" || acks[2]["status"] != "ACKNOWLEDGED" || acks[3]["status"] != "SUCCEEDED" {
|
||||
t.Fatalf("acks=%+v", acks)
|
||||
}
|
||||
firstResult := acks[0]["result_summary"].(map[string]any)
|
||||
downloadResult := acks[1]["result_summary"].(map[string]any)
|
||||
installResult := acks[2]["result_summary"].(map[string]any)
|
||||
finalResult := acks[3]["result_summary"].(map[string]any)
|
||||
if firstResult["phase"] != "downloading" || downloadResult["result_code"] != "ARTIFACT_VERIFIED" || downloadResult["verified_signing_key_id"] != "sim-test-key" || installResult["phase"] != "installing" || finalResult["installed_version"] != "2.3.1" || finalResult["verified_sha256"] != payload["sha256"] {
|
||||
t.Fatalf("firmware results=%+v", acks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorFirmwareCommandFaultsAndInterruptions(t *testing.T) {
|
||||
content := []byte("simulated firmware fault fixture")
|
||||
tests := []struct {
|
||||
fault string
|
||||
wantOutcome firmwareExecutionOutcome
|
||||
wantStatuses []string
|
||||
wantCode string
|
||||
}{
|
||||
{fault: "download_failed", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "DOWNLOAD_FAILED"},
|
||||
{fault: "digest_mismatch", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "DIGEST_MISMATCH"},
|
||||
{fault: "signature_mismatch", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "FAILED"}, wantCode: "SIGNATURE_MISMATCH"},
|
||||
{fault: "install_failed", wantOutcome: firmwareFailed, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED", "ACKNOWLEDGED", "FAILED"}, wantCode: "INSTALL_FAILED"},
|
||||
{fault: "interrupt_after_download", wantOutcome: firmwareInterrupted, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED"}, wantCode: "ARTIFACT_VERIFIED"},
|
||||
{fault: "interrupt_after_install", wantOutcome: firmwareInterrupted, wantStatuses: []string{"ACKNOWLEDGED", "ACKNOWLEDGED", "ACKNOWLEDGED"}, wantCode: "INSTALL_STARTED"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.fault, func(t *testing.T) {
|
||||
t.Setenv("SIM_FIRMWARE_FAULT", test.fault)
|
||||
payload := signedSimulatorFirmwarePayload(t, content, "2.3.1")
|
||||
acks := []map[string]any{}
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/download"):
|
||||
_, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`)
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin":
|
||||
_, _ = writer.Write(content)
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/ack"):
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&body)
|
||||
acks = append(acks, body)
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"command-1"}}`)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
|
||||
command := simulatedDeviceCommand{ID: "command-1", Type: "firmware_update", Payload: payload}
|
||||
outcome, err := sim.executeFirmwareCommand(context.Background(), command)
|
||||
if err != nil || outcome != test.wantOutcome {
|
||||
t.Fatalf("outcome=%q err=%v", outcome, err)
|
||||
}
|
||||
statuses := make([]string, 0, len(acks))
|
||||
for _, ack := range acks {
|
||||
statuses = append(statuses, ack["status"].(string))
|
||||
}
|
||||
if strings.Join(statuses, ",") != strings.Join(test.wantStatuses, ",") {
|
||||
t.Fatalf("statuses=%v want=%v", statuses, test.wantStatuses)
|
||||
}
|
||||
lastResult := acks[len(acks)-1]["result_summary"].(map[string]any)
|
||||
if lastResult["result_code"] != test.wantCode {
|
||||
t.Fatalf("last result=%+v", lastResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorFirmwareManifestAndConfigurationValidation(t *testing.T) {
|
||||
digest := strings.Repeat("a", 64)
|
||||
valid := map[string]any{"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)), "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download"}
|
||||
if _, err := parseSimulatedFirmwareManifest(valid); err != nil {
|
||||
t.Fatalf("valid manifest error=%v", err)
|
||||
}
|
||||
for name, payload := range map[string]map[string]any{
|
||||
"external path": {"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "https://example.invalid/firmware"},
|
||||
"traversal": {"release_id": "release-1", "version": "2.3.1", "sha256": digest, "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/../download"},
|
||||
"uppercase": {"release_id": "release-1", "version": "2.3.1", "sha256": strings.Repeat("A", 64), "signature": valid["signature"], "signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseSimulatedFirmwareManifest(payload); err == nil {
|
||||
t.Fatalf("invalid manifest accepted: %+v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_FAULT", "unknown")
|
||||
if _, err := simulatedFirmwareFault(); err == nil {
|
||||
t.Fatal("unknown firmware fault was accepted")
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_STEP_DELAY", "31s")
|
||||
if _, err := simulatorOptionalDuration("SIM_FIRMWARE_STEP_DELAY", 30*time.Second); err == nil {
|
||||
t.Fatal("excessive firmware step delay was accepted")
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_TRUSTED_KEYS", `{}`)
|
||||
if _, err := simulatedFirmwareTrustedKey("sim-test-key"); err == nil {
|
||||
t.Fatal("unprovisioned firmware signing key was accepted")
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_AB_UPDATE", "yes")
|
||||
if _, err := simulatedABFirmwareEnabled(); err == nil {
|
||||
t.Fatal("invalid A/B firmware flag was accepted")
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_AB_UPDATE", "true")
|
||||
t.Setenv("SIM_FIRMWARE_STATE_FILE", "relative-state.json")
|
||||
if _, err := simulatedABFirmwareEnabled(); err == nil {
|
||||
t.Fatal("relative A/B firmware state path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorABFirmwareBootConfirmationAndRollback(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, stageFault, bootFault, wantOutcome, wantPhase, wantSlot, wantCode string
|
||||
retryAfterBoot bool
|
||||
wantAckCount int
|
||||
}{
|
||||
{name: "healthy boot", stageFault: "none", bootFault: "none", wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 7},
|
||||
{name: "failed boot rolls back", stageFault: "none", bootFault: "boot_health_failed", wantOutcome: string(firmwareFailed), wantPhase: firmwareBootRolledBack, wantSlot: "A", wantCode: "BOOT_HEALTH_FAILED_ROLLED_BACK", wantAckCount: 7},
|
||||
{name: "power loss after staging resumes", stageFault: "power_loss_after_stage", bootFault: "none", wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 7},
|
||||
{name: "power loss during boot resumes", stageFault: "none", bootFault: "power_loss_during_boot", retryAfterBoot: true, wantOutcome: string(firmwareCompleted), wantPhase: firmwareBootConfirmed, wantSlot: "B", wantCode: "SIMULATED_AB_BOOT_CONFIRMED", wantAckCount: 8},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
content := []byte("simulated A/B firmware image")
|
||||
payload := signedSimulatorFirmwarePayload(t, content, "3.1.0")
|
||||
stateFile := filepath.Join(t.TempDir(), "firmware-state.json")
|
||||
t.Setenv("SIM_FIRMWARE_AB_UPDATE", "true")
|
||||
t.Setenv("SIM_FIRMWARE_STATE_FILE", stateFile)
|
||||
t.Setenv("SIM_FIRMWARE_BOOT_HEALTH_WINDOW", "30s")
|
||||
t.Setenv("SIM_FIRMWARE_CURRENT_VERSION", "0.2.0-sim")
|
||||
t.Setenv("SIM_FIRMWARE_FAULT", test.stageFault)
|
||||
acks := []map[string]any{}
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/download"):
|
||||
_, _ = io.WriteString(writer, `{"data":{"download_url":"`+server.URL+`/firmware.bin"}}`)
|
||||
case request.Method == http.MethodGet && request.URL.Path == "/firmware.bin":
|
||||
_, _ = writer.Write(content)
|
||||
case request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/ack"):
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&body)
|
||||
acks = append(acks, body)
|
||||
_, _ = io.WriteString(writer, `{"data":{"id":"command-ab"}}`)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
sim := simulator{baseURL: server.URL, deviceID: "device-1", secret: []byte("simulator-request-secret-at-least-32-bytes"), client: server.Client()}
|
||||
command := simulatedDeviceCommand{ID: "command-ab", Type: "firmware_update", Payload: payload}
|
||||
outcome, err := sim.executeFirmwareCommand(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatalf("staging outcome=%q err=%v", outcome, err)
|
||||
}
|
||||
if test.stageFault == "power_loss_after_stage" {
|
||||
if outcome != firmwareInterrupted {
|
||||
t.Fatalf("power loss staging outcome=%q", outcome)
|
||||
}
|
||||
staged, stateErr := loadSimulatedFirmwareBootState()
|
||||
if stateErr != nil || staged.Phase != firmwareBootStaged {
|
||||
t.Fatalf("power loss staged state=%+v err=%v", staged, stateErr)
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_FAULT", "none")
|
||||
outcome, err = sim.executeFirmwareCommand(context.Background(), command)
|
||||
}
|
||||
if err != nil || outcome != firmwareRebootRequired {
|
||||
t.Fatalf("reboot arming outcome=%q err=%v", outcome, err)
|
||||
}
|
||||
staged, err := loadSimulatedFirmwareBootState()
|
||||
if err != nil || staged.Phase != firmwareBootRebootPending || staged.ActiveSlot != "A" || staged.TargetSlot != "B" || staged.BootAttemptID == "" {
|
||||
t.Fatalf("staged state=%+v err=%v", staged, err)
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_FAULT", test.bootFault)
|
||||
outcome, err = sim.executeFirmwareCommand(context.Background(), command)
|
||||
if test.retryAfterBoot {
|
||||
if err != nil || outcome != firmwareInterrupted {
|
||||
t.Fatalf("boot power loss outcome=%q err=%v", outcome, err)
|
||||
}
|
||||
t.Setenv("SIM_FIRMWARE_FAULT", "none")
|
||||
outcome, err = sim.executeFirmwareCommand(context.Background(), command)
|
||||
}
|
||||
if err != nil || string(outcome) != test.wantOutcome {
|
||||
t.Fatalf("boot outcome=%q err=%v", outcome, err)
|
||||
}
|
||||
finalState, err := loadSimulatedFirmwareBootState()
|
||||
if err != nil || finalState.Phase != test.wantPhase || finalState.ActiveSlot != test.wantSlot {
|
||||
t.Fatalf("final state=%+v err=%v", finalState, err)
|
||||
}
|
||||
if len(acks) != test.wantAckCount {
|
||||
t.Fatalf("ack count=%d acks=%+v", len(acks), acks)
|
||||
}
|
||||
lastResult := acks[len(acks)-1]["result_summary"].(map[string]any)
|
||||
if lastResult["result_code"] != test.wantCode {
|
||||
t.Fatalf("last result=%+v", lastResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func signedSimulatorFirmwarePayload(t *testing.T, content []byte, version string) map[string]any {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
digestText := hex.EncodeToString(digest[:])
|
||||
manifest := fmt.Sprintf("firmware-manifest-v1\nmodel=simulated-terminal\nhardware_revision=sim-rev-a\nversion=%s\nsha256=%s\nsize=%d\n", version, digestText, len(content))
|
||||
trustedKeys, _ := json.Marshal(map[string]string{"sim-test-key": base64.StdEncoding.EncodeToString(publicKey)})
|
||||
t.Setenv("SIM_FIRMWARE_TRUSTED_KEYS", string(trustedKeys))
|
||||
return map[string]any{
|
||||
"release_id": "release-1", "version": version, "sha256": digestText,
|
||||
"signature": base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(manifest))),
|
||||
"signing_key_id": "sim-test-key", "download_path": "/device/v2/firmware-releases/release-1/download",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user