blob: 22b50cf4fde715ec2efe365fd04811b328107c6a [file] [log] [blame]
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 {
if eerr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("nix-instantiate failed: %w, stderr: %q", err, eerr.Stderr)
}
return fmt.Errorf("nix-instantiate failed: %w", err)
}
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
}