chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// Package agent contains file-backed Agent execution state and local media
|
||||
// asset lifecycle. Network session/authentication implementations are added
|
||||
// only against the published internal Proto contract; no JSON substitute is
|
||||
// used here.
|
||||
package agent
|
||||
@@ -0,0 +1,72 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
)
|
||||
|
||||
// EventWriter keeps Agent-produced realtime facts in the approved event
|
||||
// vocabulary before they are handed to Dispatcher/MQ. Transcript text is
|
||||
// written to the Agent spool as an archive as well as returned for realtime
|
||||
// publication; the archive is not a substitute for transcript.updated.
|
||||
type EventWriter struct {
|
||||
TenantID string
|
||||
TenantKey string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
func (w EventWriter) TranscriptUpdated(now time.Time, eventID, callID, turnID, segmentID, role, text string, revision int64, final bool, startMS, endMS int64) ([]byte, error) {
|
||||
if eventID == "" || callID == "" || turnID == "" || segmentID == "" || role == "" {
|
||||
return nil, errors.New("transcript event identity is required")
|
||||
}
|
||||
if revision < 1 || startMS < 0 || endMS < startMS {
|
||||
return nil, errors.New("transcript timing or revision is invalid")
|
||||
}
|
||||
return (contract.EventBuilder{
|
||||
TenantID: w.TenantID, TenantKey: w.TenantKey, TraceID: w.TraceID,
|
||||
EventType: "transcript.updated", Aggregate: "transcript_segment", AggregateID: segmentID, Version: revision,
|
||||
Payload: map[string]any{
|
||||
"call_id": callID, "turn_id": turnID, "segment_id": segmentID,
|
||||
"role": role, "revision": revision, "text": text, "is_final": final,
|
||||
"start_ms": startMS, "end_ms": endMS, "playback_state": "not_applicable",
|
||||
},
|
||||
}).Marshal(now, eventID)
|
||||
}
|
||||
|
||||
func (w EventWriter) RecordingReady(now time.Time, eventID, callID, recordingID, ossID, format string, channels int32, sampleRateHz int32, durationMS, sizeBytes int64, checksum string) ([]byte, error) {
|
||||
if eventID == "" || callID == "" || recordingID == "" || ossID == "" || checksum == "" {
|
||||
return nil, errors.New("verified recording identity and checksum are required")
|
||||
}
|
||||
if sizeBytes < 1 || durationMS < 0 || channels != 1 || sampleRateHz < 8000 {
|
||||
return nil, errors.New("recording metadata is invalid")
|
||||
}
|
||||
return (contract.EventBuilder{
|
||||
TenantID: w.TenantID, TenantKey: w.TenantKey, TraceID: w.TraceID,
|
||||
EventType: "recording.ready", Aggregate: "recording", AggregateID: recordingID, Version: 1,
|
||||
Payload: map[string]any{
|
||||
"call_id": callID, "recording_id": recordingID, "oss_id": ossID,
|
||||
"format": format, "channels": channels, "sample_rate_hz": sampleRateHz,
|
||||
"duration_ms": durationMS, "size_bytes": sizeBytes, "checksum_sha256": checksum,
|
||||
},
|
||||
}).Marshal(now, eventID)
|
||||
}
|
||||
|
||||
func (s *Spool) AppendApprovedEvent(executionID string, event []byte) error {
|
||||
var envelope struct {
|
||||
EventType string `json:"event_type"`
|
||||
}
|
||||
if err := json.Unmarshal(event, &envelope); err != nil {
|
||||
return fmt.Errorf("decode event envelope: %w", err)
|
||||
}
|
||||
if envelope.EventType != "transcript.updated" {
|
||||
return fmt.Errorf("Agent transcript archive accepts transcript.updated only, got %q", envelope.EventType)
|
||||
}
|
||||
if err := contract.ValidateEvent(event); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.AppendTranscript(executionID, event)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
)
|
||||
|
||||
func TestEventWriterBuildsApprovedRealtimeTranscript(t *testing.T) {
|
||||
writer := EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-1"}
|
||||
event, err := writer.TranscriptUpdated(time.Unix(100, 0), "event-1", "call-1", "turn-1", "segment-1", "customer", "您好", 1, true, 0, 600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := contract.ValidateEvent(event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(event) == "" || strings.Contains(string(event), "call.transcript") {
|
||||
t.Fatal("invalid transcript alias or empty event")
|
||||
}
|
||||
|
||||
spool, err := NewSpool(t.TempDir(), time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := spool.Start("execution-1", 1, "session-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := spool.AppendApprovedEvent("execution-1", event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventWriterRejectsUnverifiedRecordingAndWrongArchiveEvent(t *testing.T) {
|
||||
writer := EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-1"}
|
||||
if _, err := writer.RecordingReady(time.Unix(100, 0), "event-1", "call-1", "recording-1", "", "wav", 1, 16000, 1000, 100, strings.Repeat("a", 64)); err == nil {
|
||||
t.Fatal("expected missing OSS verification ID to be rejected")
|
||||
}
|
||||
spool, err := NewSpool(t.TempDir(), time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := spool.Start("execution-1", 1, "session-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := spool.AppendApprovedEvent("execution-1", []byte(`{"event_type":"call.transcript"}`)); err == nil {
|
||||
t.Fatal("expected invalid realtime event name to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package agent owns the Agent's file-backed execution and asset recovery
|
||||
// state. It deliberately has no business database and never proxies audio to
|
||||
// Dispatcher.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
TaskRevision int64 `json:"task_revision"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Unknown bool `json:"unknown"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Spool struct {
|
||||
root string
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSpool(root string, now func() time.Time) (*Spool, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, errors.New("spool root is required")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create spool root: %w", err)
|
||||
}
|
||||
return &Spool{root: root, now: now}, nil
|
||||
}
|
||||
|
||||
func (s *Spool) Root() string { return s.root }
|
||||
|
||||
func (s *Spool) Start(executionID string, revision int64, sessionID string) (State, error) {
|
||||
if err := validateName(executionID); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if revision < 1 {
|
||||
return State{}, errors.New("task revision must be positive")
|
||||
}
|
||||
state := State{SchemaVersion: "1", ExecutionID: executionID, TaskRevision: revision, SessionID: sessionID, Status: "reserved", UpdatedAt: s.now().UTC()}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.statePath(executionID)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return State{}, fmt.Errorf("execution state already exists: %s", executionID)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return State{}, err
|
||||
}
|
||||
for _, dir := range []string{s.executionDir(executionID), filepath.Join(s.executionDir(executionID), "transcript"), filepath.Join(s.executionDir(executionID), "assets")} {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
}
|
||||
if err := writeJSONAtomic(path, state); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Spool) Load(executionID string) (State, error) {
|
||||
if err := validateName(executionID); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
data, err := os.ReadFile(s.statePath(executionID))
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return State{}, fmt.Errorf("decode execution state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Spool) Update(executionID, status, reason string) (State, error) {
|
||||
if err := validateName(executionID); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
if status == "" {
|
||||
return State{}, errors.New("state status is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := os.ReadFile(s.statePath(executionID))
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return State{}, fmt.Errorf("decode execution state: %w", err)
|
||||
}
|
||||
state.Status, state.Reason, state.UpdatedAt = status, reason, s.now().UTC()
|
||||
state.Unknown = status == "unknown"
|
||||
if err := writeJSONAtomic(s.statePath(executionID), state); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// MarkUnknownOnBoot converts all in-flight local state to explicit unknown.
|
||||
// It never deletes or releases a remote reservation; Dispatcher reconciliation
|
||||
// must decide whether a recovered execution may proceed.
|
||||
func (s *Spool) MarkUnknownOnBoot() (RecoveryReport, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := os.ReadDir(s.root)
|
||||
if err != nil {
|
||||
return RecoveryReport{}, err
|
||||
}
|
||||
var report RecoveryReport
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
executionID := entry.Name()
|
||||
path := s.statePath(executionID)
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
quarantine := path + ".corrupt-" + s.now().UTC().Format("20060102T150405.000000000Z")
|
||||
if renameErr := os.Rename(path, quarantine); renameErr != nil {
|
||||
return report, fmt.Errorf("quarantine corrupt state: %w (decode: %v)", renameErr, err)
|
||||
}
|
||||
report.Quarantined = append(report.Quarantined, executionID)
|
||||
continue
|
||||
}
|
||||
if state.Status != "running" && state.Status != "reserved" && state.Status != "starting" && state.Status != "draining" {
|
||||
continue
|
||||
}
|
||||
state.Status, state.Unknown, state.Reason, state.UpdatedAt = "unknown", true, "agent_boot_recovery", s.now().UTC()
|
||||
if err := writeJSONAtomic(path, state); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Unknown = append(report.Unknown, executionID)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
type RecoveryReport struct {
|
||||
Unknown []string
|
||||
Quarantined []string
|
||||
}
|
||||
|
||||
func (s *Spool) AppendTranscript(executionID string, event []byte) error {
|
||||
if err := validateName(executionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(event) == 0 {
|
||||
return errors.New("transcript event is empty")
|
||||
}
|
||||
if !json.Valid(event) {
|
||||
return errors.New("transcript event must be valid JSON")
|
||||
}
|
||||
path := filepath.Join(s.executionDir(executionID), "transcript", "events.jsonl")
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Write(append(event, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func (s *Spool) WriteAsset(executionID, assetID string, r io.Reader) (string, int64, string, error) {
|
||||
if err := validateName(executionID); err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
if err := validateName(assetID); err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
if r == nil {
|
||||
return "", 0, "", errors.New("asset reader is required")
|
||||
}
|
||||
dir := filepath.Join(s.executionDir(executionID), "assets")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
part := filepath.Join(dir, assetID+".part")
|
||||
final := filepath.Join(dir, assetID)
|
||||
file, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
hash := sha256.New()
|
||||
n, copyErr := io.Copy(io.MultiWriter(file, hash), r)
|
||||
syncErr := file.Sync()
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil || syncErr != nil || closeErr != nil {
|
||||
_ = os.Remove(part)
|
||||
return "", n, "", firstError(copyErr, syncErr, closeErr)
|
||||
}
|
||||
if err := os.Rename(part, final); err != nil {
|
||||
_ = os.Remove(part)
|
||||
return "", n, "", err
|
||||
}
|
||||
return final, n, hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (s *Spool) executionDir(executionID string) string { return filepath.Join(s.root, executionID) }
|
||||
func (s *Spool) statePath(executionID string) string {
|
||||
return filepath.Join(s.executionDir(executionID), "state.json")
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(tmp, os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateName(name string) error {
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) || strings.Contains(name, "..") || strings.TrimSpace(name) != name {
|
||||
return fmt.Errorf("unsafe file name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstError(errs ...error) error {
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testSpool(t *testing.T) *Spool {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
|
||||
s, err := NewSpool(t.TempDir(), func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSpoolAtomicStateAndBootUnknown(t *testing.T) {
|
||||
s := testSpool(t)
|
||||
if _, err := s.Start("exec-1", 1, "session-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Update("exec-1", "running", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := s.MarkUnknownOnBoot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(report.Unknown) != 1 || report.Unknown[0] != "exec-1" {
|
||||
t.Fatalf("recovery report = %+v", report)
|
||||
}
|
||||
state, err := s.Load("exec-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Status != "unknown" || !state.Unknown {
|
||||
t.Fatalf("state = %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpoolQuarantinesCorruptStateAndNeverDeletesIt(t *testing.T) {
|
||||
s := testSpool(t)
|
||||
if err := os.MkdirAll(filepath.Join(s.Root(), "broken"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.Root(), "broken", "state.json"), []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := s.MarkUnknownOnBoot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(report.Quarantined) != 1 || len(report.Unknown) != 0 {
|
||||
t.Fatalf("recovery report = %+v", report)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(s.Root(), "broken", "state.json.corrupt-*"))
|
||||
if err != nil || len(matches) != 1 {
|
||||
t.Fatalf("quarantine files = %v, err=%v", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpoolTranscriptAndAssetAreDurableFiles(t *testing.T) {
|
||||
s := testSpool(t)
|
||||
if _, err := s.Start("exec-2", 1, "session-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AppendTranscript("exec-2", []byte(`{"text":"hello","final":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, n, hash, err := s.WriteAsset("exec-2", "recording.pcm", bytes.NewReader([]byte("pcm")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 || hash == "" || path == "" {
|
||||
t.Fatalf("asset result path=%q bytes=%d hash=%q", path, n, hash)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.Root(), "exec-2", "assets", "recording.pcm.part")); !os.IsNotExist(err) {
|
||||
t.Fatalf("temporary asset still exists: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
)
|
||||
|
||||
// UploadClient performs the Agent-direct data-plane upload using a restricted
|
||||
// Dispatcher grant. It never sends file bytes through Dispatcher or writes an
|
||||
// OSS credential to logs. It never deletes or moves the source asset; the
|
||||
// lifecycle owner retains it until the verified handoff is durably recorded.
|
||||
type UploadClient struct {
|
||||
HTTPClient *http.Client
|
||||
AllowedHosts map[string]struct{}
|
||||
AllowInsecureHTTP bool
|
||||
MaxResponseBodySize int64
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
var ErrUploadGrantExpired = errors.New("upload grant is expired")
|
||||
|
||||
type UploadResult struct {
|
||||
StatusCode int
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
ETag string
|
||||
}
|
||||
|
||||
func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant, path string) (result UploadResult, err error) {
|
||||
if grant == nil {
|
||||
return UploadResult{}, errors.New("upload grant is required")
|
||||
}
|
||||
if grant.TargetUrl == "" || grant.UploadId == "" || grant.ObjectKey == "" {
|
||||
return UploadResult{}, errors.New("upload URL, ID and object key are required")
|
||||
}
|
||||
if grant.ExpiresAtUnixMs <= 0 {
|
||||
return UploadResult{}, errors.New("upload grant expiry is required")
|
||||
}
|
||||
now := time.Now
|
||||
if c.Now != nil {
|
||||
now = c.Now
|
||||
}
|
||||
if !now().Before(time.UnixMilli(grant.ExpiresAtUnixMs)) {
|
||||
return UploadResult{}, ErrUploadGrantExpired
|
||||
}
|
||||
parsed, err := url.Parse(grant.TargetUrl)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return UploadResult{}, errors.New("upload URL is invalid")
|
||||
}
|
||||
if parsed.Scheme != "https" && !(c.AllowInsecureHTTP && parsed.Scheme == "http") {
|
||||
return UploadResult{}, errors.New("upload URL must use HTTPS")
|
||||
}
|
||||
if len(c.AllowedHosts) > 0 {
|
||||
if _, ok := c.AllowedHosts[strings.ToLower(parsed.Host)]; !ok {
|
||||
return UploadResult{}, fmt.Errorf("upload host %q is not allowed", parsed.Host)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if stat.IsDir() {
|
||||
_ = file.Close()
|
||||
return UploadResult{}, errors.New("upload path is a directory")
|
||||
}
|
||||
if grant.MaxBytes > 0 && stat.Size() > grant.MaxBytes {
|
||||
_ = file.Close()
|
||||
return UploadResult{}, fmt.Errorf("asset exceeds grant limit: %d > %d", stat.Size(), grant.MaxBytes)
|
||||
}
|
||||
digest, err := digestFile(file)
|
||||
closeErr := file.Close()
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return UploadResult{}, closeErr
|
||||
}
|
||||
if grant.RequiredChecksumSha256 != "" && !strings.EqualFold(grant.RequiredChecksumSha256, digest) {
|
||||
return UploadResult{}, errors.New("asset checksum does not match upload grant")
|
||||
}
|
||||
|
||||
file, err = os.Open(path)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := file.Close(); err == nil && closeErr != nil && !errors.Is(closeErr, os.ErrClosed) {
|
||||
result = UploadResult{}
|
||||
err = closeErr
|
||||
}
|
||||
}()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, parsed.String(), file)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
req.ContentLength = stat.Size()
|
||||
for _, header := range grant.Headers {
|
||||
if strings.EqualFold(header.Name, "host") || strings.EqualFold(header.Name, "content-length") {
|
||||
return UploadResult{}, errors.New("upload grant contains a forbidden header")
|
||||
}
|
||||
req.Header.Set(header.Name, header.Value)
|
||||
}
|
||||
client := c.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
copyClient := *client
|
||||
copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
||||
resp, err := copyClient.Do(req)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
maxResponse := c.MaxResponseBodySize
|
||||
if maxResponse <= 0 {
|
||||
maxResponse = 64 << 10
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse))
|
||||
return UploadResult{}, fmt.Errorf("upload returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse))
|
||||
return UploadResult{StatusCode: resp.StatusCode, SizeBytes: stat.Size(), SHA256: digest, ETag: resp.Header.Get("ETag")}, nil
|
||||
}
|
||||
|
||||
func digestFile(file *os.File) (string, error) {
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
)
|
||||
|
||||
func TestUploadClientUsesGrantAndVerifiesChecksum(t *testing.T) {
|
||||
body := []byte("mock recording bytes")
|
||||
digest := sha256.Sum256(body)
|
||||
var received []byte
|
||||
var receivedHeader string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeader = r.Header.Get("x-upload-token")
|
||||
received, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", "etag-1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
assetPath := filepath.Join(t.TempDir(), "recording.bin")
|
||||
if err := os.WriteFile(assetPath, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := &agentv1.UploadGrant{UploadId: "upload-1", TargetUrl: server.URL, ObjectKey: "recording-1", ExpiresAtUnixMs: time.Unix(101, 0).UnixMilli(), Headers: []*agentv1.Header{{Name: "x-upload-token", Value: "mock-token"}}, RequiredChecksumSha256: hex.EncodeToString(digest[:]), MaxBytes: int64(len(body))}
|
||||
result, err := (UploadClient{AllowInsecureHTTP: true, Now: func() time.Time { return time.Unix(100, 0) }, AllowedHosts: map[string]struct{}{strings.TrimPrefix(server.URL, "http://"): {}}}).UploadFile(context.Background(), grant, assetPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.StatusCode != http.StatusOK || result.SHA256 != grant.RequiredChecksumSha256 || result.ETag != "etag-1" || string(received) != string(body) || receivedHeader != "mock-token" {
|
||||
t.Fatalf("unexpected upload result: %+v body=%q header=%q", result, received, receivedHeader)
|
||||
}
|
||||
if retained, statErr := os.Stat(assetPath); statErr != nil || retained.Size() != int64(len(body)) {
|
||||
t.Fatalf("source asset was not retained after upload: stat=%v info=%v", statErr, retained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadClientFailsClosedForGrantMismatchAndHTTP(t *testing.T) {
|
||||
assetPath := filepath.Join(t.TempDir(), "recording.bin")
|
||||
if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := &agentv1.UploadGrant{UploadId: "upload-2", TargetUrl: "http://127.0.0.1:1/upload", ObjectKey: "recording-2", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), RequiredChecksumSha256: strings.Repeat("a", 64), MaxBytes: 1024}
|
||||
if _, err := (UploadClient{AllowInsecureHTTP: true}).UploadFile(context.Background(), grant, assetPath); err == nil {
|
||||
t.Fatal("expected checksum mismatch")
|
||||
}
|
||||
grant.RequiredChecksumSha256 = ""
|
||||
if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil {
|
||||
t.Fatal("expected HTTP upload URL rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadClientEnforcesSizeAndHost(t *testing.T) {
|
||||
assetPath := filepath.Join(t.TempDir(), "recording.bin")
|
||||
if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := &agentv1.UploadGrant{UploadId: "upload-3", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-3", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), MaxBytes: 1}
|
||||
if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil {
|
||||
t.Fatal("expected size rejection")
|
||||
}
|
||||
grant.MaxBytes = 1024
|
||||
if _, err := (UploadClient{AllowedHosts: map[string]struct{}{"other.example.invalid": {}}}).UploadFile(context.Background(), grant, assetPath); err == nil {
|
||||
t.Fatal("expected host rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadClientRejectsExpiredGrant(t *testing.T) {
|
||||
assetPath := filepath.Join(t.TempDir(), "recording.bin")
|
||||
if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := &agentv1.UploadGrant{UploadId: "upload-expired", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-expired", ExpiresAtUnixMs: time.Unix(100, 0).UnixMilli(), MaxBytes: 1024}
|
||||
if _, err := (UploadClient{Now: func() time.Time { return time.Unix(100, 0) }}).UploadFile(context.Background(), grant, assetPath); err == nil {
|
||||
t.Fatal("expected expired grant rejection")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user