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