Skip to content

Commit f9e224a

Browse files
authored
feat(materials): support zipped junit XML files (#1640)
Signed-off-by: Jose I. Paris <[email protected]>
1 parent ecd2e2f commit f9e224a

File tree

5 files changed

+141
-17
lines changed

5 files changed

+141
-17
lines changed

pkg/attestation/crafter/api/attestation/v1/crafting_state.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import (
2626

2727
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2828
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/jacoco"
29+
materialsjunit "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/junit"
2930
intoto "github.com/in-toto/attestation/go/v1"
30-
"github.com/joshdk/go-junit"
3131
"github.com/secure-systems-lab/go-securesystemslib/dsse"
3232
"google.golang.org/protobuf/types/known/structpb"
3333
)
@@ -85,7 +85,8 @@ func (m *Attestation_Material) GetEvaluableContent(value string) ([]byte, error)
8585
rawMaterial = m.GetArtifact().GetContent()
8686
} else if value == "" {
8787
return nil, errors.New("artifact path required")
88-
} else if m.MaterialType != v1.CraftingSchema_Material_HELM_CHART {
88+
} else if m.MaterialType != v1.CraftingSchema_Material_HELM_CHART &&
89+
m.MaterialType != v1.CraftingSchema_Material_JUNIT_XML {
8990
// read content from local filesystem (except for tgz charts)
9091
rawMaterial, err = os.ReadFile(value)
9192
if err != nil {
@@ -110,7 +111,7 @@ func (m *Attestation_Material) GetEvaluableContent(value string) ([]byte, error)
110111
// For XML based materials, we need to ingest them and read as json-like structure
111112
switch m.MaterialType {
112113
case v1.CraftingSchema_Material_JUNIT_XML:
113-
suites, err := junit.Ingest(rawMaterial)
114+
suites, err := materialsjunit.Ingest(value)
114115
if err != nil {
115116
return nil, fmt.Errorf("failed to ingest junit xml: %w", err)
116117
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// Copyright 2024 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package junit
17+
18+
import (
19+
"archive/zip"
20+
"bufio"
21+
"errors"
22+
"fmt"
23+
"io"
24+
"io/fs"
25+
"net/http"
26+
"os"
27+
"path/filepath"
28+
"strings"
29+
30+
"github.com/joshdk/go-junit"
31+
)
32+
33+
func Ingest(filePath string) ([]junit.Suite, error) {
34+
var suites []junit.Suite
35+
36+
// read first chunk
37+
f, err := os.Open(filePath)
38+
if err != nil {
39+
return nil, fmt.Errorf("opening file %q: %w", filePath, err)
40+
}
41+
r := bufio.NewReader(f)
42+
43+
buf := make([]byte, 512)
44+
_, err = io.ReadFull(r, buf)
45+
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
46+
return nil, fmt.Errorf("reading file %q: %w", filePath, err)
47+
}
48+
_ = f.Close()
49+
50+
// check if it's a zip file and try to ingest all its contents
51+
mime := http.DetectContentType(buf)
52+
switch strings.Split(mime, ";")[0] {
53+
case "application/zip":
54+
suites, err = ingestArchive(filePath)
55+
if err != nil {
56+
return nil, fmt.Errorf("could not ingest JUnit XML: %w", err)
57+
}
58+
case "text/xml", "application/xml":
59+
suites, err = junit.IngestFile(filePath)
60+
if err != nil {
61+
if errors.Is(err, fs.ErrNotExist) {
62+
return nil, fmt.Errorf("invalid file path: %w", err)
63+
}
64+
return nil, fmt.Errorf("invalid JUnit XML file: %w", err)
65+
}
66+
default:
67+
return nil, fmt.Errorf("invalid JUnit XML file: %s", filePath)
68+
}
69+
70+
return suites, nil
71+
}
72+
73+
func ingestArchive(filename string) ([]junit.Suite, error) {
74+
archive, err := zip.OpenReader(filename)
75+
if err != nil {
76+
return nil, fmt.Errorf("could not open zip archive: %w", err)
77+
}
78+
defer archive.Close()
79+
dir, err := os.MkdirTemp("", "junit")
80+
if err != nil {
81+
return nil, fmt.Errorf("could not create temporary directory: %w", err)
82+
}
83+
for _, zf := range archive.File {
84+
if zf.FileInfo().IsDir() {
85+
continue
86+
}
87+
// extract file to dir
88+
// nolint: gosec
89+
path := filepath.Join(dir, zf.Name)
90+
91+
// Check for ZipSlip (Directory traversal)
92+
if !strings.HasPrefix(path, filepath.Clean(dir)+string(os.PathSeparator)) {
93+
return nil, fmt.Errorf("illegal file path: %s", path)
94+
}
95+
96+
f, err := os.Create(path)
97+
if err != nil {
98+
return nil, fmt.Errorf("could not open file %s: %w", path, err)
99+
}
100+
101+
rc, err := zf.Open()
102+
if err != nil {
103+
return nil, fmt.Errorf("could not open file %s: %w", path, err)
104+
}
105+
106+
_, err = f.ReadFrom(rc)
107+
if err != nil {
108+
return nil, fmt.Errorf("could not read file %s: %w", path, err)
109+
}
110+
111+
rc.Close()
112+
f.Close()
113+
}
114+
115+
suites, err := junit.IngestDir(dir)
116+
if err != nil {
117+
return nil, fmt.Errorf("could not ingest JUnit XML: %w", err)
118+
}
119+
120+
return suites, nil
121+
}

pkg/attestation/crafter/materials/junit_xml.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,12 @@ package materials
1717

1818
import (
1919
"context"
20-
"errors"
2120
"fmt"
22-
"io/fs"
2321

2422
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2523
"github.com/chainloop-dev/chainloop/internal/casclient"
2624
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
27-
junit "github.com/joshdk/go-junit"
25+
materialsjunit "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/junit"
2826
"github.com/rs/zerolog"
2927
)
3028

@@ -50,13 +48,9 @@ func (i *JUnitXMLCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
5048
}
5149

5250
func (i *JUnitXMLCrafter) validate(filePath string) error {
53-
suites, err := junit.IngestFile(filePath)
51+
suites, err := materialsjunit.Ingest(filePath)
5452
if err != nil {
55-
if errors.Is(err, fs.ErrNotExist) {
56-
return fmt.Errorf("invalid file path: %w", err)
57-
}
58-
i.logger.Debug().Err(err).Msgf("error decoding file: %s", filePath)
59-
return fmt.Errorf("invalid JUnit XML file: %w", ErrInvalidMaterialType)
53+
return fmt.Errorf("failed to ingest JUnit XML: %w", err)
6054
}
6155

6256
if len(suites) == 0 {

pkg/attestation/crafter/materials/junit_xml_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package materials_test
1818

1919
import (
2020
"context"
21+
"path/filepath"
2122
"testing"
2223

2324
contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
@@ -69,6 +70,7 @@ func TestJUnitXMLCraft(t *testing.T) {
6970
name string
7071
filePath string
7172
wantErr string
73+
digest string
7274
}{
7375
{
7476
name: "invalid path",
@@ -78,16 +80,22 @@ func TestJUnitXMLCraft(t *testing.T) {
7880
{
7981
name: "invalid artifact type",
8082
filePath: "./testdata/simple.txt",
81-
wantErr: "unexpected material type",
83+
wantErr: "invalid JUnit XML file",
8284
},
8385
{
8486
name: "invalid artifact type",
8587
filePath: "./testdata/junit-invalid.xml",
86-
wantErr: "unexpected material type",
88+
wantErr: "invalid JUnit XML file",
8789
},
8890
{
8991
name: "valid artifact type",
9092
filePath: "./testdata/junit.xml",
93+
digest: "sha256:e9c941b25c06d8bd98205122cbc827504c6d03d37b7f4afd7ed03b3eeec789e2",
94+
},
95+
{
96+
name: "valid artifact type zip",
97+
filePath: "./testdata/tests.zip",
98+
digest: "sha256:1b00f89a6f23f2e99c207ce90e11dcd41ac4e754d44e81f50f295e858692e96b",
9199
},
92100
}
93101

@@ -125,9 +133,9 @@ func TestJUnitXMLCraft(t *testing.T) {
125133
assert.True(got.UploadedToCas)
126134

127135
// The result includes the digest reference
128-
assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{
129-
Id: "test", Digest: "sha256:e9c941b25c06d8bd98205122cbc827504c6d03d37b7f4afd7ed03b3eeec789e2", Name: "junit.xml",
130-
})
136+
assert.Equal(&attestationApi.Attestation_Material_Artifact{
137+
Id: "test", Digest: tc.digest, Name: filepath.Base(tc.filePath),
138+
}, got.GetArtifact())
131139
})
132140
}
133141
}
35.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)