From ce2d567dec58a572fb88212076fa3347461c9706 Mon Sep 17 00:00:00 2001
From: Jessica Yuen <im.jessicayuen@gmail.com>
Date: Mon, 6 Nov 2017 13:25:55 -0800
Subject: [PATCH] Jsonnet handling of component names with special characters

Currently, if a component name contains a special character, ex:
foo-bar, this translates to the jsonnet identifier: foo-bar, which is
invalid syntax.

This change will quote component names where there are special
characters.
---
 cmd/prototype.go               |   7 +-
 cmd/root.go                    |   4 ++
 cmd/root_test.go               |  10 +++
 metadata/params/params.go      |  68 ++++++++++++++++--
 metadata/params/params_test.go | 123 +++++++++++++++++++++++++++++++--
 utils/strings.go               |  32 +++++++++
 utils/strings_test.go          |  53 ++++++++++++++
 7 files changed, 285 insertions(+), 12 deletions(-)
 create mode 100644 utils/strings.go
 create mode 100644 utils/strings_test.go

diff --git a/cmd/prototype.go b/cmd/prototype.go
index fd35f92b..17e44bf5 100644
--- a/cmd/prototype.go
+++ b/cmd/prototype.go
@@ -25,6 +25,7 @@ import (
 	"github.com/ksonnet/ksonnet/metadata"
 	"github.com/ksonnet/ksonnet/prototype"
 	"github.com/ksonnet/ksonnet/prototype/snippet/jsonnet"
+	"github.com/ksonnet/ksonnet/utils"
 	"github.com/spf13/cobra"
 )
 
@@ -397,7 +398,11 @@ func expandPrototype(proto *prototype.SpecificationSchema, templateType prototyp
 		return "", err
 	}
 	if templateType == prototype.Jsonnet {
-		template = append([]string{`local params = std.extVar("` + metadata.ParamsExtCodeKey + `").components.` + componentName + ";"}, template...)
+		componentsText := "components." + componentName
+		if !utils.IsASCIIIdentifier(componentName) {
+			componentsText = fmt.Sprintf(`components["%s"]`, componentName)
+		}
+		template = append([]string{`local params = std.extVar("` + metadata.ParamsExtCodeKey + `").` + componentsText + ";"}, template...)
 	}
 
 	return jsonnet.Parse(componentName, strings.Join(template, "\n"))
diff --git a/cmd/root.go b/cmd/root.go
index 95a42328..38be526b 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -27,6 +27,8 @@ import (
 	"reflect"
 	"strings"
 
+	"github.com/ksonnet/ksonnet/metadata/params"
+
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 
 	log "github.com/sirupsen/logrus"
@@ -430,6 +432,7 @@ func expandEnvCmdObjs(cmd *cobra.Command, envSpec *envSpec, cwd metadata.AbsPath
 //
 //   {
 //      foo: import "components/foo.jsonnet"
+//      "foo-bar": import "components/foo-bar.jsonnet"
 //   }
 func constructBaseObj(paths []string) string {
 	var obj bytes.Buffer
@@ -441,6 +444,7 @@ func constructBaseObj(paths []string) string {
 		}
 
 		name := strings.TrimSuffix(path.Base(p), ext)
+		name = params.SanitizeComponent(name)
 		fmt.Fprintf(&obj, "  %s: import \"%s\",\n", name, p)
 	}
 	obj.WriteString("}\n")
diff --git a/cmd/root_test.go b/cmd/root_test.go
index 2bab864d..d5539cdc 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -63,6 +63,16 @@ func TestConstructBaseObj(t *testing.T) {
 			`{
   bar: import "another/fake/path/bar.jsonnet",
 }
+`,
+		},
+		// test special character case
+		{
+			[]string{
+				"another/fake/path/foo-bar.jsonnet",
+			},
+			`{
+  "foo-bar": import "another/fake/path/foo-bar.jsonnet",
+}
 `,
 		},
 	}
diff --git a/metadata/params/params.go b/metadata/params/params.go
index 1090ee03..a99ab7a5 100644
--- a/metadata/params/params.go
+++ b/metadata/params/params.go
@@ -24,6 +24,7 @@ import (
 
 	"github.com/google/go-jsonnet/ast"
 	"github.com/google/go-jsonnet/parser"
+	"github.com/ksonnet/ksonnet/utils"
 )
 
 const (
@@ -39,6 +40,38 @@ func astRoot(component, snippet string) (ast.Node, error) {
 	return parser.Parse(tokens)
 }
 
+// SanitizeComponent puts quotes around a component name if it contains special
+// characters.
+func SanitizeComponent(component string) string {
+	if !utils.IsASCIIIdentifier(component) {
+		return fmt.Sprintf(`"%s"`, component)
+	}
+	return component
+}
+
+func getFieldId(field ast.ObjectField) (string, error) {
+	switch field.Kind {
+	case ast.ObjectFieldStr:
+		// case "foo-bar": {...}
+		c, _ := field.Expr1.(*ast.LiteralString)
+		switch c.Kind {
+		case ast.StringSingle, ast.StringDouble:
+			return c.Value, nil
+		default:
+			return "", fmt.Errorf("Found unsupported LiteralString type %T", c)
+		}
+	case ast.ObjectFieldID:
+		// case foo: {...}
+		return string(*field.Id), nil
+	}
+	return "", fmt.Errorf("Found unsupported ObjectField.Kind, type %T", field)
+}
+
+func hasComponent(component string, field ast.ObjectField) (bool, error) {
+	id, err := getFieldId(field)
+	return id == component, err
+}
+
 func visitParams(component ast.Node) (Params, *ast.LocationRange, error) {
 	params := make(Params)
 	var loc *ast.LocationRange
@@ -71,7 +104,11 @@ func visitAllParams(components ast.Object) (map[string]Params, error) {
 		if err != nil {
 			return nil, err
 		}
-		params[string(*f.Id)] = p
+		id, err := getFieldId(f)
+		if err != nil {
+			return nil, err
+		}
+		params[id] = p
 	}
 
 	return params, nil
@@ -86,7 +123,12 @@ func visitParamValue(param ast.Node) (string, error) {
 	case *ast.LiteralBoolean:
 		return strconv.FormatBool(n.Value), nil
 	case *ast.LiteralString:
-		return fmt.Sprintf(`"%s"`, n.Value), nil
+		switch n.Kind {
+		case ast.StringSingle, ast.StringDouble:
+			return fmt.Sprintf(`"%s"`, n.Value), nil
+		default:
+			return "", fmt.Errorf("Found unsupported LiteralString type %T", n)
+		}
 	default:
 		return "", fmt.Errorf("Found an unsupported param AST node type: %T", n)
 	}
@@ -145,6 +187,7 @@ func visitComponentsObj(component, snippet string) (*ast.Object, error) {
 }
 
 func appendComponent(component, snippet string, params Params) (string, error) {
+	component = SanitizeComponent(component)
 	componentsNode, err := visitComponentsObj(component, snippet)
 	if err != nil {
 		return "", err
@@ -152,7 +195,11 @@ func appendComponent(component, snippet string, params Params) (string, error) {
 
 	// Ensure that the component we are trying to create params for does not already exist.
 	for _, field := range componentsNode.Fields {
-		if field.Id != nil && string(*field.Id) == component {
+		hasComponent, err := hasComponent(component, field)
+		if err != nil {
+			return "", err
+		}
+		if hasComponent {
 			return "", fmt.Errorf("Component parameters for '%s' already exists", component)
 		}
 	}
@@ -181,7 +228,11 @@ func getComponentParams(component, snippet string) (Params, *ast.LocationRange,
 	}
 
 	for _, field := range componentsNode.Fields {
-		if field.Id != nil && string(*field.Id) == component {
+		hasComponent, err := hasComponent(component, field)
+		if err != nil {
+			return nil, nil, err
+		}
+		if hasComponent {
 			return visitParams(field.Expr2)
 		}
 	}
@@ -229,7 +280,7 @@ func findEnvComponentsObj(node ast.Node) (*ast.Object, error) {
 		return findEnvComponentsObj(n.Right)
 	case *ast.Object:
 		for _, f := range n.Fields {
-			if *f.Id == "components" {
+			if *f.Id == componentsID {
 				c, isObj := f.Expr2.(*ast.Object)
 				if !isObj {
 					return nil, fmt.Errorf("Expected components node type to be object")
@@ -254,7 +305,11 @@ func getEnvironmentParams(component, snippet string) (Params, *ast.LocationRange
 	}
 
 	for _, f := range n.Fields {
-		if f.Id != nil && string(*f.Id) == component {
+		hasComponent, err := hasComponent(component, f)
+		if err != nil {
+			return nil, nil, false, err
+		}
+		if hasComponent {
 			params, loc, err := visitParams(f.Expr2)
 			return params, loc, true, err
 		}
@@ -285,6 +340,7 @@ func getAllEnvironmentParams(snippet string) (map[string]Params, error) {
 }
 
 func setEnvironmentParams(component, snippet string, params Params) (string, error) {
+	component = SanitizeComponent(component)
 	currentParams, loc, hasComponent, err := getEnvironmentParams(component, snippet)
 	if err != nil {
 		return "", err
diff --git a/metadata/params/params_test.go b/metadata/params/params_test.go
index 6c2ef26a..fe15eb62 100644
--- a/metadata/params/params_test.go
+++ b/metadata/params/params_test.go
@@ -101,6 +101,37 @@ func TestAppendComponentParams(t *testing.T) {
       replicas: 5,
     },
   },
+}`,
+		},
+		// Test appending a component with a hyphenated name
+		{
+			"foo-bar",
+			`
+{
+  global: {
+    // User-defined global parameters; accessible to all component and environments, Ex:
+    // replicas: 4,
+  },
+  components: {
+    // Component-level parameters, defined initially from 'ks prototype use ...'
+    // Each object below should correspond to a component in the components/ directory
+  },
+}`,
+			Params{"replicas": "5", "name": `"foo-bar"`},
+			`
+{
+  global: {
+    // User-defined global parameters; accessible to all component and environments, Ex:
+    // replicas: 4,
+  },
+  components: {
+    // Component-level parameters, defined initially from 'ks prototype use ...'
+    // Each object below should correspond to a component in the components/ directory
+    "foo-bar": {
+      name: "foo-bar",
+      replicas: 5,
+    },
+  },
 }`,
 		},
 	}
@@ -215,6 +246,21 @@ func TestGetComponentParams(t *testing.T) {
 }`,
 			Params{"name": `"foo"`, "replicas": "1"},
 		},
+		// Test getting the parameters for a component name with special characters
+		{
+			"foo-bar",
+			`
+{
+  global: {},
+  components: {
+    "foo-bar": {
+      name: "foo-bar",
+      replicas: 1,
+    },
+  },
+}`,
+			Params{"name": `"foo-bar"`, "replicas": "1"},
+		},
 	}
 
 	errors := []struct {
@@ -243,6 +289,30 @@ func TestGetComponentParams(t *testing.T) {
     // replicas: 4,
 		components: {},
   },
+}`,
+		},
+		// Test case where one of the component names is a block string
+		{
+			"foo",
+			`
+{
+	components: {
+		|||foo|||: {
+			name: "foo",
+		}
+	},
+}`,
+		},
+		// Test case where one of the param values is a block string
+		{
+			"foo",
+			`
+{
+	components: {
+		"foo": {
+			name: |||name is foo|||,
+		}
+	},
 }`,
 		},
 	}
@@ -305,15 +375,15 @@ func TestGetAllComponentParams(t *testing.T) {
     bar: {
       replicas: 5
     },
-    foo: {
-      name: "foo",
+    "foo-bar": {
+      name: "foo-bar",
       replicas: 1,
     },
   },
 }`,
 			map[string]Params{
-				"bar": Params{"replicas": "5"},
-				"foo": Params{"name": `"foo"`, "replicas": "1"},
+				"bar":     Params{"replicas": "5"},
+				"foo-bar": Params{"name": `"foo-bar"`, "replicas": "1"},
 			},
 		},
 	}
@@ -411,6 +481,28 @@ func TestSetComponentParams(t *testing.T) {
       replicas: 5,
     },
   },
+}`,
+		},
+		// Test setting parameter for a component with a special character in name
+		{
+			"foo-bar",
+			`
+{
+  components: {
+    "foo-bar": {
+      name: "foo-bar",
+    },
+  },
+}`,
+			Params{"replicas": "5"},
+			`
+{
+  components: {
+    "foo-bar": {
+      name: "foo-bar",
+      replicas: 5,
+    },
+  },
 }`,
 		},
 	}
@@ -508,7 +600,7 @@ params + {
       name: "bar",
       replicas: 1,
     },
-    foo +: {
+    "foo" +: {
       name: "foo",
       replicas: 5,
     },
@@ -616,6 +708,27 @@ params + {
       replicas: 5,
     },
   },
+}`,
+		},
+		// Test setting environment param case where component isn't in the snippet
+		// and the component name has special characters
+		{
+			"foo-bar",
+			`
+local params = import "/fake/path";
+params + {
+  components +: {
+  },
+}`,
+			Params{"replicas": "5"},
+			`
+local params = import "/fake/path";
+params + {
+  components +: {
+    "foo-bar" +: {
+      replicas: 5,
+    },
+  },
 }`,
 		},
 	}
diff --git a/utils/strings.go b/utils/strings.go
new file mode 100644
index 00000000..c2652f6b
--- /dev/null
+++ b/utils/strings.go
@@ -0,0 +1,32 @@
+// Copyright 2017 The kubecfg 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 utils
+
+import (
+	"strings"
+)
+
+// HasSpecialCharacter takes a string and returns true if the string contains
+// any special characters.
+func IsASCIIIdentifier(s string) bool {
+	f := func(r rune) bool {
+		return r < 'A' || r > 'z'
+	}
+	if strings.IndexFunc(s, f) != -1 {
+		return false
+	}
+	return true
+}
diff --git a/utils/strings_test.go b/utils/strings_test.go
new file mode 100644
index 00000000..447d0844
--- /dev/null
+++ b/utils/strings_test.go
@@ -0,0 +1,53 @@
+// Copyright 2017 The kubecfg 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 utils
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestIsASCIIIdentifier(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected bool
+	}{
+		{
+			input:    "HelloWorld",
+			expected: true,
+		},
+		{
+			input:    "Hello World",
+			expected: false,
+		},
+		{
+			input:    "helloworld",
+			expected: true,
+		},
+		{
+			input:    "hello-world",
+			expected: false,
+		},
+		{
+			input:    "hello世界",
+			expected: false,
+		},
+	}
+	for _, test := range tests {
+		require.EqualValues(t, test.expected, IsASCIIIdentifier(test.input))
+	}
+}
-- 
GitLab