001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.bct.assertions; 018 019import static java.nio.charset.StandardCharsets.*; 020import static org.apache.juneau.commons.utils.Shorts.f; 021 022import java.io.*; 023import java.net.*; 024import java.nio.file.*; 025import java.util.*; 026import java.util.regex.*; 027 028import org.opentest4j.*; 029 030/** 031 * Guards shipped resources against a literal closing script tag ({@code </script}, matched case-insensitively). 032 * 033 * <p> 034 * Host pages may inline JavaScript or CSS resources into an HTML {@code <script>} block. The HTML parser ends that 035 * block at the first closing script tag it sees, even one inside a comment or string, which silently truncates the 036 * rest of the resource. Nothing fails loudly, so this check names each offending file and line instead. 037 * 038 * <h5 class='section'>Example:</h5> 039 * <p class='bjava'> 040 * <jc>// Fail if any *.js or *.css file beside juneau-views.js contains a closing script tag.</jc> 041 * InlineScriptSafety.<jsm>assertNoScriptCloseTag</jsm>(ViewsMixin.<jk>class</jk>, <js>"/org/apache/juneau/views/juneau-views.js"</js>, <js>".js"</js>, <js>".css"</js>); 042 * </p> 043 */ 044public final class InlineScriptSafety { 045 046 private static final Pattern CLOSING_SCRIPT = Pattern.compile("</script", Pattern.CASE_INSENSITIVE); 047 048 private InlineScriptSafety() {} 049 050 /** 051 * Asserts that no resource beside the specified anchor resource contains a closing script tag. 052 * 053 * <p> 054 * The directory is located through the classpath, so the resources must be on the file system (not in a jar). 055 * 056 * @param anchor Class used to resolve <c>resource</c>. 057 * @param resource Resource path, absolute or relative to <c>anchor</c>'s package, of a file in the directory to scan. 058 * @param extensions File-name suffixes to scan (e.g. <js>".js"</js>). Must not be empty. 059 * @throws IOException If the directory cannot be located or a file cannot be read. 060 * @throws AssertionFailedError If no matching files were found, or any contains a closing script tag. 061 */ 062 public static void assertNoScriptCloseTag(Class<?> anchor, String resource, String... extensions) throws IOException { 063 var files = listResources(anchor, resource, extensions); 064 if (files.isEmpty()) 065 throw new AssertionFailedError(f("No resources matching '%s' found beside '%s'", String.join("', '", extensions), resource)); 066 var found = new ArrayList<String>(); 067 for (var p : files) 068 found.addAll(findCloseTags(p.getFileName().toString(), new String(Files.readAllBytes(p), UTF_8))); 069 if (! found.isEmpty()) 070 throw new AssertionFailedError(f("Closing script tag found at '%s'", String.join("', '", found))); 071 } 072 073 /** 074 * Lists the files in the directory containing the specified resource that end with one of the specified suffixes. 075 * 076 * @param anchor Class used to resolve <c>resource</c>. 077 * @param resource Resource path, absolute or relative to <c>anchor</c>'s package, of a file in the directory to scan. 078 * @param extensions File-name suffixes to include. 079 * @return The matching files, sorted by path. Never <jk>null</jk>. 080 * @throws IOException If the resource does not exist, is not on the file system, or the directory cannot be listed. 081 */ 082 public static List<Path> listResources(Class<?> anchor, String resource, String... extensions) throws IOException { 083 var url = anchor.getResource(resource); 084 if (url == null) 085 throw new FileNotFoundException(f("Resource not found: '%s'", resource)); 086 Path dir; 087 try { 088 dir = Path.of(url.toURI()).getParent(); 089 } catch (URISyntaxException | FileSystemNotFoundException e) { 090 throw new IOException(f("Resource is not on the file system: '%s'", url), e); 091 } 092 try (var s = Files.list(dir)) { 093 return s.filter(p -> { 094 var name = p.getFileName().toString(); 095 return Arrays.stream(extensions).anyMatch(name::endsWith); 096 }).sorted().toList(); 097 } 098 } 099 100 /** 101 * Finds each line of the specified text containing a closing script tag. 102 * 103 * @param name Name used to identify the text in the results (typically the file name). 104 * @param text The text to scan. 105 * @return A <c>name:line</c> entry (1-based) for each offending line. Never <jk>null</jk>. 106 */ 107 public static List<String> findCloseTags(String name, String text) { 108 var out = new ArrayList<String>(); 109 var lines = text.split("\n", -1); 110 for (var i = 0; i < lines.length; i++) 111 if (CLOSING_SCRIPT.matcher(lines[i]).find()) 112 out.add(name + ":" + (i + 1)); 113 return out; 114 } 115}