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