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 caps BEFORE sanitizing: their bound is on what comes back, not on the work done to produce it. SanitizeBudgeted and SanitizeSingleLineBudgeted are that same composition with the order reversed, for an upstream value whose size the caller does not control, where walking all of it is itself the exposure; Budget is their aggregate form, spending one shared byte budget across several values and keeping ONE truncation fact for the whole aggregate. Keeping a value's tail behind a prefixed marker remains the caller's own rune-boundary walk. 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 ¶
- func CapBytes(s string, n int) string
- func IsBidiControl(r rune) bool
- func IsUnsafe(r rune, keepCRLF bool) bool
- func IsUnsafeNonASCII(r rune) bool
- func Sanitize(s string) string
- func SanitizeBudgeted(s string, n int, marker string) (text string, cut bool)
- func SanitizeCapped(s string, n int, marker string) (text string, cut bool)
- func SanitizeSingleLine(s string) string
- func SanitizeSingleLineBounded(s string, n int) string
- func SanitizeSingleLineBudgeted(s string, n int, marker string) (text string, cut bool)
- func SanitizeSingleLineCapped(s string, n int, marker string) (text string, cut bool)
- type Budget
- type Untrusted
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CapBytes ¶
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 ¶
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 ¶
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
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 ¶
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 SanitizeBudgeted ¶ added in v1.4.0
SanitizeBudgeted is the one-value form of NewBudget: a Budget of n bytes, one Write of s, and its Result. It is SanitizeCapped with the cap moved AHEAD of the sanitizer — s is capped on a rune boundary at n bytes first, the chunk is sanitized under the keepCRLF=true policy, sanitization growth is re-capped, and the caller's marker is charged inside n — so the budget bounds the WORK done on s rather than only the size of what comes back. Reach for it when s comes from an upstream whose size the caller does not control (a response field, an error message interpolating one, a file name), where walking all of it is itself the exposure; reach for SanitizeCapped when s is already known to be small and only its output needs bounding.
The two orders agree on every valid-UTF-8 value that carries no unsafe rune, byte for byte, cut for cut, so a call site can move to this one without changing what an honest value emits. They diverge for exactly one input class: a value whose RAW form exceeds n but whose SANITIZED form would have fitted, which needs enough multi-byte unsafe runes to collapse it under the cap (each becomes a single-byte space) — the hostile shape the work bound exists for. This function cuts and marks such a value where SanitizeCapped returns it whole and unmarked, and the mark is honest either way: bytes really were dropped, before sanitizing.
SanitizeSingleLineBudgeted is the strict twin. Neither serves a caller aggregating SEVERAL values under one shared budget — that is Budget itself, which is what keeps ONE truncation fact across the writes instead of one per value.
Example ¶
ExampleSanitizeBudgeted bounds an upstream value whose size the caller does not control. The value is three megabytes of right-to-left overrides behind one letter, and the budget is eight bytes: the pre-cap form cuts the RAW input at the budget and sanitizes only that, so the two overrides that fit below byte eight are all it ever looked at. SanitizeCapped sanitizes the whole value first — every override collapsing to a single-byte space — and then fills the same budget from the megabyte of spaces that produces, which is the work the pre-cap exists to avoid.
package main
import (
"fmt"
"strings"
"github.com/cplieger/runesafe"
)
func main() {
huge := "A" + strings.Repeat("\u202e", 1<<20)
text, cut := runesafe.SanitizeBudgeted(huge, 8, "...")
fmt.Printf("%q %v\n", text, cut)
text, cut = runesafe.SanitizeCapped(huge, 8, "...")
fmt.Printf("%q %v\n", text, cut)
}
Output: "A ..." true "A ..." true
func SanitizeCapped ¶ added in v1.3.0
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. Both shapes belong to SanitizeBudgeted and Budget — a separate primitive carrying the cap ahead of the sanitizer and a running remainder, sharing this pair's rune-boundary cut, marker-inside-the-cap and cut-as-a-fact rules while leaving the contract above exactly as it is.
- 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 ¶
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
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 SanitizeSingleLineBudgeted ¶ added in v1.4.0
SanitizeSingleLineBudgeted is SanitizeBudgeted 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. The pre-cap, re-cap, marker and cut contract are identical; see SanitizeBudgeted for the full contract and for the one input class on which the pre-cap order diverges from SanitizeSingleLineCapped's.
func SanitizeSingleLineCapped ¶ added in v1.3.0
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 Budget ¶ added in v1.4.0
type Budget struct {
// contains filtered or unexported fields
}
Budget is a byte budget several untrusted values are sanitized under, together, retaining ONE truncation fact for the whole aggregate. Each value is capped on a rune boundary BEFORE the sanitizer walks it, so the budget bounds the sanitizer's WORK and not merely its output, and the marker is charged inside the budget exactly as SanitizeCapped charges it.
It is a SEPARATE primitive from the Capped pair rather than an option on it, and the reason is in that pair's own contract: SanitizeCapped sanitizes all of s by construction, because its cap must survive sanitization growth for a value already known to be small, and no marker placement changes that. A caller whose value is NOT known to be small needs the opposite order, and a caller assembling SEVERAL values under one shared bound needs a running remainder and a latched fact that a single-call function cannot express. Widening the pair to reach either shape would have made a two-argument function a four-argument one and put a work bound and an output bound behind the same name; the pair therefore stays as it is (see SanitizeCapped, which names both shapes as ones it must not be widened to serve), and this type owns them. What the two share — the rune-boundary cut, the marker charged inside the cap, the cut FACT returned rather than inferred from the marker — is identical on purpose, so a call site can move between them without re-reading either contract.
The order is the whole point of the type. A multi-megabyte upstream value walked by strings.Map in a memory-limited process is a work-amplification denial of service (CWE-400) whatever the output bound is, so a caller reading an upstream field whose size it does not control caps first. For a value that carries no unsafe rune and is valid UTF-8 the two orders are byte-identical, so adopting this one changes nothing an honest value emits; the two diverge for exactly one input class, described on SanitizeBudgeted.
A Budget is a running remainder over values that ACCUMULATE. A caller bounding several separate output fields by re-measuring an envelope after each trim (a marshaled payload against a vendor limit) is not that shape and keeps its own loop over the Capped pair.
Use NewBudget or NewSingleLineBudget; the zero value has no budget and would treat every write as a cut. A Budget must not be copied after its first write, and it is not safe for concurrent use.
Example ¶
ExampleBudget spends one shared byte budget across several untrusted values and keeps ONE truncation fact for the whole aggregate: each value is capped before it is sanitized, the separators are charged too (so a hostile value COUNT cannot grow the attribute either), and the marker is charged inside the budget, so the joined attribute lands exactly on it with a single marker rather than one per value.
package main
import (
"fmt"
"github.com/cplieger/runesafe"
)
func main() {
b := runesafe.NewBudget(24, "...")
for i, group := range []string{"SubsPlease", "Erai-raws\u202e", "LostYears", "PMR"} {
if i > 0 && !b.Write(", ") {
break
}
if !b.Write(group) {
break
}
}
attr, cut := b.Result()
fmt.Printf("%q %v %d\n", attr, cut, len(attr))
}
Output: "SubsPlease, Erai-raws..." true 24
func NewBudget ¶ added in v1.4.0
NewBudget returns a Budget of n bytes under the keepCRLF=true policy — CR and LF survive, for a sink whose encoder escapes them (JSON, slog's handlers) — appending marker to the aggregate when anything was cut. It is the Sanitize / SanitizeCapped policy in aggregate form; NewSingleLineBudget is the strict twin.
n bounds the TOTAL the aggregate can ever reach, marker included: whatever the writes do, Result returns at most max(n, 0) bytes. A non-positive n therefore accepts nothing, and reports a cut for the first non-empty write.
marker is emitted verbatim, never sanitized: it is the caller's own program text, so a marker assembled from untrusted input must be sanitized by the caller first. An empty marker is legal and yields a silent cap with the fact still returned by Result.
func NewSingleLineBudget ¶ added in v1.4.0
NewSingleLineBudget returns a Budget of n bytes 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 budget, marker and cut contract are identical to NewBudget's; the CR/LF policy is a second constructor rather than a parameter for the same reason SanitizeSingleLineCapped is a second function, so a call site names its sink instead of passing an opaque boolean.
func (*Budget) Result ¶ added in v1.4.0
Result returns the aggregate and the one truncation fact latched across every write. An aggregate that fit comes back whole and unmarked; one that lost bytes anywhere is cut back on a rune boundary to make room for the marker and marked with it, so the text never exceeds max(n, 0) bytes — the SanitizeCapped bound, applied to the whole aggregate instead of one value.
cut reports whether bytes were DROPPED, not whether sanitizing rewrote anything, and it is the fact a caller reports beside the value (a "…_truncated" log attribute, a decision to keep a fuller copy elsewhere). A marker cannot stand in for it: a value may end in the marker on its own.
A budget too small to hold the marker intact drops the marker rather than emitting a fragment of it, and reports the elision through cut alone.
Result reads the Budget without spending it: calling it twice returns the same pair, and a Write after it still appends under whatever budget is left.
func (*Budget) Write ¶ added in v1.4.0
Write appends the sanitized prefix of raw that still fits the remaining budget and reports whether the aggregate is still WHOLE — that is, whether nothing has been dropped yet. It caps raw on a rune boundary at the remaining budget FIRST, so the sanitizer never walks more bytes than the budget allows however long raw is; sanitizing can then grow the chunk (each invalid UTF-8 byte becomes the three-byte U+FFFD), so the result is re-capped on a rune boundary. Either cut latches the fact Result reports.
Once anything has been cut, or the budget is spent, further writes append nothing: a non-empty one is refused whole rather than partially, because an aggregate that mixed a truncated value with a later whole one would read as if the two were contiguous. A write of "" never sets the fact.
The return value is the loop condition an aggregating caller wants, and it is deliberately NOT "the budget has room left": a caller must ATTEMPT the write it cannot fit, because the refusal is what latches the truncation fact. Stopping on a spent-but-uncut budget instead would drop the remaining values silently, and Result would call the aggregate whole.
A fixed separator between values goes through Write too, so a hostile piece COUNT cannot grow the aggregate past the budget either. Sanitizing an ASCII separator is a no-op, so an aggregate of honest values is byte-identical to the joined-then-capped form.
Write is deliberately not io.Writer: a sink that drops bytes on purpose cannot honour that interface's "wrote all of p, or returned an error" contract, and reporting a short write as an error would make every ordinary truncation look like a failure.
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
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
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
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
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
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.