From 8b2936267f9004925a1e1227e6931fc5dfbf2c57 Mon Sep 17 00:00:00 2001 From: netixx Date: Thu, 18 Apr 2024 17:58:14 +0200 Subject: [PATCH 1/3] Add test for failing netconf parsing with # in banner --- response/netconf_test.go | 5 +++++ .../golden/record-response-11b-out.txt | 1 + response/test-fixtures/netconf-output-11b.txt | 14 ++++++++++++++ 3 files changed, 20 insertions(+) create mode 100644 response/test-fixtures/golden/record-response-11b-out.txt create mode 100644 response/test-fixtures/netconf-output-11b.txt diff --git a/response/netconf_test.go b/response/netconf_test.go index 7706b52..c253216 100644 --- a/response/netconf_test.go +++ b/response/netconf_test.go @@ -63,6 +63,11 @@ func TestNetconfRecord(t *testing.T) { version: "1.1", payloadFile: "netconf-output-11.txt", }, + "record-response-11b": { + description: "a tricky banner to parse test to test recording netconf 1.1 response", + version: "1.1", + payloadFile: "netconf-output-11b.txt", + }, } for testName, testCase := range cases { diff --git a/response/test-fixtures/golden/record-response-11b-out.txt b/response/test-fixtures/golden/record-response-11b-out.txt new file mode 100644 index 0000000..f3786b8 --- /dev/null +++ b/response/test-fixtures/golden/record-response-11b-out.txt @@ -0,0 +1 @@ +{"Host":"localhost","Port":830,"Input":null,"FramedInput":null,"RawResult":"IzI3OQogIDxiYW5uZXJzIHhtbG5zPSJodHRwOi8vY2lzY28uY29tL25zL3lhbmcvQ2lzY28tSU9TLVhSLWluZnJhLWluZnJhLWNmZyI+CiAgIDxiYW5uZXI+CiAgICA8YmFubmVyLW5hbWU+bW90ZDwvYmFubmVyLW5hbWU+CiAgICA8YmFubmVyLXRleHQ+OAojIyMjIyMjIyMjIyMjIyMjIyMjIyMjCkEgdmVyeSBjb29sIGJhbm5lciB3aXRoIGEgYnVuY2ggb2YgIyMjIyBpbiBpdCA6KQojIyMjIyMjIyMjIyMjIyMjIyMjIyMjCgo4PC9iYW5uZXItdGV4dD4KICAgPC9iYW5uZXI+CiAgPC9iYW5uZXJzPgoKIyM=","Result":"\u003cbanners xmlns=\"http://cisco.com/ns/yang/Cisco-IOS-XR-infra-infra-cfg\"\u003e\n \u003cbanner\u003e\n \u003cbanner-name\u003emotd\u003c/banner-name\u003e\n \u003cbanner-text\u003e8\n######################\nA very cool banner with a bunch of #### in it :)\n######################\n\n8\u003c/banner-text\u003e\n \u003c/banner\u003e\n \u003c/banners\u003e","StartTime":"0001-01-01T00:00:00Z","EndTime":"0001-01-01T00:00:00Z","ElapsedTime":0,"FailedWhenContains":["PHJwYy1lcnJvcj4=","PHJwYy1lcnJvcnM+","PC9ycGMtZXJyb3I+","PC9ycGMtZXJyb3JzPg=="],"Failed":null,"StripNamespaces":false,"NetconfVersion":"1.1","ErrorMessages":null,"SubscriptionID":0} \ No newline at end of file diff --git a/response/test-fixtures/netconf-output-11b.txt b/response/test-fixtures/netconf-output-11b.txt new file mode 100644 index 0000000..80f2b1d --- /dev/null +++ b/response/test-fixtures/netconf-output-11b.txt @@ -0,0 +1,14 @@ +#279 + + + motd + 8 +###################### +A very cool banner with a bunch of #### in it :) +###################### + +8 + + + +## \ No newline at end of file From 4e3b5bcced1f7c8144cee327ad5923a3c6c4a1b0 Mon Sep 17 00:00:00 2001 From: netixx Date: Thu, 18 Apr 2024 17:58:23 +0200 Subject: [PATCH 2/3] Implement netconf chunk parsing without regex --- response/netconf.go | 99 ++++++++++++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/response/netconf.go b/response/netconf.go index 6abf88d..8d8da3d 100644 --- a/response/netconf.go +++ b/response/netconf.go @@ -2,6 +2,7 @@ package response import ( "bytes" + "errors" "fmt" "regexp" "strconv" @@ -16,11 +17,19 @@ const ( v1Dot1 = "1.1" v1Dot0Delim = "]]>]]>" xmlHeader = "" + // from https://datatracker.ietf.org/doc/html/rfc6242#section-4.2 + v1Dot1MaxChunkSize = 4294967295 ) +var errNetconf1Dot1Error = errors.New("unable to parse netconf 1.1 response") + +func errNetconf1Dot1ParseError(msg string) error { + return fmt.Errorf("%w: %s", errNetconf1Dot1Error, msg) +} + type netconfPatterns struct { - v1dot1Chunk *regexp.Regexp - rpcErrors *regexp.Regexp + rpcErrors *regexp.Regexp + v1Dot1MaxChunkSizeLen int } var ( @@ -31,8 +40,8 @@ var ( func getNetconfPatterns() *netconfPatterns { netconfPatternsInstanceOnce.Do(func() { netconfPatternsInstance = &netconfPatterns{ - v1dot1Chunk: regexp.MustCompile(`(?ms)(\d+)\n(.*?)^#`), //nolint:gocritic - rpcErrors: regexp.MustCompile(`(?s)(.*)`), + rpcErrors: regexp.MustCompile(`(?s)(.*)`), + v1Dot1MaxChunkSizeLen: len(strconv.Itoa(v1Dot1MaxChunkSize)), } }) @@ -120,42 +129,78 @@ func (r *NetconfResponse) record1dot0() { r.Result = string(bytes.TrimSpace(b)) } -func (r *NetconfResponse) validateChunk(i int, b []byte) { - // does this need more ... "massaging" like scrapli? - // chunk regex matches the newline before the chunk size or end of message delimiter, so we - // subtract one for that newline char - if len(b)-1 != i { - errMsg := fmt.Sprintf("return element lengh invalid, expted: %d, got %d for element: %s\n", - i, - len(b)-1, - b) - +func (r *NetconfResponse) record1dot1() { + joined, err := r.record1dot1Chunks(r.RawResult) + if err != nil { r.Failed = &OperationError{ Input: string(r.Input), Output: r.Result, - ErrorString: errMsg, + ErrorString: err.Error(), } } + + joined = bytes.TrimPrefix(joined, []byte(xmlHeader)) + + r.Result = string(bytes.TrimSpace(joined)) } -func (r *NetconfResponse) record1dot1() { - patterns := getNetconfPatterns() +func (r *NetconfResponse) record1dot1Chunks(d []byte) ([]byte, error) { + pattern := getNetconfPatterns() - chunkSections := patterns.v1dot1Chunk.FindAllSubmatch(r.RawResult, -1) + cursor := 0 - var joined []byte + joined := []byte{} - for _, chunkSection := range chunkSections { - chunk := chunkSection[2] + for cursor < len(d) { + // allow for some amount of newlines + if d[cursor] == byte('\n') { + cursor++ - size, _ := strconv.Atoi(string(chunkSection[1])) + continue + } - r.validateChunk(size, chunk) + if d[cursor] != byte('#') { + return nil, errNetconf1Dot1ParseError(fmt.Sprintf( + "unable to parse netconf response: chunk marker missing, got '%s'", + string(d[cursor])), + ) + } - joined = append(joined, chunk[:len(chunk)-1]...) - } + cursor++ - joined = bytes.TrimPrefix(joined, []byte(xmlHeader)) + // found prompt + if d[cursor] == byte('#') { + return joined, nil + } - r.Result = string(bytes.TrimSpace(joined)) + // look for end of chunk size + // allow to match end with \n char + chunkSizeLen := 0 + for ; chunkSizeLen < pattern.v1Dot1MaxChunkSizeLen+1; chunkSizeLen++ { + if cursor+chunkSizeLen >= len(d) { + return nil, errNetconf1Dot1ParseError("chunk size not found before end of data") + } + + if d[cursor+chunkSizeLen] == byte('\n') { + break + } + } + + chunkSizeStr := string(d[cursor : cursor+chunkSizeLen]) + cursor += chunkSizeLen + 1 + + chunkSize, err := strconv.Atoi(chunkSizeStr) + if err != nil { + return nil, errNetconf1Dot1ParseError( + fmt.Sprintf("unable to parse chunk size '%s': %s", chunkSizeStr, err), + ) + } + + joined = append(joined, d[cursor:cursor+chunkSize]...) + // last new line of block is not counted + // since it's considered a delimiter for next chunk + cursor += chunkSize + 1 + } + + return joined, nil } From 3b55b687723850f7d7cb7cd9775af33725c03249 Mon Sep 17 00:00:00 2001 From: Carl Montanari Date: Fri, 19 Apr 2024 15:28:56 -0700 Subject: [PATCH 3/3] refactor: minor reswizzle, chunk size char len check fixup --- response/netconf.go | 86 ++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/response/netconf.go b/response/netconf.go index 8d8da3d..f7a2e44 100644 --- a/response/netconf.go +++ b/response/netconf.go @@ -17,8 +17,11 @@ const ( v1Dot1 = "1.1" v1Dot0Delim = "]]>]]>" xmlHeader = "" - // from https://datatracker.ietf.org/doc/html/rfc6242#section-4.2 - v1Dot1MaxChunkSize = 4294967295 + + // https://datatracker.ietf.org/doc/html/rfc6242#section-4.2 max chunk size is 4294967295 so + // for us that is a max of 10 chars that the chunk size could be when we are parsing it out of + // raw bytes. + maxChunkSizeCharLen = 10 ) var errNetconf1Dot1Error = errors.New("unable to parse netconf 1.1 response") @@ -28,8 +31,7 @@ func errNetconf1Dot1ParseError(msg string) error { } type netconfPatterns struct { - rpcErrors *regexp.Regexp - v1Dot1MaxChunkSizeLen int + rpcErrors *regexp.Regexp } var ( @@ -40,8 +42,7 @@ var ( func getNetconfPatterns() *netconfPatterns { netconfPatternsInstanceOnce.Do(func() { netconfPatternsInstance = &netconfPatterns{ - rpcErrors: regexp.MustCompile(`(?s)(.*)`), - v1Dot1MaxChunkSizeLen: len(strconv.Itoa(v1Dot1MaxChunkSize)), + rpcErrors: regexp.MustCompile(`(?s)(.*)`), } }) @@ -130,7 +131,7 @@ func (r *NetconfResponse) record1dot0() { } func (r *NetconfResponse) record1dot1() { - joined, err := r.record1dot1Chunks(r.RawResult) + err := r.record1dot1Chunks() if err != nil { r.Failed = &OperationError{ Input: string(r.Input), @@ -138,69 +139,82 @@ func (r *NetconfResponse) record1dot1() { ErrorString: err.Error(), } } - - joined = bytes.TrimPrefix(joined, []byte(xmlHeader)) - - r.Result = string(bytes.TrimSpace(joined)) } -func (r *NetconfResponse) record1dot1Chunks(d []byte) ([]byte, error) { - pattern := getNetconfPatterns() +func (r *NetconfResponse) record1dot1Chunks() error { + d := bytes.TrimSpace(r.RawResult) - cursor := 0 + if len(d) == 0 || d[0] != byte('#') { + return errNetconf1Dot1ParseError( + "unable to parse netconf response: no chunk marker at start of data", + ) + } + + var joined []byte - joined := []byte{} + var cursor int for cursor < len(d) { - // allow for some amount of newlines if d[cursor] == byte('\n') { + // we don't need this at the start of this loop, but this lets us easily handle newlines + // between chunks cursor++ continue } if d[cursor] != byte('#') { - return nil, errNetconf1Dot1ParseError(fmt.Sprintf( + return errNetconf1Dot1ParseError(fmt.Sprintf( "unable to parse netconf response: chunk marker missing, got '%s'", - string(d[cursor])), - ) + string(d[cursor]))) } cursor++ - // found prompt if d[cursor] == byte('#') { - return joined, nil + break } - // look for end of chunk size - // allow to match end with \n char - chunkSizeLen := 0 - for ; chunkSizeLen < pattern.v1Dot1MaxChunkSizeLen+1; chunkSizeLen++ { - if cursor+chunkSizeLen >= len(d) { - return nil, errNetconf1Dot1ParseError("chunk size not found before end of data") - } + var chunkSizeStr string + for chunkSizeLen := 0; chunkSizeLen <= maxChunkSizeCharLen; chunkSizeLen++ { if d[cursor+chunkSizeLen] == byte('\n') { + chunkSizeStr = string(d[cursor : cursor+chunkSizeLen]) + + cursor += chunkSizeLen + 1 + break } } - chunkSizeStr := string(d[cursor : cursor+chunkSizeLen]) - cursor += chunkSizeLen + 1 + if chunkSizeStr == "" { + return errNetconf1Dot1ParseError( + "unable to parse netconf response: failed parsing chunk size", + ) + } chunkSize, err := strconv.Atoi(chunkSizeStr) if err != nil { - return nil, errNetconf1Dot1ParseError( - fmt.Sprintf("unable to parse chunk size '%s': %s", chunkSizeStr, err), + return errNetconf1Dot1ParseError( + fmt.Sprintf( + "unable to parse netconf response: unable to parse chunk size '%s': %s", + chunkSizeStr, + err, + ), ) } joined = append(joined, d[cursor:cursor+chunkSize]...) - // last new line of block is not counted - // since it's considered a delimiter for next chunk - cursor += chunkSize + 1 + + // obviously no reason to iterate over the chunk we just yoinked out, so increment the + // cursor accordingly -- we can ignore newlines after the chunk since we handle that at + // the top of this loop + cursor += chunkSize } - return joined, nil + joined = bytes.TrimPrefix(joined, []byte(xmlHeader)) + + r.Result = string(bytes.TrimSpace(joined)) + + return nil }