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