PropertyResolverContextImpl.java

/*-
 * ========================LICENSE_START=================================
 * flyway-core
 * ========================================================================
 * Copyright (C) 2010 - 2025 Red Gate Software Ltd
 * ========================================================================
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * =========================LICENSE_END==================================
 */
package org.flywaydb.core.internal.configuration.resolvers;

import static org.flywaydb.core.internal.configuration.resolvers.ProvisionerConfiguration.createConfigurationWithEnvironment;

import java.util.Optional;
import org.flywaydb.core.FlywayTelemetryManager;
import org.flywaydb.core.ProgressLogger;
import org.flywaydb.core.api.CoreErrorCode;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.configuration.Configuration;

import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;
import org.flywaydb.core.extensibility.ConfigurationExtension;
import org.flywaydb.core.internal.configuration.models.ConfigurationModel;

public class PropertyResolverContextImpl implements PropertyResolverContext {

    private final Map<String, PropertyResolver> resolvers;
    private final Map<String, ConfigurationExtension> resolverConfigurations;
    private final String environmentName;
    private final Configuration configuration;

    private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder();
    private static final Pattern NESTED_RESOLVER_PATTERN = Pattern.compile("(^|[^$])\\$\\{(([^}]+|)\\$\\{.+?}).*?}");
    private static final Pattern RESOLVER_REGEX_PATTERN = Pattern.compile("\\${1,2}\\{[^.]+\\.[^.]+?\\}");
    private static final Pattern VERBATIM_REGEX_PATTERN = Pattern.compile("\\!\\{.*\\}");

    public PropertyResolverContextImpl(String environmentName, Configuration configuration, Map<String, PropertyResolver> resolvers, Map<String, ConfigurationExtension> resolverConfigurations) {
        this.environmentName = environmentName;
        this.configuration = createConfigurationCopy(configuration, environmentName);
        this.resolvers = resolvers;
        this.resolverConfigurations = Optional.ofNullable(resolverConfigurations).orElseGet(Map::of);
    }

    @Override
    public ConfigurationExtension getResolverConfiguration(final String resolverName) {
        return resolverConfigurations.get(resolverName);
    }

    @Override
    public ConfigurationExtension getResolverConfigurationOrThrow(final String resolverName) {
        return Optional.ofNullable(getResolverConfiguration(resolverName))
            .orElseThrow(() -> new FlywayException("Required configuration not defined for resolver/provisioner \""
                + resolverName
                + "\" for environment "
                + environmentName, CoreErrorCode.CONFIGURATION));
    }

    @Override
    public Configuration getConfiguration() {
        return configuration;
    }

    @Override
    public FlywayTelemetryManager getTelemetryManager() {
        return configuration.getPluginRegister().getPluginInstanceOf(FlywayTelemetryManager.class);
    }

    @Override
    public String getWorkingDirectory() {
        var workingDirectory = configuration.getWorkingDirectory();
        if(workingDirectory == null) {
            return System.getProperty("user.dir");
        } else {
            return workingDirectory;
        }
    }

    @Override
    public String getEnvironmentName() {
        return environmentName;
    }

    @Override
    public String resolveValue(String value, ProgressLogger progress) {
        if (value == null) {
            return null;
        }
        if (isVerbatim(value)) {
            return value.substring(2, value.length() - 1);
        }
        if (hasNestedResolvers(value)) {
            throw new FlywayException("Resolvers cannot be nested: " + value, CoreErrorCode.CONFIGURATION);
        }
        return RESOLVER_REGEX_PATTERN.matcher(value.strip()).replaceAll(m -> getPropertyResolverReplacement(m, progress));
    }

    @Override
    public String resolveValueOrThrow(final String input, final ProgressLogger progress, final String propertyName) {
        final var result = resolveValue(input, progress);
        if (result == null) {
            throw new FlywayException("Configuration value " + propertyName + " not specified for environment " + environmentName, CoreErrorCode.CONFIGURATION);
        }
        return result;
    }

    @Override
    public List<String> resolveValues(final List<String> input, final ProgressLogger progress) {
        if (input == null) {
            return null;
        }
        return input.stream().map(v -> resolveValue(v, progress)).toList();
    }

    @Override
    public List<String> resolveValuesOrThrow(final List<String> input, final ProgressLogger progress,
        final String propertyName) {
        final var result = resolveValues(input, progress);
        if (result == null) {
            throw new FlywayException("Configuration value " + propertyName + " not specified for environment " + environmentName, CoreErrorCode.CONFIGURATION);
        }
        return result;
    }

    private boolean isVerbatim(String value) {
        return VERBATIM_REGEX_PATTERN.matcher(value.strip()).matches();
    }

    private String getPropertyResolverReplacement(MatchResult resolverMatchResult, ProgressLogger progress) {
        // '\' are ignored by Matcher and '$' will break it so both need escaping with '\'.
        // See https://docs.oracle.com/javase/8/docs/api/java/util/regex/Matcher.html#replaceAll-java.lang.String-
        return parsePropertyResolver(resolverMatchResult, progress)
            .replaceAll("\\\\", "\\\\\\\\")
            .replaceAll("\\$", "\\\\\\$");
    }

    private String parsePropertyResolver(final MatchResult resolverMatchResult, final ProgressLogger progress) {
        String resolverMatch = resolverMatchResult.group();

        if (resolverMatch.startsWith("$$")) {
            return resolverMatch.substring(1);
        }

        String resolverName = resolverMatch.substring(2, resolverMatch.indexOf(".")).strip();
        if (!resolvers.containsKey(resolverName)) {
            throw new FlywayException("Unknown resolver '" + resolverName + "' for environment " + environmentName, CoreErrorCode.CONFIGURATION);
        }

        String resolverParam;
        if (resolverMatch.contains(":")) {
            resolverParam = resolverMatch.substring(resolverMatch.indexOf(".") + 1, resolverMatch.indexOf(":")).strip();
            String filter = resolverMatch.substring(resolverMatch.indexOf(":") + 1, resolverMatch.length() - 1).strip();
            return filter(resolvers.get(resolverName).resolve(resolverParam, this, progress), filter);
        }

        resolverParam = resolverMatch.substring(resolverMatch.indexOf(".") + 1, resolverMatch.length() - 1).strip();
        return resolvers.get(resolverName).resolve(resolverParam, this, progress);
    }

    static String filter(String str, String filter) {
        return str.chars().filter(c -> isAllowed((char) c, filter))
                  .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
                  .toString();
    }

    private static boolean isAllowed(char c, String filter) {
        return (filter.contains("D") && Character.isDigit(c)) ||
                (filter.contains("A") && Character.isLetter(c)) ||
                (filter.contains("a") && Character.isLetter(c) && ASCII_ENCODER.canEncode(c)) ||
                (filter.contains("d") && Character.isDigit(c) && ASCII_ENCODER.canEncode(c));
    }

    private static boolean hasNestedResolvers(final String value) {
        final var matcher = NESTED_RESOLVER_PATTERN.matcher(value);
        while (matcher.find()) {
            if (RESOLVER_REGEX_PATTERN.matcher(matcher.group(2)).find()) {
                return true;
            }
        }
        return false;
    }

    private static Configuration createConfigurationCopy(final Configuration configuration,
        final String environmentName) {
        final var environmentModel = Optional.ofNullable(configuration)
            .map(Configuration::getModernConfig)
            .map(ConfigurationModel::getEnvironments)
            .map(envs -> envs.get(environmentName))
            .orElseThrow(() -> new FlywayException("Unable to provision environment "
                + environmentName
                + " as required configuration is not defined", CoreErrorCode.CONFIGURATION));

        return createConfigurationWithEnvironment(configuration, environmentName, environmentModel);
    }
}