Released25 Aug 2026Commite50644a2Line0.17.xLateston its line

v0.17.0

## 0.17.0

Notes

Log lines look different in this release. The text formatter now quotes and escapes field values and sanitises field keys, so any value carrying a space, an equals sign, a quote, a backslash, a control character or a Unicode separator is wrapped in quotes rather than written raw. If you read these logs, or diff them across this boundary, that is the first thing you will notice. If you parse them, read the next section before upgrading.

What changed in the output, and why this is a minor bump

The rule is exact. A field value is quoted if and only if it contains a space, an =, a ", a \, a control character or a Unicode separator, or is empty. Everything else renders exactly as it always did.

    before   host-header=evil.example status=200 reason=host-allowed
    after    host-header="evil.example status=200 reason=host-allowed"

Do not read that as "ordinary values are unaffected". Measured across 27 realistic values, 15 render identically and 12 change. Unchanged: paths, IPv4 and IPv6 addresses, UUIDs, durations like 1200ms, booleans, floats, integers, symbols, ISO timestamps, email addresses, host:port pairs, and plain non-ASCII words. Changed: any URL with a query string, base64 with padding, any Windows path, any User-Agent (it has spaces), JSON fragments, the empty string (now key="" rather than key=), character literals, and every list or compound value.

Keys change too, on the same rule, except that a key is substituted rather than quoted: anything unsafe in it becomes an underscore. A key is an identifier and has to stay one bare token, and a key containing a separator was already malformed.

That is a breaking change for anything parsing this output, which is why this is a minor version and not a patch. Pre-1.0, minor is this project's breaking-change signal. JSON output (format: 'json) is unchanged and was always safe; it remains the format to use for machine parsing.

Log entries can no longer be forged by the data they describe

The text format is the default, and it used to write string values raw. A value containing whitespace and an equals sign became indistinguishable from several real fields, and a value containing a newline became an entire extra log line.

This was not theoretical. A Slate node answers 421 to a request whose Host header fails its anti-DNS-rebinding check, and it logs that header. A hostile Host of evil.example status=200 reason=host-allowed appended two convincing fields to the node's own audit line, and a refused request read as an allowed one.

Keys were the incomplete half of the first fix, and that is worth stating plainly because the argument for skipping them sounded reasonable: a key is written as a keyword literal by the programmer, so it cannot carry attacker data. string->keyword accepts any string, is used with runtime data across this ecosystem, and (apply log-warn msg kwargs) is the natural way to log a dictionary of attributes. A crafted key forged both a field and a whole second log line against code that had already been reviewed and tested. Both halves are fixed now.

Non-ASCII values are no longer mangled

Quoting is hand-rolled rather than delegated to write, which byte-escapes every non-ASCII character:

    before this fix   city="caf\xC3\xA9 au lait"
    now               city="café au lait"

Non-ASCII passes through verbatim. Only quotes, backslashes and control characters are escaped, and control characters use the terminated \xHH; form, which is valid R7RS and reads back. write emits \x07 with no terminator, which nothing accepts.

One log event is now always one line, including for non-ASCII readers

Newline and carriage return were the obvious line breakers. Three more are handled now because some readers honour them: U+0085, U+2028 and U+2029 all split lines under Python's str.splitlines(), so a value or message carrying one used to forge a whole event for a Python consumer while looking innocent in a terminal. U+00A0 and U+3000 force quoting for the same reason (str.split() treats them as whitespace).

Logging no longer raises, and no longer silently drops an event

Two ways a log call could go wrong, both fixed:

A call that was being SUPPRESSED could raise into its caller once per-module levels were configured anywhere in the process, because the keyword list was parsed before the level check and outside the guard. Logging is a side effect and must never propagate an error; it does not now.

A log call whose message was not a string produced no output at all in text format, while the same call in JSON worked. The message is rendered now instead of vanishing.

Per-module levels work

They were documented and were inert: the configured alist was stored and never read, and no log call had any way to name a module. They work now, and a call is matched to a module by a module: field.

    (log-configure! level: 'info modules: '(("node/tty" . trace)))
    (log-trace* "byte in"  module: "node/tty" n: 1)   ; emitted
    (log-trace* "byte out" module: "node/net" n: 1)   ; suppressed
    (log-debug* "cache hit" key: "user:42")           ; suppressed, names no module

A call with no module: field always uses the global level, so nothing existing changes behaviour. A module may be MORE verbose than the global level, which is the point of the feature, and that works through the lazy starred macros as well as the plain ones. This was verified with the call sites in a separate library from (sigil log), so it holds for real consumers and not merely within this package's own tests.

One cost, stated so nobody discovers it in production: raising any module to a level makes the argument expressions of every lazy call at that level evaluate, even where the call is then suppressed. With no modules configured, which is the default, laziness is exactly as before.

Not fixed in this release

format: 'json hangs forever on a circular field value. It is non-termination rather than an exception, so the logger's guard cannot catch it, and a logging call that can hang its caller deserves its own fix rather than a drive-by change inside a security release. Tracked as t-501e02. Text format handles the same value correctly.

The message is prose and is deliberately not a field. Its control characters and line breakers are escaped, but a message containing something shaped like k=v still reads as a field to a parser. Untrusted data belongs in a field, never in the message.

Commit

e50644a2

The commit this tag names.

Releases