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/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
+}