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}