mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 01:51:27 +08:00
241 lines
6.2 KiB
Go
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)
|
|
}
|