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.config.store;
018
019import static java.nio.file.StandardOpenOption.*;
020import static java.nio.file.StandardWatchEventKinds.*;
021import static org.apache.juneau.commons.utils.FileUtils.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023import static org.apache.juneau.commons.utils.StringUtils.*;
024import static org.apache.juneau.commons.utils.SystemUtils.*;
025import static org.apache.juneau.commons.utils.ThrowableUtils.*;
026
027import java.io.*;
028import java.nio.*;
029import java.nio.channels.*;
030import java.nio.charset.*;
031import java.nio.file.*;
032import java.nio.file.attribute.*;
033import java.util.concurrent.*;
034
035import org.apache.juneau.commons.collections.*;
036
037/**
038 * Filesystem-based storage location for configuration files.
039 *
040 * <p>
041 * Points to a file system directory containing configuration files.
042 *
043 * <h2 class='section'>Notes:</h2><ul>
044 *    <li class='note'>This class is thread safe and reusable.
045 * </ul>
046 */
047@SuppressWarnings({
048   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
049   "java:S3776", // read() and write() combine cache lookup, file locking and compare-and-swap of contents in one flow
050   "javabugs:S2259", // The nullable watcher and the conditional FileChannel lock resource in read() are guarded by nn()/ternary checks that flow analysis does not track
051   "resource" // Resources are managed by caller
052})
053public class FileStore extends ConfigStore {
054
055   private static final String MSG_PATH_ESCAPE = "Name escapes configured root directory.";
056
057   /**
058    * Builder class.
059    */
060   public static class Builder extends ConfigStore.Builder<Builder> {
061
062      private Charset charset;
063      private boolean allowAbsoluteNames;
064      private boolean enableWatcher;
065      private boolean updateOnWrite;
066      private String directory;
067      private String extensions;
068      private WatcherSensitivity watcherSensitivity;
069
070      /**
071       * Constructor, default settings.
072       */
073      protected Builder() {
074         charset = env("ConfigFileStore.charset").map(Charset::forName).orElse(Charset.defaultCharset());
075         directory = env("ConfigFileStore.directory", ".");
076         allowAbsoluteNames = env("ConfigFileStore.allowAbsoluteNames", false);
077         enableWatcher = env("ConfigFileStore.enableWatcher", false);
078         extensions = env("ConfigFileStore.extensions", "cfg,yml,yaml");
079         updateOnWrite = env("ConfigFileStore.updateOnWrite", false);
080         watcherSensitivity = env("ConfigFileStore.watcherSensitivity", WatcherSensitivity.MEDIUM);
081      }
082
083      /**
084       * Copy constructor.
085       *
086       * @param copyFrom The builder to copy from.
087       *    <br>Cannot be <jk>null</jk>.
088       */
089      protected Builder(Builder copyFrom) {
090         super(reqnn("copyFrom", copyFrom));
091         charset = copyFrom.charset;
092         directory = copyFrom.directory;
093         allowAbsoluteNames = copyFrom.allowAbsoluteNames;
094         enableWatcher = copyFrom.enableWatcher;
095         extensions = copyFrom.extensions;
096         updateOnWrite = copyFrom.updateOnWrite;
097         watcherSensitivity = copyFrom.watcherSensitivity;
098      }
099
100      /**
101       * Copy constructor.
102       *
103       * @param copyFrom The bean to copy from.
104       *    <br>Cannot be <jk>null</jk>.
105       */
106      protected Builder(FileStore copyFrom) {
107         super(reqnn("copyFrom", copyFrom));
108         type(copyFrom.getClass());
109         charset = copyFrom.charset;
110         directory = copyFrom.directory;
111         allowAbsoluteNames = copyFrom.allowAbsoluteNames;
112         enableWatcher = copyFrom.enableWatcher;
113         extensions = copyFrom.extensions;
114         updateOnWrite = copyFrom.updateOnWrite;
115         watcherSensitivity = copyFrom.watcherSensitivity;
116      }
117
118      @Override /* Overridden from Context.Builder<?> */
119      public FileStore build() {
120         return build(FileStore.class);
121      }
122
123      /**
124       * Charset for external files.
125       *
126       * <p>
127       * Identifies the charset of external files.
128       *
129       * @param value
130       *    The new value for this property.
131       *    <br>The default is the first value found:
132       *    <ul>
133       *       <li>System property <js>"ConfigFileStore.charset"</js>
134       *       <li>Environment variable <js>"CONFIGFILESTORE_CHARSET"</js>
135       *       <li>{@link Charset#defaultCharset()}
136       *    </ul>
137       *    <br>Cannot be <jk>null</jk>.
138       * @return This object.
139       */
140      public Builder charset(Charset value) {
141         charset = reqnn("value", value);
142         return this;
143      }
144
145      @Override /* Overridden from Context.Builder<?> */
146      public Builder copy() {
147         return new Builder(this);
148      }
149
150      /**
151       * Local file system directory.
152       *
153       * <p>
154       * Identifies the path of the directory containing the configuration files.
155       *
156       * @param value
157       *    The new value for this property.
158       *    <br>The default is the first value found:
159       *    <ul>
160       *       <li>System property <js>"ConfigFileStore.directory"</js>
161       *       <li>Environment variable <js>"CONFIGFILESTORE_DIRECTORY"</js>
162       *       <li><js>"."</js>.
163       *    </ul>
164       *    <br>Cannot be <jk>null</jk>.
165       * @return This object.
166       */
167      public Builder directory(File value) {
168         directory = reqnn("value", value).getAbsolutePath();
169         return this;
170      }
171
172      /**
173       * Local file system directory.
174       *
175       * <p>
176       * Identifies the path of the directory containing the configuration files.
177       *
178       * @param value
179       *    The new value for this property.
180       *    <br>The default is the first value found:
181       *    <ul>
182       *       <li>System property <js>"ConfigFileStore.directory"</js>
183       *       <li>Environment variable <js>"CONFIGFILESTORE_DIRECTORY"</js>
184       *       <li><js>"."</js>
185       *    </ul>
186       *    <br>Cannot be <jk>null</jk>.
187       * @return This object.
188       */
189      public Builder directory(String value) {
190         directory = reqnn("value", value);
191         return this;
192      }
193
194      /**
195       * Use watcher.
196       *
197       * <p>
198       * Use a file system watcher for file system changes.
199       *
200       * <h4 class='section'>Notes:</h4><ul>
201       *    <li class='note'>Calling {@link FileStore#close()} closes the watcher.
202       * </ul>
203       *
204       * <p>
205       *    The default is the first value found:
206       *    <ul>
207       *       <li>System property <js>"ConfigFileStore.enableWatcher"</js>
208       *       <li>Environment variable <js>"CONFIGFILESTORE_ENABLEWATCHER"</js>
209       *       <li><jk>false</jk>.
210       *    </ul>
211       *
212       * @return This object.
213       */
214      public Builder enableWatcher() {
215         enableWatcher = true;
216         return this;
217      }
218
219      /**
220       * Allows absolute file names (e.g. <js>"/etc/app/app.cfg"</js>) to be read and written outside the store directory.
221       *
222       * <p>
223       * By default, every name must resolve inside the configured directory.
224       *
225       * @return This object.
226       */
227      public Builder allowAbsoluteNames() {
228         allowAbsoluteNames = true;
229         return this;
230      }
231
232      /**
233       * File extensions.
234       *
235       * <p>
236       * Defines what file extensions to search for when the config name does not have an extension.
237       *
238       * @param value
239       *    The new value for this property.
240       *    The default is the first value found:
241       *    <ul>
242       *       <li>System property <js>"ConfigFileStore.extensions"</js>
243       *       <li>Environment variable <js>"CONFIGFILESTORE_EXTENSIONS"</js>
244       *       <li><js>"cfg"</js>
245       *    </ul>
246       *    <br>Cannot be <jk>null</jk>.
247       * @return This object.
248       */
249      public Builder extensions(String value) {
250         extensions = reqnn("value", value);
251         return this;
252      }
253
254      /**
255       * Update-on-write.
256       *
257       * <p>
258       * When enabled, the {@link FileStore#update(String, String)} method will be called immediately following
259       * calls to {@link FileStore#write(String, String, String)} when the contents are changing.
260       * <br>This allows for more immediate responses to configuration changes on file systems that use
261       * polling watchers.
262       * <br>This may cause double-triggering of {@link ConfigStoreListener ConfigStoreListeners}.
263       *
264       * <p>
265       *    The default is the first value found:
266       *    <ul>
267       *       <li>System property <js>"ConfigFileStore.updateOnWrite"</js>
268       *       <li>Environment variable <js>"CONFIGFILESTORE_UPDATEONWRITE"</js>
269       *       <li><jk>false</jk>.
270       *    </ul>
271       *
272       * @return This object.
273       */
274      public Builder updateOnWrite() {
275         updateOnWrite = true;
276         return this;
277      }
278
279      /**
280       * Watcher sensitivity.
281       *
282       * <p>
283       * Determines how frequently the file system is polled for updates.
284       *
285       * <h4 class='section'>Notes:</h4><ul>
286       *    <li class='note'>This relies on internal Sun packages and may not work on all JVMs.
287       * </ul>
288       *
289       * @param value
290       *    The new value for this property.
291       *    <br>The default is the first value found:
292       *    <ul>
293       *       <li>System property <js>"ConfigFileStore.watcherSensitivity"</js>
294       *       <li>Environment variable <js>"CONFIGFILESTORE_WATCHERSENSITIVITY"</js>
295       *       <li>{@link WatcherSensitivity#MEDIUM}
296       *    </ul>
297       *    <br>Cannot be <jk>null</jk>.
298       * @return This object.
299       */
300      public Builder watcherSensitivity(WatcherSensitivity value) {
301         watcherSensitivity = reqnn("value", value);
302         return this;
303      }
304   }
305
306   @SuppressWarnings({
307      "java:S112" // throws Exception intentional - callback/lifecycle method
308   })
309   class WatcherThread extends Thread {
310      private final WatchService watchService;
311
312      WatcherThread(File dir, WatcherSensitivity s) throws Exception {
313         watchService = FileSystems.getDefault().newWatchService();
314         var kinds = a(ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
315         var modifier = lookupModifier(s);
316         dir.toPath().register(watchService, kinds, modifier);
317      }
318
319      @Override /* Overridden from Thread */
320      public void interrupt() {
321         try {
322            watchService.close();
323         } catch (IOException e) {
324            throw toRex(e); // HTT - watchService.close() doesn't throw in normal operation
325         } finally {
326            super.interrupt();
327         }
328      }
329
330      private void handleEvent(WatchEvent<Path> event) {
331         try {
332            FileStore.this.onFileEvent(event);
333         } catch (@SuppressWarnings("unused") IOException | IllegalArgumentException e) {
334            // The file or directory vanished (or became unreadable) between the event and its handling,
335            // e.g. while the watched directory is being deleted.  Skip the event; don't kill the watcher.
336         }
337      }
338
339      @SuppressWarnings({
340         "unchecked" // Type erasure requires unchecked casts
341      })
342      @Override /* Overridden from Thread */
343      public void run() {
344         try {
345            WatchKey key;
346            while (nn(key = watchService.take())) {
347               for (var event : key.pollEvents()) {
348                  var kind = event.kind();
349                  if (kind != OVERFLOW) // HTT - OVERFLOW events are rare in tests
350                     handleEvent((WatchEvent<Path>)event);
351               }
352               if (! key.reset()) // HTT - key always resets in test environment
353                  break;
354            }
355         } catch (@SuppressWarnings("unused") InterruptedException e) {
356            Thread.currentThread().interrupt();
357         } catch (@SuppressWarnings("unused") ClosedWatchServiceException e) {
358            // Expected on shutdown: interrupt() closes the WatchService while take() is blocked in this loop.
359         } catch (Exception e) {
360            throw toRex(e); // HTT - unexpected exception from watchService.take()
361         }
362      }
363
364      @SuppressWarnings({
365         "java:S5738" // com.sun.nio.file.SensitivityWatchEventModifier is deprecated-for-removal but has no public JDK replacement for WatchService poll sensitivity; required for the watcher-sensitivity feature on JVMs that still honor it.
366      })
367      private WatchEvent.Modifier lookupModifier(WatcherSensitivity s) {
368         try {
369            return switch (s) {
370               case LOW -> com.sun.nio.file.SensitivityWatchEventModifier.LOW;
371               case MEDIUM -> com.sun.nio.file.SensitivityWatchEventModifier.MEDIUM;
372               case HIGH -> com.sun.nio.file.SensitivityWatchEventModifier.HIGH;
373            };
374         } catch (@SuppressWarnings("unused") Exception e) {
375            /* Ignore */ // HTT - SensitivityWatchEventModifier exists on all supported JVMs
376         }
377         return null; // HTT - return null is only reached if SensitivityWatchEventModifier is unavailable
378      }
379   }
380
381   /** Default file store, all default values.*/
382   public static final FileStore DEFAULT = FileStore.create().build();
383
384   /**
385    * Creates a new builder for this object.
386    *
387    * @return A new builder.
388    */
389   public static Builder create() {
390      return new Builder();
391   }
392
393   protected final boolean allowAbsoluteNames;
394   protected final boolean enableWatcher;
395   protected final boolean updateOnWrite;
396   protected final Charset charset;
397   protected final String directory;
398   protected final String extensions;
399   protected final WatcherSensitivity watcherSensitivity;
400
401   private final ConcurrentHashMap<String,String> cache = new ConcurrentHashMap<>();
402   private final ConcurrentHashMap<String,String> nameCache = new ConcurrentHashMap<>();
403   private final File dir;
404   private final String[] exts;
405   private final WatcherThread watcher;
406
407   /**
408    * Constructor.
409    *
410    * @param builder The builder for this object.
411    *    <br>Cannot be <jk>null</jk>.
412    */
413   public FileStore(Builder builder) {
414      super(builder);
415      directory = builder.directory;
416      extensions = builder.extensions;
417      charset = builder.charset;
418      allowAbsoluteNames = builder.allowAbsoluteNames;
419      enableWatcher = builder.enableWatcher;
420      updateOnWrite = builder.updateOnWrite;
421      watcherSensitivity = builder.watcherSensitivity;
422      try {
423         dir = new File(directory).getCanonicalFile();
424         dir.mkdirs();
425         exts = split(extensions).toArray(String[]::new);
426         watcher = enableWatcher ? new WatcherThread(dir, watcherSensitivity) : null;
427         if (nn(watcher))
428            watcher.start();
429      } catch (Exception e) {
430         throw toRex(e); // HTT - getCanonicalFile() IOException is not normally triggered
431      }
432   }
433
434   @Override /* Overridden from Closeable */
435   public synchronized void close() {
436      if (nn(watcher))
437         watcher.interrupt();
438   }
439
440   @Override /* Overridden from Context */
441   public Builder copy() {
442      return new Builder(this);
443   }
444
445   @Override /* Overridden from ConfigStore */
446   public synchronized boolean exists(String name) {
447      try {
448         return Files.exists(resolveFile(name));
449      } catch (@SuppressWarnings("unused") IllegalArgumentException e) {
450         return false; // Relative names that escape the store directory are treated as not found.
451      }
452   }
453
454   @Override /* Overridden from ConfigStore */
455   public synchronized String read(String name) throws IOException {
456      name = resolveName(name);
457
458      var p = resolveFile(name);
459      var pName = p.getFileName();
460      if (n(pName))
461         throw ioex("Could not resolve a file name from path '%s'.", p); // Real NPE guard: getFileName() can return null when p resolves to a root path (e.g. a caller-supplied absolute name).
462      name = pName.toString();
463      var key = p.toAbsolutePath().normalize().toString();
464
465      var s = cache.get(key);
466      if (nn(s))
467         return s;
468
469      dir.mkdirs();
470
471      // If file doesn't exist, don't trigger creation.
472      if (! Files.exists(p))
473         return "";
474
475      var isWritable = isWritable(p);
476      var oo = isWritable ? a(READ, WRITE, CREATE) : a(READ); // HTT - read-only files require special OS setup
477      try (var fc = FileChannel.open(p, oo)) {
478         try (var lock = isWritable ? fc.lock() : null) { // HTT - null lock path requires read-only file
479            var buf = ByteBuffer.allocate(1024);
480            var sb = new StringBuilder();
481            while (fc.read(buf) != -1) {
482               sb.append(charset.decode((buf.flip()))); // Fixes Java 11 issue involving overridden flip method.
483               buf.clear();
484            }
485            s = sb.toString();
486            cache.put(key, s);
487         }
488      }
489
490      return cache.get(key);
491   }
492
493   @Override /* Overridden from ConfigStore */
494   public synchronized FileStore update(String name, String newContents) {
495      cache.put(cacheKey(name), newContents);
496      super.update(name, newContents);
497      return this;
498   }
499
500   @Override /* Overridden from ConfigStore */
501   public synchronized String write(String name, String expectedContents, String newContents) throws IOException {
502      name = resolveName(name);
503
504      // This is a no-op.
505      if (eq(expectedContents, newContents))
506         return null;
507
508      dir.mkdirs();
509
510      var p = resolveFile(name);
511      var pName = p.getFileName();
512      if (n(pName))
513         throw ioex("Could not resolve a file name from path '%s'.", p); // Real NPE guard: getFileName() can return null when p resolves to a root path (e.g. a caller-supplied absolute name).
514      name = pName.toString();
515      var key = p.toAbsolutePath().normalize().toString();
516
517      var exists = Files.exists(p);
518
519      // Don't create the file if we're not going to match.
520      if ((! exists) && ine(expectedContents))
521         return "";
522
523      if (isWritable(p)) {
524         if (newContents == null)
525            Files.delete(p);
526         else {
527            try (var fc = FileChannel.open(p, READ, WRITE, CREATE)) {
528               try (var lock = fc.lock()) {
529                  var currentContents = "";
530                  if (exists) {
531                     var buf = ByteBuffer.allocate(1024);
532                     var sb = new StringBuilder();
533                     while (fc.read(buf) != -1) {
534                        sb.append(charset.decode(buf.flip()));
535                        buf.clear();
536                     }
537                     currentContents = sb.toString();
538                  }
539                  if (nn(expectedContents) && neq(currentContents, expectedContents)) {
540                     if (currentContents == null)
541                        cache.remove(key);
542                     else
543                        cache.put(key, currentContents);
544                     return currentContents;
545                  }
546                  fc.position(0);
547                  fc.write(charset.encode(newContents));
548                  fc.truncate(fc.position()); // Drop leftovers when the new contents are shorter.
549               }
550            }
551         }
552      }
553
554      if (updateOnWrite)
555         update(name, newContents);
556      else
557         cache.remove(key);  // Invalidate the cache.
558
559      return null;
560   }
561
562   /*
563    * Creates a new empty file readable and writable by its owner only, where the file system supports POSIX permissions.
564    */
565   private static boolean createOwnerOnly(Path p) throws IOException {
566      try {
567         Files.createFile(p, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
568         return true;
569      } catch (@SuppressWarnings("unused") UnsupportedOperationException e) {
570         return p.toFile().createNewFile(); // Non-POSIX file system.
571      } catch (@SuppressWarnings("unused") FileAlreadyExistsException e) {
572         return true;
573      }
574   }
575
576   private synchronized boolean isWritable(Path p) {
577      try {
578         if (! Files.exists(p)) {
579            Files.createDirectories(p.getParent());
580            if (! Files.exists(p) && ! createOwnerOnly(p)) { // HTT - createNewFile() failure requires OS-level file system restrictions
581               throw ioex("Could not create file: %s", p);
582            }
583         }
584      } catch (@SuppressWarnings("unused") IOException e) {
585         return false; // HTT - IOException from createDirectories/createNewFile requires OS-level restrictions
586      }
587      return Files.isWritable(p);
588   }
589
590   /*
591    * Rejects relative names that resolve (lexically) outside the configured directory, e.g. "../x".
592    * Absolute names are rejected unless the builder opted in with allowAbsoluteNames().
593    * Done before any file-system probe so escaping names are never even tested for existence.
594    */
595   private void checkContained(String name) {
596      try {
597         if (Path.of(name).isAbsolute()) {
598            if (allowAbsoluteNames)
599               return;
600            throw new IllegalArgumentException(MSG_PATH_ESCAPE);
601         }
602         var root = dir.toPath().toAbsolutePath().normalize();
603         var target = root.resolve(name).normalize();
604         if (! target.startsWith(root))
605            throw new IllegalArgumentException(MSG_PATH_ESCAPE);
606         // Symlinks: if the target (or its nearest existing ancestor) resolves outside the real root, reject.
607         var existing = target;
608         while (existing != null && ! Files.exists(existing))
609            existing = existing.getParent();
610         if (existing != null && Files.exists(root) && ! existing.toRealPath().startsWith(root.toRealPath()))
611            throw new IllegalArgumentException(MSG_PATH_ESCAPE);
612      } catch (@SuppressWarnings("unused") NoSuchFileException e) {
613         // The path vanished between the existence probe and toRealPath() (e.g. concurrent delete).  Nothing left to
614         // resolve through a symlink, so this is not an escape; the lexical check above already passed.
615      } catch (@SuppressWarnings("unused") InvalidPathException | IOException e) {
616         throw new IllegalArgumentException(MSG_PATH_ESCAPE);
617      }
618   }
619
620   private String cacheKey(String name) {
621      return resolveFile(name).toAbsolutePath().normalize().toString();
622   }
623
624   private Path resolveFile(String name) {
625      return dir.toPath().resolve(resolveName(name));
626   }
627
628   /**
629    * Gets called when the watcher service on this store is triggered with a file system change.
630    *
631    * @param e The file system event.
632    * @throws IOException Thrown by underlying stream.
633    */
634   protected synchronized void onFileEvent(WatchEvent<Path> e) throws IOException {
635      var fn = e.context().getFileName().toString();
636
637      var key = cacheKey(fn);
638      var oldContents = cache.get(key);
639      cache.remove(key);
640      var newContents = read(fn);
641
642      if (neq(oldContents, newContents)) { // HTT - requires actual file system watcher event with content change
643         update(fn, newContents);
644      }
645   }
646
647   @Override /* Overridden from ConfigStore */
648   protected FluentMap<String,Object> properties() {
649      return super.properties()
650         .a("charset", charset)
651         .a("extensions", extensions)
652         .a("updateOnWrite", updateOnWrite);
653   }
654
655   @Override
656   protected String resolveName(String name) {
657      checkContained(name);
658      if (! nameCache.containsKey(name)) {
659         String n = null;
660
661         // Does file exist as-is?
662         if (fileExists(dir, name))
663            n = name;
664
665         // Does name already have an extension?
666         if (n == null) {
667            for (var ext : exts) {
668               if (hasExtension(name, ext)) {
669                  n = name;
670                  break;
671               }
672            }
673         }
674
675         // Find file with the correct extension.
676         if (n == null) {
677            for (var ext : exts) {
678               if (fileExists(dir, name + '.' + ext)) {
679                  n = name + '.' + ext;
680                  break;
681               }
682            }
683         }
684
685         // If file not found, use the default which is the name with the first extension.
686         if (n == null)
687            n = exts.length == 0 ? name : (name + "." + exts[0]);
688
689         nameCache.put(name, n);
690      }
691      return nameCache.get(name);
692   }
693}