All Downloads are FREE. Search and download functionalities are using the official Maven repository.

software.amazon.awssdk.profiles.internal.ProfileFileReader Maven / Gradle / Ivy

Go to download

A single bundled dependency that includes all service and dependent JARs with third-party libraries relocated to different namespaces.

There is a newer version: 2.5.20
Show newest version
/*
 * Copyright 2010-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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.
 */

package software.amazon.awssdk.profiles.internal;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.profiles.ProfileFile;
import software.amazon.awssdk.utils.Logger;
import software.amazon.awssdk.utils.Pair;
import software.amazon.awssdk.utils.StringUtils;
import software.amazon.awssdk.utils.Validate;

/**
 * Converts an {@link InputStream} to a configuration or credentials file into a map of profiles and their properties.
 *
 * @see #parseFile(InputStream, ProfileFile.Type)
 */
@SdkInternalApi
public final class ProfileFileReader {
    private static final Logger log = Logger.loggerFor(ProfileFileReader.class);

    private static final Pattern EMPTY_LINE = Pattern.compile("^[\t ]*$");

    private static final Pattern VALID_IDENTIFIER = Pattern.compile("^[A-Za-z0-9_\\-]*$");

    private ProfileFileReader() {}

    /**
     * Parses the input and returns a mutable map from profile name to a map of properties. This will not close the provided
     * stream.
     */
    public static Map> parseFile(InputStream profileStream, ProfileFile.Type fileType) {
        ParserState state = new ParserState(fileType);

        BufferedReader profileReader = new BufferedReader(new InputStreamReader(profileStream, StandardCharsets.UTF_8));
        profileReader.lines().forEach(line -> parseLine(state, line));

        return state.profiles;
    }

    /**
     * Parse a line and update the parser state.
     */
    private static void parseLine(ParserState state, String line) {
        ++state.currentLineNumber;

        if (isEmptyLine(line) || isCommentLine(line)) {
            return; // Skip line
        }

        if (isProfileDefinitionLine(line)) {
            readProfileDefinitionLine(state, line);
        } else if (isPropertyContinuationLine(line)) {
            readPropertyContinuationLine(state, line);
        } else {
            readPropertyDefinitionLine(state, line);
        }
    }

    /**
     * Read a profile line and update the parser state with the results. This marks future properties as being in this profile.
     *
     * Configuration Files: [ Whitespace? profile Whitespace Identifier Whitespace? ] Whitespace? CommentLine?
     * Credentials Files: [ Whitespace? Identifier Whitespace? ] Whitespace? CommentLine?
     */
    private static void readProfileDefinitionLine(ParserState state, String line) {
        // Profile definitions do not require a space between the closing bracket and the comment delimiter
        String lineWithoutComments = removeTrailingComments(line, "#", ";");
        String lineWithoutWhitespace = StringUtils.trim(lineWithoutComments);

        Validate.isTrue(lineWithoutWhitespace.endsWith("]"),
                        "Profile definition must end with ']' on line " + state.currentLineNumber);

        Optional profileName = parseProfileDefinition(state, lineWithoutWhitespace);

        // If we couldn't get the profile name, ignore this entire profile.
        if (!profileName.isPresent()) {
            state.ignoringCurrentProfile = true;
            return;
        }

        state.currentProfileBeingRead = profileName.get();
        state.currentPropertyBeingRead = null;
        state.ignoringCurrentProfile = false;
        state.ignoringCurrentProperty = false;

        // If we've seen this profile before, don't override the existing properties. We'll be merging them.
        state.profiles.computeIfAbsent(profileName.get(), i -> new LinkedHashMap<>());
    }

    /**
     * Read a property definition line and update the parser state with the results. This adds the property to the current profile
     * and marks future property continuations as being part of this property.
     *
     * Identifier Whitespace? = Whitespace? Value? Whitespace? (Whitespace CommentLine)?
     */
    private static void readPropertyDefinitionLine(ParserState state, String line) {
        // If we're in an invalid profile, ignore its properties
        if (state.ignoringCurrentProfile) {
            return;
        }

        Validate.isTrue(state.currentProfileBeingRead != null,
                        "Expected a profile definition on line " + state.currentLineNumber);

        // Property definition comments must have whitespace before them, or they will be considered part of the value
        String lineWithoutComments = removeTrailingComments(line, " #", " ;", "\t#", "\t;");
        String lineWithoutWhitespace = StringUtils.trim(lineWithoutComments);

        Optional> propertyDefinition = parsePropertyDefinition(state, lineWithoutWhitespace);

        // If we couldn't get the property key and value, ignore this entire property.
        if (!propertyDefinition.isPresent()) {
            state.ignoringCurrentProperty = true;
            return;
        }

        Pair property = propertyDefinition.get();

        if (state.profiles.get(state.currentProfileBeingRead).containsKey(property.left())) {
            log.warn(() -> "Warning: Duplicate property '" + property.left() + "' detected on line " + state.currentLineNumber +
                           ". The later one in the file will be used.");
        }

        state.currentPropertyBeingRead = property.left();
        state.ignoringCurrentProperty = false;
        state.validatingContinuationsAsSubProperties = property.right().equals("");

        state.profiles.get(state.currentProfileBeingRead).put(property.left(), property.right());
    }

    /**
     * Read a property continuation line and update the parser state with the results. This adds the value in the continuation
     * to the current property, prefixed with a newline.
     *
     * Non-Blank Parent Property: Whitespace Value Whitespace?
     * Blank Parent Property (Sub-Property): Whitespace Identifier Whitespace? = Whitespace? Value Whitespace?
     */
    private static void readPropertyContinuationLine(ParserState state, String line) {
        // If we're in an invalid profile or property, ignore its continuations
        if (state.ignoringCurrentProfile || state.ignoringCurrentProperty) {
            return;
        }

        Validate.isTrue(state.currentProfileBeingRead != null && state.currentPropertyBeingRead != null,
                        "Expected a profile or property definition on line " + state.currentLineNumber);

        // Comments are not removed on property continuation lines. They're considered part of the value.
        line = StringUtils.trim(line);

        Map profileProperties = state.profiles.get(state.currentProfileBeingRead);

        String currentPropertyValue = profileProperties.get(state.currentPropertyBeingRead);
        String newPropertyValue = currentPropertyValue + "\n" + line;

        // If this is a sub-property, make sure it can be parsed correctly by the CLI.
        if (state.validatingContinuationsAsSubProperties) {
            parsePropertyDefinition(state, line);
        }

        profileProperties.put(state.currentPropertyBeingRead, newPropertyValue);
    }

    /**
     * Given a profile line, load the profile name based on the file type. If the profile name is invalid for the file type,
     * this will return empty.
     */
    private static Optional parseProfileDefinition(ParserState state, String lineWithoutWhitespace) {
        String lineWithoutBrackets = lineWithoutWhitespace.substring(1, lineWithoutWhitespace.length() - 1);
        String rawProfileName = StringUtils.trim(lineWithoutBrackets);
        boolean hasProfilePrefix = rawProfileName.startsWith("profile ") || rawProfileName.startsWith("profile\t");

        String standardizedProfileName;
        if (state.fileType == ProfileFile.Type.CONFIGURATION) {
            if (hasProfilePrefix) {
                standardizedProfileName = StringUtils.trim(rawProfileName.substring("profile".length()));
            } else if (rawProfileName.equals("default")) {
                standardizedProfileName = "default";
            } else {
                log.warn(() -> "Ignoring profile '" + rawProfileName + "' on line " + state.currentLineNumber + " because it " +
                               "did not start with 'profile ' and it was not 'default'.");
                return Optional.empty();
            }
        } else if (state.fileType == ProfileFile.Type.CREDENTIALS) {
            standardizedProfileName = rawProfileName;
        } else {
            throw new IllegalStateException("Unknown profile file type: " + state.fileType);
        }

        String profileName = StringUtils.trim(standardizedProfileName);

        // If the profile name includes invalid characters, it should be ignored.
        if (!isValidIdentifier(profileName)) {
            log.warn(() -> "Ignoring profile '" + standardizedProfileName + "' on line " + state.currentLineNumber + " because " +
                           "it was not alphanumeric with dashes or underscores.");
            return Optional.empty();
        }

        // [profile default] must take priority over [default] in configuration files.
        boolean isDefaultProfile = profileName.equals("default");
        boolean seenProfileBefore = state.profiles.containsKey(profileName);

        if (state.fileType == ProfileFile.Type.CONFIGURATION && isDefaultProfile && seenProfileBefore) {
            if (!hasProfilePrefix && state.seenDefaultProfileWithProfilePrefix) {
                log.warn(() -> "Ignoring profile '[default]' on line " + state.currentLineNumber + ", because " +
                               "'[profile default]' was already seen in the same file.");
                return Optional.empty();
            } else if (hasProfilePrefix && !state.seenDefaultProfileWithProfilePrefix) {
                log.warn(() -> "Ignoring earlier-seen '[default]', because '[profile default]' was found on line " +
                               state.currentLineNumber);
                state.profiles.remove("default");
            }
        }

        if (isDefaultProfile && hasProfilePrefix) {
            state.seenDefaultProfileWithProfilePrefix = true;
        }

        return Optional.of(profileName);
    }

    /**
     * Given a property line, load the property key and value. If the property line is invalid and should be ignored, this will
     * return empty.
     */
    private static Optional> parsePropertyDefinition(ParserState state, String line) {
        int firstEqualsLocation = line.indexOf('=');
        Validate.isTrue(firstEqualsLocation != -1, "Expected an '=' sign defining a property on line " + state.currentLineNumber);

        String propertyKey = StringUtils.trim(line.substring(0, firstEqualsLocation));
        String propertyValue = StringUtils.trim(line.substring(firstEqualsLocation + 1));

        Validate.isTrue(!propertyKey.isEmpty(), "Property did not have a name on line " + state.currentLineNumber);

        // If the profile name includes invalid characters, it should be ignored.
        if (!isValidIdentifier(propertyKey)) {
            log.warn(() -> "Ignoring property '" + propertyKey + "' on line " + state.currentLineNumber + " because " +
                           "its name was not alphanumeric with dashes or underscores.");
            return Optional.empty();
        }

        return Optional.of(Pair.of(propertyKey, propertyValue));
    }

    /**
     * Remove trailing comments from the provided line, using the provided patterns to find where the comment starts.
     *
     * Profile definitions don't require spaces before comments.
     * Property definitions require spaces before comments.
     * Property continuations don't allow trailing comments. They're considered part of the value.
     */
    private static String removeTrailingComments(String line, String... commentPatterns) {
        return line.substring(0, findEarliestMatch(line, commentPatterns));
    }

    /**
     * Search the provided string for the requested patterns, returning the index of the match that is closest to the front of the
     * string. If no match is found, this returns the length of the string (one past the final index in the string).
     */
    private static int findEarliestMatch(String line, String... searchPatterns) {
        return Stream.of(searchPatterns)
                     .mapToInt(line::indexOf)
                     .filter(location -> location >= 0)
                     .min()
                     .orElse(line.length());
    }

    private static boolean isEmptyLine(String line) {
        return EMPTY_LINE.matcher(line).matches();
    }

    private static boolean isCommentLine(String line) {
        return line.startsWith("#") || line.startsWith(";");
    }

    private static boolean isProfileDefinitionLine(String line) {
        return line.startsWith("[");
    }

    private static boolean isPropertyContinuationLine(String line) {
        return line.startsWith(" ") || line.startsWith("\t");
    }

    private static boolean isValidIdentifier(String value) {
        return VALID_IDENTIFIER.matcher(value).matches();
    }

    /**
     * When {@link #parseFile(InputStream, ProfileFile.Type)} is invoked, this is used to track the state of the parser as it
     * reads the input stream.
     */
    private static final class ParserState {
        /**
         * The type of file being parsed.
         */
        private final ProfileFile.Type fileType;

        /**
         * The line currently being parsed. Useful for error messages.
         */
        private int currentLineNumber = 0;

        /**
         * Which profile is currently being read. Updated after each [profile] has been successfully read.
         *
         * Properties read will be added to this profile.
         */
        private String currentProfileBeingRead = null;

        /**
         * Which property is currently being read. Updated after each foo = bar has been successfully read.
         *
         * Property continuations read will be appended to this property.
         */
        private String currentPropertyBeingRead = null;

        /**
         * Whether we are ignoring the current profile. Updated after a [profile] has been identified as being invalid, but we
         * do not want to fail parsing the whole file.
         *
         * All lines other than property definitions read while this is true are dropped.
         */
        private boolean ignoringCurrentProfile = false;

        /**
         * Whether we are ignoring the current property. Updated after a foo = bar has been identified as being invalid, but we
         * do not want to fail parsing the whole file.
         *
         * All property continuations read while this are true are dropped.
         */
        private boolean ignoringCurrentProperty = false;

        /**
         * Whether we are validating the current property value continuations are formatted like sub-properties. This will ensure
         * that when the same file is used with the CLI, it won't cause failures.
         */
        private boolean validatingContinuationsAsSubProperties = false;

        /**
         * Whether within the current file, we've seen the [profile default] profile definition. This is only used for
         * configuration files. If this is true we'll ignore [default] profile definitions, because [profile default] takes
         * precedence.
         */
        private boolean seenDefaultProfileWithProfilePrefix = false;

        /**
         * The profiles read so far by the parser.
         */
        private Map> profiles = new LinkedHashMap<>();

        private ParserState(ProfileFile.Type fileType) {
            this.fileType = fileType;
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy