Repository: barnybug/cli53

Issue:
Bug Report: CAA record values truncated after ; when exporting with cli53 export
### Description

When using cli53 export to export Route53 zone data, CAA record values containing ; are truncated. Everything after the semicolon is missing in the exported output. This behavior does not match the actual DNS record and can lead to incorrect certificate issuance policies.

### Steps to Reproduce

1. Create a CAA record in Route53 like:
```
example.com. 3600 IN CAA 0 issue "digicert.com; cansignhttpexchanges=yes"
```

2. Run export:
```
cli53 export example.com
```

3. Check the output.

### Actual Result
```
@       3600    IN      CAA     0 issue "digicert.com;"
```

The part after ; (cansignhttpexchanges=yes) is missing.

### Expected Result
```
@       3600    IN      CAA     0 issue "digicert.com; cansignhttpexchanges=yes"
```

### Additional Information
- Verified correct value using dig:
```
$ dig @ns-xxx.awsdns-xxx.org example.com caa +short
0 issue "digicert.com; cansignhttpexchanges=yes"
```
- **cli53 Version**: 0.8.25
- **OS Version**: Amazon Linux release 2023.9.20251110 (Amazon Linux)


Discussion:

Solution patch:
diff --git a/bind.go b/bind.go
index 0c897bc..5a6c5a0 100644
--- a/bind.go
+++ b/bind.go
@@ -436,10 +436,10 @@ func ConvertRRSetToBind(rrset *route53.ResourceRecordSet) []dns.RR {
 			}
 		case "CAA":
 			for _, rr := range rrset.ResourceRecords {
-				var flag uint8
-				var tag string
-				var quotedValue string
-				fmt.Sscanf(*rr.Value, "%d %s %s", &flag, &tag, &quotedValue)
+				fields := strings.SplitN(*rr.Value, " ", 3)
+				flag, _ := strconv.ParseUint(fields[0], 10, 8)
+				tag := fields[1]
+				value := parseCharacterString(fields[2])
 
 				dnsrr := &dns.CAA{
 					Hdr: dns.RR_Header{
@@ -448,9 +448,9 @@ func ConvertRRSetToBind(rrset *route53.ResourceRecordSet) []dns.RR {
 						Class:  dns.ClassINET,
 						Ttl:    uint32(*rrset.TTL),
 					},
-					Flag:  flag,
+					Flag:  uint8(flag),
 					Tag:   tag,
-					Value: strings.Trim(quotedValue, `"`),
+					Value: value,
 				}
 				ret = append(ret, dnsrr)
 			}
diff --git a/util.go b/util.go
index 4481522..d88693b 100644
--- a/util.go
+++ b/util.go
@@ -195,6 +195,15 @@ func splitValues(s string) []string {
 	return ret
 }
 
+// parse a <character-string> per RFC 1035 Section 5.1
+func parseCharacterString(s string) string {
+	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
+		return reBackslashed.ReplaceAllString(s[1:len(s)-1], "$1")
+	} else {
+		return s
+	}
+}
+
 var quoter = strings.NewReplacer(`\`, `\\`, `"`, `\"`)
 
 func quote(s string) string {



Test patch:
diff --git a/bind_test.go b/bind_test.go
index 9b1d1f5..f95f21f 100644
--- a/bind_test.go
+++ b/bind_test.go
@@ -291,6 +291,31 @@ var testConvertRRSetToBindTable = []struct {
 			},
 		},
 	},
+	{
+		Input: route53.ResourceRecordSet{
+			Type: aws.String("CAA"),
+			Name: aws.String("example.com."),
+			ResourceRecords: []*route53.ResourceRecord{
+				&route53.ResourceRecord{
+					Value: aws.String("128 issuewild \"example.net; key=value\""),
+				},
+			},
+			TTL: aws.Int64(86400),
+		},
+		Output: []dns.RR{
+			&dns.CAA{
+				Hdr: dns.RR_Header{
+					Name:   "example.com.",
+					Rrtype: dns.TypeCAA,
+					Class:  dns.ClassINET,
+					Ttl:    uint32(86400),
+				},
+				Flag:  128,
+				Tag:   "issuewild",
+				Value: "example.net; key=value",
+			},
+		},
+	},
 	{
 		Input: route53.ResourceRecordSet{
 			Type: aws.String("NAPTR"),
diff --git a/util_test.go b/util_test.go
index a5a945c..0118057 100644
--- a/util_test.go
+++ b/util_test.go
@@ -66,6 +66,15 @@ func TestSplitValues(t *testing.T) {
 	assert.Equal(t, []string{`a "quote" b`}, splitValues(`"a \"quote\" b"`))
 }
 
+func TestParseCharacterString(t *testing.T) {
+	assert.Equal(t, "", parseCharacterString(""))
+	assert.Equal(t, "abc", parseCharacterString("abc"))
+	assert.Equal(t, "abc", parseCharacterString(`"abc"`))
+	assert.Equal(t, "abc def", parseCharacterString(`"abc def"`))
+	assert.Equal(t, `abc" def`, parseCharacterString(`"abc\" def"`))
+	assert.Equal(t, `abc\ def`, parseCharacterString(`"abc\\ def"`))
+}
+
 func TestIsZoneId(t *testing.T) {
 	assert.True(t, isZoneId("Z1DXU7RZRUQ"))
 	assert.True(t, isZoneId("Z1DXU7RZRUQP"))