Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 126 additions & 5 deletions cmd/ingitdb/commands/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func Validate(
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
dirPath, _ := cmd.Flags().GetString("path")
safeDiagnostics, _ := cmd.Flags().GetBool("safe-diagnostics")
if dirPath == "" {
wd, err := getWd()
if err != nil {
Expand All @@ -40,7 +41,11 @@ func Validate(
return err
}
dirPath = expanded
logf("inGitDB db path: ", dirPath)
if safeDiagnostics {
logf("inGitDB database root: repository-relative path")
} else {
logf("inGitDB db path: ", dirPath)
}

// Validate --only flag
onlyVal, _ := cmd.Flags().GetString("only")
Expand All @@ -65,7 +70,11 @@ func Validate(
}
if result.HasErrors() {
message := formatValidationFailure("incremental validation", result)
return fmt.Errorf("%s", message)
if safeDiagnostics {
message = formatSafeValidationFailure("incremental validation", dirPath, result)
}
findingErr := fmt.Errorf("%s", message)
return NewValidationFailedError(findingErr)
}
return nil
}
Expand All @@ -80,13 +89,23 @@ func Validate(
validateOpt := ingitdb.Validate()
defRes, defErr := readDefinition(dirPath, validateOpt)
if defErr != nil {
return fmt.Errorf("inGitDB database validation failed: %w", defErr)
if safeDiagnostics {
validationErr := fmt.Errorf("inGitDB database definition is invalid")
return NewValidationFailedError(validationErr)
}
validationErr := fmt.Errorf("inGitDB database validation failed: %w", defErr)
return NewValidationFailedError(validationErr)
}
def = defRes
} else {
defRes, defErr := readDefinition(dirPath)
if defErr != nil {
return fmt.Errorf("inGitDB database validation failed: %w", defErr)
if safeDiagnostics {
validationErr := fmt.Errorf("inGitDB database definition is invalid")
return NewValidationFailedError(validationErr)
}
validationErr := fmt.Errorf("inGitDB database validation failed: %w", defErr)
return NewValidationFailedError(validationErr)
}
def = defRes
}
Expand All @@ -95,11 +114,18 @@ func Validate(
if shouldValidateRecords && dataVal != nil {
result, valErr := dataVal.Validate(ctx, dirPath, def)
if valErr != nil {
if safeDiagnostics {
return fmt.Errorf("data validator could not complete")
}
return fmt.Errorf("data validation failed: %w", valErr)
}
if result.HasErrors() {
message := formatValidationFailure("data validation", result)
return fmt.Errorf("%s", message)
if safeDiagnostics {
message = formatSafeValidationFailure("data validation", dirPath, result)
}
findingErr := fmt.Errorf("%s", message)
return NewValidationFailedError(findingErr)
}
// Log completion message for each collection
for collectionKey := range def.Collections {
Expand All @@ -118,9 +144,104 @@ func Validate(
cmd.Flags().String("from-commit", "", "validate only records changed since this commit")
cmd.Flags().String("to-commit", "", "validate only records up to this commit")
cmd.Flags().String("only", "", `validate only "definition" or "records" (default: both)`)
cmd.Flags().Bool("safe-diagnostics", false, "report finding identities and constraint classes without record values")
return cmd
}

func formatSafeValidationFailure(prefix, rootPath string, result *ingitdb.ValidationResult) string {
validationErrors := result.Errors()
details := formatSafeValidationErrors(rootPath, validationErrors)
if details == "" {
return fmt.Sprintf("%s found %d error(s)", prefix, result.ErrorCount())
}
return fmt.Sprintf("%s found %d error(s): %s", prefix, result.ErrorCount(), details)
}

func formatSafeValidationErrors(rootPath string, validationErrors []ingitdb.ValidationError) string {
if len(validationErrors) == 0 {
return ""
}
parts := make([]string, 0, len(validationErrors))
for _, validationErr := range validationErrors {
part := formatSafeValidationError(rootPath, validationErr)
parts = append(parts, part)
}
return strings.Join(parts, "; ")
}

func formatSafeValidationError(rootPath string, validationErr ingitdb.ValidationError) string {
parts := make([]string, 0, 5)
if validationErr.CollectionID != "" {
collectionPart := fmt.Sprintf("collection %q", validationErr.CollectionID)
parts = append(parts, collectionPart)
}
if validationErr.FilePath != "" {
filePath := safeRepositoryPath(rootPath, validationErr.FilePath)
filePart := fmt.Sprintf("file %q", filePath)
parts = append(parts, filePart)
}
if validationErr.RecordKey != "" {
recordPart := fmt.Sprintf("record %q", validationErr.RecordKey)
parts = append(parts, recordPart)
}
if validationErr.FieldName != "" {
fieldPart := fmt.Sprintf("field %q", validationErr.FieldName)
parts = append(parts, fieldPart)
}
parts = append(parts, safeConstraintClass(validationErr.Message))
return strings.Join(parts, ": ")
}

func safeRepositoryPath(rootPath, filePath string) string {
cleanRoot, rootErr := filepath.Abs(rootPath)
if rootErr != nil {
return filepath.Base(filePath)
}
cleanFile := filePath
if !filepath.IsAbs(cleanFile) {
cleanFile = filepath.Join(cleanRoot, cleanFile)
}
cleanFile, fileErr := filepath.Abs(cleanFile)
if fileErr != nil {
return filepath.Base(filePath)
}
relPath, relErr := filepath.Rel(cleanRoot, cleanFile)
separator := string(filepath.Separator)
parentPrefix := ".." + separator
if relErr != nil || relPath == ".." || strings.HasPrefix(relPath, parentPrefix) {
return filepath.Base(filePath)
}
return filepath.ToSlash(relPath)
}

func safeConstraintClass(message string) string {
normalized := strings.ToLower(message)
switch {
case strings.Contains(normalized, "foreign key"):
return "foreign-key constraint failed"
case strings.Contains(normalized, "missing required field"):
return "required-field constraint failed"
case strings.Contains(normalized, "wrong type"):
return "type constraint failed"
case strings.Contains(normalized, "not one of the permitted values"):
return "enum constraint failed"
case strings.Contains(normalized, "min_value"), strings.Contains(normalized, "max_value"):
return "range constraint failed"
case strings.Contains(normalized, "min_length"), strings.Contains(normalized, "max_length"), strings.Contains(normalized, "required length"):
return "length constraint failed"
case strings.Contains(normalized, "undeclared field"):
return "declared-field constraint failed"
case strings.Contains(normalized, "computed column"):
return "computed-field constraint failed"
case strings.Contains(normalized, "record(s)"):
return "record-count constraint failed"
case strings.Contains(normalized, "parse"):
return "record-format constraint failed"
default:
return "validation constraint failed"
}
}

func formatValidationFailure(prefix string, result *ingitdb.ValidationResult) string {
details := formatValidationErrors(result.Errors())
if details == "" {
Expand Down
82 changes: 82 additions & 0 deletions cmd/ingitdb/commands/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,82 @@ func TestValidate_DataValidationErrors(t *testing.T) {
if err == nil {
t.Fatal("expected error when data validation has errors")
}
if !errors.Is(err, ErrValidationFailed) {
t.Fatalf("expected ErrValidationFailed, got: %v", err)
}
}

func TestValidate_SafeDiagnosticsRedactRecordValue(t *testing.T) {
t.Parallel()

dir := t.TempDir()
secretValue := "github_pat_secret_value"
readDef := func(_ string, _ ...ingitdb.ReadOption) (*ingitdb.Definition, error) {
return &ingitdb.Definition{}, nil
}
dataVal := &mockDataValidator{
result: func() *ingitdb.ValidationResult {
result := &ingitdb.ValidationResult{}
validationErr := ingitdb.ValidationError{
CollectionID: "spaces",
FilePath: filepath.Join(dir, "sneat", "spaces", "space-1", "space.yaml"),
RecordKey: "space-1",
FieldName: "token",
Message: fmt.Sprintf("value %s for field %q is not one of the permitted values", secretValue, "token"),
}
result.Append(validationErr)
return result
}(),
}
homeDir := func() (string, error) { return "/tmp/home", nil }
getWd := func() (string, error) { return dir, nil }
var logs []string
logf := func(args ...any) {
line := fmt.Sprint(args...)
logs = append(logs, line)
}
cmd := Validate(homeDir, getWd, readDef, dataVal, nil, logf)
err := runCobraCommand(cmd, "--path="+dir, "--safe-diagnostics")
if err == nil {
t.Fatal("expected validation failure")
}
message := err.Error()
for _, want := range []string{"collection \"spaces\"", "file \"sneat/spaces/space-1/space.yaml\"", "record \"space-1\"", "field \"token\"", "enum constraint failed"} {
if !strings.Contains(message, want) {
t.Fatalf("safe diagnostic %q does not contain %q", message, want)
}
}
for _, forbidden := range []string{secretValue, dir} {
if strings.Contains(message, forbidden) {
t.Fatalf("safe diagnostic %q contains forbidden value %q", message, forbidden)
}
for _, line := range logs {
if strings.Contains(line, forbidden) {
t.Fatalf("safe diagnostic log %q contains forbidden value %q", line, forbidden)
}
}
}
}

func TestValidate_SafeDiagnosticsRedactDefinitionError(t *testing.T) {
t.Parallel()

dir := t.TempDir()
secretValue := "definition-secret-value"
readDef := func(_ string, _ ...ingitdb.ReadOption) (*ingitdb.Definition, error) {
return nil, errors.New(secretValue)
}
homeDir := func() (string, error) { return "/tmp/home", nil }
getWd := func() (string, error) { return dir, nil }
logf := func(...any) {}
cmd := Validate(homeDir, getWd, readDef, nil, nil, logf)
err := runCobraCommand(cmd, "--path="+dir, "--safe-diagnostics")
if err == nil {
t.Fatal("expected validation failure")
}
if strings.Contains(err.Error(), secretValue) {
t.Fatalf("safe definition diagnostic contains secret: %v", err)
}
}

func TestValidate_DataValidationError(t *testing.T) {
Expand Down Expand Up @@ -206,6 +282,9 @@ func TestValidate_IncrementalErrors(t *testing.T) {
if err == nil {
t.Fatal("expected error when incremental validation has errors")
}
if !errors.Is(err, ErrValidationFailed) {
t.Fatalf("expected ErrValidationFailed, got: %v", err)
}
}

func TestValidate_IncrementalValidationError(t *testing.T) {
Expand Down Expand Up @@ -264,6 +343,9 @@ func TestValidate_ReadDefinitionError(t *testing.T) {
if err == nil {
t.Fatal("expected error when readDefinition fails")
}
if !errors.Is(err, ErrValidationFailed) {
t.Fatalf("expected ErrValidationFailed, got: %v", err)
}
}

func TestValidate_GetWdError(t *testing.T) {
Expand Down
23 changes: 23 additions & 0 deletions cmd/ingitdb/commands/validation_failure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package commands

import (
"errors"
"fmt"
)

// ValidationFailedExitCode is returned when the validate command completed and
// found that the repository does not satisfy its InGitDB definition.
const ValidationFailedExitCode = 2

// ErrValidationFailed identifies repository validation findings separately
// from command configuration, I/O, startup, and other runtime failures.
var ErrValidationFailed = errors.New("repository validation failed")

// NewValidationFailedError preserves a human-readable finding while making it
// possible for the process boundary to select ValidationFailedExitCode.
func NewValidationFailedError(err error) error {
if err == nil {
return ErrValidationFailed
}
return fmt.Errorf("%w: %v", ErrValidationFailed, err)
}
27 changes: 26 additions & 1 deletion cmd/ingitdb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package main

import (
"context"
"errors"
"fmt"
"io"
"os"

bubbletea "charm.land/bubbletea/v2"
Expand All @@ -26,16 +28,39 @@ import (
var exit = os.Exit

func main() {
guardProcess(executeMain, os.Stderr, exit)
}

func executeMain() {
fatal := func(err error) {
_, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err)
exit(1)
exit(exitCodeForError(err))
}
logf := func(args ...any) {
_, _ = fmt.Fprintln(os.Stderr, args...)
}
run(os.Args, os.UserHomeDir, os.Getwd, validator.ReadDefinition, fatal, logf)
}

func guardProcess(runCommand func(), errorWriter io.Writer, exitProcess func(int)) {
defer func() {
recovered := recover()
if recovered == nil {
return
}
_, _ = fmt.Fprintln(errorWriter, "error: command crashed")
exitProcess(1)
}()
runCommand()
}

func exitCodeForError(err error) int {
if errors.Is(err, commands.ErrValidationFailed) {
return commands.ValidationFailedExitCode
}
return 1
}

func defaultNewDB(rootDirPath string, def *ingitdb.Definition) (dal.DB, error) {
return dalgo2fsingitdb.NewLocalDBWithDef(rootDirPath, def)
}
Expand Down
Loading