Files
larksuite-cli/internal/qualitygate/semantic/config.go
2026-06-17 16:29:33 +08:00

241 lines
6.2 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package semantic
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/internal/vfs"
)
const defaultBaseURL = "https://ark.ap-southeast.bytepluses.com/api/v3"
var (
rolloutIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
modelIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
)
type ModelConfig struct {
Default string `json:"default"`
Allowed []string `json:"allowed"`
AllowedBaseURLs []string `json:"allowed_base_urls"`
}
func ParseBlockMode(value string) bool {
return value == "true"
}
func LoadPolicy(repo string) (Policy, error) {
data, err := vfs.ReadFile(filepath.Join(repo, "internal", "qualitygate", "config", "semantic", "policy.json"))
if err != nil {
return Policy{}, err
}
var p Policy
if err := json.Unmarshal(data, &p); err != nil {
return Policy{}, err
}
if err := validatePolicy(p); err != nil {
return Policy{}, err
}
return p, nil
}
func validatePolicy(p Policy) error {
if p.SchemaVersion != 1 {
return fmt.Errorf("invalid policy schema_version: %d", p.SchemaVersion)
}
if p.DefaultEnforcement != "observe" {
return fmt.Errorf("invalid default_enforcement: %q", p.DefaultEnforcement)
}
if len(p.BlockCategories) == 0 {
return fmt.Errorf("block_categories must not be empty")
}
blockCategories := map[string]bool{}
for _, category := range p.BlockCategories {
if !allowedCategory(category) {
return fmt.Errorf("invalid block category: %q", category)
}
blockCategories[category] = true
}
seenGroups := map[string]bool{}
for _, group := range p.RolloutGroups {
if !rolloutIDPattern.MatchString(group.ID) {
return fmt.Errorf("invalid rollout group id: %q", group.ID)
}
if seenGroups[group.ID] {
return fmt.Errorf("duplicate rollout group id: %q", group.ID)
}
seenGroups[group.ID] = true
if group.Enforcement != "blocking" {
return fmt.Errorf("invalid rollout enforcement for %q: %q", group.ID, group.Enforcement)
}
if strings.TrimSpace(group.Owner) == "" || strings.TrimSpace(group.Reason) == "" {
return fmt.Errorf("rollout group %q requires owner and reason", group.ID)
}
if len(group.Categories) == 0 {
return fmt.Errorf("rollout group %q categories must not be empty", group.ID)
}
for _, category := range group.Categories {
if !blockCategories[category] {
return fmt.Errorf("rollout group %q category %q is outside block_categories", group.ID, category)
}
}
if err := validateScopeSelector(group.Scope); err != nil {
return fmt.Errorf("rollout group %q: %w", group.ID, err)
}
}
return nil
}
func validateScopeSelector(scope ScopeSelector) error {
for _, factKind := range scope.FactKinds {
if !allowedFactKind(factKind) {
return fmt.Errorf("invalid fact kind: %q", factKind)
}
}
for _, source := range scope.Sources {
switch source {
case "builtin", "shortcut", "service":
default:
return fmt.Errorf("invalid source: %q", source)
}
}
return nil
}
func LoadModelConfig(repo string) (ModelConfig, error) {
data, err := vfs.ReadFile(filepath.Join(repo, "internal", "qualitygate", "config", "semantic", "models.json"))
if err != nil {
return ModelConfig{}, err
}
var cfg ModelConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return ModelConfig{}, err
}
if err := validateModelConfig(&cfg); err != nil {
return ModelConfig{}, err
}
return cfg, nil
}
func validateModelConfig(cfg *ModelConfig) error {
if cfg.Default != "" {
return fmt.Errorf("default model is not supported; configure ARK_MODEL explicitly")
}
allowed := map[string]bool{}
for _, model := range cfg.Allowed {
if !modelIDPattern.MatchString(model) {
return fmt.Errorf("invalid model id: %q", model)
}
allowed[model] = true
}
cfg.Allowed = sortedKeys(allowed)
baseURLs := map[string]bool{}
for _, raw := range cfg.AllowedBaseURLs {
normalized, err := normalizeBaseURL(raw)
if err != nil {
return fmt.Errorf("invalid base URL %q: %w", raw, err)
}
baseURLs[normalized] = true
}
if len(baseURLs) == 0 {
return fmt.Errorf("allowed_base_urls must not be empty")
}
defaultNormalized, err := normalizeBaseURL(defaultBaseURL)
if err != nil {
return err
}
if !baseURLs[defaultNormalized] {
return fmt.Errorf("default base URL %q is not allowed", defaultBaseURL)
}
cfg.AllowedBaseURLs = sortedKeys(baseURLs)
return nil
}
func (cfg ModelConfig) AllowsModel(model string) bool {
for _, allowed := range cfg.Allowed {
if model == allowed {
return true
}
}
return false
}
func IsTrustedBaseURL(raw string, cfg ModelConfig) bool {
normalized, err := normalizeBaseURL(raw)
if err != nil {
return false
}
for _, allowed := range cfg.AllowedBaseURLs {
allowedNormalized, err := normalizeBaseURL(allowed)
if err == nil && normalized == allowedNormalized {
return true
}
}
return false
}
func normalizeBaseURL(raw string) (string, error) {
if strings.TrimSpace(raw) != raw || raw == "" {
return "", fmt.Errorf("base URL must not be blank or padded")
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https")
}
if u.User != nil {
return "", fmt.Errorf("userinfo is not allowed")
}
if u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("query and fragment are not allowed")
}
if u.Hostname() == "" {
return "", fmt.Errorf("host is required")
}
if port := u.Port(); port != "" && port != "443" {
return "", fmt.Errorf("unexpected port %q", port)
}
if u.Path == "" || u.Path == "/" {
return "", fmt.Errorf("path is required")
}
if strings.HasSuffix(u.Path, "/") {
return "", fmt.Errorf("trailing slash is not allowed")
}
cleanPath := path.Clean(u.Path)
if cleanPath != u.Path {
return "", fmt.Errorf("path is not canonical")
}
host := strings.ToLower(u.Hostname())
if u.Port() == "443" {
return "https://" + host + cleanPath, nil
}
return "https://" + host + cleanPath, nil
}
func sortedKeys(values map[string]bool) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}
func missingFile(err error) bool {
return errors.Is(err, os.ErrNotExist)
}