feat(classes): report what the HTML policy removes

- SanitizeHTMLReport returns the policy output plus HTMLRemovals: removed
  elements, attributes (repeats and changed values included) and comments,
  at most ten distinct names per list with exact counts
- SanitizeHTML is the string half of it; FormatHTML, FormatTranslation and
  CheckHTML keep their behaviour
This commit is contained in:
2026-10-10 11:59:45 +02:00
parent 086e485215
commit cbf4891703

View File

@@ -79,12 +79,200 @@ func FormatHTML(src string) (string, error) {
// FormatHTML allow-list and strips what the policy does not allow. HTML the
// policy keeps whole is returned byte for byte. It is the gate for
// content_html supplied without markdown, such as a translation that carries
// only content_html.
// only content_html. It is the string half of SanitizeHTMLReport.
func SanitizeHTML(doc string) string {
out, _ := sanitizeHTML(doc)
out, _ := SanitizeHTMLReport(doc)
return out
}
// maxRemovalNames is how many distinct names an HTMLRemovals list keeps.
const maxRemovalNames = 10
// HTMLRemovals reports what SanitizeHTMLReport removed from a document.
// Elements and Attributes are the distinct removed element and attribute
// names in first-seen order, at most ten each; ElementCount and
// AttributeCount count every removal, so they stay exact when a list is cut.
// An attribute counts as removed when the policy dropped it, when it is a
// repeat of a name the tag already has, or when the policy changed its value
// (a style attribute that lost a property counts under "style"). Comments
// counts removed HTML comments. Other is set when the policy changed the
// document in a way none of the other fields describe.
type HTMLRemovals struct {
Elements []string
ElementCount int
Attributes []string
AttributeCount int
Comments int
Other bool
// moreElements and moreAttributes are set when a list was cut.
moreElements bool
moreAttributes bool
}
// Empty reports whether nothing was removed, which is exactly when
// SanitizeHTMLReport returned its input unchanged.
func (r HTMLRemovals) Empty() bool {
return r.ElementCount == 0 && r.AttributeCount == 0 && r.Comments == 0 && !r.Other
}
func (r *HTMLRemovals) addElement(name string) {
r.ElementCount++
r.Elements, r.moreElements = addRemovalName(r.Elements, r.moreElements, name)
}
func (r *HTMLRemovals) addAttribute(name string) {
r.AttributeCount++
r.Attributes, r.moreAttributes = addRemovalName(r.Attributes, r.moreAttributes, name)
}
func addRemovalName(names []string, cut bool, name string) ([]string, bool) {
if slices.Contains(names, name) {
return names, cut
}
if len(names) >= maxRemovalNames {
return names, true
}
return append(names, name), cut
}
// SanitizeHTMLReport is SanitizeHTML with a report of what the policy
// removed. HTML the policy keeps whole is returned byte for byte with an
// empty report. Otherwise the policy's output is returned and the report is
// built by aligning the start tags of doc with those of the output in order
// (the policy only removes, it never adds or reorders): an input tag without
// a counterpart is a removed element, and an attribute of a matched tag that
// the output lacks or holds with another value is a removed attribute.
// Comments are counted on their own. The report is empty exactly when the
// returned string equals doc.
func SanitizeHTMLReport(doc string) (string, HTMLRemovals) {
clean, changed := sanitizeHTML(doc)
if !changed || clean == doc {
return doc, HTMLRemovals{}
}
report := removalReport(doc, clean)
if report.Empty() {
report.Other = true
}
return clean, report
}
// reportTag is one start tag of a document as removalReport sees it.
type reportTag struct {
name string
raw []string // attribute names in source order, repeats included
attrs map[string]string // decoded values, the first of a repeated name
}
// reportTokens returns doc's start tags and its number of comments.
func reportTokens(doc string) ([]reportTag, int) {
var (
tags []reportTag
comments int
)
z := xhtml.NewTokenizer(strings.NewReader(doc))
for {
tt := z.Next()
switch tt {
case xhtml.ErrorToken:
return tags, comments
case xhtml.CommentToken:
comments++
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
raw := rawAttrNames(z.Raw())
t := z.Token()
attrs := make(map[string]string, len(t.Attr))
for _, a := range t.Attr {
key := a.Key
if a.Namespace != "" {
key = a.Namespace + ":" + a.Key
}
if _, seen := attrs[key]; !seen {
attrs[key] = a.Val
}
}
tags = append(tags, reportTag{name: t.Data, raw: raw, attrs: attrs})
}
}
}
// keeps reports whether out can be the policy's output of in: the same
// element, and every output attribute present in the input with the same
// value, except style, whose properties the policy filters one by one.
func (in reportTag) keeps(out reportTag) bool {
if in.name != out.name {
return false
}
for key, val := range out.attrs {
got, ok := in.attrs[key]
if !ok || (got != val && key != "style") {
return false
}
}
return true
}
// removalReport compares doc with the policy's output clean. It aligns the
// start tags greedily, preferring an output tag whose attributes the input
// tag can have produced; when that leaves output tags unmatched, it aligns
// by element name only, which always succeeds when the output's tags are a
// subsequence of the input's.
func removalReport(doc, clean string) HTMLRemovals {
in, inComments := reportTokens(doc)
out, outComments := reportTokens(clean)
match := alignTags(in, out, reportTag.keeps)
if match == nil {
match = alignTags(in, out, func(a, b reportTag) bool { return a.name == b.name })
}
var r HTMLRemovals
for i, tag := range in {
j := -1
if match != nil {
j = match[i]
}
if j < 0 {
r.addElement(tag.name)
continue
}
kept := out[j]
seen := make(map[string]bool, len(tag.raw))
for _, name := range tag.raw {
if seen[name] {
r.addAttribute(name) // a repeat the tokenizer dropped
continue
}
seen[name] = true
val, ok := kept.attrs[name]
if !ok || val != tag.attrs[name] {
r.addAttribute(name)
}
}
}
if inComments > outComments {
r.Comments = inComments - outComments
}
return r
}
// alignTags matches every tag of out to a tag of in, in order, with the
// first input tag for which same holds. The result maps each input index to
// its output index or -1. It is nil when an output tag stays unmatched.
func alignTags(in, out []reportTag, same func(in, out reportTag) bool) []int {
match := make([]int, len(in))
j := 0
for i := range in {
match[i] = -1
if j < len(out) && same(in[i], out[j]) {
match[i] = j
j++
}
}
if j < len(out) {
return nil
}
return match
}
// FormatTranslation sets attrs["content_html"] for one locale's translated
// post attributes, so a translated content_html is stored under the same
// policy as the host column. When attrs["content"] is not blank, content_html