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}