Files
kandev/apps/backend/internal/plugins/repository_provider_inspect.go
T

315 lines
11 KiB
Go

package plugins
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/kandev/kandev/internal/repoclone"
"github.com/kandev/kandev/pkg/pluginsdk"
"go.uber.org/zap"
)
const (
repositoryInspectActionKey = "repositories.inspect"
maxRepositoryInspectResponseSize = 1 << 20
repositoryInspectActionTimeout = 15 * time.Second
maxRepositoryProviderScopeBytes = 512
nullJSONValue = "null"
repositoryProviderInspectionNone = "none"
)
// RepositoryProviderInspectionRequest contains the untrusted URL and optional
// immutable identity hints supplied by a task-create request. The hints are
// checked against the plugin result but are never sent to the plugin.
type RepositoryProviderInspectionRequest struct {
Provider string
URL string
ProviderScope string
ProviderRepositoryID string
}
// RepositoryProviderInspection is the authoritative, credential-free
// repository identity returned by an active repository-provider plugin.
type RepositoryProviderInspection struct {
ProviderID string `json:"provider_id"`
ProviderHost string `json:"provider_host"`
ProviderScope string `json:"provider_scope"`
ProviderRepositoryID string `json:"provider_repository_id"`
OwnerOrProject string `json:"owner_or_project"`
Name string `json:"name"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
}
// RepositoryProviderErrorCode classifies a repository-provider inspection
// failure without exposing plugin response bodies or upstream error text.
type RepositoryProviderErrorCode string
const (
RepositoryProviderErrorInvalid RepositoryProviderErrorCode = "invalid"
RepositoryProviderErrorNotFound RepositoryProviderErrorCode = "not_found"
RepositoryProviderErrorUnavailable RepositoryProviderErrorCode = "unavailable"
)
// RepositoryProviderError is a safe, typed failure from repository-provider
// inspection. The wrapped cause is available to internal callers for
// cancellation checks, while Error never includes it.
type RepositoryProviderError struct {
Code RepositoryProviderErrorCode
cause error
}
func (e *RepositoryProviderError) Error() string {
switch e.Code {
case RepositoryProviderErrorInvalid:
return "repository provider returned an invalid repository descriptor"
case RepositoryProviderErrorNotFound:
return "repository was not found by the repository provider"
case RepositoryProviderErrorUnavailable:
return "repository provider is unavailable"
default:
return "repository provider inspection failed"
}
}
func (e *RepositoryProviderError) Unwrap() error { return e.cause }
// InspectRepositoryProvider asks the active manifest owner of the provider to
// resolve a repository URL into a validated descriptor.
func (s *Service) InspectRepositoryProvider(
ctx context.Context, workspaceID string, request RepositoryProviderInspectionRequest,
) (*RepositoryProviderInspection, error) {
return s.inspectRepositoryProvider(ctx, workspaceID, request, s.InvokeAction)
}
func (s *Service) inspectRepositoryProvider(
ctx context.Context,
workspaceID string,
request RepositoryProviderInspectionRequest,
invoke repositoryActionInvoker,
) (inspection *RepositoryProviderInspection, err error) {
started := time.Now()
providerID := strings.TrimSpace(request.Provider)
pluginID := ""
responseShape := "not_sent"
defer func() {
if s.log == nil {
return
}
failureCategory := repositoryProviderInspectionNone
if err != nil {
failureCategory = repositoryProviderInspectionFailureCategory(err)
}
s.log.Info("plugin repository inspection",
zap.String("provider_id", providerID),
zap.String("plugin_id", pluginID),
zap.String("workspace_id", workspaceID),
zap.Duration("duration", time.Since(started)),
zap.String("response_shape", responseShape),
zap.String("failure_category", failureCategory))
}()
request.Provider = strings.TrimSpace(request.Provider)
request.URL = strings.TrimSpace(request.URL)
if strings.TrimSpace(workspaceID) == "" || request.Provider == "" || request.URL == "" {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
record, action, err := s.repositoryProviderAction(request.Provider, repositoryInspectActionKey)
if err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorUnavailable, err)
}
pluginID = record.ID
body, err := json.Marshal(map[string]string{"url": request.URL})
if err != nil || action.MaxBodyBytes <= 0 || len(body) > action.MaxBodyBytes {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
invokeCtx, cancel := context.WithTimeout(ctx, repositoryInspectActionTimeout)
defer cancel()
response, err := invoke(invokeCtx, record.ID, dispatchGeneration(record), &pluginsdk.PluginActionRequest{
ActionKey: repositoryInspectActionKey,
Context: pluginsdk.VerifiedActionContext{WorkspaceID: workspaceID},
Body: body,
})
responseShape = repositoryProviderInspectionResponseShape(response)
if err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorUnavailable, err)
}
return parseRepositoryProviderActionResponse(response, request)
}
func parseRepositoryProviderActionResponse(
response *pluginsdk.PluginActionResponse, request RepositoryProviderInspectionRequest,
) (*RepositoryProviderInspection, error) {
if response != nil && response.Status != 0 && (response.Status < 200 || response.Status >= 300) {
return nil, repositoryProviderStatusError(response.Status)
}
return parseRepositoryProviderInspection(response, request)
}
func repositoryProviderInspectionFailureCategory(err error) string {
var providerErr *RepositoryProviderError
if errors.As(err, &providerErr) && providerErr.Code != "" {
return string(providerErr.Code)
}
return "unknown"
}
func repositoryProviderInspectionResponseShape(response *pluginsdk.PluginActionResponse) string {
if response == nil {
return repositoryProviderInspectionNone
}
if response.Status != 0 && (response.Status < 200 || response.Status >= 300) {
return "http_status"
}
if len(response.Body) == 0 {
return "empty"
}
if len(response.Body) > maxRepositoryInspectResponseSize {
return "oversized"
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(response.Body, &raw); err != nil {
return "invalid_json"
}
if matched, exists := raw["matched"]; exists {
var value bool
if err := json.Unmarshal(matched, &value); err != nil {
return "invalid_matched"
}
if !value {
return "matched_false"
}
}
if _, nested := raw["repository"]; nested {
return "nested_descriptor"
}
return "direct_descriptor"
}
func parseRepositoryProviderInspection(
response *pluginsdk.PluginActionResponse, request RepositoryProviderInspectionRequest,
) (*RepositoryProviderInspection, error) {
if response == nil || len(response.Body) == 0 || len(response.Body) > maxRepositoryInspectResponseSize {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(response.Body, &raw); err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
if matched, exists := raw["matched"]; exists {
var value bool
if err := json.Unmarshal(matched, &value); err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
if !value {
if repository, hasRepository := raw["repository"]; hasRepository && string(repository) != nullJSONValue {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
return nil, newRepositoryProviderError(RepositoryProviderErrorNotFound, nil)
}
}
candidate, err := inspectionCandidate(raw)
if err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
return validateRepositoryProviderInspection(candidate, request)
}
func inspectionCandidate(raw map[string]json.RawMessage) (*RepositoryProviderInspection, error) {
if repository, nested := raw["repository"]; nested {
if string(repository) == nullJSONValue {
return nil, errors.New("repository descriptor is null")
}
var candidate RepositoryProviderInspection
if err := json.Unmarshal(repository, &candidate); err != nil {
return nil, err
}
return &candidate, nil
}
var candidate RepositoryProviderInspection
body, err := json.Marshal(raw)
if err != nil {
return nil, err
}
if err := json.Unmarshal(body, &candidate); err != nil {
return nil, err
}
return &candidate, nil
}
func validateRepositoryProviderInspection(
candidate *RepositoryProviderInspection, request RepositoryProviderInspectionRequest,
) (*RepositoryProviderInspection, error) {
if candidate == nil || !strings.EqualFold(strings.TrimSpace(candidate.ProviderID), request.Provider) {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
host, err := repoclone.HTTPSProviderOrigin(candidate.ProviderHost)
if err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
scope, err := normalizeRepositoryProviderScope(candidate.ProviderScope)
if err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
repositoryID := strings.TrimSpace(candidate.ProviderRepositoryID)
owner := strings.TrimSpace(candidate.OwnerOrProject)
name := strings.TrimSpace(candidate.Name)
cloneURL := strings.TrimSpace(candidate.CloneURL)
defaultBranch := strings.TrimSpace(candidate.DefaultBranch)
if repositoryID == "" || owner == "" || name == "" || cloneURL == "" || defaultBranch == "" ||
strings.ContainsAny(repositoryID+owner+name+defaultBranch, "\x00") {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
if err := repoclone.ValidateHTTPSCloneOrigin(cloneURL, host); err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
if err := validateRepositoryProviderHint(request.ProviderScope, scope); err != nil {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, err)
}
if hint := strings.TrimSpace(request.ProviderRepositoryID); hint != "" && hint != repositoryID {
return nil, newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
return &RepositoryProviderInspection{
ProviderID: request.Provider, ProviderHost: host, ProviderScope: scope,
ProviderRepositoryID: repositoryID, OwnerOrProject: owner, Name: name,
CloneURL: cloneURL, DefaultBranch: defaultBranch,
}, nil
}
func normalizeRepositoryProviderScope(raw string) (string, error) {
scope := strings.TrimSpace(raw)
if len(scope) > maxRepositoryProviderScopeBytes || strings.ContainsRune(scope, '\x00') {
return "", errors.New("repository provider scope is invalid")
}
return scope, nil
}
func validateRepositoryProviderHint(hint, value string) error {
normalized, err := normalizeRepositoryProviderScope(hint)
if err != nil {
return err
}
if normalized != "" && normalized != value {
return errors.New("repository provider scope does not match the request hint")
}
return nil
}
func repositoryProviderStatusError(status int) error {
if status == 404 {
return newRepositoryProviderError(RepositoryProviderErrorNotFound, nil)
}
if status >= 400 && status < 500 && status != 408 && status != 429 {
return newRepositoryProviderError(RepositoryProviderErrorInvalid, nil)
}
return newRepositoryProviderError(RepositoryProviderErrorUnavailable, nil)
}
func newRepositoryProviderError(code RepositoryProviderErrorCode, cause error) error {
return &RepositoryProviderError{Code: code, cause: cause}
}