/*******************************************************************************
* Copyright (c) 2017 Synopsys, Inc
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Synopsys, Inc - initial implementation and documentation
*******************************************************************************/
package jenkins.plugins.coverity;
import java.util.ArrayList;
import java.util.List;
import hudson.EnvVars;
import static jenkins.plugins.coverity.EnvParser.State.DOUBLE_QUOTE;
import static jenkins.plugins.coverity.EnvParser.State.NORMAL;
import static jenkins.plugins.coverity.EnvParser.State.SINGLE_QUOTE;
/**
* Parser for replacing environment variables on a given string and split it into tokens.
* Environment variables, single quotes and double quotes are handled in a similar way to bash.
* Specifically, input strings are first subject to variable expansion (by interpolate()), then
* are divided into tokens (by tokenize()).
*/
public class EnvParser {
public enum State {
NORMAL,
SINGLE_QUOTE,
DOUBLE_QUOTE,
SPACE
}
/*
* Parse the given string, returning a list of tokens. The language accepted is
* commandLine ::= whiteSpace* token whiteSpace+ commandLine
* | {}
* token ::= tokenPiece+
* tokenPiece ::= text+ | singleQuote | doubleQuote
* singleQuote ::= '\'' singleCharacter* '\''
* singleCharacter ::= any character except '\''
* doubleQuote ::= '"' doubleCharacter* '"'
* doubleCharacter ::= any character except '"'
* whiteSpace ::= [ \n\t\r]
* text ::= any character except '\'' and '"'
*/
public static List<String> tokenize(String input) throws ParseException {
List<String> result = new ArrayList<>();
StringBuilder tokenBuilder = new StringBuilder();
// Start in SPACE state
State state = State.SPACE;
Scanner scanner = new Scanner(input);
// While there are characters to get
while(scanner.get()) {
char c = scanner.got();
// execute the state transition, with the side effect of collecting token characters sometimes.
switch (state){
case NORMAL:
if(c == '\"'){
state = State.DOUBLE_QUOTE;
} else if(c == '\''){
state = State.SINGLE_QUOTE;
} else if(c == ' ' || c == '\n' || c == '\r' || c == '\t'){
state = State.SPACE;
// spaces terminate tokens, so buffer the token and reset the token builder
result.add(tokenBuilder.toString());
tokenBuilder = new StringBuilder();
} else {
tokenBuilder.append(c);
}
break;
case SINGLE_QUOTE:
if(c == '\''){
state = State.NORMAL;
} else {
tokenBuilder.append(c);
}
break;
case DOUBLE_QUOTE:
if(c == '\"'){
state = State.NORMAL;
} else {
tokenBuilder.append(c);
}
break;
case SPACE:
if(c == '\"'){
state = State.DOUBLE_QUOTE;
tokenBuilder = new StringBuilder();
} else if(c == '\''){
state = State.SINGLE_QUOTE;
tokenBuilder = new StringBuilder();
} else if(c == ' ' || c == '\n' || c == '\r' || c == '\t') {
} else {
state = NORMAL;
tokenBuilder = new StringBuilder();
tokenBuilder.append(c);
}
break;
}
}
// Handle exhaustion of characters.
switch(state) {
case NORMAL:
// exxhaustion terminates the existing token, if there is one.
String token = tokenBuilder.toString();
if(!token.isEmpty()) {
result.add(token);
}
break;
case SINGLE_QUOTE:
// Illegal input
throw new ParseException("Command line parsing failed: Unclosed double-quoted string.");
// Illegal input
case DOUBLE_QUOTE:
throw new ParseException("Command line parsing failed: Unclosed single-quoted string.");
case SPACE:
}
return result;
}
public static List<String> tokenizeWithRuntimeException(String input) throws RuntimeException {
try {
return tokenize(input);
} catch (ParseException e) {
throw new RuntimeException(e.getMessage());
}
}
/*
* This class provides a string with a movable cursor.
*/
private static class Scanner {
private String input;
private int index;
private char next;
public Scanner(String input) {
this.input = input;
index = 0;
}
// If get returns true, there's another character available
public boolean get() {
if(index < input.length()) {
next = input.charAt(index++);
return true;
}
return false;
}
// Back up the cursor
public void unget(){
--index;
}
// return the next character
public char got() {
return next;
}
// do a get and require it to succeed.
public char hunt(char sought) throws ParseException {
if(!get()) {
throw new ParseException("Parsing failed while seeking '" + sought + "'.");
}
return got();
}
}
// The first char of an env var can't be a digit
private static boolean isFirstEnvVarChar(char c){
return c == '_'
|| c >= 'a' && c <= 'z'
|| c >= 'A' && c <= 'Z';
}
private static boolean isEnvVarChar(char c) {
return isFirstEnvVarChar(c) || c >= '0' && c <= '9';
}
/*
* This method expands environment variables in the input string. The language
* accepted is:
* commandLine ::= text* commandLine
* | envVar commandLine
* | quote commandLine
* | {}
* envVar ::= '$' '{' varName '}'
* | '$' varName
* varName ::= first subsequent*
* first ::= [_A-Za-z]
* subsequent ::= [0-9] | first
* text ::= any character except '$' and '\''
* quote ::= '\'' nonQuote* '\''
* nonQuote ::= any character except '\''
*
* Each envVar is replaced by the value found by looking up the associated varName in
* the given environment variable map, or by the empty string if the map does not contain
* an entry for varName.
*/
public static String interpolate(String input, EnvVars environment) throws ParseException {
StringBuilder builder = new StringBuilder();
State state = NORMAL;
Scanner scanner = new Scanner(input);
while(scanner.get()) {
char c = scanner.got();
switch (state){
case NORMAL:
if(c == '$'){
// Starting an env var name
if(!scanner.get()) {
throw makeInterpolationException("Missing environment variable.");
}
c = scanner.got();
StringBuilder envVarNameBuilder = new StringBuilder();
if(c == '{') {
// In this branch the nv var is terminated by a } char
c = scanner.hunt('}');
if(isFirstEnvVarChar(c)) {
envVarNameBuilder.append(c);
} else {
throw makeInterpolationException("Invalid first environment variable character: '"
+ c + "'.");
}
while((c = scanner.hunt('}')) != '}'){
if(isEnvVarChar(c)) {
envVarNameBuilder.append(c);
} else {
throw makeInterpolationException("Invalid environment variable character: '"
+ c + "'.");
}
}
} else {
// here the env var name is terminated by a non-env-var char or end of input.
if(isFirstEnvVarChar(c)) {
envVarNameBuilder.append(c);
} else {
throw makeInterpolationException("Invalid first environment variable character: '"
+ c + "'.");
}
while(scanner.get()) {
c = scanner.got();
if(isEnvVarChar(c)) {
envVarNameBuilder.append(c);
} else {
// Unconsume the last char so it can be used in the next trip around the loop
scanner.unget();
break;
}
}
}
if(envVarNameBuilder.length() == 0){
throw makeInterpolationException("Empty environment variable name");
}
// Substitute the value of the env var
String envValue = environment.get(envVarNameBuilder.toString());
if(envValue != null) {
builder.append(envValue);
}
} else if(c == '\''){
state = State.SINGLE_QUOTE;
builder.append(c);
} else {
builder.append(c);
}
break;
case SINGLE_QUOTE:
if(c == '\'') {
state = NORMAL;
}
builder.append(c);
break;
}
}
if(!state.equals(NORMAL)){
throw makeInterpolationException("Unterminated single-quoted string.");
}
return builder.toString();
}
private static ParseException makeInterpolationException(String reason) {
return new ParseException("Error expanding environment variables: " + reason);
}
public static String interpolateRecursively(String input, int depth, EnvVars environment) throws ParseException {
if(depth > 20){
throw new ParseException("Recursive environment variable referenced in \"" + input + "\"");
}
String interpolated = EnvParser.interpolate(input, environment);
if(interpolated.equals(input)){
return input;
} else {
return interpolateRecursively(interpolated, depth + 1, environment);
}
}
}