package gitlabcivalidator import ( "fmt" "gitflame.ru/CI_VLDR/errors" "gopkg.in/yaml.v3" "strings" "time" ) // computeEndPosition возвращает конечные координаты для фрагмента, предполагая, что текст находится на одной строке. func computeEndPosition(startLine, startColumn int, text string) (endLine, endColumn int) { // Если текст пустой, выделим хотя бы один символ if len(text) == 0 { return startLine, startColumn + 1 } return startLine, startColumn + len(text) } // ValidateGitLabCIFile – главная точка входа в библиотеку. func ValidateGitLabCIFile(content string, opts ValidationOptions) error { validationErr := &errors.ValidationError{} if len(content) == 0 { validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0) return validationErr } // Повторно распарсить YAML, чтобы получить узел и позиционную информацию var root yaml.Node err := yaml.Unmarshal([]byte(content), &root) if err != nil { validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err), 0, 0, 0, 0) return validationErr } if len(root.Content) == 0 { validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, "файл .gitlab-ci.yml пуст или не содержит допустимых данных", 0, 0, 0, 0) return validationErr } if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, "некорректный формат YAML: ожидался корневой объект", 0, 0, 0, 0) return validationErr } // Получаем корневой узел mainNode := root.Content[0] // Проверяем неизвестные ключи и добавляем ошибки в объект валидации unknowns := findUnknownRootKeysWithSpan(mainNode) for _, unk := range unknowns { validationErr.AddDetailWithSpan(errors.ErrUnknownRootKey, fmt.Sprintf("unexpected key: '%s'", unk.Key), unk.Line, unk.Column, unk.EndLine, unk.EndColumn) } // Парсим YAML в структуру конфигурации cfg, err := ParseGitLabCIConfig([]byte(content)) if err != nil { validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err), 0, 0, 0, 0) return validationErr } validateRootObject(cfg, validationErr) validateJobs(cfg, validationErr) validateChanges(cfg, validationErr) validatePaths(cfg, validationErr) validateService(cfg, validationErr) validateStages(cfg, validationErr) validateVariables(cfg, validationErr) validateIncludeRules(cfg, validationErr) if !validationErr.IsEmpty() { return validationErr } return nil } func checkSHAExistsInBranchesOrTags(sha string) bool { return false } func validateRootObject(cfg *GitLabCIConfig, ve *errors.ValidationError) { validateBeforeScript(cfg.BeforeScript, ve) } func validateBeforeScript(before interface{}, ve *errors.ValidationError) { if before == nil { return } if _, ok := before.(string); ok { return } if arr, ok := before.([]interface{}); ok { if !validateNestedStringArray(arr, 1, 10) { ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, "before_script config should be a string or a nested array of strings up to 10 levels deep", 0, 0, 0, 0) } } else { ve.AddDetailWithSpan(errors.ErrBeforeScriptInvalid, "before_script config should be a string or a nested array of strings up to 10 levels deep", 0, 0, 0, 0) } } func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bool { if currentLevel > maxLevel { return false } for _, item := range arr { switch item.(type) { case string: case []interface{}: if !validateNestedStringArray(item.([]interface{}), currentLevel+1, maxLevel) { return false } default: return false } } return true } func validateService(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Services == nil { return } switch cfg.Services.(type) { case string: case map[string]interface{}: default: ve.AddDetailWithSpan(errors.ErrServiceInvalid, "config should be a hash or a string", 0, 0, 0, 0) } } func validateStages(cfg *GitLabCIConfig, ve *errors.ValidationError) { for i, st := range cfg.Stages { if strings.TrimSpace(st) == "" { ve.AddDetailWithSpan(errors.ErrStageInvalid, fmt.Sprintf("stage at index %d is empty; stage config should be a string", i), 0, 0, 0, 0) } } } func validateVariables(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Variables == nil { return } varsMap, ok := cfg.Variables.(map[string]interface{}) if !ok { ve.AddDetailWithSpan(errors.ErrVariablesInvalid, "variables config should be a map", 0, 0, 0, 0) return } allowedKeys := map[string]struct{}{ "value": {}, "description": {}, "expand": {}, "options": {}, } for varName, val := range varsMap { if isScalar(val) { // ok } else if nested, ok := val.(map[string]interface{}); ok { invalidKeys := []string{} for k := range nested { if _, allowed := allowedKeys[k]; !allowed { invalidKeys = append(invalidKeys, k) } } if len(invalidKeys) > 0 { ve.AddDetailWithSpan(errors.ErrVariablesInvalidKey, fmt.Sprintf("variable '%s' uses invalid data keys: %s", varName, strings.Join(invalidKeys, ", ")), 0, 0, 0, 0) } } else { ve.AddDetailWithSpan(errors.ErrVariablesInvalid, fmt.Sprintf("variable '%s' must be a scalar or a map", varName), 0, 0, 0, 0) } } } func isScalar(val interface{}) bool { switch val.(type) { case string, bool, int, int64, float64, float32: return true default: return false } } func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Jobs == nil || len(cfg.Jobs) == 0 { ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0) return } stagesAllowed := make(map[string]struct{}) for _, st := range cfg.Stages { stagesAllowed[st] = struct{}{} } visibleCount := 0 dependencyGraph := make(map[string][]string) for _, job := range cfg.Jobs { // a) Проверка имени задачи if job.Name == "" { ve.AddDetailWithSpan(errors.ErrJobNameBlank, "job name can't be blank", job.Line, job.Column, job.Line, job.Column+1) continue } if job.Name[0] != '.' { visibleCount++ } // d) Проверяем наличие хотя бы одного из script, run или trigger if job.Script == nil && job.Run == nil && job.Trigger == nil { ve.AddDetailWithSpan(errors.ErrMissingScript, fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name), job.Line, job.Column, job.Line, job.Column+1) } // e) Проверка stage if job.Stage != "" { if _, ok := stagesAllowed[job.Stage]; !ok { ve.AddDetailWithSpan(errors.ErrJobStageNotExist, fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages), job.Line, job.Column, job.Line, job.Column+len(job.Stage)) } } // f) Проверка dependencies dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...) for _, dep := range job.Dependencies { depPos, _ := job.DependencyLines[dep] if _, ok := cfg.Jobs[dep]; !ok { endLine, endCol := computeEndPosition(depPos.Line, depPos.Column, dep) ve.AddDetailWithSpan(errors.ErrUndefinedDependency, fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет зависимость '%s' (Line %d, Col %d-%d), которой нет среди job'ов", job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column, endCol), job.Line, job.Column, endLine, endCol) } else { if !checkStageOrder(cfg, dep, job.Name) { ve.AddDetailWithSpan(errors.ErrInvalidStageOrder, fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name), job.Line, job.Column, job.Line, job.Column+len(dep)) } } } // g) Проверка блока needs if len(job.Needs) > 0 { needNames := make(map[string]struct{}) for _, nd := range job.Needs { // Проверка дублирования if _, exist := needNames[nd]; exist { ndStartLine, ndStartCol := job.NeedLines[nd].Line, job.NeedLines[nd].Column ndEndLine, ndEndCol := computeEndPosition(ndStartLine, ndStartCol, nd) ve.AddDetailWithSpan(errors.ErrDuplicateNeeds, fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd), ndStartLine, ndStartCol, ndEndLine, ndEndCol) } else { needNames[nd] = struct{}{} } // Если имя пустое if nd == "" { ve.AddDetailWithSpan(errors.ErrJobNameBlank, fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name), job.Line, job.Column, job.Line, job.Column+1) } // Проверка максимальной длины if len(nd) > MaxJobNameLength { ndStartLine, ndStartCol := job.NeedLines[nd].Line, job.NeedLines[nd].Column ndEndLine, ndEndCol := computeEndPosition(ndStartLine, ndStartCol, nd) ve.AddDetailWithSpan(errors.ErrNeedNameTooLong, fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength), ndStartLine, ndStartCol, ndEndLine, ndEndCol) } // Проверяем, что задача из needs существует needPos, posExists := job.NeedLines[nd] if !posExists { needPos = struct{ Line, Column int }{Line: job.Line, Column: job.Column} } if _, ok := cfg.Jobs[nd]; !ok { ndEndLine, ndEndCol := computeEndPosition(needPos.Line, needPos.Column, nd) ve.AddDetailWithSpan(errors.ErrUndefinedNeed, fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, ndEndCol, nd), needPos.Line, needPos.Column, ndEndLine, ndEndCol) } else if !checkStageOrder(cfg, nd, job.Name) { ndEndLine, ndEndCol := computeEndPosition(needPos.Line, needPos.Column, nd) ve.AddDetailWithSpan(errors.ErrInvalidStageOrder, fmt.Sprintf("задача '%s' (Line %d, Col %d) имеет need '%s' (Line %d, Col %d-%d) со стадией, идущей позже, чем '%s'", job.Name, job.Line, job.Column, nd, needPos.Line, needPos.Column, ndEndCol, job.Name), needPos.Line, needPos.Column, ndEndLine, ndEndCol) } dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd) } } validateJobArtifacts(job, ve) validateJobDelayedParameters(job, ve) validateJobDependenciesNeedsConsistency(job, ve) validateJobRules(job, ve) } if visibleCount == 0 { ve.AddDetailWithSpan(errors.ErrNoVisibleJob, "в разделе jobs не обнаружено ни одной «видимой» задачи", 0, 0, 0, 0) } cycle := detectCycles(dependencyGraph) if len(cycle) > 0 { ve.AddDetailWithSpan(errors.ErrCyclicDependency, fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle), 0, 0, 0, 0) } } func validateJobArtifacts(job *Job, ve *errors.ValidationError) { if job.Artifacts != nil { if len(job.Artifacts.Paths) == 0 { ve.AddDetailWithSpan(errors.ErrArtifactsPathsBlank, fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name), job.Line, job.Column, job.Line, job.Column+1) } } } func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool { depStage := cfg.Jobs[jobDep].Stage jobStage := cfg.Jobs[jobName].Stage if depStage == "" || jobStage == "" { return true } allStages := cfg.Stages depIndex, jobIndex := -1, -1 for i, st := range allStages { if st == depStage { depIndex = i } if st == jobStage { jobIndex = i } } if depIndex == -1 || jobIndex == -1 { return true } return depIndex <= jobIndex } func validateChanges(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Changes == nil { return } switch v := cfg.Changes.(type) { case []interface{}: if len(v) > 50 { ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0, 0, 0) } for _, item := range v { if _, ok := item.(string); !ok { ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0) break } } case map[string]interface{}: paths, ok := v["paths"] if !ok { ve.AddDetailWithSpan(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0, 0, 0) } else { arr, ok := paths.([]interface{}) if !ok { ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0) } else { if len(arr) > 50 { ve.AddDetailWithSpan(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0, 0, 0) } for _, item := range arr { if _, ok := item.(string); !ok { ve.AddDetailWithSpan(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0, 0, 0) break } } } } case string: ve.AddDetailWithSpan(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0, 0, 0) default: ve.AddDetailWithSpan(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0, 0, 0) } } func validatePaths(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Paths == nil { return } arr, ok := cfg.Paths.([]interface{}) if !ok { ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0, 0, 0) return } for _, item := range arr { if _, ok := item.(string); !ok { ve.AddDetailWithSpan(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0, 0, 0) break } } } func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) { if job.When == "delayed" { if job.StartIn == "" { ve.AddDetailWithSpan(errors.ErrStartInMissing, fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name), job.Line, job.Column, job.Line, job.Column+1) } else { d, err := time.ParseDuration(job.StartIn) if err != nil { ve.AddDetailWithSpan(errors.ErrStartInInvalid, fmt.Sprintf("задача '%s': job start in should be a duration", job.Name), job.Line, job.Column, job.Line, job.Column+1) } else if d > 7*24*time.Hour { ve.AddDetailWithSpan(errors.ErrStartInTooLong, fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name), job.Line, job.Column, job.Line, job.Column+1) } } } else { if job.StartIn != "" { ve.AddDetailWithSpan(errors.ErrStartInMustBeBlank, fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name), job.Line, job.Column, job.Line, job.Column+1) } } } func validateJobDependenciesNeedsConsistency(job *Job, ve *errors.ValidationError) { if len(job.Dependencies) > 0 && len(job.Needs) > 0 { needNames := make(map[string]struct{}) for _, nd := range job.Needs { needNames[nd] = struct{}{} } for _, dep := range job.Dependencies { if _, ok := needNames[dep]; !ok { ve.AddDetailWithSpan(errors.ErrDependencyNotInNeeds, fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep), job.Line, job.Column, job.Line, job.Column+len(dep)) } } } } func validateJobRules(job *Job, ve *errors.ValidationError) { if job.Rules != nil { if job.Only != nil && job.Except != nil { ve.AddDetailWithSpan(errors.ErrRulesOnlyExcept, fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name), job.Line, job.Column, job.Line, job.Column+1) } else if job.Only != nil { ve.AddDetailWithSpan(errors.ErrRulesOnly, fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name), job.Line, job.Column, job.Line, job.Column+1) } else if job.Except != nil { ve.AddDetailWithSpan(errors.ErrRulesExcept, fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name), job.Line, job.Column, job.Line, job.Column+1) } if rulesMap, ok := job.Rules.(map[string]interface{}); ok { if ifVal, exists := rulesMap["if"]; exists { if ifStr, ok := ifVal.(string); ok { if !validateExpressionSyntax(ifStr) { ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax, fmt.Sprintf("задача '%s': invalid expression syntax", job.Name), job.Line, job.Column, job.Line, job.Column+len(ifStr)) } } else { ve.AddDetailWithSpan(errors.ErrInvalidExpressionSyntax, fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name), job.Line, job.Column, job.Line, job.Column+1) } } if af, exists := rulesMap["allow_failure"]; exists { if _, ok := af.(bool); !ok { ve.AddDetailWithSpan(errors.ErrBooleanValue, fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name), job.Line, job.Column, job.Line, job.Column+1) } } allowed := map[string]struct{}{ "if": {}, "when": {}, "start_in": {}, "allow_failure": {}, "changes": {}, "exists": {}, } for key := range rulesMap { if _, ok := allowed[key]; !ok { ve.AddDetailWithSpan(errors.ErrUnknownRulesKey, fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key), job.Line, job.Column, job.Line, job.Column+len(key)) } } } } } func validateExpressionSyntax(expr string) bool { return expr != "" } func detectCycles(dependencyGraph map[string][]string) []string { visited := make(map[string]bool) recStack := make(map[string]bool) var cycle []string var dfs func(string) bool dfs = func(node string) bool { if recStack[node] { cycle = append(cycle, node) return true } if visited[node] { return false } visited[node] = true recStack[node] = true for _, neighbor := range dependencyGraph[node] { if dfs(neighbor) { cycle = append(cycle, node) return true } } recStack[node] = false return false } for node := range dependencyGraph { if !visited[node] { if dfs(node) { break } } } return cycle } func validateIncludeRules(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Include == nil { return } switch v := cfg.Include.(type) { case map[string]interface{}: validateIncludeRuleMap(v, ve) case []interface{}: for _, item := range v { if m, ok := item.(map[string]interface{}); ok { validateIncludeRuleMap(m, ve) } } } } func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError) { keysToCheck := []string{"exists", "changes"} for _, key := range keysToCheck { if val, exists := m[key]; exists { switch val.(type) { case string: // ok case []interface{}: for _, item := range val.([]interface{}) { if _, ok := item.(string); !ok { ve.AddDetailWithSpan(errors.ErrIncludeRulesInvalid, fmt.Sprintf("include rule key '%s' should be an array of strings", key), 0, 0, 0, 0) break } } default: ve.AddDetailWithSpan(errors.ErrIncludeRulesInvalid, fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key), 0, 0, 0, 0) } } } }