Skip to content

feat(xmlgen): XML generation with automatic escaping and validation (1/4) - #199

Merged
sthanikan2000 merged 1 commit into
mainfrom
feature/xmlgen
Sep 17, 2026
Merged

sthanikan2000 merged 1 commit into
mainfrom
feature/xmlgen

Conversation

@sthanikan2000

@sthanikan2000 sthanikan2000 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

Core has no way to generate an XML document. encoding/xml is imported nowhere, and the only templating in the repo (uiprojector/projector.go) performs no escaping — acceptable for a markdown blurb, not for a document that leaves the system.

Changes

New standalone module xmlgen (stdlib + testify; root module untouched). A template is the target XML with text/template actions in it.

func Generate(ctx context.Context, tmpl []byte, data any, opts ...Option) ([]byte, error)
func GenerateTo(ctx context.Context, w io.Writer, tmpl []byte, data any, opts ...Option) error

func WithStrictKeys() Option        // a referenced-but-absent key becomes an error
func WithMaxOutputBytes(n int64) Option
func SkipNamespaceCheck() Option
  • Escaping is automatic. After parsing, the template is rewritten so every printing action is piped through an escape function — {{ .name }} becomes {{ .name | xml }}, the technique html/template uses. Rewriting the template rather than the data also covers range variables, map keys and pipeline tails. The rewrite walks every tree a parse produced, not just the main one: {{ define }} and {{ block }} install their bodies as associated templates, and Templates() is provably the complete set (execution reaches a tree only via Lookup, which reads the same map). Opt out per action with raw or cdata; text outside an action is never touched.
  • Numbers keep their exact text. JSON is decoded with UseNumber, so 10000000 stays 10000000 rather than becoming 1e+07.
  • Output is validated and never rewritten, so the bytes stay signable: exactly one root element, no DOCTYPE, no text outside the root, no undeclared namespace prefix — the last because encoding/xml documents that it does not reject one. The check is not a backstop for raw, which is the deliberate opt-out: a raw value in an attribute can close the quote and forge further attributes, and that output is well-formed. The README says so explicitly.
  • Following a pointer or interface is depth-bounded. A caller-supplied cycle would otherwise overflow the stack, which Go treats as fatal and recover cannot catch.
  • Wires the module into ci.yml, dependabot.yml and the root README table.

Worth a reviewer's eye: rewrite.go builds a parse.CommandNode literal, so its unexported tr field is nil. Only CommandNode.Copy() dereferences it, reached solely via html/template, which xmlgen never uses. TestInjectEscaping_SurvivesClone pins that.

Testing

cd xmlgen
go test -race ./...
golangci-lint run -c ../.golangci.yml --timeout=5m ./...

115 subtests, race-clean, 0 lint issues. Covers escaping (metacharacters, attribute contexts, a value that is itself valid XML, already-escaped input, invalid UTF-8, control characters, map keys, range variables), every structural rejection above, a UTF-8 BOM, a non-UTF-8 encoding declaration, the size cap, context cancellation, and GenerateTo writing zero bytes when validation fails.

Related

Implements #189. Stacked chain — 1 of 4, review in order:

#199 (this)#201 helpers and resolvers → #202 golden fixtures → #200 failure paths

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d44e92ec-fff3-48d1-a30f-bacb47e42279


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sthanikan2000 sthanikan2000 changed the title feat(xmlgen): generate XML from structured data and a template feat(xmlgen): XML generation with automatic escaping and validation (1/4) Sep 15, 2026
@sthanikan2000
sthanikan2000 added this pull request to stack #204 September 16, 2026 04:55
@sthanikan2000 sthanikan2000 self-assigned this Sep 16, 2026

@ginaxu1 ginaxu1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add tests for both define and block with a markup-carrying value, next to TestEscaping_Metacharacters.

Also check this: the rewriter only walks the main parse tree. {{ define }} and {{ block }} install their bodies as associated templates that text/template removes from that tree, and walkNode treats TemplateNode as a no-op. I rendered this template with v set to <Consignee><Name>Evil</Name></Consignee>:

<R>{{block "item" .}}<Item>{{ .v }}</Item>{{end}}</R>

Generate returned:

<R><Item><Consignee><Name>Evil</Name></Consignee></Item></R>

The same injection happens with {{ define }} plus {{ template }}. That contradicts the claim that every printing action is piped through xml and that a value carrying markup cannot forge elements. checkDocument cannot catch this: the output is well-formed. PR 201 does not close the hole. It still rewrites only t.Tree, and a line-item fragment is the natural place someone would reach for block.

@sthanikan2000

Copy link
Copy Markdown
Collaborator Author

Good catch, and you're right — this was a real hole, not a documentation gap. Fixed in c74da2b.

Confirmed and reproduced, including a case worse than the one you found: {{ block }} nested inside {{ define }} creates a third tree, so an incomplete fix that only followed one level of association would still have leaked.

<R>{{block "item" .}}<Item>{{ .v }}</Item>{{end}}</R>
→ <R><Item><Consignee><Name>Evil</Name></Consignee></Item></R>     (before)
→ <R><Item>&lt;Consignee&gt;&lt;Name&gt;Evil&lt;/Name&gt;…</Item></R>  (after)

Root cause was a false premise I wrote into rewrite.go: "xmlgen parses a single template with no associates." That is wrong — one Parse call returns a map of trees (parse.Parsemap[string]*parse.Tree), and parse.(*Tree).blockControl does block := New(name), leaving only a TemplateNode in the main tree. injectEscaping(t.Tree) never saw the bodies, and as you say checkDocument can't help because the output is well-formed.

Fix: injectEscaping now takes the *template.Template and walks every non-nil tree from t.Templates().

This is complete rather than merely broader, and it's worth stating why: execution reaches a tree only through walkTemplates.tmpl.Lookup(name)walk(dot, tmpl.Root). Lookup reads t.tmpl; Templates() returns that map's values. So the set of exec-reachable trees is exactly what Templates() returns — there is no tree reachable at execution but absent from it. A nested {{define}} inside a list is a parse error, so nothing can hide; {{block}} inside {{define}} shares the parent's tree set and lands in the same map.

Tests added{{ block }}, {{ define }} + {{ template }}, {{ block }} inside {{ define }}, and a block body reached through range, all with your <Consignee> value, asserting both the escaped bytes and that xml.Unmarshal finds zero child elements. Plus internal tests that the associated trees are rewritten, mutual {{ template }} recursion between two defines, and that compile is idempotent — the root is already a member of Templates(), so that property was previously true only by accident.


Two further defects turned up while I was verifying the fix, both now addressed:

  1. text() recursed through pointers and interfaces with no depth bound. A caller-supplied cycle overflows the stack, which Go treats as fatal and recover cannot catch — so it would have killed the process rather than failing the render, and limitWriter couldn't catch it because nothing is ever written. Now depth-bounded, returning ErrUnsupportedValue.

  2. The README oversold the validator with respect to raw. It said the document check still applies "so a stray & is caught", which implies a backstop. In attribute position that's false: <E ref="{{ raw .ref }}"/> with ref set to " classified="SECRET forges an attribute and the result is perfectly well-formed. The docs now say plainly that the check is not a backstop for raw in attribute or element-name position.

Also reserved break and continue as resolver names in #201parse.(*Tree).startParse sets breakOK from !hasFunction("break"), so a resolver by that name would silently demote the keyword and change what every existing {{ break }} does. Not an escaping bypass, but silent wrong output.

The chain has been rebased and force-pushed; #201, #202 and #200 are unchanged apart from that. Subtest counts across the chain: 115 / 153 / 170 / 195.

Core had no way to produce an XML document: encoding/xml was imported
nowhere, and the only templating in the repo performs no escaping. This adds
the xmlgen module -- the rendering engine and its two entry points. The
built-in helper vocabulary, caller-supplied resolvers and the whole-document
golden fixtures follow in stacked PRs.

Escaping is not the template author's job. After parsing, the template is
rewritten so every printing action is piped through an escape function --
the approach html/template takes to the same problem -- so forgetting is not
something a template can do, and a value carrying markup cannot forge
elements. Rewriting the template rather than the data also covers what a
data-side approach cannot reach: range variables, map keys, pipeline tails
and function return values. Templates opt out per action with raw or cdata.
Text outside an action is never touched, so a literal marker element written
in an else branch reaches the document verbatim.

The rewrite walks every tree a parse produced, not only the main one. One
Parse call returns a map of trees: {{ define }} and {{ block }} install their
bodies as associated templates, leaving only a TemplateNode behind, so
walking the main tree alone would let everything a block prints reach the
document unescaped. Templates() is exactly the set execution can reach --
walkTemplate resolves a name through Lookup, which reads the same map -- so
walking all of it is complete rather than merely thorough.

JSON input is decoded with UseNumber, so numbers keep the text they were
written with: 10000000 stays 10000000 rather than becoming 1e+07, and 1.50
keeps its trailing zero. Following a pointer or interface is depth-bounded:
a caller-supplied cycle would otherwise overflow the stack, which Go treats
as fatal and recover cannot catch.

Output is checked as a document before it is returned, and never rewritten,
so the bytes stay stable and remain valid to sign. Beyond well-formedness
that means exactly one root element, no DOCTYPE, no text outside the root,
and no undeclared namespace prefix -- encoding/xml documents that it does
not reject the last, recording the prefix as the namespace instead, so a
prefix typed one letter wrong would otherwise reach the far end unnoticed.

That check is not a backstop for raw, which is documented as the deliberate
opt-out: a raw value placed in an attribute can close the quote and forge
further attributes, and the result is well-formed.

Refs #189

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@sthanikan2000
sthanikan2000 merged commit 9c09678 into main Sep 17, 2026
21 checks passed
@sthanikan2000
sthanikan2000 deleted the feature/xmlgen branch September 17, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants