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 }