diff --git a/classes/format_html.go b/classes/format_html.go index 6d74337..b1f23ed 100644 --- a/classes/format_html.go +++ b/classes/format_html.go @@ -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