diff --git a/BUILD b/BUILD index d5300754..fe75de7c 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 34f8acc2..222d3c7d 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 00000000..6e01b4f9 --- /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 00000000..8d005a7b --- /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 00000000..acb6d990 --- /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 00000000..4a80df23 --- /dev/null +++ b/nix/readtree/BUILD @@ -0,0 +1,2 @@ +# Used by //go/workspace tests. +exports_files(["default.nix"])