Skip to content

Commit 65fe78d

Browse files
feat: enhance BOM read/write functions with format validation and add comprehensive tests
1 parent 60d4717 commit 65fe78d

File tree

2 files changed

+210
-14
lines changed

2 files changed

+210
-14
lines changed

internal/io/io.go

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,21 @@ func ReadBOM(path string, format string) (*cdx.BOM, error) {
2020
defer f.Close()
2121

2222
actual := strings.ToLower(strings.TrimSpace(format))
23-
if actual == "" || actual == "auto" {
24-
if strings.EqualFold(filepath.Ext(path), ".xml") {
23+
switch actual {
24+
case "", "auto":
25+
switch strings.ToLower(filepath.Ext(path)) {
26+
case ".xml":
2527
actual = "xml"
26-
} else {
28+
case ".json":
29+
actual = "json"
30+
default:
31+
// keep existing behavior: default to JSON when not .xml
2732
actual = "json"
2833
}
34+
case "json", "xml":
35+
// ok
36+
default:
37+
return nil, fmt.Errorf("unsupported BOM format: %q", format)
2938
}
3039

3140
fileFmt := cdx.BOMFileFormatJSON
@@ -50,25 +59,28 @@ func WriteBOM(bom *cdx.BOM, outputPath string, format string, spec string) error
5059
ext := filepath.Ext(outputPath)
5160

5261
actual := strings.ToLower(strings.TrimSpace(format))
53-
if actual == "" || actual == "auto" {
62+
switch actual {
63+
case "", "auto":
5464
if strings.EqualFold(ext, ".xml") {
5565
actual = "xml"
5666
} else {
5767
actual = "json"
5868
}
69+
case "json", "xml":
70+
// ok
71+
default:
72+
return fmt.Errorf("unsupported BOM format: %q", format)
5973
}
6074

6175
// Validate extension matches format
62-
if actual != "auto" {
63-
switch actual {
64-
case "xml":
65-
if ext != ".xml" {
66-
return fmt.Errorf("output path extension %q does not match format %q", ext, actual)
67-
}
68-
case "json":
69-
if ext != ".json" {
70-
return fmt.Errorf("output path extension %q does not match format %q", ext, actual)
71-
}
76+
switch actual {
77+
case "xml":
78+
if ext != ".xml" {
79+
return fmt.Errorf("output path extension %q does not match format %q", ext, actual)
80+
}
81+
case "json":
82+
if ext != ".json" {
83+
return fmt.Errorf("output path extension %q does not match format %q", ext, actual)
7284
}
7385
}
7486

@@ -99,6 +111,8 @@ func WriteBOM(bom *cdx.BOM, outputPath string, format string, spec string) error
99111

100112
// ParseSpecVersion parses a spec version string to a CycloneDX SpecVersion.
101113
func ParseSpecVersion(s string) (cdx.SpecVersion, bool) {
114+
s = strings.TrimSpace(s)
115+
102116
switch s {
103117
case "1.0":
104118
return cdx.SpecVersion1_0, true

internal/io/io_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package io
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
cdx "github.com/CycloneDX/cyclonedx-go"
9+
)
10+
11+
func minimalBOM() *cdx.BOM {
12+
bom := cdx.NewBOM()
13+
bom.SpecVersion = cdx.SpecVersion1_6
14+
bom.Metadata = &cdx.Metadata{
15+
Component: &cdx.Component{
16+
Name: "test-model",
17+
},
18+
}
19+
return bom
20+
}
21+
22+
func TestParseSpecVersion_AllCases(t *testing.T) {
23+
tcs := []struct {
24+
in string
25+
want cdx.SpecVersion
26+
ok bool
27+
}{
28+
{"1.0", cdx.SpecVersion1_0, true},
29+
{"1.1", cdx.SpecVersion1_1, true},
30+
{"1.2", cdx.SpecVersion1_2, true},
31+
{"1.3", cdx.SpecVersion1_3, true},
32+
{"1.4", cdx.SpecVersion1_4, true},
33+
{"1.5", cdx.SpecVersion1_5, true},
34+
{"1.6", cdx.SpecVersion1_6, true},
35+
{"2.0", cdx.SpecVersion1_6, false}, // default branch
36+
{" 1.6 ", cdx.SpecVersion1_6, true}, // now trimmed in ParseSpecVersion
37+
{"", cdx.SpecVersion1_6, false},
38+
{"1", cdx.SpecVersion1_6, false},
39+
{"1.7", cdx.SpecVersion1_6, false},
40+
{"nope", cdx.SpecVersion1_6, false},
41+
}
42+
43+
for _, tc := range tcs {
44+
got, ok := ParseSpecVersion(tc.in)
45+
if got != tc.want || ok != tc.ok {
46+
t.Fatalf("ParseSpecVersion(%q) = (%v,%v), want (%v,%v)", tc.in, got, ok, tc.want, tc.ok)
47+
}
48+
}
49+
}
50+
51+
func TestReadBOM_OpenError(t *testing.T) {
52+
_, err := ReadBOM(filepath.Join(t.TempDir(), "missing.json"), "auto")
53+
if err == nil {
54+
t.Fatalf("expected error for missing file")
55+
}
56+
}
57+
58+
func TestReadBOM_DecodeError_WhenFormatDoesNotMatchContent(t *testing.T) {
59+
dir := t.TempDir()
60+
p := filepath.Join(dir, "bom.json")
61+
if err := os.WriteFile(p, []byte(`{}`), 0o600); err != nil {
62+
t.Fatalf("WriteFile: %v", err)
63+
}
64+
65+
_, err := ReadBOM(p, "xml")
66+
if err == nil {
67+
t.Fatalf("expected decode error when reading JSON as XML")
68+
}
69+
}
70+
71+
func TestReadBOM_DecodeError_InvalidJSON(t *testing.T) {
72+
dir := t.TempDir()
73+
p := filepath.Join(dir, "bom.json")
74+
if err := os.WriteFile(p, []byte(`{`), 0o600); err != nil {
75+
t.Fatalf("WriteFile: %v", err)
76+
}
77+
78+
_, err := ReadBOM(p, "json")
79+
if err == nil {
80+
t.Fatalf("expected decode error for invalid JSON")
81+
}
82+
}
83+
84+
func TestWriteBOM_JSON_Auto_SpecEmpty_RoundTrip(t *testing.T) {
85+
dir := t.TempDir()
86+
out := filepath.Join(dir, "bom.json")
87+
88+
if err := WriteBOM(minimalBOM(), out, "auto", ""); err != nil {
89+
t.Fatalf("WriteBOM: %v", err)
90+
}
91+
92+
got, err := ReadBOM(out, " JSON ")
93+
if err != nil {
94+
t.Fatalf("ReadBOM: %v", err)
95+
}
96+
if got == nil || got.Metadata == nil || got.Metadata.Component == nil || got.Metadata.Component.Name != "test-model" {
97+
t.Fatalf("roundtrip BOM missing expected metadata.component.name")
98+
}
99+
}
100+
101+
func TestWriteBOM_JSON_Explicit_SpecVersion_RoundTrip(t *testing.T) {
102+
dir := t.TempDir()
103+
out := filepath.Join(dir, "bom.json")
104+
105+
if err := WriteBOM(minimalBOM(), out, " json ", "1.6"); err != nil {
106+
t.Fatalf("WriteBOM: %v", err)
107+
}
108+
109+
got, err := ReadBOM(out, "json")
110+
if err != nil {
111+
t.Fatalf("ReadBOM: %v", err)
112+
}
113+
if got.SpecVersion == 0 {
114+
t.Fatalf("expected specVersion to be set after decode")
115+
}
116+
}
117+
118+
func TestWriteBOM_XML_Explicit_RoundTrip_AndReadAuto(t *testing.T) {
119+
dir := t.TempDir()
120+
out := filepath.Join(dir, "bom.xml")
121+
122+
if err := WriteBOM(minimalBOM(), out, "xml", ""); err != nil {
123+
t.Fatalf("WriteBOM: %v", err)
124+
}
125+
126+
got, err := ReadBOM(out, "")
127+
if err != nil {
128+
t.Fatalf("ReadBOM: %v", err)
129+
}
130+
if got == nil {
131+
t.Fatalf("expected BOM")
132+
}
133+
}
134+
135+
func TestWriteBOM_XML_Auto_SelectsByExtension_RoundTrip(t *testing.T) {
136+
dir := t.TempDir()
137+
out := filepath.Join(dir, "bom.xml")
138+
139+
if err := WriteBOM(minimalBOM(), out, "auto", ""); err != nil {
140+
t.Fatalf("WriteBOM: %v", err)
141+
}
142+
143+
got, err := ReadBOM(out, "xml")
144+
if err != nil {
145+
t.Fatalf("ReadBOM: %v", err)
146+
}
147+
if got == nil || got.Metadata == nil || got.Metadata.Component == nil || got.Metadata.Component.Name != "test-model" {
148+
t.Fatalf("roundtrip BOM missing expected metadata.component.name")
149+
}
150+
}
151+
152+
func TestReadBOM_Auto_NoExtension_DefaultsToJSONByContent(t *testing.T) {
153+
dir := t.TempDir()
154+
p := filepath.Join(dir, "bom") // no extension
155+
156+
// Looks like JSON => current impl appears to treat this as JSON in "auto"/"" mode.
157+
if err := os.WriteFile(p, []byte(`{}`), 0o600); err != nil {
158+
t.Fatalf("WriteFile: %v", err)
159+
}
160+
161+
got, err := ReadBOM(p, "") // "" behaves like auto in this package
162+
if err != nil {
163+
t.Fatalf("expected no error when auto-reading JSON content without extension, got: %v", err)
164+
}
165+
if got == nil {
166+
t.Fatalf("expected BOM")
167+
}
168+
}
169+
170+
func TestWriteBOM_UnsupportedFormat(t *testing.T) {
171+
dir := t.TempDir()
172+
p := filepath.Join(dir, "bom.json")
173+
174+
if err := WriteBOM(minimalBOM(), p, "json", ""); err != nil {
175+
t.Fatalf("WriteBOM: %v", err)
176+
}
177+
178+
_, err := ReadBOM(p, "yaml")
179+
if err == nil {
180+
t.Fatalf("expected error for unsupported format")
181+
}
182+
}

0 commit comments

Comments
 (0)