runesafe

package module
v1.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: GPL-2.0, GPL-3.0 Imports: 6 Imported by: 0

README

runesafe

Go Reference Go version Test coverage OpenSSF Best Practices OpenSSF Scorecard

One rune-safety policy for untrusted upstream text headed to slog, JSON, or rendered output

A standalone Go library for a boundary every scraper-adjacent app meets: text the app did not author (API response fields, upstream error messages, file names, titles) that will be emitted into a log line, a JSON document, or a rendered report. Four rune classes survive that trip with their control semantics intact and let the upstream author forge or garble what the operator sees:

  • C0 controls (U+0000–U+001F) and DEL (U+007F): terminal escape sequences and log-record forgery. ESC introduces CSI/OSC sequences that can retitle the terminal, clear the screen, or write to the clipboard; a raw newline splits one record into two, fabricating a whole log line. CR/LF are optionally kept for sinks whose encoder escapes them (JSON).
  • C1 controls (U+0080–U+009F): single-rune escape introducers (CSI U+009B, OSC U+009D, …) with the same terminal powers as ESC sequences. encoding/json and slog's JSONHandler emit them raw, so escaping C0 alone does not close the terminal-injection hole.
  • Unicode Bidi_Control format characters (U+061C, U+200E/U+200F, U+202A–U+202E, U+2066–U+2069): visually reorder rendered text, Trojan-Source-style, so a link or verdict reads differently than it compares. The set matches unicode.Bidi_Control exactly.
  • Line and paragraph separators U+2028/U+2029: legal unescaped in JSON but line terminators to JavaScript and many viewers, so they split records like a raw newline.

runesafe classifies these runes and provides the shared sanitizers, so an app's slog emitter, JSON report writer, and renderer apply an identical policy instead of three drifting hand-rolled ones.

Standard library only, zero dependencies.

Install

go get github.com/cplieger/runesafe@latest

Usage

The shared sanitizer

Apply at the emit boundary (the slog call site, or just before JSON encoding) so comparisons and dedupe keys keep operating on the raw value:

slog.Warn("better release available",
    "title", runesafe.Sanitize(upstream.Title),
    "group", runesafe.Sanitize(upstream.Group))

Each unsafe rune becomes a space; CR and LF pass through (slog's JSON encoder escapes them); invalid UTF-8 bytes become U+FFFD, so the result is always valid UTF-8. Sanitizing is idempotent, so double-sanitizing at two layers is harmless.

Single-line sinks

For a sink where a raw newline forges a record boundary (a plain-text log line, a one-line error message, a rendered table cell), SanitizeSingleLine applies the strict policy (CR and LF become spaces too):

msg := runesafe.SanitizeSingleLine(upstreamErr.Error())

Choose per sink: Sanitize when the sink's encoder provably escapes CR/LF (JSON), SanitizeSingleLine when it does not.

Bounding sanitized text

Sanitizing can grow a string (each invalid UTF-8 byte becomes the three-byte U+FFFD), so a byte cap belongs after sanitizing. A naive s[:n] can then split a multi-byte rune, leaving a partial-rune tail whose raw 0x80–0x9F bytes a non-UTF-8 terminal reads as C1 escape introducers: the very class the sanitizer just removed. CapBytes cuts on a rune boundary:

body := runesafe.CapBytes(runesafe.Sanitize(raw), maxBodyBytes)

For the common log-attribute case (single-line, capped, visibly marked), SanitizeSingleLineBounded packages the composition: SanitizeSingleLine, then CapBytes on the sanitized form, then "..." appended outside the cap. n budgets the retained body, so a truncated result is at most n+3 bytes; a within-cap result comes back byte-identical, with no marker. Truncated output always ends in the marker, but the converse does not hold (input may itself end in ...); a caller that must know whether truncation occurred composes the primitives itself. A non-positive n yields "..." alone for non-empty input, and "" stays "":

slog.Warn("upstream rejected request",
    "reason", runesafe.SanitizeSingleLineBounded(upstreamErr.Error(), 200))

When the cap is a real budget — a record persisted under a write limit, a payload assembled under a vendor byte cap, a fixed-width column — SanitizeCapped (CR/LF kept, for JSON sinks) and SanitizeSingleLineCapped (strict) take the marker from the caller and charge it against n, so the returned text never exceeds the limit, and they return the truncation fact rather than leaving it to be inferred from the marker:

attr, cut := runesafe.SanitizeSingleLineCapped(key, maxLoggedKeyBytes, "...")
slog.Warn("unknown upstream keys", "key", attr, "key_truncated", cut)

A cap too small to hold the marker drops the marker rather than emitting a fragment of it (cut still reports the elision); an empty marker caps silently. The marker is emitted verbatim, so build it from program text, not from untrusted input. Neither function serves a caller that must cap before sanitizing to bound the sanitizer's work on a huge value, nor one that keeps a value's tail behind a prefixed marker; compose those locally.

Rune classification

IsUnsafe exposes the policy rune-by-rune, with an explicit CR/LF switch:

runesafe.IsUnsafe('\x1b', true)  // true:  ESC is always unsafe
runesafe.IsUnsafe('\n', true)    // false: the sink's encoder escapes it
runesafe.IsUnsafe('\n', false)   // true:  single-line sink; a newline forges a record
A custom replacement policy

For a sink that needs a different replacement (strip instead of space), compose IsUnsafe yourself:

// Remove (rather than blank) unsafe runes for a compact identifier.
id = strings.Map(func(r rune) rune {
    if runesafe.IsUnsafe(r, false) {
        return -1
    }
    return r
}, id)

For context-aware escapers that percent-encode rather than replace (for example a Markdown link-URL escaper that must keep the URL usable), IsUnsafeNonASCII classifies the above-ASCII subset of the policy: C1 controls, the Bidi_Control set, and U+2028/U+2029. A URL encoder already covers ASCII controls and whitespace itself:

// Percent-encode the policy runes url.Parse accepts but a viewer must never see raw.
for _, r := range u {
    if runesafe.IsUnsafeNonASCII(r) {
        for _, b := range []byte(string(r)) {
            fmt.Fprintf(&out, "%%%02X", b)
        }
        continue
    }
    out.WriteRune(r)
}

IsBidiControl exposes just the Bidi_Control subset for policies scoped to reordering alone.

Tagging provenance: the Untrusted type

Per-call sanitizing re-derives the same fact (this text is untrusted) at every emit site, and a forgotten wrap is invisible. Untrusted records the fact once, where it is actually known: the struct that decodes the upstream payload.

type Episode struct {
    Title runesafe.Untrusted `json:"title"`
}

Decoding is untouched: a string-kinded named type unmarshals natively, raw bytes in. Emission fires the policy through the standard interfaces:

  • slog resolves the value sanitized (slog.LogValuer).
  • fmt renders it sanitized (fmt.Stringer), so fmt.Errorf("upstream said %s", v) carries no escape introducers from construction on; that is the one boundary that covers error values. This form keeps CR/LF, so route such an error's text through SingleLine() if it is ever bound for a hand-built sink that escapes nothing.
  • encoding/json emits it sanitized at any nesting depth (encoding.TextMarshaler). Map keys are the exception: encoding/json reads a string-kinded key's bytes directly without calling MarshalText, so key marshaled documents by v.String(), never by the tagged value.

Compute paths keep the exact bytes via Raw():

slog.Warn("better release available", "title", ep.Title) // sanitized automatically
if ep.Title.Raw() == onDisk.Title.Raw() { /* matching stays raw */ }

Two rules keep it honest. Structs persisted for the program's own re-reading store Raw() in plain string fields: MarshalText fires inside every json.Marshal, so a tagged field in a state file would round-trip sanitized, not raw. And a string(v) conversion silently drops the tag; use Raw() so intentional unwrapping stays greppable.

API

Symbol Contract
Sanitize(s string) string Replaces each unsafe rune (keepCRLF=true policy) with a space. Valid UTF-8 out, rune count preserved, idempotent.
SanitizeSingleLine(s string) string The strict preset (keepCRLF=false): everything Sanitize replaces, plus CR and LF.
SanitizeSingleLineBounded(s string, n int) string SanitizeSingleLine, then a rune-boundary cap of the sanitized form at n bytes with "..." appended outside the cap (truncated result ≤ n+3 bytes; within-cap input byte-identical, no marker). Non-positive n yields "..." for non-empty input; "" stays "".
SanitizeCapped(s string, n int, marker string) (text string, cut bool) Sanitize, then a rune-boundary cap with the caller's marker counted inside the cap: the text is always ≤ max(n, 0) bytes. cut is true exactly when the sanitized form was shortened. A cap below len(marker) drops the marker; the marker is emitted verbatim, never sanitized.
SanitizeSingleLineCapped(s string, n int, marker string) (text string, cut bool) The strict twin of SanitizeCapped (CR and LF replaced too), same cap, marker and cut contract.
CapBytes(s string, n int) string Truncates to at most n bytes on a rune boundary; never ends in a partial rune. Non-positive n returns "".
IsUnsafe(r rune, keepCRLF bool) bool One rune under the policy: C0 (CR/LF exempt when keepCRLF), DEL, C1, Bidi_Control, U+2028/U+2029.
IsUnsafeNonASCII(r rune) bool The above-ASCII subset: C1, Bidi_Control, U+2028/U+2029. For escapers whose sink already covers ASCII (URL percent-encoders).
IsBidiControl(r rune) bool Exactly unicode.Is(unicode.Bidi_Control, r), without the table lookup.
Untrusted (string type) Provenance tag for upstream text: decodes raw, emits Sanitize'd through slog.LogValuer, fmt.Stringer, and encoding.TextMarshaler.
Untrusted.Raw() string The exact bytes as received: matching, dedupe keys, compute-side caps, and composed escapers operate on this. An emit-bound byte cap belongs on the sanitized form: CapBytes(v.String(), n).
Untrusted.SingleLine() string The strict SanitizeSingleLine form, for hand-built single-line sinks.

Adoption guidance

  • One policy per app. Route every untrusted attribute through Sanitize / SanitizeSingleLine (or one app-local wrapper around IsUnsafe) so two sinks cannot disagree about what is dangerous.
  • Construction-time sanitization still has a place. For text that must be safe unconditionally through every future sink, like a captured error body, sanitize at construction instead of tagging.
  • Context-aware sinks still need their own escaping. A Markdown table cell, a link URL, or an HTML page has injection vectors this rune policy does not address (pipes, brackets, angle brackets); apply the sink's escaper on top.

Unsupported by Design

Feature Rationale
HTML/XSS sanitization Different threat model and sink; use a real HTML sanitizer. This policy is for logs, JSON, and rendered reports.
Unicode normalization (NFC/NFKC) Normalization changes text identity; a display-safety policy must not. Normalize separately if the app needs it.
Zero-width and confusable runes Invisible-character and homoglyph spoofing is a rabbit hole with legitimate-text collateral (ZWJ in emoji, real Cyrillic). The policy targets runes with control semantics, where replacement is always correct.
Configurable replacement rune The space is the policy; a different replacement is a two-line strings.Map over IsUnsafe (shown above).
Removing instead of replacing Deletion changes rune offsets and can splice adjacent fragments into new tokens; 1:1 replacement preserves shape. Compose IsUnsafe for the rare sink that genuinely wants removal.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

GPL-3.0-or-later. See LICENSE.

Documentation

Overview

Package runesafe classifies runes that are unsafe in untrusted text bound for logs, JSON, or rendered output, and provides shared sanitizers that neutralize them.

Untrusted upstream text — API response fields, upstream error messages, file names, titles — eventually reaches a sink that renders it: a slog line read in a terminal, a JSON report opened in a viewer, a Markdown table. Four classes of rune survive the trip into those sinks with their control semantics intact and let the upstream author forge or garble what the operator sees:

  • C0 controls (U+0000-U+001F) and DEL (U+007F): terminal escape sequences (ESC introduces CSI/OSC sequences that can retitle the terminal, clear the screen, or write to the clipboard) and log-record forgery (a raw newline splits one record into two, letting upstream text fabricate a whole log line). JSON encoders escape C0, so CR and LF may be kept for sinks whose encoder provably escapes them (the keepCRLF policy switch).
  • C1 controls (U+0080-U+009F): single-rune escape introducers (CSI U+009B, OSC U+009D, ...) with the same terminal powers as ESC sequences. encoding/json and slog's JSONHandler emit them raw, so escaping C0 alone does not close the terminal-injection hole.
  • Unicode Bidi_Control format characters (U+061C, U+200E/U+200F, U+202A-U+202E, U+2066-U+2069): visually reorder rendered text (Trojan-Source-style spoofing — a link or verdict reads differently than it compares).
  • The line and paragraph separators U+2028/U+2029: legal unescaped in JSON but line terminators to JavaScript and many viewers, so they split records like a raw newline.

The Untrusted string type makes the policy travel with the value: tag an upstream field at its decode struct and every standard sink applies Sanitize automatically — slog via LogValuer, fmt and error construction via Stringer, encoders via TextMarshaler — while Raw preserves the exact bytes for matching, dedupe keys, and composed escapers. Machine-read persistence must store Raw (encoding fires the sanitizer), and construction-time sanitization remains the boundary for text that must be safe unconditionally through every future sink.

IsUnsafe classifies one rune under an explicit CR/LF policy, IsUnsafeNonASCII exposes the above-ASCII subset (C1, bidi controls, the separators) for escapers whose sink already covers ASCII, and IsBidiControl exposes the Bidi_Control subset. Sanitize (CR/LF kept, for JSON-encoded sinks) and SanitizeSingleLine (CR/LF replaced too, for single-line sinks) apply the two policies to whole strings, replacing each unsafe rune with a space. CapBytes truncates on a rune boundary, so a byte cap applied after sanitizing cannot re-introduce a partial-rune C1 tail, and SanitizeSingleLineBounded packages the log-bound composition — SanitizeSingleLine, then CapBytes on the sanitized form, then a "..." marker outside the cap — for upstream-controlled values headed into capped log attributes. SanitizeCapped and SanitizeSingleLineCapped are the general form of that composition, one per CR/LF policy, for the callers the preset cannot serve: the marker is caller-supplied and charged against the cap, so the returned text is a hard total bound (what a persisted record's write limit or a vendor payload budget needs), and the truncation fact comes back as a second result instead of having to be inferred from the marker. Neither serves a caller that must cap BEFORE sanitizing to bound the sanitizer's work, nor one that keeps a value's tail behind a prefixed marker. Apply the sanitizer at the emit boundary (the slog call site, just before JSON encoding) so comparisons and dedupe keys keep operating on the raw value, and use one policy per application so two sinks cannot drift.

The package is one deliberately small policy. It is not an HTML/XSS sanitizer, does not normalize Unicode (NFC/NFKC), and does not touch zero-width or confusable runes; context-aware sinks (Markdown cells, URLs, HTML) need their own escaping on top of this rune policy.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func CapBytes

func CapBytes(s string, n int) string

CapBytes truncates s to at most n bytes without splitting a multi-byte rune at the cut: the cut backs off to the nearest rune start, so the result never ends in a partial rune. It exists because sanitizing can grow a string (each invalid UTF-8 byte becomes the three-byte U+FFFD), so a pre-sanitize byte cap does not survive sanitization, and a naive re-cap can split a rune — leaving a partial-rune tail whose raw 0x80-0x9F bytes a non-UTF-8 terminal reads as C1 escape introducers, re-minting the very class of byte the sanitizer removed. For valid UTF-8 input (any Sanitize or SanitizeSingleLine output) the backoff discards at most three bytes below n and the result is a valid-UTF-8 prefix of s. A non-positive n returns the empty string.

Example

ExampleCapBytes bounds a sanitized string without splitting a multi-byte rune: the cut backs off to a rune boundary instead of leaving a partial rune's raw tail bytes.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	s := "葬送のフリーレン" // three bytes per rune
	fmt.Printf("%q\n", runesafe.CapBytes(s, 7))
}
Output:
"葬送"

func IsBidiControl

func IsBidiControl(r rune) bool

IsBidiControl reports whether r is one of Unicode's Bidi_Control format characters: the singleton marks U+061C (ALM) and U+200E/U+200F (LRM/RLM), the override/embedding range U+202A-U+202E (LRE/RLE/PDF/LRO/RLO), and the isolate range U+2066-U+2069 (LRI/RLI/FSI/PDI). Any of them in untrusted text can visually reorder rendered output (Trojan-Source-style report and link spoofing), so every output sanitizer treats the full set as unsafe. The set matches unicode.Bidi_Control exactly, without the table lookup.

Example

ExampleIsBidiControl classifies a right-to-left override against a plain letter.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	fmt.Println(runesafe.IsBidiControl('\u202e'), runesafe.IsBidiControl('a'))
}
Output:
true false

func IsUnsafe

func IsUnsafe(r rune, keepCRLF bool) bool

IsUnsafe reports whether r is unsafe in untrusted text bound for a log, JSON, or rendered-output sink: a C0 control, DEL, a C1 control (U+0080-U+009F, single-rune terminal-escape introducers), a Unicode bidi control (IsBidiControl), or the U+2028/U+2029 line separators. keepCRLF selects the CR/LF policy: true treats CR and LF as safe, for sinks whose encoder escapes them (JSON); false treats them as unsafe like every other C0 control, for single-line sinks where a raw newline forges a new record. Sanitize and SanitizeSingleLine apply the two policies to whole strings.

Example

ExampleIsUnsafe shows the CR/LF policy switch: a newline is safe for a JSON sink whose encoder escapes it, and unsafe for a single-line sink.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	fmt.Println(runesafe.IsUnsafe('\n', true), runesafe.IsUnsafe('\n', false))
	fmt.Println(runesafe.IsUnsafe('\u009b', true), runesafe.IsUnsafe('\u009b', false))
}
Output:
false true
true true

func IsUnsafeNonASCII added in v1.1.0

func IsUnsafeNonASCII(r rune) bool

IsUnsafeNonASCII reports whether r is an unsafe rune above the ASCII range: a C1 control (U+0080-U+009F), a Unicode bidi control (IsBidiControl), or the U+2028/U+2029 line separators. It is the IsUnsafe policy minus C0, DEL, and the CR/LF axis (all ASCII), for composed escapers whose sink already covers ASCII: a URL percent-encoder escapes C0, DEL, and whitespace itself, but url.Parse accepts these non-ASCII runes raw, and a terminal or Markdown viewer must never receive them. The CR/LF policy switch is moot above ASCII, so there is no keepCRLF parameter: IsUnsafeNonASCII(r) equals IsUnsafe(r, keepCRLF) && r > unicode.MaxASCII under either policy.

Example

ExampleIsUnsafeNonASCII classifies the runes a URL percent-escaper must encode even though url.Parse accepts them raw: the C1 escape introducer and the bidi override are flagged, while an ASCII control (the sink's own escaping covers it) and a plain letter are not.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	fmt.Println(runesafe.IsUnsafeNonASCII('\u009b'), runesafe.IsUnsafeNonASCII('\u202e'))
	fmt.Println(runesafe.IsUnsafeNonASCII('\x1b'), runesafe.IsUnsafeNonASCII('é'))
}
Output:
true true
false false

func Sanitize

func Sanitize(s string) string

Sanitize makes an untrusted string safe for slog/JSON sinks by replacing each unsafe rune with a space: C0 controls (except CR/LF, which JSON encoders escape), DEL, C1 controls (U+0080-U+009F, single-rune terminal-escape introducers emitted raw by encoding/json and slog's JSONHandler), Unicode bidi controls, and the U+2028/U+2029 line separators. Invalid UTF-8 bytes become U+FFFD (a strings.Map property), so the result is always valid UTF-8. Apply it to every untrusted attribute at the emit boundary — one policy shared by all of an app's sinks, so they cannot drift. For a single-line sink where CR/LF must also go, use SanitizeSingleLine.

Example

ExampleSanitize sanitizes an upstream-controlled title before it becomes a slog attribute: the terminal-escape introducer and the bidi override become spaces, ordinary text passes through.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	title := "Frieren\x1b[2J \u202egpj.exe"
	fmt.Printf("%q\n", runesafe.Sanitize(title))
}
Output:
"Frieren [2J  gpj.exe"

func SanitizeCapped added in v1.3.0

func SanitizeCapped(s string, n int, marker string) (text string, cut bool)

SanitizeCapped is Sanitize followed by a byte cap that INCLUDES the marker. It sanitizes s under the keepCRLF=true policy (CR and LF survive, for a sink whose encoder escapes them — JSON, slog's handlers), and if the sanitized form exceeds n bytes it cuts that form on a rune boundary at n-len(marker) bytes and appends marker, so the returned text is at most max(n, 0) bytes whatever marker is. A within-cap value comes back untouched — byte-identical to Sanitize, no marker.

cut reports whether the sanitized form was SHORTENED, not whether sanitizing rewrote anything: it is true exactly when that form did not fit in n bytes, and false whenever the returned text is the whole sanitized form. It exists because a marker cannot prove a cut — a value may end in the marker on its own — so a caller that must report truncation as a fact (a log attribute beside the value, a decision to keep a fuller copy elsewhere) would otherwise re-implement this composition just to set its own flag.

n bounds the TOTAL, which is what a caller with a real budget needs: a record persisted under a write limit, a payload assembled under a vendor byte cap, a fixed-width column. SanitizeSingleLineBounded puts its marker OUTSIDE the cap (a truncated result runs to n+3 bytes), which leaves such a caller subtracting the marker's width by hand at every call site.

A marker longer than n cannot be shown intact, and a partial marker is indistinguishable from content, so for n < len(marker) the result is the rune-boundary prefix of the sanitized form alone — the empty string for a non-positive n — and the elision is reported through cut only. An empty marker is legal and yields a silent cap (CapBytes over the sanitized form) with the fact still returned. An empty s returns "" and false under any n, negative included. marker is emitted verbatim, never sanitized: it is the caller's own program text, and rewriting it would silently alter a chosen mark — a marker assembled from untrusted input must be sanitized by the caller first.

The CR/LF policy is a second function rather than a parameter on this one, deliberately: the package already expresses that choice as the named Sanitize / SanitizeSingleLine pair, so a call site names its sink instead of passing an opaque boolean next to two other tuning arguments. Only IsUnsafe takes the flag, because a composed policy needs it as data. SanitizeSingleLineCapped is the strict twin.

Two consumer shapes this pair deliberately does NOT serve, and must not be widened to serve:

  • Capping BEFORE sanitizing, to bound the sanitizer's WORK rather than its output. A caller that must never walk a multi-megabyte upstream value in a memory-limited process caps the raw bytes first (CapBytes, then Sanitize on the chunk); this function sanitizes all of s by construction, and no marker placement changes that. Such a caller also tends to aggregate one truncation fact across several appends, which a single-call primitive cannot express.
  • Keeping the TAIL behind a PREFIXED marker. When the identifying part of a value sits at its end (a path's file name), the caller wants marker + suffix; this function keeps the head. Compose the rune-boundary walk locally for that.
Example

ExampleSanitizeCapped bounds a multi-line upstream body for a JSON sink: CR/LF survive the sanitizer, the caller's own marker is charged against the cap so the result never exceeds it, and cut carries the truncation fact for the caller to record.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	body, cut := runesafe.SanitizeCapped("line one\nline two\x1b[2J", 16, "...(truncated)")
	fmt.Printf("%q %v %d\n", body, cut, len(body))

	body, cut = runesafe.SanitizeCapped("short\nbody", 16, "...(truncated)")
	fmt.Printf("%q %v\n", body, cut)
}
Output:
"li...(truncated)" true 16
"short\nbody" false

func SanitizeSingleLine

func SanitizeSingleLine(s string) string

SanitizeSingleLine makes an untrusted string safe for a single-line sink — a plain-text log line, a one-line error message, a rendered table cell — by replacing each unsafe rune with a space under the strict keepCRLF=false policy: everything Sanitize replaces, plus CR and LF, whose raw appearance in a single-line sink forges a record boundary. Invalid UTF-8 bytes become U+FFFD, so the result is always valid UTF-8 and carries no line break.

Example

ExampleSanitizeSingleLine flattens an upstream error message for a one-line sink: the newline that would forge a second log record becomes a space along with the escape introducer.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	msg := "bad request\nlevel=ERROR forged\x1b[2J"
	fmt.Printf("%q\n", runesafe.SanitizeSingleLine(msg))
}
Output:
"bad request level=ERROR forged [2J"

func SanitizeSingleLineBounded added in v1.2.0

func SanitizeSingleLineBounded(s string, n int) string

SanitizeSingleLineBounded is SanitizeSingleLine followed by a byte cap: an over-cap result is truncated to at most n bytes on a rune boundary (CapBytes) with "..." appended to mark the cut, while a result within the cap is returned untouched — no marker, byte-identical to the unbounded form. It is the log-bound preset for VOLUME as well as rune safety: an upstream-controlled value headed for a log attribute (a query parameter, a JSON key name, an upstream error body) must not balloon a record past downstream log-pipeline limits, and every consumer hand-rolled exactly this sanitize-cap-mark composition. The cap applies to the SANITIZED form (sanitizing can grow invalid bytes into the three-byte U+FFFD, so a pre-sanitize cap does not survive), and the marker sits outside the cap: a truncated result is at most n+3 bytes and always ends in the marker. The converse does not hold — a within-cap input may itself end in "..." — so the marker marks the cut without proving one; a caller that must know whether truncation occurred, supply its own marker, or hold the total (marker included) under a hard budget uses SanitizeSingleLineCapped, which returns the fact and counts the marker inside the cap. A non-positive n yields "..." alone for a non-empty input ("" stays "", whatever n is). The marker's placement outside the cap is this preset's settled contract, not an accident of its implementation: it stays as it is so every existing call site keeps its byte-for-byte output.

Example

ExampleSanitizeSingleLineBounded bounds an upstream error message for a capped log attribute: sanitization happens before the cap is measured, a within-cap result comes back untouched, and an over-cap result is cut on a rune boundary with the "..." marker appended outside the cap (at most n+3 bytes).

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	fmt.Printf("%q\n", runesafe.SanitizeSingleLineBounded("bad\nrequest", 20))
	fmt.Printf("%q\n", runesafe.SanitizeSingleLineBounded("葬送のフリーレン", 7))
}
Output:
"bad request"
"葬送..."

func SanitizeSingleLineCapped added in v1.3.0

func SanitizeSingleLineCapped(s string, n int, marker string) (text string, cut bool)

SanitizeSingleLineCapped is SanitizeCapped under the strict keepCRLF=false policy: CR and LF become spaces along with every other unsafe rune, for a single-line sink where a raw newline forges a record boundary — a plain-text log line, a one-line error message, a rendered table cell. The cap, marker and cut contract are identical, marker counted INSIDE n and the n < len(marker) degenerate case included; see SanitizeCapped for the full contract, for why the CR/LF policy is a separate function, and for the two consumer shapes the pair does not serve.

Example

ExampleSanitizeSingleLineCapped bounds an upstream error for a capped log attribute and logs the truncation as a fact rather than inferring it from the marker.

package main

import (
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	reason, cut := runesafe.SanitizeSingleLineCapped("bad request\nlevel=ERROR forged", 14, "...")
	fmt.Printf("%q %v %d\n", reason, cut, len(reason))
}
Output:
"bad request..." true 14

Types

type Untrusted added in v1.1.0

type Untrusted string

Untrusted marks a string as untrusted upstream text at the moment it enters the program — an API response field, an upstream error message, a file name, a title — so every human-facing sink it later reaches applies the package's rune policy automatically. The type makes provenance portable: the trust decision is recorded once, in the DTO struct that decodes the upstream payload, instead of re-derived at every emit site.

Ingestion is free. A string-kinded named type without an UnmarshalText method decodes natively (encoding/json assigns into it directly), so tagging a decode-struct field preserves the raw bytes exactly as received. Emission is where the policy fires, through the standard interfaces:

  • slog: LogValue implements slog.LogValuer, so a value passed as a bare attr ("title", v) resolves to its Sanitize'd form in every handler, through groups, before encoding.
  • fmt and errors: String implements fmt.Stringer, so %s, %v, %q — and fmt.Errorf("upstream said %s", v) — render sanitized text. An error built this way carries no escape introducers from construction on, the one boundary that covers error values (slog handlers stringify errors inside the encoder, after any attribute rewriting). String keeps CR/LF (the Sanitize preset), so the error text is safe where its eventual sink escapes or quotes them — slog's handlers, JSON — but not as-is for a hand-built sink that escapes nothing; there, build the message from SingleLine instead.
  • encoders: MarshalText implements encoding.TextMarshaler, so encoding/json and any TextMarshaler-aware encoder emit the Sanitize'd form, however deeply the value nests in a document. Map KEYS are the one exception: encoding/json uses a string-kinded key's bytes directly, never calling MarshalText, so a map[Untrusted]V key emits raw — key marshaled documents by u.String().

Raw returns the exact bytes for the paths that must not be transformed: matching, dedupe keys, context-aware escapers, and byte caps on the raw value itself (a cap bounding the EMITTED form applies after sanitizing: CapBytes(v.String(), n), never Untrusted(CapBytes(v.Raw(), n)), because sanitizing can grow invalid bytes into the 3-byte U+FFFD). A plain string(v) conversion yields the same bytes but silently drops the tag; prefer Raw so intentional unwrapping stays greppable.

Two rules keep the type honest:

  • Machine-read persistence stores Raw. MarshalText fires inside every json.Marshal, so a tagged field written to a state file and read back would round-trip sanitized-not-raw. Structs persisted for the program's own consumption keep plain string fields, populated via Raw at construction; the tagged form is for human-facing documents only.
  • Untrusted does not replace construction-time sanitization for text that must be safe unconditionally through every future sink (a captured error body embedded in a returned value), and context-aware sinks (Markdown cells, URLs, HTML) still need their own escaping on top of the rune policy, composed over Raw.

LogValue, String, and MarshalText apply Sanitize (the keepCRLF=true preset): correct for JSON sinks, and safe for slog's TextHandler, whose quoting escapes a kept CR or LF. For a hand-built single-line sink whose encoder escapes nothing, use SingleLine explicitly.

Example

ExampleUntrusted tags an upstream field at the decode boundary: the raw bytes survive ingestion for matching (Raw), while fmt, errors, and JSON emission all render the sanitized form automatically.

package main

import (
	"encoding/json"
	"fmt"

	"github.com/cplieger/runesafe"
)

func main() {
	var ep struct {
		Title runesafe.Untrusted `json:"title"`
	}
	_ = json.Unmarshal([]byte(`{"title":"Frieren\u202egpj.exe"}`), &ep)

	fmt.Println(len(ep.Title.Raw()))             // raw bytes preserved for compute
	fmt.Println(ep.Title)                        // fmt renders sanitized
	fmt.Println(fmt.Errorf("bad: %s", ep.Title)) // errors carry sanitized text
	out, _ := json.Marshal(ep)
	fmt.Println(string(out)) // encoders emit sanitized
}
Output:
17
Frieren gpj.exe
bad: Frieren gpj.exe
{"title":"Frieren gpj.exe"}

func (Untrusted) LogValue added in v1.1.0

func (u Untrusted) LogValue() slog.Value

LogValue implements slog.LogValuer: a tagged attr value resolves to its Sanitize'd form in every handler before encoding.

func (Untrusted) MarshalText added in v1.1.0

func (u Untrusted) MarshalText() ([]byte, error)

MarshalText implements encoding.TextMarshaler: encoding/json and any TextMarshaler-aware encoder emit the Sanitize'd form at any nesting depth — except as a map key: encoding/json resolves a string-kinded map key directly from its bytes and never calls MarshalText, so a map[Untrusted]V key marshals RAW. Key a marshaled document by u.String() instead. Decoding is deliberately untouched (no UnmarshalText), so raw bytes survive ingestion; see the type comment for the machine-read persistence rule this asymmetry imposes.

func (Untrusted) Raw added in v1.1.0

func (u Untrusted) Raw() string

Raw returns the exact bytes as received, for matching, dedupe keys, byte caps, and context-aware escapers. A byte cap meant to bound an EMITTED form belongs on the sanitized string — CapBytes(u.String(), n) — because sanitizing can grow the raw bytes (each invalid byte becomes the three-byte U+FFFD), so a cap applied to Raw does not survive emission. Prefer Raw over a string conversion so intentional unwrapping stays greppable.

func (Untrusted) SingleLine added in v1.1.0

func (u Untrusted) SingleLine() string

SingleLine returns the SanitizeSingleLine'd form, for hand-built single-line sinks whose encoder does not escape CR/LF.

func (Untrusted) String added in v1.1.0

func (u Untrusted) String() string

String implements fmt.Stringer: %s, %v, %q, and fmt.Errorf render the Sanitize'd form, so an error wrapping the value carries no escape introducers from construction on. The form keeps CR/LF (see the type comment): fine wherever the text is later quoted or encoded (slog, JSON), not for a hand-built single-line sink — use SingleLine there.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL