001/* 002 * jPOS Project [http://jpos.org] 003 * Copyright (C) 2000-2026 jPOS Software SRL 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jpos.core; 020 021import org.jpos.iso.ISOUtil; 022import org.jpos.util.Loggeable; 023import org.yaml.snakeyaml.Yaml; 024import org.yaml.snakeyaml.scanner.ScannerException; 025 026import java.io.*; 027import java.util.*; 028import java.util.concurrent.atomic.AtomicReference; 029import java.util.stream.Collectors; 030 031/** 032 * Manages environment-specific configuration for jPOS applications. 033 * 034 * <p>Environment provides property resolution with support for: 035 * <ul> 036 * <li>YAML ({@code .yml}) and properties ({@code .cfg}) configuration files</li> 037 * <li>Property expressions: {@code ${property.name}}</li> 038 * <li>Default values: {@code ${property.name:default}}</li> 039 * <li>Equality tests: {@code ${property.name=expected}}</li> 040 * <li>Boolean negation: {@code ${!property.name}}</li> 041 * <li>Prefix-specific lookups: 042 * <ul> 043 * <li>{@code $env{VAR}} - OS environment variable only</li> 044 * <li>{@code $sys{prop}} - Java system property only</li> 045 * <li>{@code $cfg{prop}} - Configuration file only</li> 046 * <li>{@code $verb{text}} - Verbatim (no expansion)</li> 047 * </ul> 048 * </li> 049 * <li>Nested expressions: {@code ${outer:${inner:default}}}</li> 050 * </ul> 051 * 052 * <p>The default property resolution order (for {@code ${prop}}) is: 053 * <ol> 054 * <li>OS environment variable ({@code prop})</li> 055 * <li>OS environment variable ({@code PROP} with dots replaced by underscores)</li> 056 * <li>Java system property</li> 057 * <li>Configuration file property</li> 058 * </ol> 059 * 060 * <p>Configuration is loaded from the directory specified by {@code jpos.envdir} 061 * (default: "cfg") with the filename from {@code jpos.env} (default: "default"). 062 * 063 * @see EnvironmentProvider 064 */ 065public class Environment implements Loggeable { 066 private static final String DEFAULT_ENVDIR = "cfg"; // default dir for the env file (relative to cwd), overridable with sys prop "jpos.envdir" 067 068 private static final String CFG_PREFIX = "cfg"; 069 private static final String SYSTEM_PREFIX = "sys"; 070 private static final String ENVIRONMENT_PREFIX = "env"; 071 072 private static Environment INSTANCE; 073 074 private String name; 075 private String envDir; 076 private AtomicReference<Properties> propRef = new AtomicReference<>(new Properties()); 077 private static String SP_PREFIX = "system.property."; 078 private static int SP_PREFIX_LENGTH = SP_PREFIX.length(); 079 private String errorString; 080 private ServiceLoader<EnvironmentProvider> serviceLoader; 081 // Sentinel used to protect verbatim '$' so it is not expanded in later passes. 082 private static final String VERB_DOLLAR_SENTINEL = "\u0000$VERB_DOLLAR$\u0000"; 083 084 085 static { 086 try { 087 INSTANCE = new Environment(); 088 } catch (Throwable e) { 089 e.printStackTrace(); 090 throw new RuntimeException(e); 091 } 092 } 093 094 protected static Map<String,String> notMap = new HashMap<>(); 095 static { 096 notMap.put("false", "true"); 097 notMap.put("true", "false"); 098 notMap.put("yes", "no"); 099 notMap.put("no", "yes"); 100 } 101 102 private Environment() throws IOException { 103 name = System.getProperty ("jpos.env"); 104 name = name == null ? "default" : name; 105 envDir = System.getProperty("jpos.envdir", DEFAULT_ENVDIR); 106 serviceLoader = ServiceLoader.load(EnvironmentProvider.class); 107 readConfig (); 108 } 109 110 /** 111 * Returns the name of the current environment. 112 * Determined by the {@code jpos.env} system property, defaults to "default". 113 * 114 * @return the environment name 115 */ 116 public String getName() { 117 return name; 118 } 119 120 /** 121 * Returns the directory where environment configuration files are located. 122 * Determined by the {@code jpos.envdir} system property, defaults to "cfg". 123 * 124 * @return the environment directory path 125 */ 126 public String getEnvDir() { 127 return envDir; 128 } 129 130 /** 131 * Reloads the environment configuration from disk. 132 * Reads the {@code jpos.env} and {@code jpos.envdir} system properties 133 * and reloads the corresponding configuration files. 134 * 135 * @return the newly loaded Environment instance 136 * @throws IOException if an error occurs reading configuration files 137 */ 138 public static Environment reload() throws IOException { 139 return (INSTANCE = new Environment()); 140 } 141 142 /** 143 * Returns the singleton Environment instance. 144 * 145 * @return the current Environment 146 */ 147 public static Environment getEnvironment() { 148 return INSTANCE; 149 } 150 151 /** 152 * Resolves a property expression using the singleton Environment. 153 * If the property cannot be resolved, returns the original expression. 154 * 155 * @param p the property expression to resolve (e.g., "${my.property}") 156 * @return the resolved value, or the original expression if unresolved 157 * @see #getProperty(String) 158 */ 159 public static String get (String p) { 160 return getEnvironment().getProperty(p, p); 161 } 162 163 /** 164 * Resolves the given expression using the current {@link Environment} 165 * instance, applying the standard priority resolution rules. 166 * 167 * @param p the expression or literal value to resolve; may contain 168 * placeholders using the {@code ${...}} syntax 169 * @return the resolved value after applying environment priority rules. 170 * 171 * @see Environment#resolveWithPriority(String) 172 * @see #getEnvironment() 173 */ 174 public static String resolve (String p) { 175 return getEnvironment().resolveWithPriority(p); 176 } 177 /** 178 * Resolves a property expression using the singleton Environment. 179 * If the property cannot be resolved, returns the specified default. 180 * 181 * @param p the property expression to resolve 182 * @param def the default value to return if the property is unresolved 183 * @return the resolved value, or {@code def} if unresolved 184 * @see #getProperty(String, String) 185 */ 186 public static String get (String p, String def) { 187 return getEnvironment().getProperty(p, def); 188 } 189 190 /** 191 * Resolves a property expression with a default fallback. 192 * 193 * @param p the property expression to resolve 194 * @param def the default value to return if the property resolves to null 195 * @return the resolved value, or {@code def} if null 196 * @see #getProperty(String) 197 */ 198 public String getProperty (String p, String def) { 199 String s = getProperty (p); 200 return s != null ? s : def; 201 } 202 203 /** 204 * Returns any error message from the last configuration load attempt. 205 * Typically set when YAML parsing fails. 206 * 207 * @return the error message, or null if no error occurred 208 */ 209 public String getErrorString() { 210 return errorString; 211 } 212 213 /** 214 * If property name has the pattern <code>${propname}</code>, this method will 215 * 216 * <ul> 217 * <li>Attempt to get it from an operating system environment variable called 'propname'</li> 218 * <li>If not present, it will try to pick it from the Java system.property</li> 219 * <li>If not present either, it will try the target environment (either <code>.yml</code> or <code>.cfg</code></li> 220 * <li>Otherwise it returns null</li> 221 * </ul> 222 * 223 * The special pattern <code>$env{propname}</code> would just try to pick it from the OS environment. 224 * <code>$sys{propname}</code> will just try to get it from a System.property and 225 * <code>$verb{propname}</code> will return a verbatim copy of the value. 226 * 227 * @param s property name 228 * @return property value 229 */ 230 public String getProperty (String s) { 231 if (s == null) 232 return null; 233 234 // Fast-path: no possible expressions. 235 if (s.indexOf('$') < 0) 236 return s; 237 238 if (s.startsWith("$verb{")) { 239 int closeIdx = s.indexOf('}', 6); // first '}' after "$verb{" 240 if (closeIdx == s.length() - 1 && s.length() > "$verb{}".length()) { 241 return s.substring(6, closeIdx); 242 } 243 } 244 String r = s; 245 246 // Bounded expansion + cycle detection 247 final int MAX_EXPANSION_STEPS = 256; 248 final int MAX_SEEN_STATES = 2048; 249 250 final Set<String> seen = new HashSet<>(); 251 seen.add(r); 252 253 for (int step = 0; step < MAX_EXPANSION_STEPS; step++) { 254 String next = expandOnce(r); 255 if (Objects.equals(next, r)) 256 break; 257 if (!seen.add(next)) 258 break; 259 if (seen.size() > MAX_SEEN_STATES) 260 break; 261 r = next; 262 } 263 r = unescapeVerbatimDollars(r); 264 // If no expansion happened (result equals input), return null so caller's default can be used 265 return r.equals(s) ? null : r; 266 } 267 268 /** 269 * Expands all occurrences of $...{...} in the input string in a single linear pass. 270 * This method is deliberately regex-free to avoid backtracking / stack overflow. 271 */ 272 private String expandOnce(String in) { 273 StringBuilder out = new StringBuilder(in.length()); 274 int i = 0; 275 boolean changed = false; 276 277 while (i < in.length()) { 278 char ch = in.charAt(i); 279 if (ch != '$') { 280 out.append(ch); 281 i++; 282 continue; 283 } 284 285 // Inline $verb{...}: verbatim payload, no expansion even across passes, Terminate at the first '}' 286 if (in.startsWith("$verb{", i)) { 287 int closeIdx = in.indexOf('}', i + 6); 288 if (closeIdx != -1) { 289 String payload = in.substring(i + 6, closeIdx); 290 out.append(escapeVerbatimDollars(payload)); 291 i = closeIdx + 1; 292 changed = true; 293 continue; 294 } 295 // If no closing brace found, fall through and treat '$' as literal (via parseToken failure path). 296 } 297 Token t = parseToken(in, i); 298 if (t == null) { 299 // Not a valid token; treat '$' as literal. 300 out.append('$'); 301 i++; 302 continue; 303 } 304 305 String replacement = evaluateToken(t, in.substring(t.start(), t.endExclusive())); 306 if (replacement == null) { 307 return in; 308 } 309 310 out.append(replacement); 311 i = t.endExclusive(); 312 changed = true; 313 } 314 315 return changed ? out.toString() : in; 316 } 317 318 private boolean isKnownPrefix(String prefix) { 319 return prefix.isEmpty() 320 || CFG_PREFIX.equals(prefix) 321 || SYSTEM_PREFIX.equals(prefix) 322 || ENVIRONMENT_PREFIX.equals(prefix); 323 } 324 325 private String evaluateToken(Token t, String originalTokenText) { 326 if (!isKnownPrefix(t.prefix())) { 327 return null; // expandOnce() will return the original input unchanged. 328 } 329 boolean negated = t.negated(); 330 String defValueLiteral = null; 331 332 String resolved = resolveByPrefix(t.prefix(), t.property()); 333 334 if (t.op() == '=') { 335 String rhsResolved = t.rhs() == null ? "" : getProperty(t.rhs()); 336 resolved = (resolved != null && resolved.equals(rhsResolved)) ? "true" : "false"; 337 } else if (t.op() == ':') { 338 // Default case. Keep literal default as-is; outer expansion passes will dereference it. 339 if (resolved == null) { 340 defValueLiteral = t.rhs(); // may be null or empty, both are meaningful 341 resolved = defValueLiteral; 342 } 343 } else { 344 // No op, resolved stays as-is (may be null). 345 } 346 347 if (resolved != null) { 348 resolved = applyProviderTransformations(resolved); 349 resolved = applyNegation(resolved, negated, defValueLiteral == null); 350 return resolved; 351 } 352 // Undefined/unresolved and no default. 353 // If negated and unresolved => "true", otherwise token removal is NOT desired 354 if (negated) { 355 return "true"; 356 } 357 // prefixed lookups resolve to empty when missing. 358 return t.prefix().isEmpty() ? originalTokenText : ""; 359 } 360 361 private String resolveByPrefix(String prefix, String prop) { 362 return switch (prefix) { 363 case CFG_PREFIX -> propRef.get().getProperty(prop, null); 364 case SYSTEM_PREFIX -> System.getProperty(prop); 365 case ENVIRONMENT_PREFIX -> System.getenv(prop); 366 default -> prefix.isEmpty() ? resolveWithPriority(prop) : null; 367 }; 368 } 369 370 /** 371 * Resolves a property using the default priority: ENV > System property > cfg file. 372 */ 373 private String resolveWithPriority(String prop) { 374 String r = System.getenv(prop); 375 if (r == null) r = System.getenv(prop.replace('.', '_').toUpperCase()); 376 if (r == null) r = System.getProperty(prop); 377 if (r == null) r = propRef.get().getProperty(prop); 378 return r; 379 } 380 381 /** 382 * Applies EnvironmentProvider transformations. Some providers may return a value 383 * that still begins with the same provider prefix (e.g., obf(obf(x))). 384 * In that case, transformations are applied repeatedly until no provider matches 385 * or a safety limit is reached. 386 */ 387 private String applyProviderTransformations(String value) { 388 String v = value; 389 if (v == null) 390 return null; 391 392 final int MAX_PROVIDER_STEPS = 32; // safety against misbehaving providers 393 394 for (int step = 0; step < MAX_PROVIDER_STEPS; step++) { 395 boolean changed = false; 396 397 for (EnvironmentProvider p : serviceLoader) { 398 String prefix = p.prefix(); 399 int prefixLen = prefix.length(); 400 if (v.length() > prefixLen && v.startsWith(prefix)) { 401 String next = p.get(v.substring(prefixLen)); 402 if (next == null) { 403 // Be conservative: if provider returns null, stop transforming and return current. 404 return v; 405 } 406 if (!Objects.equals(next, v)) { 407 v = next; 408 changed = true; 409 } else { 410 // No progress; avoid tight loops. 411 return v; 412 } 413 break; // restart from first provider on the new value 414 } 415 } 416 417 if (!changed) 418 return v; 419 } 420 421 // Safety stop: return the last value we reached. 422 return v; 423 } 424 425 /** 426 * Applies boolean negation if the token was negated and it's not a default literal. 427 */ 428 private String applyNegation(String value, boolean negated, boolean canNegate) { 429 if (negated && canNegate) { 430 String normalized = value.trim().toLowerCase(); 431 return notMap.getOrDefault(normalized, value); 432 } 433 return value; 434 } 435 436 /** 437 * Token parsed from $...{...}. 438 * @param start position of '$' in the input string 439 * @param endExclusive index just after the closing '}' 440 * @param prefix "", "cfg", "sys", "env", or unknown (unknown handled earlier) 441 * @param negated true if property name started with '!' 442 * @param property the property name (without '!' prefix) 443 * @param op operator: 0 (none), ':', or '=' 444 * @param rhs default value or equals RHS (may be null/empty) 445 */ 446 private record Token(int start, int endExclusive, String prefix, boolean negated, String property, char op, String rhs) {} 447 448 private Token parseToken(String s, int dollarPos) { 449 final int n = s.length(); 450 int i = dollarPos; 451 452 if (i >= n || s.charAt(i) != '$') 453 return null; 454 i++; // skip '$' 455 456 // prefix: [\w]* (may be empty) until '{' 457 int prefixStart = i; 458 while (i < n && isWordChar(s.charAt(i))) { 459 i++; 460 } 461 if (i >= n || s.charAt(i) != '{') { 462 return null; 463 } 464 String prefix = s.substring(prefixStart, i); 465 i++; // skip '{' 466 467 // property name: [-!\w.]+ (but stop on ':' '=' or '}' ) 468 if (i >= n) 469 return null; 470 471 boolean negated = false; 472 int propStart = i; 473 474 // read property characters 475 while (i < n) { 476 char c = s.charAt(i); 477 if (c == ':' || c == '=' || c == '}') 478 break; 479 if (!isPropChar(c)) 480 return null; 481 i++; 482 } 483 484 if (i == propStart) // empty property name is not valid 485 return null; 486 487 String prop = s.substring(propStart, i); 488 if (prop.startsWith("!")) { 489 negated = true; 490 prop = prop.substring(1); 491 if (prop.isEmpty()) 492 return null; 493 } 494 495 // operator? 496 if (i >= n) 497 return null; 498 499 char op = 0; 500 String rhs = null; 501 502 char c = s.charAt(i); 503 if (c == '}' ) { 504 // simple ${prop} 505 return new Token(dollarPos, i + 1, prefix, negated, prop, op, rhs); 506 } else if (c == ':' || c == '=') { 507 op = c; 508 i++; // skip op 509 int rhsStart = i; 510 511 // find matching '}' for this token, supporting nested $...{...} in RHS 512 int end = findTokenEnd(s, dollarPos); 513 if (end < 0) 514 return null; 515 516 // RHS is content between op and final '}' of this token. 517 rhs = s.substring(rhsStart, end); 518 519 return new Token(dollarPos, end + 1, prefix, negated, prop, op, rhs); 520 } else { 521 // unexpected character 522 return null; 523 } 524 } 525 526 /** 527 * Returns the index of the '}' that closes the token beginning at dollarPos, or -1 if not found. 528 * Supports nested tokens inside defaults/RHS by counting nested "$...{" starts. 529 */ 530 private int findTokenEnd(String s, int dollarPos) { 531 final int n = s.length(); 532 int i = dollarPos; 533 534 // We know s[dollarPos] == '$'. Find the first '{' that starts this token. 535 i++; // after '$' 536 while (i < n && isWordChar(s.charAt(i))) i++; 537 if (i >= n || s.charAt(i) != '{') return -1; 538 i++; // after the '{' of the outer token 539 540 int depth = 0; // nested token depth within RHS 541 while (i < n) { 542 char ch = s.charAt(i); 543 544 if (ch == '$' && looksLikeTokenStart(s, i)) { 545 // consume "$" + prefix + "{", and count as nested 546 depth++; 547 i++; // after '$' 548 while (i < n && isWordChar(s.charAt(i))) i++; 549 if (i < n && s.charAt(i) == '{') { 550 i++; // after '{' 551 continue; 552 } else { 553 // Should not happen because looksLikeTokenStart checked it, but be defensive. 554 continue; 555 } 556 } 557 558 if (ch == '}') { 559 if (depth == 0) { 560 return i; 561 } 562 depth--; 563 i++; 564 continue; 565 } 566 567 i++; 568 } 569 return -1; 570 } 571 572 private boolean looksLikeTokenStart(String s, int pos) { 573 final int n = s.length(); 574 if (pos < 0 || pos >= n || s.charAt(pos) != '$') 575 return false; 576 577 int i = pos + 1; 578 579 // prefix: [\w]* (may be empty) 580 while (i < n && isWordChar(s.charAt(i))) i++; 581 582 // must have '{' 583 if (i >= n || s.charAt(i) != '{') 584 return false; 585 586 int j = i + 1; // first char inside '{' 587 if (j >= n) 588 return false; 589 590 char first = s.charAt(j); 591 592 // Reject empty property name: "${}" 593 // Reject operator immediately: "${:...}" or "${=...}" 594 // In general, require at least one property-name char. 595 if (first == '}' || first == ':' || first == '=') 596 return false; 597 598 // And it must be a valid property-name char 599 return isPropChar(first); 600 } 601 602 private static boolean isWordChar(char c) { 603 return (c == '_' || 604 (c >= '0' && c <= '9') || 605 (c >= 'A' && c <= 'Z') || 606 (c >= 'a' && c <= 'z')); 607 } 608 609 private static boolean isPropChar(char c) { 610 return isWordChar(c) || c == '.' || c == '-' || c == '!'; 611 } 612 613 @SuppressWarnings("unchecked") 614 private void readConfig () throws IOException { 615 if (name != null) { 616 Properties properties = new Properties(); 617 String[] names = ISOUtil.commaDecode(name); 618 for (String n: names) { 619 if (!readYAML(n, properties)) 620 readCfg(n, properties); 621 } 622 extractSystemProperties(); 623 propRef.get().put ("jpos.env", name); 624 propRef.get().put ("jpos.envdir", envDir); 625 } 626 } 627 628 private void extractSystemProperties() { 629 Properties properties = propRef.get(); 630 properties 631 .stringPropertyNames() 632 .stream() 633 .filter(e -> e.startsWith(SP_PREFIX)) 634 .forEach(prop -> System.setProperty( 635 prop.substring(SP_PREFIX_LENGTH), getProperty ((String) properties.get(prop))) 636 ); 637 } 638 639 private boolean readYAML (String n, Properties properties) throws IOException { 640 errorString = null; 641 File f = new File(envDir + "/" + n + ".yml"); 642 if (f.exists() && f.canRead()) { 643 try (InputStream fis = new FileInputStream(f)) { 644 Yaml yaml = new Yaml(); 645 Iterable<Object> document = yaml.loadAll(fis); 646 document.forEach(d -> { 647 flat(properties, null, (Map<String, Object>) d, false); 648 }); 649 propRef.set(properties); 650 return true; 651 } catch (ScannerException e) { 652 errorString = "Environment (" + getName() + ") error " + e.getMessage(); 653 } 654 } 655 return false; 656 } 657 658 private boolean readCfg (String n, Properties properties) throws IOException { 659 File f = new File(envDir + "/" + n + ".cfg"); 660 if (f.exists() && f.canRead()) { 661 try (InputStream fis = new FileInputStream(f)) { 662 properties.load(new BufferedInputStream(fis)); 663 propRef.set(properties); 664 return true; 665 } 666 } 667 return false; 668 } 669 670 /** 671 * Flattens a nested Map structure into a flat Properties object using dot notation. 672 * For example, a nested structure like {@code {server: {port: 8080}}} becomes 673 * the property {@code server.port=8080}. 674 * 675 * <p>List values are comma-encoded using {@link ISOUtil#commaEncode(String[])}. 676 * 677 * @param properties the Properties object to populate 678 * @param prefix the current key prefix (null for root level) 679 * @param c the Map to flatten 680 * @param dereference if true, resolve property expressions in string values 681 */ 682 @SuppressWarnings("unchecked") 683 public static void flat (Properties properties, String prefix, Map<String,Object> c, boolean dereference) { 684 for (Map.Entry<String,Object> entry : c.entrySet()) { 685 String p = prefix == null ? entry.getKey() : (prefix + "." + entry.getKey()); 686 if (entry.getValue() instanceof Map) { 687 flat(properties, p, (Map<String, Object>) entry.getValue(), dereference); 688 } else if (entry.getValue() instanceof List<?> listParams) { 689 List<String> list = listParams.stream() 690 .map(Object::toString) 691 .map(str -> dereference ? Environment.get(str) : str) 692 .collect(Collectors.toList()); 693 properties.put (p, ISOUtil.commaEncode(list.toArray(new String[0]))); 694 } else { 695 Object obj = entry.getValue(); 696 properties.put (p, (dereference && obj instanceof String ? 697 Environment.get((String) obj) : 698 entry.getValue().toString())); 699 } 700 } 701 } 702 703 @Override 704 public void dump(final PrintStream p, String indent) { 705 p.printf ("%s<environment name='%s' envdir='%s'>%n", indent, name, envDir); 706 Properties properties = propRef.get(); 707 properties.stringPropertyNames() 708 .stream() 709 .sorted(). 710 forEachOrdered(prop -> p.printf ("%s %s=%s%n", indent, prop, properties.getProperty(prop)) ); 711 p.printf ("%s</environment>%n", indent); 712 } 713 714 @Override 715 public String toString() { 716 StringBuilder sb = new StringBuilder(); 717 if (name != null) { 718 sb.append(String.format("[%s]%n", name)); 719 Properties properties = propRef.get(); 720 properties.stringPropertyNames().stream(). 721 forEachOrdered(prop -> { 722 String s = properties.getProperty(prop); 723 String ds = Environment.get(String.format("${%s}", prop)); // de-referenced string 724 boolean differ = !s.equals(ds); 725 sb.append(String.format (" %s=%s%s%n", 726 prop, 727 s, 728 differ ? " (*)" : "" 729 ) 730 ); 731 }); 732 if (serviceLoader.iterator().hasNext()) { 733 sb.append (" providers:"); 734 sb.append (System.lineSeparator()); 735 for (EnvironmentProvider provider : serviceLoader) { 736 sb.append(String.format(" %s%n", provider.getClass().getCanonicalName())); 737 } 738 } 739 } 740 return sb.toString(); 741 } 742 743 private static String escapeVerbatimDollars(String s) { 744 return s == null ? null : s.replace("$", VERB_DOLLAR_SENTINEL); 745 } 746 747 private static String unescapeVerbatimDollars(String s) { 748 return s == null ? null : s.replace(VERB_DOLLAR_SENTINEL, "$"); 749 } 750}