Skip to content

Commit d75df92

Browse files
committed
fix(cli): reject invalid CSV encodings and strengthen header tests
1 parent 41d1024 commit d75df92

2 files changed

Lines changed: 88 additions & 39 deletions

File tree

‎cmd/multi_service_csv.go‎

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strconv"
1313
"strings"
1414
"time"
15+
"unicode/utf8"
1516

1617
"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
1718
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations"
@@ -47,12 +48,10 @@ func loadRecommendationsFromCSV(csvPath string) ([]common.Recommendation, error)
4748
if err != nil {
4849
return nil, fmt.Errorf("failed to read CSV header: %w", err)
4950
}
51+
if err = validateCSVUTF8(header); err != nil {
52+
return nil, fmt.Errorf("CSV header: %w", err)
53+
}
5054

51-
// Build column index map, rejecting headers that lack the columns a
52-
// row cannot be meaningful without (#1327). Before this check a header
53-
// the parser did not recognize (e.g. "Instance Type" / "Instance Count")
54-
// decoded every row to an empty service and was only caught by the
55-
// Count cell validation, if at all.
5655
colIdx, err := buildColumnIndexMap(header)
5756
if err != nil {
5857
return nil, err
@@ -67,16 +66,18 @@ func loadRecommendationsFromCSV(csvPath string) ([]common.Recommendation, error)
6766
return parsed, nil
6867
}
6968

70-
// requiredCSVColumns are the header columns a recommendation row cannot be
71-
// meaningful without. Term, PaymentOption, Account, Engine and the derived
72-
// columns stay optional: minimal CSVs and Savings Plans rows legitimately
73-
// omit them, and their values are validated downstream where they are used.
69+
// Minimal CSVs require these columns; service-specific columns stay optional.
7470
var requiredCSVColumns = []string{"Service", "Region", "ResourceType", "Count"}
7571

76-
// buildColumnIndexMap creates a map from column names to indices, failing
77-
// loudly when a required column is missing. A UTF-8 BOM on the first header
78-
// cell is stripped so Excel-exported CSVs parse; other encodings (UTF-16,
79-
// Latin-1) still fail the required-column check with a clear error.
72+
func validateCSVUTF8(fields []string) error {
73+
for i, field := range fields {
74+
if !utf8.ValidString(field) {
75+
return fmt.Errorf("column %d: invalid UTF-8 encoding", i+1)
76+
}
77+
}
78+
return nil
79+
}
80+
8081
func buildColumnIndexMap(header []string) (map[string]int, error) {
8182
if len(header) > 0 {
8283
header[0] = strings.TrimPrefix(header[0], "\ufeff")
@@ -109,6 +110,10 @@ func parseCSVRecords(reader *csv.Reader, colIdx map[string]int) ([]common.Recomm
109110
if err != nil {
110111
return nil, fmt.Errorf("failed to read CSV record: %w", err)
111112
}
113+
if err = validateCSVUTF8(record); err != nil {
114+
line, _ := reader.FieldPos(0)
115+
return nil, fmt.Errorf("CSV line %d: %w", line, err)
116+
}
112117

113118
// Skip the trailing TOTAL summary row that writeMultiServiceCSVReport
114119
// emits (label in the Service column). Without this, feeding the tool
@@ -193,12 +198,7 @@ func getCSVField(record []string, colIdx map[string]int, fieldName string) strin
193198
return ""
194199
}
195200

196-
// parseCSVCount parses the required Count field as a whole positive
197-
// integer. It drives purchase quantities, so a blank, missing, zero,
198-
// fractional or otherwise malformed cell is an error rather than a
199-
// truncated or zero value. Zero in particular bought nothing but still
200-
// triggered a live purchase call (#2116); the tool's own CSVs never
201-
// emit 0 because the Savings Plans client sets Count to 1.
201+
// Savings Plans exports use Count=1, preserving positive-count round trips.
202202
func parseCSVCount(record []string, colIdx map[string]int, target *int) error {
203203
const fieldName = "Count"
204204
idx, ok := colIdx[fieldName]
@@ -220,11 +220,7 @@ func parseCSVCount(record []string, colIdx map[string]int, target *int) error {
220220
return nil
221221
}
222222

223-
// parseCSVFloat parses a finite, non-negative float field from a CSV
224-
// record. A blank or absent cell leaves target untouched (see
225-
// requireRankingSignal). Negative values are rejected: EstimatedSavings
226-
// is the only caller, and a negative savings figure is a malformed row,
227-
// not a signal to buy (#2116).
223+
// Blank savings stay absent-as-zero for requireRankingSignal.
228224
func parseCSVFloat(record []string, colIdx map[string]int, fieldName string, target *float64) error {
229225
value := strings.TrimSpace(getCSVField(record, colIdx, fieldName))
230226
if value == "" {

‎cmd/multi_service_csv_header_test.go‎

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,110 @@
11
package main
22

33
import (
4+
"encoding/binary"
45
"testing"
6+
"unicode/utf16"
57

8+
"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
69
"github.com/stretchr/testify/assert"
710
"github.com/stretchr/testify/require"
811
)
912

10-
// Header validation (#1327): before this, a header the parser did not
11-
// recognize silently decoded every row to an empty Service/Region/
12-
// ResourceType, and only the Count cell check (added in #1944) caught
13-
// anything at all, with a misleading "missing Count column" message when
14-
// the real problem was a wrong header. The run must fail loudly up front
15-
// and name the missing columns.
1613
func TestLoadRecommendationsFromCSV_HeaderValidation_1327(t *testing.T) {
1714
tests := []struct {
1815
name string
1916
header string
17+
row string
2018
errContains string
2119
}{
22-
{"missing Service", "Region,ResourceType,Count\n", "Service"},
23-
{"missing Region", "Service,ResourceType,Count\n", "Region"},
24-
{"missing ResourceType", "Service,Region,Count\n", "ResourceType"},
25-
{"missing Count", "Service,Region,ResourceType\n", "Count"},
26-
{"multiple missing", "Service,Count\n", "Region, ResourceType"},
20+
{"missing Service", "Region,ResourceType,Count\n", "us-east-1,db.t3.micro,2\n", "Service"},
21+
{"missing Region", "Service,ResourceType,Count\n", "rds,db.t3.micro,2\n", "Region"},
22+
{"missing ResourceType", "Service,Region,Count\n", "rds,us-east-1,2\n", "ResourceType"},
23+
{"missing Count", "Service,Region,ResourceType\n", "rds,us-east-1,db.t3.micro\n", "Count"},
24+
{"multiple missing", "Service,Count\n", "rds,2\n", "Region, ResourceType"},
2725
{
2826
"legacy TEST-02 headers rejected",
2927
"Service,Region,Instance Type,Instance Count\n",
28+
"rds,us-east-1,db.t3.micro,2\n",
3029
"ResourceType, Count",
3130
},
3231
}
3332
for _, tt := range tests {
3433
t.Run(tt.name, func(t *testing.T) {
35-
err := loadCSVContent(t, tt.header+"rds,us-east-1,db.t3.micro,2\n")
34+
err := loadCSVContent(t, tt.header+tt.row)
3635
require.Error(t, err)
3736
assert.Contains(t, err.Error(), "CSV header missing required columns")
3837
assert.Contains(t, err.Error(), tt.errContains)
3938
})
4039
}
4140
}
4241

43-
// A UTF-8 BOM is stripped before header matching so Excel-exported CSVs
44-
// parse instead of failing the required-column check on the BOM-prefixed
45-
// first header cell.
4642
func TestLoadRecommendationsFromCSV_BOMPrefixed_1327(t *testing.T) {
4743
recs, err := loadRecommendationsFromCSV(writeTestRecommendationsCSV(t,
4844
"\ufeffService,Region,ResourceType,Count\nrds,us-east-1,db.t3.micro,2\n"))
4945
require.NoError(t, err)
5046
require.Len(t, recs, 1)
47+
assert.Equal(t, common.ServiceRDS, recs[0].Service)
5148
assert.Equal(t, "us-east-1", recs[0].Region)
5249
assert.Equal(t, 2, recs[0].Count)
5350
}
5451

52+
func TestLoadRecommendationsFromCSV_Encoding_1327(t *testing.T) {
53+
t.Run("UTF-16", func(t *testing.T) {
54+
units := utf16.Encode([]rune("Service,Region,ResourceType,Count\nrds,us-east-1,db.t3.micro,2\n"))
55+
for _, tt := range []struct {
56+
name string
57+
bom []byte
58+
order binary.ByteOrder
59+
}{
60+
{"little endian", []byte{0xff, 0xfe}, binary.LittleEndian},
61+
{"big endian", []byte{0xfe, 0xff}, binary.BigEndian},
62+
} {
63+
t.Run(tt.name, func(t *testing.T) {
64+
encoded := make([]byte, 2+2*len(units))
65+
copy(encoded, tt.bom)
66+
for i, unit := range units {
67+
tt.order.PutUint16(encoded[2+2*i:], unit)
68+
}
69+
err := loadCSVContent(t, string(encoded))
70+
require.Error(t, err)
71+
assert.EqualError(t, err, "CSV header: column 1: invalid UTF-8 encoding")
72+
})
73+
}
74+
})
75+
76+
t.Run("Latin-1 optional header", func(t *testing.T) {
77+
err := loadCSVContent(t, "Service,Region,ResourceType,Count,Caf\xe9\n"+
78+
"rds,us-east-1,db.t3.micro,2,prod\n")
79+
require.Error(t, err)
80+
assert.EqualError(t, err, "CSV header: column 5: invalid UTF-8 encoding")
81+
})
82+
83+
t.Run("Latin-1 AccountName", func(t *testing.T) {
84+
err := loadCSVContent(t, "Service,Region,ResourceType,Count,AccountName\n"+
85+
"rds,us-east-1,db.t3.micro,2,Caf\xe9\n")
86+
require.Error(t, err)
87+
assert.EqualError(t, err, "CSV line 2: column 5: invalid UTF-8 encoding")
88+
})
89+
90+
t.Run("invalid ignored field in TOTAL after multiline record", func(t *testing.T) {
91+
err := loadCSVContent(t, "Service,Region,ResourceType,Count,Ignored\n"+
92+
"rds,us-east-1,db.t3.micro,2,\"prod\naccount\"\n"+
93+
"TOTAL,,,2,\xff\n")
94+
require.Error(t, err)
95+
assert.EqualError(t, err, "CSV line 4: column 5: invalid UTF-8 encoding")
96+
})
97+
98+
t.Run("valid non-ASCII UTF-8", func(t *testing.T) {
99+
recs, err := loadRecommendationsFromCSV(writeTestRecommendationsCSV(t,
100+
"Service,Region,ResourceType,Count,AccountName\n"+
101+
"rds,us-east-1,db.t3.micro,2,Café 東京\n"))
102+
require.NoError(t, err)
103+
require.Len(t, recs, 1)
104+
assert.Equal(t, "Café 東京", recs[0].AccountName)
105+
})
106+
}
107+
55108
// Lock in encoding/csv behaviors the purchase path relies on.
56109
func TestLoadRecommendationsFromCSV_EdgeCases_1327(t *testing.T) {
57110
t.Run("CRLF line endings", func(t *testing.T) {

0 commit comments

Comments
 (0)