diff --git a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java index 9abe695421c..b42c1d46456 100644 --- a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java +++ b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/SimpleGroovyClassDoc.java @@ -1245,6 +1245,24 @@ public static String encodeAngleBrackets(String text) { return text == null ? null : text.replace("<", "<").replace(">", ">"); } + /** + * Escapes text for use as the value of a quoted HTML attribute, so that text taken from a + * doc comment cannot close the attribute or the tag around it. + * + * @param text the text to escape + * @return the escaped text + */ + public static String encodeAttribute(String text) { + if (text == null) return null; + // The ampersand is replaced first so that the entities introduced below are not + // themselves re-escaped. + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + /** * Stores the rendered class name including any type arguments. * diff --git a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java index 8f7cfa6a7da..064a6db5a5b 100644 --- a/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java +++ b/subprojects/groovy-groovydoc/src/main/java/org/codehaus/groovy/tools/groovydoc/TagRenderer.java @@ -30,6 +30,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; + +import groovy.util.regex.RegexGuard; +import groovy.util.regex.RegexTimeoutException; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; @@ -100,6 +103,13 @@ */ final class TagRenderer { + /** + * How long a single snippet markup directive may spend matching one line. A directive + * carries a regex written by the author of the documented source, so the budget bounds what + * a doc comment can cost the build rather than trying to decide which patterns are safe. + */ + private static final long MATCH_TIMEOUT_MILLIS = 250L; + /** Block-tag names that get merged under a single display heading. */ static final Map COLLATED_TAGS = new LinkedHashMap<>(); static { @@ -450,9 +460,15 @@ private static int renderSnippetAt(String text, int start, int nameEnd, StringBu // or hyperlinks. String processed = processSnippetMarkup(dedented, links, relPath, rootDoc, classDoc); out.append("
').append(processed).append("
"); return endPos - start; } @@ -759,15 +775,28 @@ private static String applyDirective(String escaped, Directive d, GroovyRootDoc rootDoc, SimpleGroovyClassDoc classDoc) { Pattern pat = buildMatchPattern(d); if (pat == null) return escaped; // no match clause — nothing to do - Matcher m = pat.matcher(escaped); + // Both the pattern and the line it runs against come from the documented source, so a + // directive can otherwise pin the doc build on a few bytes. Give each directive a + // deadline rather than trying to judge which patterns are safe. + Matcher m; + try { + m = RegexGuard.matcher(pat, escaped, MATCH_TIMEOUT_MILLIS); + } catch (RegexTimeoutException e) { + return escaped; + } StringBuilder sb = new StringBuilder(); int last = 0; - while (m.find()) { - sb.append(escaped, last, m.start()); - String match = m.group(); - String wrapped = wrapForDirective(match, d, links, relPath, rootDoc, classDoc); - sb.append(wrapped); - last = m.end(); + try { + while (m.find()) { + sb.append(escaped, last, m.start()); + String match = m.group(); + String wrapped = wrapForDirective(match, d, links, relPath, rootDoc, classDoc); + sb.append(wrapped); + last = m.end(); + } + } catch (RegexTimeoutException e) { + // Leave the line as it was rather than half annotated. + return escaped; } sb.append(escaped, last, escaped.length()); return sb.toString(); diff --git a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java index 76f648a3c27..60566d00d69 100644 --- a/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java +++ b/subprojects/groovy-groovydoc/src/test/groovy/org/codehaus/groovy/tools/groovydoc/GroovyDocToolTest.java @@ -252,6 +252,80 @@ public void testSnippetTagExternalFormLoadsFromSnippetFiles() throws Exception { snip.contains("Licensed to the Apache")); } + // GROOVY-12275: snippet attribute values reach quoted HTML attributes. The attribute parser + // accepts a double quote inside a single-quoted or unquoted value, so an id could close the + // attribute and the tag around it. + public void testSnippetAttributeValuesCannotEscapeTheirAttribute() throws Exception { + String pkg = "org/codehaus/groovy/tools/groovydoc/testfiles/docfiles"; + Path tmp = Files.createTempDirectory("snippet-attr-"); + Path pkgDir = tmp.resolve(pkg); + Files.createDirectories(pkgDir); + Files.writeString(pkgDir.resolve("SnippetAttr.groovy"), + "package " + pkg.replace('/', '.') + "\n" + + "/**\n" + + " * {@snippet id='x\">(), null, new Properties() + ); + tool.add(List.of(pkg + "/" + simpleName + ".groovy")); + MockOutputTool output = new MockOutputTool(); + tool.renderToOutput(output, MOCK_DIR); + return output.getText(MOCK_DIR + "/" + pkg + "/" + simpleName + ".html"); + } + // Auto-strip opt-out: {@snippet file="X" keepHeader=true} preserves the // file content verbatim, and lang is inferred from the file's extension // when no explicit lang= is given.