diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index 670945c83..830ddabcf 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -73,8 +73,9 @@ export enum CraftingSchema_Material_MaterialType { CONTAINER_IMAGE = 2, ARTIFACT = 3, SBOM_CYCLONEDX_JSON = 4, - /** SBOM_SPDX_JSON - SARIF = 5; */ SBOM_SPDX_JSON = 5, + /** JUNIT_XML - SARIF = 5; */ + JUNIT_XML = 6, UNRECOGNIZED = -1, } @@ -98,6 +99,9 @@ export function craftingSchema_Material_MaterialTypeFromJSON(object: any): Craft case 5: case "SBOM_SPDX_JSON": return CraftingSchema_Material_MaterialType.SBOM_SPDX_JSON; + case 6: + case "JUNIT_XML": + return CraftingSchema_Material_MaterialType.JUNIT_XML; case -1: case "UNRECOGNIZED": default: @@ -119,6 +123,8 @@ export function craftingSchema_Material_MaterialTypeToJSON(object: CraftingSchem return "SBOM_CYCLONEDX_JSON"; case CraftingSchema_Material_MaterialType.SBOM_SPDX_JSON: return "SBOM_SPDX_JSON"; + case CraftingSchema_Material_MaterialType.JUNIT_XML: + return "JUNIT_XML"; case CraftingSchema_Material_MaterialType.UNRECOGNIZED: default: return "UNRECOGNIZED"; diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index b704c4e64..557b9ef45 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -93,7 +93,8 @@ const ( CraftingSchema_Material_CONTAINER_IMAGE CraftingSchema_Material_MaterialType = 2 CraftingSchema_Material_ARTIFACT CraftingSchema_Material_MaterialType = 3 CraftingSchema_Material_SBOM_CYCLONEDX_JSON CraftingSchema_Material_MaterialType = 4 - CraftingSchema_Material_SBOM_SPDX_JSON CraftingSchema_Material_MaterialType = 5 // SARIF = 5; + CraftingSchema_Material_SBOM_SPDX_JSON CraftingSchema_Material_MaterialType = 5 + CraftingSchema_Material_JUNIT_XML CraftingSchema_Material_MaterialType = 6 // SARIF = 5; ) // Enum value maps for CraftingSchema_Material_MaterialType. @@ -105,6 +106,7 @@ var ( 3: "ARTIFACT", 4: "SBOM_CYCLONEDX_JSON", 5: "SBOM_SPDX_JSON", + 6: "JUNIT_XML", } CraftingSchema_Material_MaterialType_value = map[string]int32{ "MATERIAL_TYPE_UNSPECIFIED": 0, @@ -113,6 +115,7 @@ var ( "ARTIFACT": 3, "SBOM_CYCLONEDX_JSON": 4, "SBOM_SPDX_JSON": 5, + "JUNIT_XML": 6, } ) @@ -344,7 +347,7 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x63, 0x68, 0x65, 0x6d, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2e, 0x76, 0x31, 0x1a, 0x17, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf7, 0x05, 0x0a, 0x0e, 0x43, 0x72, + 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x86, 0x06, 0x0a, 0x0e, 0x43, 0x72, 0x61, 0x66, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x12, 0x30, 0x0a, 0x0e, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x09, 0xfa, 0x42, 0x06, 0x72, 0x04, 0x0a, 0x02, 0x76, 0x31, 0x52, @@ -371,7 +374,7 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x47, 0x49, 0x54, 0x48, 0x55, 0x42, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x47, 0x49, 0x54, 0x4c, 0x41, 0x42, 0x5f, 0x50, 0x49, 0x50, - 0x45, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x02, 0x1a, 0xc9, 0x02, 0x0a, 0x08, 0x4d, 0x61, 0x74, 0x65, + 0x45, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x02, 0x1a, 0xd8, 0x02, 0x0a, 0x08, 0x4d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x57, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x39, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x61, 0x66, 0x74, 0x69, 0x6e, @@ -383,7 +386,7 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x89, 0x01, 0x0a, 0x0c, 0x4d, 0x61, 0x74, 0x65, + 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22, 0x98, 0x01, 0x0a, 0x0c, 0x4d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x19, 0x4d, 0x41, 0x54, 0x45, 0x52, 0x49, 0x41, 0x4c, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, @@ -392,12 +395,13 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x46, 0x41, 0x43, 0x54, 0x10, 0x03, 0x12, 0x17, 0x0a, 0x13, 0x53, 0x42, 0x4f, 0x4d, 0x5f, 0x43, 0x59, 0x43, 0x4c, 0x4f, 0x4e, 0x45, 0x44, 0x58, 0x5f, 0x4a, 0x53, 0x4f, 0x4e, 0x10, 0x04, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x42, 0x4f, 0x4d, 0x5f, 0x53, 0x50, 0x44, 0x58, 0x5f, 0x4a, 0x53, 0x4f, - 0x4e, 0x10, 0x05, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, - 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, 0x70, 0x2d, 0x64, 0x65, 0x76, 0x2f, - 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, 0x70, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x63, 0x6f, - 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x77, - 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2f, - 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4e, 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x4a, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x58, 0x4d, 0x4c, + 0x10, 0x06, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, 0x70, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x63, + 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, 0x70, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x77, 0x6f, + 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2f, 0x76, + 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index fbc13456e..cdfd227d6 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -54,6 +54,7 @@ message CraftingSchema { ARTIFACT = 3; SBOM_CYCLONEDX_JSON = 4; SBOM_SPDX_JSON = 5; + JUNIT_XML = 6; // SARIF = 5; } } diff --git a/go.mod b/go.mod index 8e1015487..f2d3897fb 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/in-toto/in-toto-golang v0.8.0 github.com/jackc/pgx/v4 v4.18.1 github.com/jedib0t/go-pretty/v6 v6.4.6 + github.com/joshdk/go-junit v1.0.0 github.com/lib/pq v1.10.7 github.com/moby/moby v23.0.1+incompatible github.com/opencontainers/image-spec v1.1.0-rc2 diff --git a/go.sum b/go.sum index e05df420e..c7a34c94d 100644 --- a/go.sum +++ b/go.sum @@ -744,6 +744,8 @@ github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqx github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= diff --git a/internal/attestation/crafter/materials/junit_xml.go b/internal/attestation/crafter/materials/junit_xml.go new file mode 100644 index 000000000..be2fe820a --- /dev/null +++ b/internal/attestation/crafter/materials/junit_xml.go @@ -0,0 +1,77 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials + +import ( + "context" + "fmt" + "io" + "os" + + "encoding/xml" + + api "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/casclient" + junit "github.com/joshdk/go-junit" + "github.com/rs/zerolog" +) + +type JUnitXMLCrafter struct { + *crafterCommon + uploader casclient.Uploader +} + +func NewJUnitXMLCrafter(schema *schemaapi.CraftingSchema_Material, uploader casclient.Uploader, l *zerolog.Logger) (*JUnitXMLCrafter, error) { + if schema.Type != schemaapi.CraftingSchema_Material_JUNIT_XML { + return nil, fmt.Errorf("material type is not JUnit XML") + } + craftCommon := &crafterCommon{logger: l, input: schema} + return &JUnitXMLCrafter{uploader: uploader, crafterCommon: craftCommon}, nil +} + +func (i *JUnitXMLCrafter) Craft(ctx context.Context, filePath string) (*api.Attestation_Material, error) { + if err := i.validate(filePath); err != nil { + return nil, err + } + + return uploadAndCraft(ctx, i.input, i.uploader, filePath) +} + +func (i *JUnitXMLCrafter) validate(filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("can't open the file: %w", err) + } + defer f.Close() + + bytes, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("can't read the file: %w", err) + } + + if err := xml.Unmarshal(bytes, &junit.Suite{}); err != nil { + return fmt.Errorf("invalid JUnit XML file: %w", ErrInvalidMaterialType) + } + + _, err = junit.IngestReader(f) + if err != nil { + i.logger.Debug().Err(err).Msgf("error decoding file: %s", filePath) + return fmt.Errorf("invalid JUnit XML file: %w", ErrInvalidMaterialType) + } + + return nil +} diff --git a/internal/attestation/crafter/materials/junit_xml_test.go b/internal/attestation/crafter/materials/junit_xml_test.go new file mode 100644 index 000000000..5038e3269 --- /dev/null +++ b/internal/attestation/crafter/materials/junit_xml_test.go @@ -0,0 +1,130 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials_test + +import ( + "context" + "testing" + "time" + + attestationApi "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/attestation/crafter/materials" + "github.com/chainloop-dev/chainloop/internal/casclient" + mUploader "github.com/chainloop-dev/chainloop/internal/casclient/mocks" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewJUnitXMLCrafter(t *testing.T) { + testCases := []struct { + name string + input *contractAPI.CraftingSchema_Material + wantErr bool + }{ + { + name: "happy path", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_JUNIT_XML, + }, + }, + { + name: "wrong type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := materials.NewJUnitXMLCrafter(tc.input, nil, nil) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} +func TestJUnitXMLCraft(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + }{ + { + name: "invalid path", + filePath: "./testdata/non-existing.json", + wantErr: "no such file or directory", + }, + { + name: "invalid artifact type", + filePath: "./testdata/simple.txt", + wantErr: "unexpected material type", + }, + { + name: "invalid artifact type", + filePath: "./testdata/junit-invalid.xml", + wantErr: "unexpected material type", + }, + { + name: "valid artifact type", + filePath: "./testdata/junit.xml", + }, + } + + assert := assert.New(t) + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_JUNIT_XML, + } + l := zerolog.Nop() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Mock uploader + uploader := mUploader.NewUploader(t) + if tc.wantErr == "" { + uploader.On("UploadFile", context.TODO(), tc.filePath). + Return(&casclient.UpDownStatus{ + Digest: "deadbeef", + Filename: "test.xml", + }, nil) + } + + crafter, err := materials.NewJUnitXMLCrafter(schema, uploader, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + if tc.wantErr != "" { + assert.ErrorContains(err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(contractAPI.CraftingSchema_Material_JUNIT_XML.String(), got.MaterialType.String()) + assert.WithinDuration(time.Now(), got.AddedAt.AsTime(), 5*time.Second) + + // The result includes the digest reference + assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{ + Id: "test", Digest: "deadbeef", Name: "test.xml", + }) + }) + } +} diff --git a/internal/attestation/crafter/materials/materials.go b/internal/attestation/crafter/materials/materials.go index e46ec2875..ba3082743 100644 --- a/internal/attestation/crafter/materials/materials.go +++ b/internal/attestation/crafter/materials/materials.go @@ -18,11 +18,13 @@ package materials import ( "context" "fmt" + "time" api "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/internal/casclient" "github.com/rs/zerolog" + "google.golang.org/protobuf/types/known/timestamppb" ) // ErrInvalidMaterialType is returned when the provided material type @@ -34,6 +36,30 @@ type crafterCommon struct { input *schemaapi.CraftingSchema_Material } +// uploadAndCraft uploads the artifact to CAS and crafts the material +// this function is used by all the uploadable artifacts crafters (SBOMs, JUnit, and more in the future) +func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Material, uploader casclient.Uploader, artifactPath string) (*api.Attestation_Material, error) { + result, err := uploader.UploadFile(ctx, artifactPath) + if err != nil { + return nil, fmt.Errorf("uploading material: %w", err) + } + + res := &api.Attestation_Material{ + AddedAt: timestamppb.New(time.Now()), + MaterialType: input.Type, + M: &api.Attestation_Material_Artifact_{ + Artifact: &api.Attestation_Material_Artifact{ + Id: input.Name, + Name: result.Filename, + Digest: result.Digest, + IsSubject: input.Output, + }, + }, + } + + return res, nil +} + type Craftable interface { Craft(ctx context.Context, value string) (*api.Attestation_Material, error) } @@ -53,6 +79,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewCyclonedxJSONCrafter(materialSchema, uploader, logger) case schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON: crafter, err = NewSPDXJSONCrafter(materialSchema, uploader, logger) + case schemaapi.CraftingSchema_Material_JUNIT_XML: + crafter, err = NewJUnitXMLCrafter(materialSchema, uploader, logger) default: return nil, fmt.Errorf("material of type %q not supported yet", materialSchema.Type) } diff --git a/internal/attestation/crafter/materials/testdata/junit-invalid.xml b/internal/attestation/crafter/materials/testdata/junit-invalid.xml new file mode 100644 index 000000000..8aa990ea7 --- /dev/null +++ b/internal/attestation/crafter/materials/testdata/junit-invalid.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/attestation/crafter/materials/testdata/junit.xml b/internal/attestation/crafter/materials/testdata/junit.xml new file mode 100644 index 000000000..b63ff78c0 --- /dev/null +++ b/internal/attestation/crafter/materials/testdata/junit.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + +