diff --git a/.changes/unreleased/Fixed-20260708-184106.yaml b/.changes/unreleased/Fixed-20260708-184106.yaml new file mode 100644 index 0000000..c1a68f4 --- /dev/null +++ b/.changes/unreleased/Fixed-20260708-184106.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: 'logging: escape DEL, C1 controls, and invalid UTF-8 in field values' +time: 2026-07-08T18:41:06.151703+02:00 diff --git a/log.c b/log.c index 7d24210..7856f8e 100644 --- a/log.c +++ b/log.c @@ -4,6 +4,8 @@ static int journal_mode = 0; +static const char hexd[] = "0123456789abcdef"; + void log_setup(void) { @@ -20,14 +22,56 @@ log_setup(void) } } +/* Return the length of the strict UTF-8 sequence at p (RFC 3629: no + * overlongs, no surrogates, max U+10FFFF) when it encodes a code point that + * may pass through unescaped (>= U+00A0, past the C1 controls), else 0. */ +static int +utf8_passthrough(const unsigned char *p) +{ + unsigned r; + int len, i; + + if ((p[0] & 0xe0) == 0xc0) { len = 2; r = p[0] & 0x1f; } + else if ((p[0] & 0xf0) == 0xe0) { len = 3; r = p[0] & 0x0f; } + else if ((p[0] & 0xf8) == 0xf0) { len = 4; r = p[0] & 0x07; } + else + return 0; + for (i = 1; i < len; i++) { + if ((p[i] & 0xc0) != 0x80) + return 0; + r = (r << 6) | (p[i] & 0x3f); + } + if (len == 2 && r < 0xa0) return 0; /* overlong or C1 control */ + if (len == 3 && r < 0x800) return 0; /* overlong */ + if (len == 4 && r < 0x10000) return 0; /* overlong */ + if (r >= 0xd800 && r <= 0xdfff) + return 0; + if (r > 0x10ffff) + return 0; + return len; +} + static int needs_quote(const char *s) { - if (!*s) + const unsigned char *p = (const unsigned char *)s; + int len; + + if (!*p) return 1; - for (const unsigned char *p = (const unsigned char *)s; *p; p++) - if (*p == ' ' || *p == '=' || *p == '"' || *p == '\\' || *p < 0x20) + while (*p) { + if (*p == ' ' || *p == '=' || *p == '"' || *p == '\\' || + *p < 0x20 || *p == 0x7f) return 1; + if (*p < 0x80) { + p++; + continue; + } + len = utf8_passthrough(p); + if (!len) + return 1; + p += len; + } return 0; } @@ -47,19 +91,29 @@ log_enc(char *out, size_t outsz, const char *val) } /* quoted form */ #define PUT(c) do { if (n + 1 < outsz) out[n++] = (c); } while (0) +#define PUTX(c) do { PUT('\\'); PUT('x'); PUT(hexd[(c) >> 4]); PUT(hexd[(c) & 0xf]); } while (0) PUT('"'); - for (const unsigned char *p = (const unsigned char *)val; *p; p++) { + for (const unsigned char *p = (const unsigned char *)val; *p; ) { unsigned char c = *p; - if (c == '"' || c == '\\') { PUT('\\'); PUT(c); } - else if (c == '\n') { PUT('\\'); PUT('n'); } - else if (c == '\t') { PUT('\\'); PUT('t'); } - else if (c == '\r') { PUT('\\'); PUT('r'); } - else if (c < 0x20) { - static const char hexd[] = "0123456789abcdef"; - PUT('\\'); PUT('x'); PUT(hexd[c >> 4]); PUT(hexd[c & 0xf]); - } else PUT(c); + int len; + + if (c == '"' || c == '\\') { PUT('\\'); PUT(c); p++; } + else if (c == '\n') { PUT('\\'); PUT('n'); p++; } + else if (c == '\t') { PUT('\\'); PUT('t'); p++; } + else if (c == '\r') { PUT('\\'); PUT('r'); p++; } + else if (c < 0x20 || c == 0x7f) { PUTX(c); p++; } + else if (c < 0x80) { PUT(c); p++; } + else if ((len = utf8_passthrough(p)) == 0) { + /* invalid UTF-8 or a C1 control: escape this + * byte, retry at the next */ + PUTX(c); + p++; + } else { + while (len--) { PUT(*p); p++; } + } } PUT('"'); +#undef PUTX #undef PUT out[n < outsz ? n : outsz - 1] = '\0'; return n; @@ -190,7 +244,6 @@ static char hexbuf_buf[2 * HEXBUF_MAX_IN + 1]; const char * log_hexbuf(const void *buf, size_t len) { - static const char hexd[] = "0123456789abcdef"; const unsigned char *b = buf; size_t i, n = len > HEXBUF_MAX_IN ? HEXBUF_MAX_IN : len; for (i = 0; i < n; i++) { diff --git a/t/test_log.c b/t/test_log.c index d651e6b..2b5e8eb 100644 --- a/t/test_log.c +++ b/t/test_log.c @@ -8,7 +8,7 @@ is_enc(const char *val, const char *expected) { char buf[256]; log_enc(buf, sizeof(buf), val); - if (!ok(strcmp(buf, expected) == 0, "enc(%s)", val)) + if (!ok(strcmp(buf, expected) == 0, "enc -> %s", expected)) tap_diag("got: %s want: %s", buf, expected); } @@ -37,6 +37,26 @@ main(void) is_enc("t\tend", "\"t\\tend\""); is_enc("r\rend", "\"r\\rend\""); is_enc("\x01", "\"\\x01\""); + /* DEL and C1 controls must not reach the output raw */ + is_enc("\x7f", "\"\\x7f\""); + is_enc("a\x7f" "b", "\"a\\x7fb\""); + is_enc("\x9b", "\"\\x9b\""); /* bare continuation byte */ + is_enc("\xc2\x9b", "\"\\xc2\\x9b\""); /* U+009B CSI */ + is_enc("\xc2\x80", "\"\\xc2\\x80\""); /* U+0080 */ + /* valid printable UTF-8 passes through, bare or quoted */ + is_enc("bl\xc3\xa5" "b\xc3\xa6r", "bl\xc3\xa5" "b\xc3\xa6r"); + is_enc("s\xc3\xb8 blue", "\"s\xc3\xb8 blue\""); + is_enc("\xc2\xa0", "\xc2\xa0"); /* U+00A0, first past C1 */ + is_enc("\xe2\x82\xac", "\xe2\x82\xac"); /* U+20AC */ + is_enc("\xf0\x9f\x98\x80", "\xf0\x9f\x98\x80"); /* U+1F600 */ + /* malformed UTF-8 is escaped byte by byte */ + is_enc("\xc0\x9b", "\"\\xc0\\x9b\""); /* overlong 2-byte */ + is_enc("\xe0\x80\xa0", "\"\\xe0\\x80\\xa0\""); /* overlong 3-byte */ + is_enc("\xed\xa0\x80", "\"\\xed\\xa0\\x80\""); /* surrogate U+D800 */ + is_enc("\xf4\x90\x80\x80", "\"\\xf4\\x90\\x80\\x80\""); /* above U+10FFFF */ + is_enc("\xc3", "\"\\xc3\""); /* truncated at end */ + is_enc("\xe2\x80", "\"\\xe2\\x80\""); /* truncated at end */ + is_enc("\xe2" "AB", "\"\\xe2AB\""); /* resync after bad lead */ ok(strcmp(log_u(42u), "42") == 0, "U(42)"); ok(strcmp(log_i(-7), "-7") == 0, "I(-7)"); ok(strcmp(log_hex(0xa2u), "0xa2") == 0, "HEX(0xa2)");