package runner import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" ) const ( localBinaryResultDirName = "results" localBinaryPlaceholderPrefix = "[GatewayBinary:v1;" localBinaryGenericBase64MinLength = 4096 localBinaryMaxDepth = 64 defaultLocalResultTTLHours = 24 defaultLocalResultMinFreeBytes = int64(10 * 1024 * 1024 * 1024) defaultLocalResultMaxBytes = int64(256 * 1024 * 1024) defaultLocalResultMaxTaskBytes = int64(512 * 1024 * 1024) ) type localBinaryDescriptor struct { Prefix string SHA256 string Size int64 ContentType string Encoding string } type localBinaryMaterializer struct { service *Service taskDir string writeFiles bool enforceLimits bool totalBytes int64 seen map[string]struct{} createdFiles []string } // materializeLocalBinaryResult replaces every inline binary value with a // bounded placeholder after atomically writing and verifying the bytes locally. func (s *Service) materializeLocalBinaryResult(ctx context.Context, taskID string, result map[string]any) (map[string]any, bool, error) { if _, _, err := s.transformLocalBinaryResult(ctx, taskID, result, false, true); err != nil { return nil, false, err } return s.transformLocalBinaryResult(ctx, taskID, result, true, true) } func (s *Service) transformLocalBinaryResult(ctx context.Context, taskID string, result map[string]any, writeFiles bool, enforceLimits bool) (map[string]any, bool, error) { root := s.localBinaryResultRoot() taskDir := filepath.Join(root, safeLocalBinaryTaskDir(taskID)) materializer := &localBinaryMaterializer{ service: s, taskDir: taskDir, writeFiles: writeFiles, enforceLimits: enforceLimits, seen: map[string]struct{}{}, } next, changed, err := materializer.materializeValue(ctx, result, "", nil, 0) if err != nil { materializer.rollbackCreatedFiles() return nil, false, err } mapped, ok := next.(map[string]any) if !ok { materializer.rollbackCreatedFiles() return nil, false, &clients.ClientError{ Code: "result_binary_not_materialized", Message: "generated result is not a JSON object", StatusCode: 500, Retryable: false, } } if changed && enforceLimits && !writeFiles { if err := os.MkdirAll(root, 0o750); err != nil { return nil, false, localBinaryStorageError(err) } if err := ensureLocalBinaryDiskHeadroom(root, materializer.totalBytes, s.localResultMinFreeBytes()); err != nil { return nil, false, err } } return mapped, changed, nil } // MaterializeTaskResultForStorage exposes the same verified materialization // path to the explicit historical maintenance command. func (s *Service) MaterializeTaskResultForStorage(ctx context.Context, taskID string, result map[string]any) (map[string]any, bool, error) { return s.materializeLocalBinaryResult(ctx, taskID, result) } // CompactExpiredTaskResultForStorage computes the same deterministic // placeholders without creating files that are already outside the recovery // window. func (s *Service) CompactExpiredTaskResultForStorage(ctx context.Context, taskID string, result map[string]any) (map[string]any, bool, error) { return s.transformLocalBinaryResult(ctx, taskID, result, false, false) } func TaskResultHasInlineBinary(result map[string]any) bool { return localBinaryValueHasPayload(result, "", nil, 0) } func localBinaryValueHasPayload(value any, key string, siblings map[string]any, depth int) bool { if depth >= localBinaryMaxDepth { return false } switch typed := value.(type) { case map[string]any: if _, _, ok := localBufferObjectBytes(typed); ok { return true } for childKey, child := range typed { if localBinaryValueHasPayload(child, childKey, typed, depth+1) { return true } } case []any: if localBinaryKey(key) { if _, ok := bytesFromNumberArray(typed); ok { return true } } for _, child := range typed { if localBinaryValueHasPayload(child, key, siblings, depth+1) { return true } } case []byte: return len(typed) > 0 case string: _, _, _, ok := localBinaryStringBytes(key, typed, siblings) return ok } return false } func (m *localBinaryMaterializer) materializeValue(ctx context.Context, value any, key string, siblings map[string]any, depth int) (any, bool, error) { if err := ctx.Err(); err != nil { return nil, false, err } if depth >= localBinaryMaxDepth { return nil, false, &clients.ClientError{ Code: "result_binary_not_materialized", Message: "generated result exceeds the maximum JSON depth", StatusCode: 500, Retryable: false, } } switch typed := value.(type) { case map[string]any: if payload, contentType, ok := localBufferObjectBytes(typed); ok { return m.persistBinary(ctx, payload, contentType, "buffer") } next := make(map[string]any, len(typed)) changed := false for childKey, childValue := range typed { child, childChanged, err := m.materializeValue(ctx, childValue, childKey, typed, depth+1) if err != nil { return nil, false, err } next[childKey] = child changed = changed || childChanged } if !changed { return value, false, nil } return next, true, nil case []any: if localBinaryKey(key) { if payload, ok := bytesFromNumberArray(typed); ok { return m.persistBinary(ctx, payload, mediaContentTypeFromItem(siblings), "buffer") } } next := make([]any, len(typed)) changed := false for index, item := range typed { child, childChanged, err := m.materializeValue(ctx, item, key, siblings, depth+1) if err != nil { return nil, false, err } next[index] = child changed = changed || childChanged } if !changed { return value, false, nil } return next, true, nil case []byte: if len(typed) == 0 { return value, false, nil } return m.persistBinary(ctx, append([]byte(nil), typed...), mediaContentTypeFromItem(siblings), "buffer") case string: payload, contentType, encoding, ok := localBinaryStringBytes(key, typed, siblings) if !ok { return value, false, nil } return m.persistBinary(ctx, payload, contentType, encoding) default: return value, false, nil } } func (m *localBinaryMaterializer) persistBinary(ctx context.Context, payload []byte, contentType string, encoding string) (any, bool, error) { if err := ctx.Err(); err != nil { return nil, false, err } if len(payload) == 0 { return nil, false, nil } size := int64(len(payload)) if m.enforceLimits && size > m.service.localResultMaxBytes() { return nil, false, &clients.ClientError{ Code: "binary_result_too_large", Message: "one generated binary result exceeds the local storage limit", StatusCode: 502, Retryable: false, } } digest := sha256.Sum256(payload) digestHex := hex.EncodeToString(digest[:]) if _, exists := m.seen[digestHex]; !exists { if m.enforceLimits && m.totalBytes+size > m.service.localResultMaxTaskBytes() { return nil, false, &clients.ClientError{ Code: "binary_result_too_large", Message: "generated binary results exceed the per-task local storage limit", StatusCode: 502, Retryable: false, } } if m.writeFiles { created, err := m.service.writeLocalBinaryResult(m.taskDir, digestHex, payload) if err != nil { return nil, false, err } if created { m.createdFiles = append(m.createdFiles, filepath.Join(m.taskDir, digestHex+".bin")) } } m.seen[digestHex] = struct{}{} m.totalBytes += size } descriptor := localBinaryDescriptor{ Prefix: localBinaryPrefix(payload), SHA256: digestHex, Size: size, ContentType: normalizedLocalBinaryContentType(contentType), Encoding: normalizedLocalBinaryEncoding(encoding), } return localBinaryPlaceholder(descriptor), true, nil } func (m *localBinaryMaterializer) rollbackCreatedFiles() { for _, path := range m.createdFiles { _ = os.Remove(path) } _ = os.Remove(m.taskDir) m.createdFiles = nil } func (s *Service) writeLocalBinaryResult(taskDir string, digestHex string, payload []byte) (bool, error) { if err := os.MkdirAll(taskDir, 0o750); err != nil { return false, localBinaryStorageError(err) } targetPath := filepath.Join(taskDir, digestHex+".bin") if info, err := os.Stat(targetPath); err == nil { if !info.IsDir() && info.Size() == int64(len(payload)) { if err := verifyLocalBinaryFile(targetPath, digestHex, int64(len(payload))); err == nil { now := time.Now() if err := os.Chtimes(targetPath, now, now); err != nil { return false, localBinaryStorageError(err) } return false, nil } } return false, &clients.ClientError{ Code: "binary_result_corrupted", Message: "existing local result file does not match its content hash", StatusCode: 500, Retryable: false, } } else if !errors.Is(err, os.ErrNotExist) { return false, localBinaryStorageError(err) } if err := ensureLocalBinaryDiskHeadroom(taskDir, int64(len(payload)), s.localResultMinFreeBytes()); err != nil { return false, err } tempFile, err := os.CreateTemp(taskDir, ".gateway-result-*") if err != nil { return false, localBinaryStorageError(err) } tempPath := tempFile.Name() cleanup := func() { _ = tempFile.Close() _ = os.Remove(tempPath) } if err := tempFile.Chmod(0o640); err != nil { cleanup() return false, localBinaryStorageError(err) } if _, err := tempFile.Write(payload); err != nil { cleanup() return false, localBinaryStorageError(err) } if err := tempFile.Sync(); err != nil { cleanup() return false, localBinaryStorageError(err) } if err := tempFile.Close(); err != nil { _ = os.Remove(tempPath) return false, localBinaryStorageError(err) } if err := os.Rename(tempPath, targetPath); err != nil { _ = os.Remove(tempPath) return false, localBinaryStorageError(err) } if err := verifyLocalBinaryFile(targetPath, digestHex, int64(len(payload))); err != nil { _ = os.Remove(targetPath) return false, err } return true, nil } func ensureLocalBinaryDiskHeadroom(path string, incomingBytes int64, minFreeBytes int64) error { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { return localBinaryStorageError(err) } freeBytes := int64(stat.Bavail) * int64(stat.Bsize) if freeBytes-incomingBytes < minFreeBytes { return &clients.ClientError{ Code: "local_result_storage_unavailable", Message: "local result storage does not have enough free space", StatusCode: 503, Retryable: false, } } return nil } func localBinaryStorageError(err error) error { return &clients.ClientError{ Code: "local_result_storage_unavailable", Message: "local result storage failed: " + err.Error(), StatusCode: 503, Retryable: false, } } func verifyLocalBinaryFile(path string, expectedHash string, expectedSize int64) error { file, err := os.Open(path) if err != nil { return localBinaryStorageError(err) } defer file.Close() hasher := sha256.New() size, err := io.Copy(hasher, file) if err != nil { return localBinaryStorageError(err) } if size != expectedSize || hex.EncodeToString(hasher.Sum(nil)) != expectedHash { return &clients.ClientError{ Code: "binary_result_corrupted", Message: "local result file failed size or hash verification", StatusCode: 500, Retryable: false, } } return nil } // HydrateTaskResult restores placeholders from verified local files. It is only // used by result/detail/replay endpoints, never by task lists or callbacks. func (s *Service) HydrateTaskResult(ctx context.Context, taskID string, result map[string]any) (map[string]any, error) { next, changed, err := s.hydrateLocalBinaryValue(ctx, taskID, result, 0) if err != nil { return nil, err } if !changed { return result, nil } mapped, ok := next.(map[string]any) if !ok { return nil, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result is not a JSON object", StatusCode: 500} } return mapped, nil } func (s *Service) hydrateLocalBinaryValue(ctx context.Context, taskID string, value any, depth int) (any, bool, error) { if err := ctx.Err(); err != nil { return nil, false, err } if depth >= localBinaryMaxDepth { return nil, false, &clients.ClientError{Code: "binary_result_corrupted", Message: "stored result exceeds the maximum JSON depth", StatusCode: 500} } switch typed := value.(type) { case map[string]any: next := make(map[string]any, len(typed)) changed := false for key, childValue := range typed { child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1) if err != nil { return nil, false, err } next[key] = child changed = changed || childChanged } if !changed { return value, false, nil } return next, true, nil case []any: next := make([]any, len(typed)) changed := false for index, childValue := range typed { child, childChanged, err := s.hydrateLocalBinaryValue(ctx, taskID, childValue, depth+1) if err != nil { return nil, false, err } next[index] = child changed = changed || childChanged } if !changed { return value, false, nil } return next, true, nil case string: descriptor, ok := parseLocalBinaryPlaceholder(typed) if !ok { return value, false, nil } payload, err := s.readLocalBinaryResult(taskID, descriptor) if err != nil { return nil, false, err } encoded := base64.StdEncoding.EncodeToString(payload) if descriptor.Encoding == "data-uri" { return "data:" + descriptor.ContentType + ";base64," + encoded, true, nil } return encoded, true, nil default: return value, false, nil } } func (s *Service) readLocalBinaryResult(taskID string, descriptor localBinaryDescriptor) ([]byte, error) { path := filepath.Join(s.localBinaryResultRoot(), safeLocalBinaryTaskDir(taskID), descriptor.SHA256+".bin") info, err := os.Stat(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, &clients.ClientError{Code: "binary_result_expired", Message: "local binary result has expired", StatusCode: 410, Retryable: false} } return nil, localBinaryStorageError(err) } if info.IsDir() || info.Size() != descriptor.Size { return nil, &clients.ClientError{Code: "binary_result_corrupted", Message: "local binary result has an invalid size", StatusCode: 500, Retryable: false} } if info.ModTime().Before(time.Now().Add(-time.Duration(s.localResultTTLHours()) * time.Hour)) { return nil, &clients.ClientError{Code: "binary_result_expired", Message: "local binary result has expired", StatusCode: 410, Retryable: false} } payload, err := os.ReadFile(path) if err != nil { return nil, localBinaryStorageError(err) } digest := sha256.Sum256(payload) if int64(len(payload)) != descriptor.Size || hex.EncodeToString(digest[:]) != descriptor.SHA256 { return nil, &clients.ClientError{Code: "binary_result_corrupted", Message: "local binary result failed size or hash verification", StatusCode: 500, Retryable: false} } return payload, nil } func localBinaryStringBytes(key string, value string, siblings map[string]any) ([]byte, string, string, bool) { raw := strings.TrimSpace(value) if raw == "" || strings.HasPrefix(raw, localBinaryPlaceholderPrefix) { return nil, "", "", false } if strings.HasPrefix(strings.ToLower(raw), "data:") { contentType, encoded, ok, err := parseBase64DataURL(raw) if err == nil && ok { payload, decodeErr := decodeBase64Payload(encoded) if decodeErr == nil && len(payload) > 0 { return payload, contentType, "data-uri", true } } return nil, "", "", false } strict := localBinaryKey(key) if !strict && len(raw) < localBinaryGenericBase64MinLength { return nil, "", "", false } payload, err := decodeBase64Payload(raw) if err != nil || len(payload) == 0 { return nil, "", "", false } return payload, firstNonEmptyString(mediaContentTypeFromItem(siblings), defaultContentTypeForRawMediaKey(key)), "raw", true } func localBufferObjectBytes(value map[string]any) ([]byte, string, bool) { if normalizeLocalBinaryKey(stringFromAny(value["type"])) != "buffer" { return nil, "", false } contentType := firstNonEmptyString( stringFromAny(value["mime_type"]), stringFromAny(value["mimeType"]), stringFromAny(value["contentType"]), ) switch data := value["data"].(type) { case []byte: if len(data) == 0 { return nil, "", false } return append([]byte(nil), data...), contentType, true case []any: payload, ok := bytesFromNumberArray(data) return payload, contentType, ok default: return nil, "", false } } func localBinaryKey(key string) bool { normalized := normalizeLocalBinaryKey(key) return normalized == "b64" || normalized == "b64json" || normalized == "base64" || normalized == "buffer" || normalized == "bytes" || strings.Contains(normalized, "base64") || strings.Contains(normalized, "buffer") || strings.Contains(normalized, "binary") || strings.HasSuffix(normalized, "b64") || strings.HasSuffix(normalized, "bytes") } func normalizeLocalBinaryKey(value string) string { return strings.Map(func(char rune) rune { switch { case char >= 'a' && char <= 'z': return char case char >= 'A' && char <= 'Z': return char + ('a' - 'A') case char >= '0' && char <= '9': return char default: return -1 } }, value) } func localBinaryPrefix(payload []byte) string { prefix := base64.StdEncoding.EncodeToString(payload) if len(prefix) > 16 { prefix = prefix[:16] } return prefix } func normalizedLocalBinaryContentType(value string) string { value = normalizeGeneratedContentType(value) if value == "" || len(value) > 32 || !localBinaryContentTypeSafe(value) { return "application/octet-stream" } return value } func localBinaryContentTypeSafe(value string) bool { for _, char := range value { switch { case char >= 'a' && char <= 'z': case char >= 'A' && char <= 'Z': case char >= '0' && char <= '9': case char == '/', char == '.', char == '+', char == '-': default: return false } } return true } func normalizedLocalBinaryEncoding(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "data-uri": return "data-uri" case "buffer": return "buffer" default: return "raw" } } func localBinaryPlaceholder(descriptor localBinaryDescriptor) string { return fmt.Sprintf( "[GatewayBinary:v1;prefix=%s;sha256=%s;bytes=%d;mime=%s;encoding=%s]", descriptor.Prefix, descriptor.SHA256, descriptor.Size, descriptor.ContentType, descriptor.Encoding, ) } func parseLocalBinaryPlaceholder(value string) (localBinaryDescriptor, bool) { if !strings.HasPrefix(value, localBinaryPlaceholderPrefix) || !strings.HasSuffix(value, "]") { return localBinaryDescriptor{}, false } content := strings.TrimSuffix(strings.TrimPrefix(value, localBinaryPlaceholderPrefix), "]") fields := map[string]string{} for _, item := range strings.Split(content, ";") { key, fieldValue, ok := strings.Cut(item, "=") if !ok { continue } fields[key] = fieldValue } size, err := strconv.ParseInt(fields["bytes"], 10, 64) if err != nil || size <= 0 || len(fields["sha256"]) != sha256.Size*2 { return localBinaryDescriptor{}, false } if _, err := hex.DecodeString(fields["sha256"]); err != nil { return localBinaryDescriptor{}, false } descriptor := localBinaryDescriptor{ Prefix: fields["prefix"], SHA256: strings.ToLower(fields["sha256"]), Size: size, ContentType: normalizedLocalBinaryContentType(fields["mime"]), Encoding: normalizedLocalBinaryEncoding(fields["encoding"]), } if len(descriptor.Prefix) > 16 { return localBinaryDescriptor{}, false } return descriptor, true } func localBinaryResultHasPlaceholders(value any) bool { switch typed := value.(type) { case map[string]any: for _, child := range typed { if localBinaryResultHasPlaceholders(child) { return true } } case []any: for _, child := range typed { if localBinaryResultHasPlaceholders(child) { return true } } case string: return strings.HasPrefix(typed, localBinaryPlaceholderPrefix) } return false } func safeLocalBinaryTaskDir(taskID string) string { taskID = strings.TrimSpace(taskID) if taskID != "" { safe := true for _, char := range taskID { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' { continue } safe = false break } if safe && taskID != "." && taskID != ".." && len(taskID) <= 128 { return taskID } } digest := sha256.Sum256([]byte(taskID)) return "task-" + hex.EncodeToString(digest[:16]) } func (s *Service) localBinaryResultRoot() string { root := strings.TrimSpace(s.cfg.LocalGeneratedStorageDir) if root == "" { root = config.DefaultLocalGeneratedStorageDir } return filepath.Join(root, localBinaryResultDirName) } func (s *Service) localResultTTLHours() int { if s.cfg.LocalResultTTLHours <= 0 { return defaultLocalResultTTLHours } return s.cfg.LocalResultTTLHours } func (s *Service) localResultMinFreeBytes() int64 { if s.cfg.LocalResultMinFreeBytes <= 0 { return defaultLocalResultMinFreeBytes } return s.cfg.LocalResultMinFreeBytes } func (s *Service) localResultMaxBytes() int64 { if s.cfg.LocalResultMaxBytes <= 0 { return defaultLocalResultMaxBytes } return s.cfg.LocalResultMaxBytes } func (s *Service) localResultMaxTaskBytes() int64 { if s.cfg.LocalResultMaxTaskBytes <= 0 { return defaultLocalResultMaxTaskBytes } return s.cfg.LocalResultMaxTaskBytes }