go/workspace: implement EvalHscloudNix

This allows us to access hscloud nix 'facts' from Go.

Change-Id: Ic8fc3350a7d073947c44529fcae0bbb8627421aa
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1508
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/BUILD b/BUILD
index d530075..fe75de7 100644
--- a/BUILD
+++ b/BUILD
@@ -1,6 +1,9 @@
 # Gazelle settings
 load("@bazel_gazelle//:def.bzl", "gazelle")
 
+# Used by //go/workspace tests.
+exports_files(["WORKSPACE", "default.nix"])
+
 # gazelle:prefix code.hackerspace.pl/hscloud
 # gazelle:go_naming_convention go_default_library
 gazelle(
diff --git a/go/workspace/BUILD.bazel b/go/workspace/BUILD.bazel
index 34f8acc..222d3c7 100644
--- a/go/workspace/BUILD.bazel
+++ b/go/workspace/BUILD.bazel
@@ -1,8 +1,24 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
 
 go_library(
     name = "go_default_library",
-    srcs = ["workspace.go"],
+    srcs = [
+        "nix.go",
+        "workspace.go",
+    ],
     importpath = "code.hackerspace.pl/hscloud/go/workspace",
     visibility = ["//visibility:public"],
 )
+
+go_test(
+    name = "go_default_test",
+    srcs = ["nix_test.go"],
+    data = [
+        ":exports.nix",
+        "//:WORKSPACE",
+        "//:default.nix",
+        "//nix/readtree:default.nix",
+    ],
+    embed = [":go_default_library"],
+    deps = ["@com_github_google_go_cmp//cmp:go_default_library"],
+)
diff --git a/go/workspace/exports.nix b/go/workspace/exports.nix
new file mode 100644
index 0000000..6e01b4f
--- /dev/null
+++ b/go/workspace/exports.nix
@@ -0,0 +1,12 @@
+# This file contains test exports for //go/workspace.EvalHscloudNix tests.
+{ hscloud, ... }:
+
+{
+  someArray = ["hello" "there"];
+  someAttrset = {
+    foo = "foo";
+    bar = {
+      baz = 42;
+    };
+  };
+}
diff --git a/go/workspace/nix.go b/go/workspace/nix.go
new file mode 100644
index 0000000..8d005a7
--- /dev/null
+++ b/go/workspace/nix.go
@@ -0,0 +1,159 @@
+package workspace
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os/exec"
+	"unicode"
+)
+
+// EvalHscloudNix takes a hscloud attribute path (eg.
+// ops.machines.exports.kubeMachineNames) and evaluates its value. The
+// resulting value is then deserialized into the given target, which uses
+// encoding/json under the hood.
+//
+// The given path will be checked for basic mistakes, but is otherwise not
+// sanitized. Do not call this function with untrusted input. Even better, only
+// call it with constant strings.
+func EvalHscloudNix(ctx context.Context, target any, path string) error {
+	if err := checkNixPath(path); err != nil {
+		return fmt.Errorf("invalid path: %w", err)
+	}
+	ws, err := Get()
+	if err != nil {
+		return fmt.Errorf("could not find workspace: %w", err)
+	}
+
+	expression := `
+		let
+			hscloud = (import %q {});
+		in
+			hscloud.%s
+
+	`
+	expression = fmt.Sprintf(expression, ws, path)
+
+	args := []string{
+		// Do not attempt to actually instantiate derivation, just do an eval.
+		"--eval",
+		// Fully evaluate expression.
+		"--strict",
+		// Serialize evaluated expression into JSON.
+		"--json",
+		// Use following expression instead of file.
+		"-E",
+		expression,
+	}
+	cmd := exec.CommandContext(ctx, "nix-instantiate", args...)
+	out, err := cmd.Output()
+	if err != nil {
+		eerr := err.(*exec.ExitError)
+		return fmt.Errorf("nix-instantiate failed: %w, stderr: %q", err, eerr.Stderr)
+	}
+
+	if err := json.Unmarshal(out, target); err != nil {
+		return fmt.Errorf("unmarshaling json into target failed: %w", err)
+	}
+	return nil
+}
+
+// checkNixPath validates that the given path looks enough like a Nix attribute
+// path. It's not a security measure, it's an anti-footgun measure.
+func checkNixPath(s string) error {
+	if len(s) < 1 {
+		return fmt.Errorf("empty path")
+	}
+
+	// Split path into parts, supporting quotes.
+	var parts []string
+
+	// State machine: either:
+	//  !quote && !mustEnd or
+	//   quote && !mustEnd or
+	//  !quote && mustEnd.
+	quoted := false
+	mustEnd := false
+	// Current part.
+	part := ""
+	for _, c := range s {
+		if c > unicode.MaxASCII {
+			return fmt.Errorf("only ASCII characters supported")
+		}
+
+		// If we must end a part, make sure it actually ends here.
+		if mustEnd {
+			if c != '.' {
+				return fmt.Errorf("quotes can only end at path boundaries")
+			}
+			mustEnd = false
+			parts = append(parts, part)
+			part = ""
+			continue
+		}
+
+		// Perform quoting logic. Only one a full part may be quoted.
+		if c == '"' {
+			if !quoted {
+				if len(part) > 0 {
+					return fmt.Errorf("quotes can only start at path boundaries")
+				}
+				quoted = true
+				continue
+			} else {
+				quoted = false
+				mustEnd = true
+			}
+			continue
+		}
+
+		// Perform dot/period logic - different if we're in a quoted fragment
+		// or not.
+		if !quoted {
+			// Not in quoted part: finish part.
+			if c == '.' {
+				parts = append(parts, part)
+				part = ""
+				continue
+			}
+		} else {
+			// In quoted part: consume dot into part.
+			if c == '.' {
+				part += string(c)
+				continue
+			}
+		}
+
+		// Consume characters, making sure we only handle what's supported in a
+		// nix attrset accessor.
+		switch {
+		// Letters and underscores can be anywhere in the partq.
+		case c >= 'a' && c <= 'z':
+		case c >= 'A' && c <= 'Z':
+		case c == '_':
+
+		// Digits/hyphens cannot start a part.
+		case c >= '0' && c <= '9', c == '-':
+			if len(part) == 0 {
+				return fmt.Errorf("part cannot start with a %q", string(c))
+			}
+		default:
+			return fmt.Errorf("unexpected char: %q", string(c))
+		}
+		part += string(c)
+
+	}
+	if quoted {
+		return fmt.Errorf("unterminated quote")
+	}
+	parts = append(parts, part)
+
+	// Make sure no parts are empty, ie. there are no double dots.
+	for _, part := range parts {
+		if len(part) == 0 {
+			return fmt.Errorf("empty attrpath part")
+		}
+	}
+
+	return nil
+}
diff --git a/go/workspace/nix_test.go b/go/workspace/nix_test.go
new file mode 100644
index 0000000..acb6d99
--- /dev/null
+++ b/go/workspace/nix_test.go
@@ -0,0 +1,90 @@
+package workspace
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestCheckNixPath(t *testing.T) {
+	for _, te := range []struct {
+		in   string
+		okay bool
+	}{
+		{"foo", true},
+		{"foo.bar.baz", true},
+		{"foo.bar.baz.", false},
+		{"foo.bar.baz..", false},
+		{".foo.bar.baz", false},
+		{"..foo.bar.baz", false},
+		{"foo..bar.baz", false},
+		{".", false},
+		{"", false},
+
+		{"ops.machines.\"test.example.com\".config", true},
+		{"ops.machines.\"test.example.com.config", false},
+		{"ops.machines.\"test.example.com\"bar.config", false},
+		{"ops.machines.bar\"test.example.com\".config", false},
+		{"\"test.example.com\"bar", false},
+		{"test.example.com\"", false},
+
+		{"foo--.__bar.b-a-z---", true},
+		{"foo.0bar", false},
+		{"foo.-bar", false},
+
+		{"test\\.test", false},
+		{"test test", false},
+	} {
+		err := checkNixPath(te.in)
+		if te.okay && err != nil {
+			t.Errorf("%q: expected okay, got %v", te.in, err)
+		}
+		if !te.okay && err == nil {
+			t.Errorf("%q: expected error", te.in)
+		}
+	}
+}
+
+// TestEvalHscloud nix exercises EvalHscloudNix against
+// //go/workspace/exports.nix.
+func TestEvalHscloudNix(t *testing.T) {
+	ctx, ctxC := context.WithCancel(context.Background())
+	defer ctxC()
+
+	{
+		var got []string
+		if err := EvalHscloudNix(ctx, &got, "go.workspace.exports.someArray"); err != nil {
+			t.Errorf("Accessing someArray failed: %v", err)
+		} else {
+			want := []string{"hello", "there"}
+			if diff := cmp.Diff(want, got); diff != "" {
+				t.Errorf("someArray diff:\n%s", diff)
+			}
+		}
+	}
+
+	{
+		type barS struct {
+			Baz int64 `json:"baz"`
+		}
+		type gotS struct {
+			Foo string `json:"foo"`
+			Bar barS   `json:"bar"`
+		}
+		var got gotS
+		if err := EvalHscloudNix(ctx, &got, "go.workspace.exports.someAttrset"); err != nil {
+			t.Errorf("Accessing someAttrset failed: %v", err)
+		} else {
+			want := gotS{
+				Foo: "foo",
+				Bar: barS{
+					Baz: 42,
+				},
+			}
+			if diff := cmp.Diff(want, got); diff != "" {
+				t.Errorf("someAttrset diff:\n%s", diff)
+			}
+		}
+	}
+}
diff --git a/nix/readtree/BUILD b/nix/readtree/BUILD
new file mode 100644
index 0000000..4a80df2
--- /dev/null
+++ b/nix/readtree/BUILD
@@ -0,0 +1,2 @@
+# Used by //go/workspace tests.
+exports_files(["default.nix"])