Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(validation): #217 handle multi model validation (not delimited) #271

Closed
Closed
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
15 changes: 15 additions & 0 deletions src/pkg/files/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,24 @@ func IsJsonOrYaml(path string) (err error) {
return nil
}

// IsJson returns error if the file is not a json file.
func IsJson(path string) (err error) {
if !strings.HasSuffix(path, ".json") {
return errors.New("please specify a json file")
}
return nil
}

// ReadOscalFile reads an OSCAL file and returns the bytes
func ReadOscalFile(inputFile string) (bytes []byte, err error) {
// Validate the input file is a json or yaml file
if !strings.HasSuffix(inputFile, "json") && !strings.HasSuffix(inputFile, "yaml") {
return bytes, errors.New("please specify a json or yaml file")
}
// Read the input file
bytes, err = os.ReadFile(inputFile)
if err != nil {
return nil, fmt.Errorf("reading input file: %s", err)
}
return bytes, nil
}
15 changes: 13 additions & 2 deletions src/pkg/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,25 @@ func FindValue(model map[string]interface{}, keys []string) interface{} {
return find(model, keys)
}

// GenModels takes a model and returns a slice of models
// This is used to get all the models given oscal complete schema that violates oneOf
func GenModels(model map[string]interface{}) (models []map[string]interface{}, err error) {
for key, value := range model {
newModel := make(map[string]interface{})
newModel[key] = value
models = append(models, newModel)
}
return models, nil
}

// GetModelType returns the type of the model if the model is valid
// returns error if more than one model is found or no models are found (consistent with OSCAL spec)
func GetModelType(model map[string]interface{}) (modelType string, err error) {
if len(model) != 1 {
return "", fmt.Errorf("expected model to have 1 key, got %d", len(model))
}
for key := range model {
modelType = key
for modelType = range model {
break
}
return modelType, nil
}
Expand Down
29 changes: 29 additions & 0 deletions src/pkg/model/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,33 @@ func TestModelUtils(t *testing.T) {

})

t.Run("GenModels", func(t *testing.T) {
t.Parallel()
models, err := model.GenModels(validModel)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if len(models) != 1 {
t.Errorf("expected 1 model, got %d", len(models))
}
})

t.Run("GenModels multi models", func(t *testing.T) {
t.Parallel()
gooscaltest.GetByteMap(t)

bytes := gooscaltest.ByteMap[gooscaltest.MultipleDocPath]
oscalModel, err := model.CoerceToJsonMap(bytes)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
models, err := model.GenModels(oscalModel)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if len(models) != 2 {
t.Errorf("expected 2 models, got %d", len(models))
}
})

}
58 changes: 48 additions & 10 deletions src/pkg/validation/validation_command.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package validation

import (
"errors"
"fmt"
"os"
"strings"

"github.com/defenseunicorns/go-oscal/src/pkg/files"
"github.com/defenseunicorns/go-oscal/src/pkg/model"
"github.com/defenseunicorns/go-oscal/src/pkg/versioning"
"github.com/santhosh-tekuri/jsonschema/v5"
)
Expand All @@ -23,19 +22,59 @@ type ValidationResponse struct {
// ValidationCommand validates an OSCAL document
// Returns a ValidationResponse and an error
func ValidationCommand(inputFile string) (validationResponse ValidationResponse, err error) {
// Validate the input file is a json or yaml file
if !strings.HasSuffix(inputFile, "json") && !strings.HasSuffix(inputFile, "yaml") {
return validationResponse, errors.New("please specify a json or yaml file")
bytes, err := files.ReadOscalFile(inputFile)
if err != nil {
return validationResponse, err
}
return ValidationCommandWithModel(inputFile, bytes)
}

// ValidationCommandMulti validates an OSCAL document with multiple models ie (Catalog, Component, Profile, System)
// Ignores "oneOf" constraint for oscalTypes_X_X_X.OscalCompleteSchema
// Does not handle yaml delimited files
// Returns a ValidationResponse and an error
func ValidationCommandMulti(inputFile string) (responses []ValidationResponse, err error) {
// Read the input file
bytes, err := os.ReadFile(inputFile)
bytes, err := files.ReadOscalFile(inputFile)
if err != nil {
return responses, err
}

// Coerce the bytes to a json map
jsonModel, err := model.CoerceToJsonMap(bytes)
if err != nil {
return responses, err
}

// Generate the models
models, err := model.GenModels(jsonModel)
if err != nil {
return validationResponse, fmt.Errorf("reading input file: %s", err)
return responses, err
}

// Validate each model
for _, m := range models {
// get the key (model type), at this point is the only one
modelType, _ := model.GetModelType(m)

// TODO: get input on path format (current: multiple_model.json#component-definition)
pathWithType := fmt.Sprintf("%s#%s", inputFile, modelType)
validationResponse, err := ValidationCommandWithModel(pathWithType, m)
// TODO: should we bail on non-validation errors?
if err != nil {
return responses, err
}
responses = append(responses, validationResponse)
}
Comment on lines +55 to +68
Copy link
Collaborator Author

@mike-winberry mike-winberry Jun 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specific feedback/thoughts requested marked by TODO


return responses, nil
}

// ValidationCommandWithModel validates an OSCAL document with a file path and a InterfaceOrBytes
// Returns a ValidationResponse and an error
func ValidationCommandWithModel(inputFile string, jsonModel model.InterfaceOrBytes) (validationResponse ValidationResponse, err error) {
// Create and set the validator in the validation response
validator, err := NewValidator(bytes)
validator, err := NewValidator(jsonModel)
if err != nil {
return validationResponse, fmt.Errorf("failed to create validator: %s", err)
}
Expand Down Expand Up @@ -70,6 +109,5 @@ func ValidationCommand(inputFile string) (validationResponse ValidationResponse,
if err != nil {
return validationResponse, fmt.Errorf("shouldn't error,check the GetValidationResult method for more information: %s", err)
}

return validationResponse, nil
}
68 changes: 68 additions & 0 deletions src/pkg/validation/validation_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,71 @@ func TestValidationCommand(t *testing.T) {
}
})
}
func TestValidationCommandMulti(t *testing.T) {
t.Parallel()

t.Run("returns an error if no input file is provided", func(t *testing.T) {
_, err := validation.ValidationCommandMulti("")
if err == nil {
t.Error("expected an error, got nil")
}
})

t.Run("returns an error if the input file is not a json or yaml file", func(t *testing.T) {
_, err := validation.ValidationCommandMulti("test.txt")
if err == nil {
t.Error("expected an error, got nil")
}
})

t.Run("returns an error if the input file is not a valid oscal document", func(t *testing.T) {
_, err := validation.ValidationCommandMulti("test.json")
if err == nil {
t.Error("expected an error, got nil")
}
})

t.Run("returns a warning if the oscal version is not the latest", func(t *testing.T) {
validationResponses, err := validation.ValidationCommandMulti(gooscaltest.ValidComponentPath)
if err != nil {
t.Error("expected an error, got nil")
}

if len(validationResponses[0].Warnings) == 0 {
t.Error("expected a warning, got none")
}
})

t.Run("returns a validation response if the input file is valid", func(t *testing.T) {
validationResponses, err := validation.ValidationCommandMulti(gooscaltest.ValidComponentPath)
if err != nil {
t.Errorf("expected no error, got %s", err)
}

if validationResponses[0].Result.Valid != true {
t.Error("expected a valid result, got invalid")
}
})

t.Run("returns no error and validation result if the input file fails validation", func(t *testing.T) {
validationResponses, err := validation.ValidationCommandMulti(gooscaltest.InvalidCatalogPath)
if err != nil {
t.Errorf("expected no error, got %s", err)
}

if validationResponses[0].Result.Valid != false {
t.Error("expected an invalid result, got valid")
}
})

t.Run("returns a validation response for each model type", func(t *testing.T) {
validationResponses, err := validation.ValidationCommandMulti(gooscaltest.MultipleDocPath)
if err != nil {
t.Errorf("expected no error, got %s", err)
}

if len(validationResponses) < 2 {
t.Errorf("expected at least 2 validation responses, got %d", len(validationResponses))
}
})
}