Skip to content

Commit 154d24c

Browse files
committed
feat(json): snake case support option
1 parent 582b0ca commit 154d24c

File tree

6 files changed

+789
-0
lines changed

6 files changed

+789
-0
lines changed

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ func init() {
115115
// Pretty-print json
116116
rootCmd.Flags().BoolVar(&config.OutputOptions.JSONOptions.PrettyPrint, "json-pretty", true, "--json-pretty=false (pretty prints JSON output)")
117117

118+
// Snake case json keys
119+
rootCmd.Flags().BoolVar(&config.OutputOptions.JSONOptions.SnakeCase, "json-snake-case", false, "--json-snake-case=true (converts JSON keys to snake_case)")
120+
118121
// Configs related to SQLite
119122
rootCmd.Flags().StringVar(&config.OutputOptions.SqliteOutputOptions.DSN, "sqlite-dsn", "nmap.sqlite", "--sqlite-dsn nmap.sqlite")
120123
rootCmd.Flags().StringVar(&config.OutputOptions.SqliteOutputOptions.ScanIdentifier, "scan-id", "", "--scan-id abc123")

formatter/formatter_json.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ type JSONFormatter struct {
1212

1313
// Format the data and output it to appropriate io.Writer
1414
func (f *JSONFormatter) Format(td *TemplateData, templateContent string) (err error) {
15+
// Use snake_case encoder if requested
16+
if td.OutputOptions.JSONOptions.SnakeCase {
17+
encoder := newSnakeCaseEncoder(f.config.Writer, td.OutputOptions.JSONOptions.PrettyPrint)
18+
return encoder.Encode(td.NMAPRun)
19+
}
20+
21+
// Default JSON encoding
1522
jsonData := new(bytes.Buffer)
1623
encoder := json.NewEncoder(jsonData)
1724
if td.OutputOptions.JSONOptions.PrettyPrint {

formatter/json_snake_case.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package formatter
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"unicode"
8+
)
9+
10+
// snakeCaseEncoder wraps a standard JSON encoder and converts keys to snake_case
11+
type snakeCaseEncoder struct {
12+
writer io.Writer
13+
indent string
14+
prettyPrint bool
15+
someIdHereHttp bool
16+
}
17+
18+
// newSnakeCaseEncoder creates a new encoder that converts JSON keys to snake_case
19+
func newSnakeCaseEncoder(w io.Writer, prettyPrint bool) *snakeCaseEncoder {
20+
indent := ""
21+
if prettyPrint {
22+
indent = " "
23+
}
24+
return &snakeCaseEncoder{
25+
writer: w,
26+
indent: indent,
27+
prettyPrint: prettyPrint,
28+
}
29+
}
30+
31+
// Encode encodes the value to JSON with snake_case keys
32+
func (e *snakeCaseEncoder) Encode(v interface{}) error {
33+
// First, encode normally to a buffer
34+
buf := new(bytes.Buffer)
35+
encoder := json.NewEncoder(buf)
36+
if e.prettyPrint {
37+
encoder.SetIndent("", e.indent)
38+
}
39+
if err := encoder.Encode(v); err != nil {
40+
return err
41+
}
42+
43+
// Convert keys to snake_case
44+
converted := convertKeysToSnakeCase(buf.Bytes())
45+
46+
// Write to the actual writer
47+
_, err := e.writer.Write(converted)
48+
return err
49+
}
50+
51+
// toSnakeCase converts a CamelCase string to snake_case
52+
// This is optimized for performance - it processes the string in a single pass
53+
func toSnakeCase(s string) string {
54+
if s == "" {
55+
return s
56+
}
57+
58+
// Pre-allocate buffer with estimated size (original length + 30% for underscores)
59+
var buf bytes.Buffer
60+
buf.Grow(len(s) + len(s)/3)
61+
62+
var prevLower bool
63+
var prevUnderscore bool
64+
65+
for i, r := range s {
66+
if unicode.IsUpper(r) {
67+
// Add underscore before uppercase if:
68+
// 1. Not the first character
69+
// 2. Previous character was lowercase
70+
// 3. Previous character wasn't already an underscore
71+
if i > 0 && prevLower && !prevUnderscore {
72+
buf.WriteByte('_')
73+
}
74+
buf.WriteRune(unicode.ToLower(r))
75+
prevLower = false
76+
prevUnderscore = false
77+
} else if r == '_' {
78+
buf.WriteRune(r)
79+
prevLower = false
80+
prevUnderscore = true
81+
} else {
82+
buf.WriteRune(r)
83+
prevLower = true
84+
prevUnderscore = false
85+
}
86+
}
87+
88+
return buf.String()
89+
}
90+
91+
// convertKeysToSnakeCase converts all JSON keys in the byte slice to snake_case
92+
// This processes the JSON in a streaming fashion for better performance
93+
func convertKeysToSnakeCase(data []byte) []byte {
94+
result := make([]byte, 0, len(data))
95+
inString := false
96+
escaped := false
97+
keyStart := 0
98+
99+
for i := 0; i < len(data); i++ {
100+
b := data[i]
101+
102+
if escaped {
103+
result = append(result, b)
104+
escaped = false
105+
continue
106+
}
107+
108+
switch b {
109+
case '\\':
110+
if inString {
111+
escaped = true
112+
}
113+
result = append(result, b)
114+
115+
case '"':
116+
if !inString {
117+
// Starting a potential key
118+
inString = true
119+
result = append(result, b)
120+
keyStart = len(result)
121+
} else {
122+
// Ending a string
123+
inString = false
124+
125+
// Check if this was a key (followed by colon after whitespace)
126+
isKey := false
127+
j := i + 1
128+
for j < len(data) && (data[j] == ' ' || data[j] == '\t' || data[j] == '\n' || data[j] == '\r') {
129+
j++
130+
}
131+
if j < len(data) && data[j] == ':' {
132+
isKey = true
133+
}
134+
135+
if isKey {
136+
// Extract the key and convert it
137+
key := result[keyStart:]
138+
snakeKey := toSnakeCase(string(key))
139+
result = result[:keyStart]
140+
result = append(result, []byte(snakeKey)...)
141+
}
142+
143+
result = append(result, b)
144+
}
145+
146+
default:
147+
result = append(result, b)
148+
}
149+
}
150+
151+
return result
152+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package formatter
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
)
7+
8+
// closeableBuffer wraps bytes.Buffer to implement io.WriteCloser
9+
type closeableBuffer struct {
10+
*bytes.Buffer
11+
}
12+
13+
func (cb *closeableBuffer) Close() error {
14+
return nil
15+
}
16+
17+
// Create a realistic NMAP run for benchmarking
18+
func createBenchmarkNMAPRun() *NMAPRun {
19+
return &NMAPRun{
20+
Scanner: "nmap",
21+
Args: "-sV -sC -p- -T4",
22+
Start: 1234567890,
23+
StartStr: "2009-02-13 23:31:30 UTC",
24+
Version: "7.80",
25+
ScanInfo: ScanInfo{
26+
Type: "syn",
27+
Protocol: "tcp",
28+
NumServices: 1000,
29+
Services: "1-1000",
30+
},
31+
Host: []Host{
32+
{
33+
StartTime: 1234567890,
34+
EndTime: 1234567900,
35+
HostAddress: []HostAddress{
36+
{Address: "192.168.1.1", AddressType: "ipv4"},
37+
{Address: "00:11:22:33:44:55", AddressType: "mac", Vendor: "Vendor Inc"},
38+
},
39+
HostNames: HostNames{
40+
HostName: []HostName{
41+
{Name: "example.com", Type: "PTR"},
42+
},
43+
},
44+
Status: HostStatus{State: "up", Reason: "echo-reply"},
45+
Port: []Port{
46+
{PortID: 22, Protocol: "tcp", State: PortState{State: "open", Reason: "syn-ack"}, Service: PortService{Name: "ssh", Product: "OpenSSH", Version: "8.0"}},
47+
{PortID: 80, Protocol: "tcp", State: PortState{State: "open", Reason: "syn-ack"}, Service: PortService{Name: "http", Product: "nginx", Version: "1.18"}},
48+
{PortID: 443, Protocol: "tcp", State: PortState{State: "open", Reason: "syn-ack"}, Service: PortService{Name: "https", Product: "nginx", Version: "1.18"}},
49+
},
50+
OS: OS{
51+
OSMatch: []OSMatch{
52+
{Name: "Linux 4.15 - 5.6", Accuracy: "95"},
53+
},
54+
},
55+
},
56+
{
57+
StartTime: 1234567890,
58+
EndTime: 1234567905,
59+
HostAddress: []HostAddress{
60+
{Address: "192.168.1.2", AddressType: "ipv4"},
61+
},
62+
Status: HostStatus{State: "up", Reason: "echo-reply"},
63+
Port: []Port{
64+
{PortID: 3306, Protocol: "tcp", State: PortState{State: "open", Reason: "syn-ack"}, Service: PortService{Name: "mysql", Product: "MySQL", Version: "5.7.30"}},
65+
{PortID: 8080, Protocol: "tcp", State: PortState{State: "open", Reason: "syn-ack"}, Service: PortService{Name: "http-proxy", Product: "Squid", Version: "4.10"}},
66+
},
67+
},
68+
},
69+
Verbose: Verbose{Level: 1},
70+
Debugging: Debugging{Level: 0},
71+
RunStats: RunStats{
72+
Finished: Finished{
73+
Time: 1234567900,
74+
TimeStr: "2009-02-13 23:31:40 UTC",
75+
Elapsed: 10.5,
76+
Summary: "Nmap done: 2 IP addresses (2 hosts up) scanned in 10.50 seconds",
77+
Exit: "success",
78+
},
79+
Hosts: StatHosts{Up: 2, Down: 0, Total: 2},
80+
},
81+
}
82+
}
83+
84+
func BenchmarkJSONFormatter_Default(b *testing.B) {
85+
nmapRun := createBenchmarkNMAPRun()
86+
td := &TemplateData{
87+
NMAPRun: *nmapRun,
88+
OutputOptions: OutputOptions{
89+
JSONOptions: JSONOutputOptions{
90+
PrettyPrint: false,
91+
SnakeCase: false,
92+
},
93+
},
94+
}
95+
96+
b.ResetTimer()
97+
for i := 0; i < b.N; i++ {
98+
buf := &closeableBuffer{Buffer: new(bytes.Buffer)}
99+
formatter := &JSONFormatter{
100+
&Config{Writer: buf},
101+
}
102+
if err := formatter.Format(td, ""); err != nil {
103+
b.Fatal(err)
104+
}
105+
}
106+
}
107+
108+
func BenchmarkJSONFormatter_SnakeCase(b *testing.B) {
109+
nmapRun := createBenchmarkNMAPRun()
110+
td := &TemplateData{
111+
NMAPRun: *nmapRun,
112+
OutputOptions: OutputOptions{
113+
JSONOptions: JSONOutputOptions{
114+
PrettyPrint: false,
115+
SnakeCase: true,
116+
},
117+
},
118+
}
119+
120+
b.ResetTimer()
121+
for i := 0; i < b.N; i++ {
122+
buf := &closeableBuffer{Buffer: new(bytes.Buffer)}
123+
formatter := &JSONFormatter{
124+
&Config{Writer: buf},
125+
}
126+
if err := formatter.Format(td, ""); err != nil {
127+
b.Fatal(err)
128+
}
129+
}
130+
}
131+
132+
func BenchmarkJSONFormatter_PrettyPrint(b *testing.B) {
133+
nmapRun := createBenchmarkNMAPRun()
134+
td := &TemplateData{
135+
NMAPRun: *nmapRun,
136+
OutputOptions: OutputOptions{
137+
JSONOptions: JSONOutputOptions{
138+
PrettyPrint: true,
139+
SnakeCase: false,
140+
},
141+
},
142+
}
143+
144+
b.ResetTimer()
145+
for i := 0; i < b.N; i++ {
146+
buf := &closeableBuffer{Buffer: new(bytes.Buffer)}
147+
formatter := &JSONFormatter{
148+
&Config{Writer: buf},
149+
}
150+
if err := formatter.Format(td, ""); err != nil {
151+
b.Fatal(err)
152+
}
153+
}
154+
}
155+
156+
func BenchmarkJSONFormatter_SnakeCasePretty(b *testing.B) {
157+
nmapRun := createBenchmarkNMAPRun()
158+
td := &TemplateData{
159+
NMAPRun: *nmapRun,
160+
OutputOptions: OutputOptions{
161+
JSONOptions: JSONOutputOptions{
162+
PrettyPrint: true,
163+
SnakeCase: true,
164+
},
165+
},
166+
}
167+
168+
b.ResetTimer()
169+
for i := 0; i < b.N; i++ {
170+
buf := &closeableBuffer{Buffer: new(bytes.Buffer)}
171+
formatter := &JSONFormatter{
172+
&Config{Writer: buf},
173+
}
174+
if err := formatter.Format(td, ""); err != nil {
175+
b.Fatal(err)
176+
}
177+
}
178+
}
179+
180+
func BenchmarkToSnakeCase(b *testing.B) {
181+
testCases := []string{
182+
"CamelCase",
183+
"StartStr",
184+
"NumServices",
185+
"HTTPServer",
186+
"HostAddress",
187+
"AddressType",
188+
}
189+
190+
b.ResetTimer()
191+
for i := 0; i < b.N; i++ {
192+
for _, tc := range testCases {
193+
_ = toSnakeCase(tc)
194+
}
195+
}
196+
}
197+
198+
func BenchmarkConvertKeysToSnakeCase(b *testing.B) {
199+
// Sample JSON data
200+
jsonData := []byte(`{"Scanner":"nmap","Args":"-sV","Start":1234567890,"StartStr":"2009-02-13 23:31:30 UTC","Version":"7.80","ScanInfo":{"Type":"syn","Protocol":"tcp","NumServices":1000},"Host":[{"StartTime":1234567890,"EndTime":1234567900,"HostAddress":[{"Address":"192.168.1.1","AddressType":"ipv4"}],"Status":{"State":"up","Reason":"echo-reply"}}]}`)
201+
202+
b.ResetTimer()
203+
for i := 0; i < b.N; i++ {
204+
_ = convertKeysToSnakeCase(jsonData)
205+
}
206+
}

0 commit comments

Comments
 (0)