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