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.marshall.cp;
018
019import static org.apache.juneau.commons.utils.CollectionUtils.*;
020import static org.apache.juneau.commons.utils.FileUtils.*;
021import static org.apache.juneau.commons.utils.IoUtils.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023import static org.apache.juneau.commons.utils.StringUtils.*;
024import static org.apache.juneau.commons.utils.StringUtils.isEmpty;
025
026import java.io.*;
027import java.util.*;
028import java.util.ResourceBundle.*;
029import java.util.concurrent.*;
030import java.util.regex.*;
031
032import org.apache.juneau.commons.collections.*;
033import org.apache.juneau.commons.io.*;
034
035/**
036 * Basic implementation of a {@link FileFinder}.
037 *
038 * <p>
039 * Specialized behavior can be implemented by overridding the {@link #find(String, Locale)} method.
040 *
041 * <h5 class='section'>Example:</h5>
042 * <p class='bjava'>
043 *    <jk>public class</jk> MyFileFinder <jk>extends</jk> BasicFileFinder {
044 *       <ja>@Override</ja>
045 *       <jk>protected</jk> Optional&lt;InputStream&gt; find(String <jv>name</jv>, Locale <jv>locale</jv>) <jk>throws</jk> IOException {
046 *          <jc>// Do special handling or just call super.find().</jc>
047 *          <jk>return super</jk>.find(<jv>name</jv>, <jv>locale</jv>);
048 *       }
049 *    }
050 * </p>
051 *
052 */
053@SuppressWarnings({
054   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
055   "resource" // Resource management handled externally
056})
057public class BasicFileFinder implements FileFinder {
058
059   private static final ResourceBundle.Control RB_CONTROL = ResourceBundle.Control.getControl(Control.FORMAT_DEFAULT);
060
061   private final Map<String,LocalFile> files = new ConcurrentHashMap<>();
062   private final Map<Locale,Map<String,LocalFile>> localizedFiles = new ConcurrentHashMap<>();
063
064   private final LocalDir[] roots;
065   private final long cachingLimit;
066   private final Pattern[] include;
067   private final Pattern[] exclude;
068   private final String[] includePatterns;
069   private final String[] excludePatterns;
070   private final int hashCode;
071
072   /**
073    * Builder-based constructor.
074    *
075    * @param builder The builder object.
076    */
077   public BasicFileFinder(FileFinder.Builder builder) {
078      this.roots = builder.roots.toArray(new LocalDir[builder.roots.size()]);
079      this.cachingLimit = builder.cachingLimit;
080      this.include = builder.include;
081      this.exclude = builder.exclude;
082      this.includePatterns = l(include).stream().map(Pattern::pattern).toArray(String[]::new);
083      this.excludePatterns = l(exclude).stream().map(Pattern::pattern).toArray(String[]::new);
084      this.hashCode = h(getClass(), roots, cachingLimit, includePatterns, excludePatterns);
085   }
086
087   /**
088    * Default constructor.
089    *
090    * <p>
091    * Can be used when providing a subclass that overrides the {@link #find(String, Locale)} method.
092    */
093   protected BasicFileFinder() {
094      this.roots = new LocalDir[0];
095      this.cachingLimit = -1;
096      this.include = new Pattern[0];
097      this.exclude = new Pattern[0];
098      this.includePatterns = new String[0];
099      this.excludePatterns = new String[0];
100      this.hashCode = h(getClass(), roots, cachingLimit, includePatterns, excludePatterns);
101   }
102
103   @Override /* Overridden from Object */
104   public boolean equals(Object o) {
105      return o instanceof BasicFileFinder o2 && eq(this, o2, (x, y) -> eq(x.hashCode, y.hashCode) && eq(x.getClass(), y.getClass()) && eq(x.roots, y.roots) && eq(x.cachingLimit, y.cachingLimit)
106         && eq(x.includePatterns, y.includePatterns) && eq(x.excludePatterns, y.excludePatterns));
107   }
108
109   @Override /* Overridden from FileFinder */
110   public final Optional<InputStream> getStream(String name, Locale locale) throws IOException {
111      return find(name, locale);
112   }
113
114   @Override /* Overridden from FileFinder */
115   public Optional<String> getString(String name, Locale locale) throws IOException {
116      return o(read(find(name, locale).orElse(null)));
117   }
118
119   @Override
120   public int hashCode() {
121      return hashCode;
122   }
123
124   protected FluentMap<String,Object> properties() {
125      // @formatter:off
126      return filteredBeanPropertyMap()
127         .a("cachingLimit", cachingLimit)
128         .a("class", cns(getClass()))
129         .a("exclude", excludePatterns)
130         .a("include", includePatterns)
131         .a("roots", roots)
132         .a("hashCode", hashCode);
133      // @formatter:on
134   }
135
136   @Override /* Overridden from Object */
137   public String toString() {
138      return r(properties());
139   }
140
141   /**
142    * The main implementation method for finding files.
143    *
144    * <p>
145    * Subclasses can override this method to provide their own handling.
146    *
147    * @param name The resource name.
148    *    See {@link Class#getResource(String)} for format.
149    * @param locale
150    *    The locale of the resource to retrieve.
151    *    <br>If <jk>null</jk>, won't look for localized file names.
152    * @return The resolved resource contents, or an empty {@link Optional} if the resource was not found.
153    * @throws IOException Thrown by underlying stream.
154    */
155   @SuppressWarnings({
156      "java:S3776", // Cognitive complexity acceptable for this specific logic
157      "null" // Null handling verified by context or framework
158   })
159   protected Optional<InputStream> find(String name, Locale locale) throws IOException {
160      name = trimSlashesAndSpaces(name);
161
162      if (isInvalidPath(name))
163         return oe();
164
165      if (nn(locale))
166         localizedFiles.putIfAbsent(locale, new ConcurrentHashMap<>());
167
168      Map<String,LocalFile> fileCache = locale == null ? files : localizedFiles.get(locale);
169
170      LocalFile lf = fileCache.get(name);
171
172      if (lf == null) {
173         List<String> candidateFileNames = getCandidateFileNames(name, locale);
174         for (LocalDir root : roots) {
175            for (var cfn : candidateFileNames) {
176               lf = root.resolve(cfn);
177               if (nn(lf))
178                  break;
179            }
180            if (nn(lf))
181               break;
182         }
183
184         if (nn(lf) && isIgnoredFile(lf.getName()))
185            lf = null;
186
187         if (nn(lf)) {
188            fileCache.put(name, lf);
189
190            if (cachingLimit >= 0) {
191               long size = lf.size();
192               if (size > 0 && size <= cachingLimit)
193                  lf.cache();
194            }
195         }
196      }
197
198      return o(lf == null ? null : lf.read());
199   }
200
201   /**
202    * Returns the candidate file names for the specified file name in the specified locale.
203    *
204    * <p>
205    * For example, if looking for the <js>"MyResource.txt"</js> file in the Japanese locale, the iterator will return
206    * names in the following order:
207    * <ol>
208    *    <li><js>"MyResource_ja_JP.txt"</js>
209    *    <li><js>"MyResource_ja.txt"</js>
210    *    <li><js>"MyResource.txt"</js>
211    * </ol>
212    *
213    * <p>
214    * If the locale is <jk>null</jk>, then it will only return <js>"MyResource.txt"</js>.
215    *
216    * @param fileName The name of the file to get candidate file names on.
217    * @param locale
218    *    The locale.
219    *    <br>If <jk>null</jk>, won't look for localized file names.
220    * @return An iterator of file names to look at.
221    */
222   protected List<String> getCandidateFileNames(String fileName, Locale locale) {
223
224      if (locale == null)
225         return Collections.singletonList(fileName);
226
227      var list = new ArrayList<String>();
228      var baseName = getBaseName(fileName);
229      var ext = getFileExtension(fileName);
230
231      getCandidateLocales(locale).forEach(x -> {
232         var ls = x.toString();
233         if (ls.isEmpty())
234            list.add(fileName);
235         else {
236            list.add(baseName + "_" + ls + (ext.isEmpty() ? "" : ('.' + ext)));
237            list.add(ls.replace('_', '/') + '/' + fileName);
238         }
239      });
240
241      return list;
242   }
243
244   /**
245    * Returns the candidate locales for the specified locale.
246    *
247    * <p>
248    * For example, if <c>locale</c> is <js>"ja_JP"</js>, then this method will return:
249    * <ol>
250    *    <li><js>"ja_JP"</js>
251    *    <li><js>"ja"</js>
252    *    <li><js>""</js>
253    * </ol>
254    *
255    * @param locale The locale to get the list of candidate locales for.
256    * @return The list of candidate locales.
257    */
258   protected List<Locale> getCandidateLocales(Locale locale) {
259      return RB_CONTROL.getCandidateLocales("", locale);
260   }
261
262   /**
263    * Returns <jk>true</jk> if the file should be ignored based on file name.
264    *
265    * @param name The name to check.
266    * @return <jk>true</jk> if the file should be ignored.
267    */
268   protected boolean isIgnoredFile(String name) {
269      for (var p : exclude)
270         if (p.matcher(name).matches())
271            return true;
272      for (var p : include)
273         if (p.matcher(name).matches())
274            return false;
275      return true;
276   }
277
278   /**
279    * Checks for path malformations such as use of <js>".."</js> which can be used to open up security holes.
280    *
281    * <p>
282    * Default implementation returns <jk>true</jk> if the path is any of the following:
283    * <ul>
284    *    <li>Is blank or <jk>null</jk>.
285    *    <li>Contains <js>".."</js> (to prevent traversing out of working directory).
286    *    <li>Contains <js>"%"</js> (to prevent URI trickery).
287    * </ul>
288    *
289    * @param path The path to check.
290    * @return <jk>true</jk> if the path is invalid.
291    */
292   protected boolean isInvalidPath(String path) {
293      return isEmpty(path) || path.contains("..") || path.contains("%");
294   }
295}