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