diff --git a/pkg/expansion/expand.go b/pkg/expansion/expand.go new file mode 100644 index 000000000..afbbe1211 --- /dev/null +++ b/pkg/expansion/expand.go @@ -0,0 +1,119 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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. +*/ + +// Package expansion provides functions find and replace $(FOO) style variables in strings. +package expansion + +import ( + "bytes" +) + +const ( + operator = '$' + referenceOpener = '(' + referenceCloser = ')' +) + +// syntaxWrap returns the input string wrapped by the expansion syntax. +func syntaxWrap(input string) string { + return string(operator) + string(referenceOpener) + input + string(referenceCloser) +} + +// MappingFuncFor returns a mapping function for use with Expand that +// implements the expansion semantics defined in the expansion spec; it +// returns the input string wrapped in the expansion syntax if no mapping +// for the input is found. +func MappingFuncFor(context ...map[string]string) func(string) string { + return func(input string) string { + for _, vars := range context { + val, ok := vars[input] + if ok { + return val + } + } + + return syntaxWrap(input) + } +} + +// Expand replaces variable references in the input string according to +// the expansion spec using the given mapping function to resolve the +// values of variables. +func Expand(input string, mapping func(string) string) string { + var buf bytes.Buffer + checkpoint := 0 + for cursor := 0; cursor < len(input); cursor++ { + if input[cursor] == operator && cursor+1 < len(input) { + // Copy the portion of the input string since the last + // checkpoint into the buffer + buf.WriteString(input[checkpoint:cursor]) + + // Attempt to read the variable name as defined by the + // syntax from the input string + read, isVar, advance := tryReadVariableName(input[cursor+1:]) + + if isVar { + // We were able to read a variable name correctly; + // apply the mapping to the variable name and copy the + // bytes into the buffer + buf.WriteString(mapping(read)) + } else { + // Not a variable name; copy the read bytes into the buffer + buf.WriteString(read) + } + + // Advance the cursor in the input string to account for + // bytes consumed to read the variable name expression + cursor += advance + + // Advance the checkpoint in the input string + checkpoint = cursor + 1 + } + } + + // Return the buffer and any remaining unwritten bytes in the + // input string. + return buf.String() + input[checkpoint:] +} + +// tryReadVariableName attempts to read a variable name from the input +// string and returns the content read from the input, whether that content +// represents a variable name to perform mapping on, and the number of bytes +// consumed in the input string. +// +// The input string is assumed not to contain the initial operator. +func tryReadVariableName(input string) (string, bool, int) { + switch input[0] { + case operator: + // Escaped operator; return it. + return input[0:1], false, 1 + case referenceOpener: + // Scan to expression closer + for i := 1; i < len(input); i++ { + if input[i] == referenceCloser { + return input[1:i], true, i + 1 + } + } + + // Incomplete reference; return it. + return string(operator) + string(referenceOpener), false, 1 + default: + // Not the beginning of an expression, ie, an operator + // that doesn't begin an expression. Return the operator + // and the first rune in the string. + return (string(operator) + string(input[0])), false, 1 + } +} diff --git a/pkg/expansion/expand_test.go b/pkg/expansion/expand_test.go new file mode 100644 index 000000000..ecdff304c --- /dev/null +++ b/pkg/expansion/expand_test.go @@ -0,0 +1,301 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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. +*/ + +package expansion + +import ( + "testing" + + api "k8s.io/api/core/v1" +) + +func TestMapReference(t *testing.T) { + envs := []api.EnvVar{ + { + Name: "FOO", + Value: "bar", + }, + { + Name: "ZOO", + Value: "$(FOO)-1", + }, + { + Name: "BLU", + Value: "$(ZOO)-2", + }, + } + + declaredEnv := map[string]string{ + "FOO": "bar", + "ZOO": "$(FOO)-1", + "BLU": "$(ZOO)-2", + } + + serviceEnv := map[string]string{} + + mapping := MappingFuncFor(declaredEnv, serviceEnv) + + for _, env := range envs { + declaredEnv[env.Name] = Expand(env.Value, mapping) + } + + expectedEnv := map[string]string{ + "FOO": "bar", + "ZOO": "bar-1", + "BLU": "bar-1-2", + } + + for k, v := range expectedEnv { + if e, a := v, declaredEnv[k]; e != a { + t.Errorf("Expected %v, got %v", e, a) + } else { + delete(declaredEnv, k) + } + } + + if len(declaredEnv) != 0 { + t.Errorf("Unexpected keys in declared env: %v", declaredEnv) + } +} + +func TestMapping(t *testing.T) { + context := map[string]string{ + "VAR_A": "A", + "VAR_B": "B", + "VAR_C": "C", + "VAR_REF": "$(VAR_A)", + "VAR_EMPTY": "", + } + mapping := MappingFuncFor(context) + + doExpansionTest(t, mapping) +} + +func TestMappingDual(t *testing.T) { + context := map[string]string{ + "VAR_A": "A", + "VAR_EMPTY": "", + } + context2 := map[string]string{ + "VAR_B": "B", + "VAR_C": "C", + "VAR_REF": "$(VAR_A)", + } + mapping := MappingFuncFor(context, context2) + + doExpansionTest(t, mapping) +} + +func doExpansionTest(t *testing.T, mapping func(string) string) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "whole string", + input: "$(VAR_A)", + expected: "A", + }, + { + name: "repeat", + input: "$(VAR_A)-$(VAR_A)", + expected: "A-A", + }, + { + name: "beginning", + input: "$(VAR_A)-1", + expected: "A-1", + }, + { + name: "middle", + input: "___$(VAR_B)___", + expected: "___B___", + }, + { + name: "end", + input: "___$(VAR_C)", + expected: "___C", + }, + { + name: "compound", + input: "$(VAR_A)_$(VAR_B)_$(VAR_C)", + expected: "A_B_C", + }, + { + name: "escape & expand", + input: "$$(VAR_B)_$(VAR_A)", + expected: "$(VAR_B)_A", + }, + { + name: "compound escape", + input: "$$(VAR_A)_$$(VAR_B)", + expected: "$(VAR_A)_$(VAR_B)", + }, + { + name: "mixed in escapes", + input: "f000-$$VAR_A", + expected: "f000-$VAR_A", + }, + { + name: "backslash escape ignored", + input: "foo\\$(VAR_C)bar", + expected: "foo\\Cbar", + }, + { + name: "backslash escape ignored", + input: "foo\\\\$(VAR_C)bar", + expected: "foo\\\\Cbar", + }, + { + name: "lots of backslashes", + input: "foo\\\\\\\\$(VAR_A)bar", + expected: "foo\\\\\\\\Abar", + }, + { + name: "nested var references", + input: "$(VAR_A$(VAR_B))", + expected: "$(VAR_A$(VAR_B))", + }, + { + name: "nested var references second type", + input: "$(VAR_A$(VAR_B)", + expected: "$(VAR_A$(VAR_B)", + }, + { + name: "value is a reference", + input: "$(VAR_REF)", + expected: "$(VAR_A)", + }, + { + name: "value is a reference x 2", + input: "%%$(VAR_REF)--$(VAR_REF)%%", + expected: "%%$(VAR_A)--$(VAR_A)%%", + }, + { + name: "empty var", + input: "foo$(VAR_EMPTY)bar", + expected: "foobar", + }, + { + name: "unterminated expression", + input: "foo$(VAR_Awhoops!", + expected: "foo$(VAR_Awhoops!", + }, + { + name: "expression without operator", + input: "f00__(VAR_A)__", + expected: "f00__(VAR_A)__", + }, + { + name: "shell special vars pass through", + input: "$?_boo_$!", + expected: "$?_boo_$!", + }, + { + name: "bare operators are ignored", + input: "$VAR_A", + expected: "$VAR_A", + }, + { + name: "undefined vars are passed through", + input: "$(VAR_DNE)", + expected: "$(VAR_DNE)", + }, + { + name: "multiple (even) operators, var undefined", + input: "$$$$$$(BIG_MONEY)", + expected: "$$$(BIG_MONEY)", + }, + { + name: "multiple (even) operators, var defined", + input: "$$$$$$(VAR_A)", + expected: "$$$(VAR_A)", + }, + { + name: "multiple (odd) operators, var undefined", + input: "$$$$$$$(GOOD_ODDS)", + expected: "$$$$(GOOD_ODDS)", + }, + { + name: "multiple (odd) operators, var defined", + input: "$$$$$$$(VAR_A)", + expected: "$$$A", + }, + { + name: "missing open expression", + input: "$VAR_A)", + expected: "$VAR_A)", + }, + { + name: "shell syntax ignored", + input: "${VAR_A}", + expected: "${VAR_A}", + }, + { + name: "trailing incomplete expression not consumed", + input: "$(VAR_B)_______$(A", + expected: "B_______$(A", + }, + { + name: "trailing incomplete expression, no content, is not consumed", + input: "$(VAR_C)_______$(", + expected: "C_______$(", + }, + { + name: "operator at end of input string is preserved", + input: "$(VAR_A)foobarzab$", + expected: "Afoobarzab$", + }, + { + name: "shell escaped incomplete expr", + input: "foo-\\$(VAR_A", + expected: "foo-\\$(VAR_A", + }, + { + name: "lots of $( in middle", + input: "--$($($($($--", + expected: "--$($($($($--", + }, + { + name: "lots of $( in beginning", + input: "$($($($($--foo$(", + expected: "$($($($($--foo$(", + }, + { + name: "lots of $( at end", + input: "foo0--$($($($(", + expected: "foo0--$($($($(", + }, + { + name: "escaped operators in variable names are not escaped", + input: "$(foo$$var)", + expected: "$(foo$$var)", + }, + { + name: "newline not expanded", + input: "\n", + expected: "\n", + }, + } + + for _, tc := range cases { + expanded := Expand(tc.input, mapping) + if e, a := tc.expected, expanded; e != a { + t.Errorf("%v: expected %q, got %q", tc.name, e, a) + } + } +}