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}