Repository: hashicorp/go-hclog
Pull request #96 (2021-07-06 22:14:06)

add Quote type to enable safe concise output of untrusted strings
Output from hashicorp/nomad#10858 using hclog v0.14.1 (double double quotes):

```
2021-07-06T10:06:01.590-0700 [WARN] ... task=redis health=critical output=""This is the output for state 2\n""
```

Output using hclog v0.16.1 (breaks naive 1-line-per-log parsers):

```
2021-07-06T15:41:34.112-0700 [WARN] ... task=redis health=warning
  output=
  | This is the output for state 1
```

Output using this PR:

```
2021-07-06T15:42:28.192-0700 [WARN] ... task=redis health=warning output="This is the output for state 1\n"
```


Patch:
diff --git a/intlogger.go b/intlogger.go
index 6099e67..d491ae8 100644
--- a/intlogger.go
+++ b/intlogger.go
@@ -295,6 +295,9 @@ func (l *intLogger) logPlain(t time.Time, name string, level Level, msg string,
 				continue FOR
 			case Format:
 				val = fmt.Sprintf(st[0].(string), st[1:]...)
+			case Quote:
+				raw = true
+				val = strconv.Quote(string(st))
 			default:
 				v := reflect.ValueOf(st)
 				if v.Kind() == reflect.Slice {
diff --git a/logger.go b/logger.go
index 7f36b1f..6a4665b 100644
--- a/logger.go
+++ b/logger.go
@@ -67,6 +67,12 @@ type Octal int
 // text output. For example: L.Info("bits", Binary(17))
 type Binary int
 
+// A simple shortcut to format strings with Go quoting. Control and
+// non-printable characters will be escaped with their backslash equivalents in
+// output. Intended for untrusted or multiline strings which should be logged
+// as concisely as possible.
+type Quote string
+
 // ColorOption expresses how the output should be colored, if at all.
 type ColorOption uint8
 


Test patch:
diff --git a/logger_test.go b/logger_test.go
index 6e4bd58..656cfb2 100644
--- a/logger_test.go
+++ b/logger_test.go
@@ -184,9 +184,9 @@ func TestLogger(t *testing.T) {
 		}
 
 		logger := New(&LoggerOptions{
-			Name:             "test",
-			Output:           &buf,
-			IncludeLocation:  true,
+			Name:                     "test",
+			Output:                   &buf,
+			IncludeLocation:          true,
 			AdditionalLocationOffset: 1,
 		})
 
@@ -414,6 +414,32 @@ func TestLogger(t *testing.T) {
 		assert.Equal(t, "[INFO]  test: this is test: bytes=0xc perms=0755 bits=0b101\n", rest)
 	})
 
+	t.Run("supports quote formatting", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:   "test",
+			Output: &buf,
+		})
+
+		// unsafe is a string containing control characters and a byte
+		// sequence which is invalid utf8 ("\xFFa") to assert that all
+		// characters are properly encoded and produce valid utf8 output
+		unsafe := "foo\nbar\bbaz\xFFa"
+
+		logger.Info("this is test",
+			"unquoted", "unquoted", "quoted", Quote("quoted"),
+			"unsafeq", Quote(unsafe))
+
+		str := buf.String()
+		dataIdx := strings.IndexByte(str, ' ')
+		rest := str[dataIdx+1:]
+
+		assert.Equal(t, "[INFO]  test: this is test: "+
+			"unquoted=unquoted quoted=\"quoted\" "+
+			"unsafeq=\"foo\\nbar\\bbaz\\xffa\"\n", rest)
+	})
+
 	t.Run("supports resetting the output", func(t *testing.T) {
 		var first, second bytes.Buffer
 
@@ -804,6 +830,49 @@ func TestLogger_JSON(t *testing.T) {
 		assert.Equal(t, float64(5), raw["bits"])
 	})
 
+	t.Run("ignores quote formatting requests", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:       "test",
+			Output:     &buf,
+			JSONFormat: true,
+		})
+
+		// unsafe is a string containing control characters and a byte
+		// sequence which is invalid utf8 ("\xFFa") to assert that all
+		// characters are properly encoded and produce valid json
+		unsafe := "foo\nbar\bbaz\xFFa"
+
+		logger.Info("this is test",
+			"unquoted", "unquoted", "quoted", Quote("quoted"),
+			"unsafeq", Quote(unsafe), "unsafe", unsafe)
+
+		b := buf.Bytes()
+
+		// Assert the JSON only contains valid utf8 strings with the
+		// illegal byte replaced with the utf8 replacement character,
+		// and not invalid json with byte(255)
+		// Note: testify/assert.Contains did not work here
+		if needle := []byte(`\ufffda`); !bytes.Contains(b, needle) {
+			t.Fatalf("could not find %q (%v) in json bytes: %q", needle, needle, b)
+		}
+		if needle := []byte{255, 'a'}; bytes.Contains(b, needle) {
+			t.Fatalf("found %q (%v) in json bytes: %q", needle, needle, b)
+		}
+
+		var raw map[string]interface{}
+		if err := json.Unmarshal(b, &raw); err != nil {
+			t.Fatal(err)
+		}
+
+		assert.Equal(t, "this is test", raw["@message"])
+		assert.Equal(t, "unquoted", raw["unquoted"])
+		assert.Equal(t, "quoted", raw["quoted"])
+		assert.Equal(t, "foo\nbar\bbaz\uFFFDa", raw["unsafe"])
+		assert.Equal(t, "foo\nbar\bbaz\uFFFDa", raw["unsafeq"])
+	})
+
 	t.Run("includes the caller location", func(t *testing.T) {
 		var buf bytes.Buffer
 
@@ -837,10 +906,10 @@ func TestLogger_JSON(t *testing.T) {
 		}
 
 		logger := New(&LoggerOptions{
-			Name:             "test",
-			Output:           &buf,
-			JSONFormat:       true,
-			IncludeLocation:  true,
+			Name:                     "test",
+			Output:                   &buf,
+			JSONFormat:               true,
+			IncludeLocation:          true,
 			AdditionalLocationOffset: 1,
 		})