[engine] Prioritize version warning over unknown fields

If `shac.textproto` specifies a `min_shac_version`, check that shac is
running at that version before validating the remaining fields of
`shac.textproto` in case one of the fields is unknown only because it
was introduced in a later version of shac.

Change-Id: I253468cf81e1a7d7a0488942a6e691ecbb8c9aa1
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/921276
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
diff --git a/internal/engine/config.go b/internal/engine/config.go
index d90956d..7320853 100644
--- a/internal/engine/config.go
+++ b/internal/engine/config.go
@@ -24,21 +24,29 @@
 	"strings"
 )
 
+func (doc *Document) CheckVersion() error {
+	if doc.MinShacVersion == "" {
+		return nil
+	}
+	v := parseVersion(doc.MinShacVersion)
+	if v == nil || len(v) > len(Version) {
+		return errors.New("min_shac_version is invalid")
+	}
+	for i := range v {
+		if v[i] > Version[i] {
+			return fmt.Errorf("min_shac_version specifies unsupported version %q, running %s", doc.MinShacVersion, Version)
+		}
+		if v[i] < Version[i] {
+			break
+		}
+	}
+	return nil
+}
+
 // Validate verifies a shac.textproto document is valid.
 func (doc *Document) Validate() error {
-	if doc.MinShacVersion != "" {
-		v := parseVersion(doc.MinShacVersion)
-		if v == nil || len(v) > len(Version) {
-			return errors.New("min_shac_version is invalid")
-		}
-		for i := range v {
-			if v[i] > Version[i] {
-				return fmt.Errorf("min_shac_version specifies unsupported version %q, running %d.%d.%d", doc.MinShacVersion, Version[0], Version[1], Version[2])
-			}
-			if v[i] < Version[i] {
-				break
-			}
-		}
+	if err := doc.CheckVersion(); err != nil {
+		return err
 	}
 	deps := map[string]string{}
 	aliases := map[string]struct{}{}
diff --git a/internal/engine/config_test.go b/internal/engine/config_test.go
index efedfdb..1ce8874 100644
--- a/internal/engine/config_test.go
+++ b/internal/engine/config_test.go
@@ -268,10 +268,8 @@
 			"min_shac_version: \"1000\"\n",
 			func() string {
 				return fmt.Sprintf(
-					"min_shac_version specifies unsupported version \"1000\", running %d.%d.%d",
-					Version[0],
-					Version[1],
-					Version[2],
+					"min_shac_version specifies unsupported version \"1000\", running %s",
+					Version,
 				)
 			}(),
 		},
diff --git a/internal/engine/run.go b/internal/engine/run.go
index 0851e2d..705c07c 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -222,7 +222,20 @@
 	var b []byte
 	doc := Document{}
 	if b, err = os.ReadFile(p); err == nil {
-		if err = prototext.Unmarshal(b, &doc); err != nil {
+		// First parse the config file ignoring unknown fields and check only
+		// min_shac_version, so users get an "unsupported version" error if they
+		// set fields that are only available in a later version of shac (as
+		// long as min_shac_version is set appropriately).
+		opts := prototext.UnmarshalOptions{DiscardUnknown: true}
+		if err = opts.Unmarshal(b, &doc); err != nil {
+			return err
+		}
+		if err = doc.CheckVersion(); err != nil {
+			return err
+		}
+		// Parse the config file again, failing on any unknown fields.
+		opts.DiscardUnknown = false
+		if err = opts.Unmarshal(b, &doc); err != nil {
 			return err
 		}
 	} else if !errors.Is(err, fs.ErrNotExist) {
@@ -694,15 +707,3 @@
 	}
 	return err
 }
-
-func parseVersion(s string) []int {
-	var out []int
-	for _, x := range strings.Split(s, ".") {
-		i, err := strconv.Atoi(x)
-		if err != nil {
-			return nil
-		}
-		out = append(out, i)
-	}
-	return out
-}
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 5c09186..15146ee 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -80,8 +80,34 @@
 					Dir: root,
 				}
 			}(),
-			// The encoding is not deterministic.
-			"...: unknown field: bad",
+			//
+			"...unexpected EOF",
+		},
+		{
+			"config file with unknown field",
+			func() Options {
+				root := t.TempDir()
+				writeFile(t, root, "shac.star", ``)
+				writeFile(t, root, "shac.textproto", "unknown_field: true\n")
+				return Options{
+					Dir: root,
+				}
+			}(),
+			"...unknown field: unknown_field",
+		},
+		{
+			"config file with newer min_shac_version and unknown field",
+			func() Options {
+				root := t.TempDir()
+				writeFile(t, root, "shac.star", ``)
+				writeFile(t, root, "shac.textproto",
+					"min_shac_version: \"2000\"\n"+
+						"unknown_field: true\n")
+				return Options{
+					Dir: root,
+				}
+			}(),
+			fmt.Sprintf("min_shac_version specifies unsupported version \"2000\", running %s", Version),
 		},
 		{
 			"no shac.star file",
diff --git a/internal/engine/version.go b/internal/engine/version.go
index 9433863..e88d772 100644
--- a/internal/engine/version.go
+++ b/internal/engine/version.go
@@ -14,9 +14,33 @@
 
 package engine
 
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+type shacVersion [3]int
+
 var (
 	// Version is the current tool version.
 	//
 	// TODO(maruel): Add proper version, preferably from git tag.
-	Version = [...]int{0, 1, 7}
+	Version = shacVersion{0, 1, 8}
 )
+
+func (v shacVersion) String() string {
+	return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2])
+}
+
+func parseVersion(s string) []int {
+	var out []int
+	for _, x := range strings.Split(s, ".") {
+		i, err := strconv.Atoi(x)
+		if err != nil {
+			return nil
+		}
+		out = append(out, i)
+	}
+	return out
+}