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}