From 2a5d3c248fbccfd1f711d027d70ba855625f22f3 Mon Sep 17 00:00:00 2001
From: Adin Scannell <ascannell@google.com>
Date: Tue, 5 Jan 2021 10:40:58 -0800
Subject: Add YAML validation for configuration files.

For validation, the "on" key in existing YAML files is changed to a literal
string. In the YAML spec, on is a keyword which encodes a boolean value, so
without relying on a specific implementation the YAML files are technically
not encoding an object that complies with the specification.

PiperOrigin-RevId: 350172147
---
 .github/workflows/build.yml         |   2 +-
 .github/workflows/go.yml            |   2 +-
 .github/workflows/issue_reviver.yml |   2 +-
 .github/workflows/labeler.yml       |   2 +-
 .github/workflows/stale.yml         |   2 +-
 BUILD                               |  19 ++++++
 WORKSPACE                           |  36 +++++++++-
 tools/nogo/BUILD                    |   2 +
 tools/nogo/config-schema.json       |  97 ++++++++++++++++++++++++++
 tools/yamltest/BUILD                |  13 ++++
 tools/yamltest/defs.bzl             |  41 +++++++++++
 tools/yamltest/main.go              | 133 ++++++++++++++++++++++++++++++++++++
 12 files changed, 345 insertions(+), 6 deletions(-)
 create mode 100644 tools/nogo/config-schema.json
 create mode 100644 tools/yamltest/BUILD
 create mode 100644 tools/yamltest/defs.bzl
 create mode 100644 tools/yamltest/main.go

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3be10b9bb..ab0bf9cb5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -6,7 +6,7 @@
 # This workflow also generates the build badge that is referred to by
 # the main README.
 name: "Build"
-on:
+"on":
   push:
     branches:
       - master
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index ff3059e2a..e62991691 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -3,7 +3,7 @@
 # workflow simply generates and pushes the branch, as long as appropriate
 # permissions are available.
 name: "Go"
-on:
+"on":
   push:
     branches:
       - master
diff --git a/.github/workflows/issue_reviver.yml b/.github/workflows/issue_reviver.yml
index f03b814c9..3bd883035 100644
--- a/.github/workflows/issue_reviver.yml
+++ b/.github/workflows/issue_reviver.yml
@@ -1,7 +1,7 @@
 # This workflow revives issues that are still referenced in the code, and may
 # have been accidentally closed or marked stale.
 name: "Issue reviver"
-on:
+"on":
   schedule:
     - cron: '0 0 * * *'
 
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index a53fdb3e9..3a19065e1 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -1,6 +1,6 @@
 # Labeler labels incoming pull requests.
 name: "Labeler"
-on:
+"on":
 - pull_request
 
 jobs:
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index be10c5bc4..3a4aa22e2 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -1,7 +1,7 @@
 # The stale workflow closes stale issues and pull requests, unless specific
 # tags have been applied in order to keep them open.
 name: "Close stale issues"
-on:
+"on":
   schedule:
   - cron: "0 0 * * *"
 
diff --git a/BUILD b/BUILD
index 6f802fe6a..d19d19866 100644
--- a/BUILD
+++ b/BUILD
@@ -1,5 +1,6 @@
 load("//tools:defs.bzl", "build_test", "gazelle", "go_path")
 load("//tools/nogo:defs.bzl", "nogo_config")
+load("//tools/yamltest:defs.bzl", "yaml_test")
 load("//website:defs.bzl", "doc")
 
 package(licenses = ["notice"])
@@ -50,6 +51,24 @@ doc(
     weight = "99",
 )
 
+yaml_test(
+    name = "nogo_config_test",
+    srcs = glob(["nogo*.yaml"]),
+    schema = "//tools/nogo:config-schema.json",
+)
+
+yaml_test(
+    name = "github_workflows_test",
+    srcs = glob([".github/workflows/*.yml"]),
+    schema = "@github_workflow_schema//file",
+)
+
+yaml_test(
+    name = "buildkite_pipelines_test",
+    srcs = glob([".buildkite/*.yaml"]),
+    schema = "@buildkite_pipeline_schema//file",
+)
+
 # The sandbox filegroup is used for sandbox-internal dependencies.
 package_group(
     name = "sandbox",
diff --git a/WORKSPACE b/WORKSPACE
index 933c1ff19..031c21163 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,4 +1,4 @@
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 
 # Bazel/starlark utilities.
@@ -176,6 +176,19 @@ http_archive(
     ],
 )
 
+# Schemas for testing.
+http_file(
+    name = "buildkite_pipeline_schema",
+    sha256 = "3369c58038b4d55c08928affafb653716eb1e7b3cabb4a391aef979dd921f4e1",
+    urls = ["https://raw.githubusercontent.com/buildkite/pipeline-schema/f7a0894074d194bcf19eec5411fec0528f7f4180/schema.json"],
+)
+
+http_file(
+    name = "github_workflow_schema",
+    sha256 = "2c375bb43dbc8b32b1bed46c290d0b70a8fa2aca7a5484dfca1b6e9c38cf9e7a",
+    urls = ["https://raw.githubusercontent.com/SchemaStore/schemastore/27612065234778feaac216ce14dd47846fe0a2dd/src/schemas/json/github-workflow.json"],
+)
+
 # External Go repositories.
 #
 # Unfortunately, gazelle will automatically parse go modules in the
@@ -1391,3 +1404,24 @@ go_repository(
     sum = "h1:+ySTxfHnfzZb9ys375PXNlLhkJPLKgHajBU0N62BDvE=",
     version = "v0.0.0-20190801114015-581e00157fb1",
 )
+
+go_repository(
+    name = "com_github_xeipuuv_gojsonpointer",
+    importpath = "github.com/xeipuuv/gojsonpointer",
+    sum = "h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=",
+    version = "v0.0.0-20190905194746-02993c407bfb",
+)
+
+go_repository(
+    name = "com_github_xeipuuv_gojsonreference",
+    importpath = "github.com/xeipuuv/gojsonreference",
+    sum = "h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=",
+    version = "v0.0.0-20180127040603-bd5ef7bd5415",
+)
+
+go_repository(
+    name = "com_github_xeipuuv_gojsonschema",
+    importpath = "github.com/xeipuuv/gojsonschema",
+    sum = "h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=",
+    version = "v1.2.0",
+)
diff --git a/tools/nogo/BUILD b/tools/nogo/BUILD
index 12b8b597c..566e0889e 100644
--- a/tools/nogo/BUILD
+++ b/tools/nogo/BUILD
@@ -3,6 +3,8 @@ load("//tools/nogo:defs.bzl", "nogo_objdump_tool", "nogo_stdlib", "nogo_target")
 
 package(licenses = ["notice"])
 
+exports_files(["config-schema.json"])
+
 nogo_target(
     name = "target",
     goarch = select_goarch(),
diff --git a/tools/nogo/config-schema.json b/tools/nogo/config-schema.json
new file mode 100644
index 000000000..3c25fe221
--- /dev/null
+++ b/tools/nogo/config-schema.json
@@ -0,0 +1,97 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "definitions": {
+    "group": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "description": "The name of the group.",
+          "type": "string"
+        },
+        "regex": {
+          "description": "A regular expression for matching paths.",
+          "type": "string"
+        },
+        "default": {
+          "description": "Whether the group is enabled by default.",
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "name",
+        "regex",
+        "default"
+      ],
+      "additionalProperties": false
+    },
+    "regexlist": {
+      "description": "A list of regular expressions.",
+      "oneOf": [
+        {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        {
+          "type": "null"
+        }
+      ]
+    },
+    "rule": {
+      "type": "object",
+      "properties": {
+        "exclude": {
+          "description": "A regular expression for paths to exclude.",
+          "$ref": "#/definitions/regexlist"
+        },
+        "suppress": {
+          "description": "A regular expression for messages to suppress.",
+          "$ref": "#/definitions/regexlist"
+        }
+      },
+      "additionalProperties": false
+    },
+    "ruleList": {
+      "type": "object",
+      "additionalProperties": {
+        "oneOf": [
+          {
+            "$ref": "#/definitions/rule"
+          },
+          {
+            "type": "null"
+          }
+        ]
+      }
+    }
+  },
+  "properties": {
+    "groups": {
+      "description": "A definition of all groups.",
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/group"
+      },
+      "minItems": 1
+    },
+    "global": {
+      "description": "A global set of rules.",
+      "type": "object",
+      "additionalProperties": {
+        "$ref": "#/definitions/rule"
+      }
+    },
+    "analyzers": {
+      "description": "A definition of all groups.",
+      "type": "object",
+      "additionalProperties": {
+        "$ref": "#/definitions/ruleList"
+      }
+    }
+  },
+  "required": [
+    "groups"
+  ],
+  "additionalProperties": false
+}
diff --git a/tools/yamltest/BUILD b/tools/yamltest/BUILD
new file mode 100644
index 000000000..475b3badd
--- /dev/null
+++ b/tools/yamltest/BUILD
@@ -0,0 +1,13 @@
+load("//tools:defs.bzl", "go_binary")
+
+package(licenses = ["notice"])
+
+go_binary(
+    name = "yamltest",
+    srcs = ["main.go"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "@com_github_xeipuuv_gojsonschema//:go_default_library",
+        "@in_gopkg_yaml_v2//:go_default_library",
+    ],
+)
diff --git a/tools/yamltest/defs.bzl b/tools/yamltest/defs.bzl
new file mode 100644
index 000000000..fd04f947d
--- /dev/null
+++ b/tools/yamltest/defs.bzl
@@ -0,0 +1,41 @@
+"""Tools for testing yaml files against schemas."""
+
+def _yaml_test_impl(ctx):
+    """Implementation for yaml_test."""
+    runner = ctx.actions.declare_file(ctx.label.name)
+    ctx.actions.write(runner, "\n".join([
+        "#!/bin/bash",
+        "set -euo pipefail",
+        "%s -schema=%s -- %s" % (
+            ctx.files._tool[0].short_path,
+            ctx.files.schema[0].short_path,
+            " ".join([f.short_path for f in ctx.files.srcs]),
+        ),
+    ]), is_executable = True)
+    return [DefaultInfo(
+        runfiles = ctx.runfiles(files = ctx.files._tool + ctx.files.schema + ctx.files.srcs),
+        executable = runner,
+    )]
+
+yaml_test = rule(
+    implementation = _yaml_test_impl,
+    doc = "Tests a yaml file against a schema.",
+    attrs = {
+        "srcs": attr.label_list(
+            doc = "The input yaml files.",
+            mandatory = True,
+            allow_files = True,
+        ),
+        "schema": attr.label(
+            doc = "The schema file in JSON schema format.",
+            allow_single_file = True,
+            mandatory = True,
+        ),
+        "_tool": attr.label(
+            executable = True,
+            cfg = "host",
+            default = Label("//tools/yamltest:yamltest"),
+        ),
+    },
+    test = True,
+)
diff --git a/tools/yamltest/main.go b/tools/yamltest/main.go
new file mode 100644
index 000000000..88271fb66
--- /dev/null
+++ b/tools/yamltest/main.go
@@ -0,0 +1,133 @@
+// Copyright 2020 The gVisor 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.
+
+// Binary yamltest does strict yaml parsing and validation.
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+
+	"github.com/xeipuuv/gojsonschema"
+	yaml "gopkg.in/yaml.v2"
+)
+
+func fixup(v interface{}) (interface{}, error) {
+	switch x := v.(type) {
+	case map[interface{}]interface{}:
+		// Coerse into a string-based map, required for yaml.
+		strMap := make(map[string]interface{})
+		for k, v := range x {
+			strK, ok := k.(string)
+			if !ok {
+				// This cannot be converted to JSON at all.
+				return nil, fmt.Errorf("invalid key %T in (%#v)", k, x)
+			}
+			fv, err := fixup(v)
+			if err != nil {
+				return nil, fmt.Errorf(".%s%w", strK, err)
+			}
+			strMap[strK] = fv
+		}
+		return strMap, nil
+	case []interface{}:
+		for i := range x {
+			fv, err := fixup(x[i])
+			if err != nil {
+				return nil, fmt.Errorf("[%d]%w", i, err)
+			}
+			x[i] = fv
+		}
+		return x, nil
+	default:
+		return v, nil
+	}
+}
+
+func loadFile(filename string) (gojsonschema.JSONLoader, error) {
+	f, err := os.Open(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	dec := yaml.NewDecoder(f)
+	dec.SetStrict(true)
+	var object interface{}
+	if err := dec.Decode(&object); err != nil {
+		return nil, err
+	}
+	fixedObject, err := fixup(object) // For serialization.
+	if err != nil {
+		return nil, err
+	}
+	bytes, err := json.Marshal(fixedObject)
+	if err != nil {
+		return nil, err
+	}
+	return gojsonschema.NewStringLoader(string(bytes)), nil
+}
+
+var schema = flag.String("schema", "", "path to JSON schema file.")
+
+func main() {
+	flag.Parse()
+	if *schema == "" || len(flag.Args()) == 0 {
+		flag.Usage()
+		os.Exit(2)
+	}
+
+	// Construct our schema loader.
+	schemaLoader := gojsonschema.NewReferenceLoader(fmt.Sprintf("file://%s", *schema))
+
+	// Parse all documents.
+	allErrors := make(map[string][]error)
+	for _, filename := range flag.Args() {
+		// Record the filename with an empty slice for below, where
+		// we will emit all files (even those without any errors).
+		allErrors[filename] = nil
+		documentLoader, err := loadFile(filename)
+		if err != nil {
+			allErrors[filename] = append(allErrors[filename], err)
+			continue
+		}
+		result, err := gojsonschema.Validate(schemaLoader, documentLoader)
+		if err != nil {
+			allErrors[filename] = append(allErrors[filename], err)
+			continue
+		}
+		for _, desc := range result.Errors() {
+			allErrors[filename] = append(allErrors[filename], errors.New(desc.String()))
+		}
+	}
+
+	// Print errors in yaml format.
+	totalErrors := 0
+	for filename, errs := range allErrors {
+		totalErrors += len(errs)
+		if len(errs) == 0 {
+			fmt.Fprintf(os.Stderr, "%s: ✓\n", filename)
+			continue
+		}
+		fmt.Fprintf(os.Stderr, "%s:\n", filename)
+		for _, err := range errs {
+			fmt.Fprintf(os.Stderr, "- %s\n", err)
+		}
+	}
+	if totalErrors != 0 {
+		os.Exit(1)
+	}
+}
-- 
cgit v1.2.3