package gitlabcivalidator import ( "fmt" "gitflame.ru/CI_VLDR/errors" "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 — главная точка входа в библиотеку. // Принимает на вход содержимое GitLab CI-файла (в формате YAML) и опции валидации. // Возвращает ошибку, если есть проблемы в конфигурации (или nil, если всё хорошо). func ValidateGitLabCIFile(content string, opts ValidationOptions) error { validationErr := &errors.ValidationError{} // 1) Проверяем, что содержимое не пустое if len(content) == 0 { validationErr.AddDetailWithSpan(errors.ErrEmptyFile, "Please provide content of .gitflame-ci.yml", 0, 0, 0, 0) return validationErr } // 2) Парсим YAML cfg, err := ParseGitLabCIConfig([]byte(content)) if err != nil { validationErr.AddDetailWithSpan(errors.ErrYamlSyntax, fmt.Sprintf("ошибка синтаксического анализа YAML: %v", err), 0, 0, 0, 0) return validationErr } // 3) (Опционально) Проверяем SHA проекта, // если включена опция и "проект с заданным SHA существует, но SHA не содержится ни в одной ветке/теге" //if opts.VerifyProjectSHA && opts.ProjectSHA != "" { // // Заглушка: предположим, что проект существует, // // а также предположим, что SHA нигде не встречается // // (в реальном мире тут нужно обращаться к GitLab API). // shaExists := checkSHAExistsInBranchesOrTags(opts.ProjectSHA) // if !shaExists { // validationErr.Add(fmt.Errorf("указанный SHA (%s) не содержится ни в одной ветке или теге", opts.ProjectSHA)) // } //} // 4) Валидируем структуру конфигурации 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 } // checkSHAExistsInBranchesOrTags - фейковая функция для проверки SHA. // В реальном проекте здесь вы бы обращались к GitLab API / репозиторию. func checkSHAExistsInBranchesOrTags(sha string) bool { // Для демонстрации пусть всегда возвращает false, // т.е. "SHA не найден". return false } // validateRootObject проверяет допустимые ключи на корневом уровне. func validateRootObject(cfg *GitLabCIConfig, ve *errors.ValidationError) { validateBeforeScript(cfg.BeforeScript, ve) } // validateBeforeScript проверяет, что поле before_script представлено строкой // или вложенным массивом строк (до 10 уровней вложенности). 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) } } // validateNestedStringArray рекурсивно проверяет, что все элементы массива являются строками // или вложенными массивами строк и что глубина вложенности не превышает maxLevel. func validateNestedStringArray(arr []interface{}, currentLevel, maxLevel int) bool { if currentLevel > maxLevel { return false } for _, item := range arr { switch v := item.(type) { case string: // ок case []interface{}: if !validateNestedStringArray(v, currentLevel+1, maxLevel) { return false } default: return false } } return true } // validateService проверяет, что конфигурация сервиса имеет тип string или map[string]interface{}. 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) } } // validateStages проверяет, что каждая стадия задана как непустая строка. 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) } } } // validateVariables проверяет корректность описания переменных. // Если значение переменной — скаляр, оно должно быть строкой, числом или булевым значением. // Если переменная задана как хэш, допустимы ключи: value, description, expand, options. 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) { // всё ок } 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) } } } // isScalar возвращает true, если значение является скаляром (string, bool, числовым типом). func isScalar(val interface{}) bool { switch val.(type) { case string, bool, int, int64, float64, float32: return true default: return false } } // validateJobs проводит серию проверок для job'ов. func validateJobs(cfg *GitLabCIConfig, ve *errors.ValidationError) { // Если jobs отсутствуют, возвращаем ошибку с позицией 0,0 (так как позиция не определена) if cfg.Jobs == nil || len(cfg.Jobs) == 0 { ve.AddDetailWithSpan(errors.ErrMissingJobs, "отсутствуют job'ы (jobs) в конфигурации", 0, 0, 0, 0) return } // Формируем список разрешённых стадий из cfg.Stages stagesAllowed := make(map[string]struct{}) for _, st := range cfg.Stages { stagesAllowed[st] = struct{}{} } visibleCount := 0 // Для проверки циклов зависимости можно собрать dependency graph (пока не используем для цикла) dependencyGraph := make(map[string][]string) // Обходим все job'ы 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) continue } // b) Если имя не начинается с точки — задача считается видимой if job.Name[0] != '.' { visibleCount++ } //// c) Проверяем обязательное наличие script или run //scriptPresent := job.Script != nil //if !scriptPresent && job.Run == nil { // ve.AddDetail(errors.ErrMissingScript, // fmt.Sprintf("задача '%s' не содержит script или run", job.Name), // job.Line, job.Column) //} // d) Нельзя использовать одновременно script и run if job.Script == nil && job.Run == nil && job.Trigger == nil { ve.AddDetail(errors.ErrMissingScript, fmt.Sprintf("задача '%s' не содержит script, run или trigger", job.Name), job.Line, job.Column) } // e) Если указан stage, он должен присутствовать в списке разрешённых if job.Stage != "" { if _, ok := stagesAllowed[job.Stage]; !ok { ve.AddDetail(errors.ErrJobStageNotExist, fmt.Sprintf("задача '%s': выбранный stage '%s' не существует; доступные stage: %v", job.Name, job.Stage, cfg.Stages), job.Line, job.Column) } } // f) Проверяем, что все зависимости (dependencies) ссылаются на существующие job'ы dependencyGraph[job.Name] = append(dependencyGraph[job.Name], job.Dependencies...) for _, dep := range job.Dependencies { depPos, _ := job.DependencyLines[dep] if _, ok := cfg.Jobs[dep]; !ok { ve.AddDetail(errors.ErrUndefinedDependency, fmt.Sprintf("задача '%s' (строка %d, символ %d) имеет зависимость '%s' (строка %d, символ %d), которой нет среди job'ов", job.Name, job.Line, job.Column, dep, depPos.Line, depPos.Column), job.Line, job.Column) } else { // Проверяем порядок stage — зависимость не должна идти позже if !checkStageOrder(cfg, dep, job.Name) { ve.AddDetail(errors.ErrInvalidStageOrder, fmt.Sprintf("задача '%s' имеет зависимость '%s' со стадией, идущей позже, чем '%s'", job.Name, dep, job.Name), job.Line, job.Column) } } } // g) Проверяем блок needs if len(job.Needs) > 0 { needNames := make(map[string]struct{}) for _, nd := range job.Needs { // Не должно быть дублирующихся записей в needs if _, exist := needNames[nd]; exist { // Здесь, например, выделяем весь фрагмент с нужной зависимостью. // Предположим, что мы можем вычислить окончание как: endCol := job.Column + len(nd) ve.AddDetailWithSpan(errors.ErrDuplicateNeeds, fmt.Sprintf("задача '%s' содержит дублирующуюся зависимость '%s' в needs", job.Name, nd), job.Line, job.Column, job.Line, endCol) } else { needNames[nd] = struct{}{} } // Имя в needs не должно быть пустым if nd == "" { ve.AddDetailWithSpan(errors.ErrJobNameBlank, fmt.Sprintf("задача '%s' имеет needs с пустым именем задачи", job.Name), job.Line, job.Column, job.Line, job.Column) } // Проверяем максимальную длину имени if len(nd) > MaxJobNameLength { endCol := job.Column + len(nd) ve.AddDetailWithSpan(errors.ErrNeedNameTooLong, fmt.Sprintf("задача '%s' содержит needs '%s', длина имени задачи превышает %d", job.Name, nd, MaxJobNameLength), job.Line, job.Column, job.Line, endCol) } // Каждая задача, указанная в 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 { endCol := needPos.Column + len(nd) ve.AddDetailWithSpan(errors.ErrUndefinedNeed, fmt.Sprintf("задача '%s' (Line %d, Col %d-%d) ссылается на несуществующую задачу '%s'", job.Name, needPos.Line, needPos.Column, endCol, nd), job.Line, job.Column, needPos.Line, endCol) } else if !checkStageOrder(cfg, nd, job.Name) { endCol := needPos.Column + len(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, endCol, job.Name), job.Line, job.Column, needPos.Line, endCol) } // Добавляем запись needs в граф зависимостей dependencyGraph[job.Name] = append(dependencyGraph[job.Name], nd) } } validateJobArtifacts(job, ve) validateJobDelayedParameters(job, ve) validateJobDependenciesNeedsConsistency(job, ve) validateJobRules(job, ve) } // Проверка: должна быть хотя бы одна видимая задача if visibleCount == 0 { ve.AddDetail(errors.ErrNoVisibleJob, "в разделе jobs не обнаружено ни одной «видимой» задачи", 0, 0) } // После обхода всех job проверяем граф зависимостей на циклы. cycle := detectCycles(dependencyGraph) if len(cycle) > 0 { ve.AddDetail(errors.ErrCyclicDependency, fmt.Sprintf("проверка циклических зависимостей: обнаружен цикл между job: %v", cycle), 0, 0) } } func validateJobArtifacts(job *Job, ve *errors.ValidationError) { if job.Artifacts != nil { // Если paths не указаны или массив пустой, возвращаем ошибку if len(job.Artifacts.Paths) == 0 { ve.AddDetail( errors.ErrArtifactsPathsBlank, fmt.Sprintf("задача '%s': artifacts paths can't be blank", job.Name), job.Line, job.Column, ) } } } // checkStageOrder проверяет, что stage у jobDep не позже, чем stage у jobName func checkStageOrder(cfg *GitLabCIConfig, jobDep, jobName string) bool { depStage := cfg.Jobs[jobDep].Stage jobStage := cfg.Jobs[jobName].Stage // Если stage пустые, условимся, что порядок "любопытен", // но в реальном GitLab это тоже может вызывать ошибки. if depStage == "" || jobStage == "" { return true // условно пропустим } allStages := cfg.Stages depIndex := -1 jobIndex := -1 for i, st := range allStages { if st == depStage { depIndex = i } if st == jobStage { jobIndex = i } } // Если не нашли stage, тоже пропустим, ибо сам факт уже поднимался выше if depIndex == -1 || jobIndex == -1 { return true } // Зависимость не должна иметь stage с индексом больше, чем у job 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.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0) } for _, item := range v { if _, ok := item.(string); !ok { ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) break } } case map[string]interface{}: paths, ok := v["paths"] if !ok { ve.AddDetail(errors.ErrChangesMissingPaths, "changes config hash must contain key 'paths'", 0, 0) } else { arr, ok := paths.([]interface{}) if !ok { ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) } else { if len(arr) > 50 { ve.AddDetail(errors.ErrChangesTooManyEntries, "has too many entries (maximum 50)", 0, 0) } for _, item := range arr { if _, ok := item.(string); !ok { ve.AddDetail(errors.ErrChangesNotArrayOfStrings, "changes config should be an array of strings", 0, 0) break } } } } case string: ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0) default: ve.AddDetail(errors.ErrChangesInvalidType, "should be an array or a hash", 0, 0) } } func validatePaths(cfg *GitLabCIConfig, ve *errors.ValidationError) { if cfg.Paths == nil { return } arr, ok := cfg.Paths.([]interface{}) if !ok { ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0) return } for _, item := range arr { if _, ok := item.(string); !ok { ve.AddDetail(errors.ErrPathsNotArrayOfStrings, "paths config should be an array of strings", 0, 0) break } } } // validateJobDelayedParameters проверяет параметры для отложенных (delayed) задач. func validateJobDelayedParameters(job *Job, ve *errors.ValidationError) { if job.When == "delayed" { if job.StartIn == "" { ve.AddDetail(errors.ErrStartInMissing, fmt.Sprintf("задача '%s': start_in should be specified for delayed job", job.Name), job.Line, job.Column) } else { d, err := time.ParseDuration(job.StartIn) if err != nil { ve.AddDetail(errors.ErrStartInInvalid, fmt.Sprintf("задача '%s': job start in should be a duration", job.Name), job.Line, job.Column) } else if d > 7*24*time.Hour { ve.AddDetail(errors.ErrStartInTooLong, fmt.Sprintf("задача '%s': job start in should not exceed the limit", job.Name), job.Line, job.Column) } } } else { // Если задача не отложенная, start_in должен быть пустым if job.StartIn != "" { ve.AddDetail(errors.ErrStartInMustBeBlank, fmt.Sprintf("задача '%s': job start in must be blank when not delayed", job.Name), job.Line, job.Column) } } } // validateJobDependenciesNeedsConsistency убеждается, что все зависимости присутствуют в needs. 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.AddDetail(errors.ErrDependencyNotInNeeds, fmt.Sprintf("задача '%s': dependency '%s' should be part of needs", job.Name, dep), job.Line, job.Column) } } } } func validateJobRules(job *Job, ve *errors.ValidationError) { if job.Rules != nil { // Если rules используется, не должно быть only или except if job.Only != nil && job.Except != nil { ve.AddDetail(errors.ErrRulesOnlyExcept, fmt.Sprintf("задача '%s': may not be used with rules: only, except", job.Name), job.Line, job.Column) } else if job.Only != nil { ve.AddDetail(errors.ErrRulesOnly, fmt.Sprintf("задача '%s': may not be used with rules: only", job.Name), job.Line, job.Column) } else if job.Except != nil { ve.AddDetail(errors.ErrRulesExcept, fmt.Sprintf("задача '%s': may not be used with rules: except", job.Name), job.Line, job.Column) } // Если в секции rules присутствует ключ if, проверим его тип и синтаксис 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.AddDetail(errors.ErrInvalidExpressionSyntax, fmt.Sprintf("задача '%s': invalid expression syntax", job.Name), job.Line, job.Column) } } else { ve.AddDetail(errors.ErrInvalidExpressionSyntax, fmt.Sprintf("задача '%s': 'if' in rules should be a string", job.Name), job.Line, job.Column) } } // Проверим, что ключ allow_failure, если присутствует, имеет булевое значение if af, exists := rulesMap["allow_failure"]; exists { if _, ok := af.(bool); !ok { ve.AddDetail(errors.ErrBooleanValue, fmt.Sprintf("задача '%s': allow_failure in rules should be a boolean value", job.Name), job.Line, job.Column) } } // Проверка допустимых ключей в rules allowed := map[string]struct{}{ "if": {}, "when": {}, "start_in": {}, "allow_failure": {}, "changes": {}, "exists": {}, } for key := range rulesMap { if _, ok := allowed[key]; !ok { ve.AddDetail(errors.ErrUnknownRulesKey, fmt.Sprintf("задача '%s': unknown key in rules: %s", job.Name, key), job.Line, job.Column) } } } } } // validateExpressionSyntax выполняет базовую проверку синтаксиса выражения. // В реальной реализации здесь может быть полноценный парсер. func validateExpressionSyntax(expr string) bool { // Для демонстрации считаем, что пустая строка или отсутствие каких-либо специальных символов — это ошибка. return expr != "" } // detectCycles принимает граф зависимостей в виде мапы: job -> список зависимых job, // и возвращает список job, участвующих в цикле (если он обнаружен). 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] { // Если 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 } // Запускаем DFS для каждой job. for node := range dependencyGraph { if !visited[node] { if dfs(node) { break // если цикл обнаружен, прерываем обход } } } return cycle } // Функция для валидации include-правил 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) } else { // Если элемент не является картой, можно считать его допустимым, // либо добавить дополнительную проверку при необходимости. } } default: // Если include задан не как карта и не как массив, пропускаем проверку. } } // Функция, которая проверяет отдельную карту include-правила func validateIncludeRuleMap(m map[string]interface{}, ve *errors.ValidationError) { // Проверяем ключи "exists" и "changes" keysToCheck := []string{"exists", "changes"} for _, key := range keysToCheck { if val, exists := m[key]; exists { switch v := val.(type) { case string: // Корректное значение – строка. case []interface{}: // Проверяем, что все элементы массива – строки. for _, item := range v { if _, ok := item.(string); !ok { ve.AddDetail(errors.ErrIncludeRulesInvalid, fmt.Sprintf("include rule key '%s' should be an array of strings", key), 0, 0) break } } default: ve.AddDetail(errors.ErrIncludeRulesInvalid, fmt.Sprintf("include rule key '%s' should be a string or an array of strings", key), 0, 0) } } } }