Serge Bazanski | a03b60b | 2023-04-01 14:47:44 +0000 | [diff] [blame] | 1 | package workspace |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os/exec" |
| 8 | "unicode" |
| 9 | ) |
| 10 | |
| 11 | // EvalHscloudNix takes a hscloud attribute path (eg. |
| 12 | // ops.machines.exports.kubeMachineNames) and evaluates its value. The |
| 13 | // resulting value is then deserialized into the given target, which uses |
| 14 | // encoding/json under the hood. |
| 15 | // |
| 16 | // The given path will be checked for basic mistakes, but is otherwise not |
| 17 | // sanitized. Do not call this function with untrusted input. Even better, only |
| 18 | // call it with constant strings. |
| 19 | func EvalHscloudNix(ctx context.Context, target any, path string) error { |
| 20 | if err := checkNixPath(path); err != nil { |
| 21 | return fmt.Errorf("invalid path: %w", err) |
| 22 | } |
| 23 | ws, err := Get() |
| 24 | if err != nil { |
| 25 | return fmt.Errorf("could not find workspace: %w", err) |
| 26 | } |
| 27 | |
| 28 | expression := ` |
| 29 | let |
| 30 | hscloud = (import %q {}); |
| 31 | in |
| 32 | hscloud.%s |
| 33 | |
| 34 | ` |
| 35 | expression = fmt.Sprintf(expression, ws, path) |
| 36 | |
| 37 | args := []string{ |
| 38 | // Do not attempt to actually instantiate derivation, just do an eval. |
| 39 | "--eval", |
| 40 | // Fully evaluate expression. |
| 41 | "--strict", |
| 42 | // Serialize evaluated expression into JSON. |
| 43 | "--json", |
| 44 | // Use following expression instead of file. |
| 45 | "-E", |
| 46 | expression, |
| 47 | } |
| 48 | cmd := exec.CommandContext(ctx, "nix-instantiate", args...) |
| 49 | out, err := cmd.Output() |
| 50 | if err != nil { |
Serge Bazanski | 54183ba | 2023-09-01 19:12:14 +0200 | [diff] [blame] | 51 | if eerr, ok := err.(*exec.ExitError); ok { |
| 52 | return fmt.Errorf("nix-instantiate failed: %w, stderr: %q", err, eerr.Stderr) |
| 53 | } |
| 54 | return fmt.Errorf("nix-instantiate failed: %w", err) |
Serge Bazanski | a03b60b | 2023-04-01 14:47:44 +0000 | [diff] [blame] | 55 | } |
| 56 | |
| 57 | if err := json.Unmarshal(out, target); err != nil { |
| 58 | return fmt.Errorf("unmarshaling json into target failed: %w", err) |
| 59 | } |
| 60 | return nil |
| 61 | } |
| 62 | |
| 63 | // checkNixPath validates that the given path looks enough like a Nix attribute |
| 64 | // path. It's not a security measure, it's an anti-footgun measure. |
| 65 | func checkNixPath(s string) error { |
| 66 | if len(s) < 1 { |
| 67 | return fmt.Errorf("empty path") |
| 68 | } |
| 69 | |
| 70 | // Split path into parts, supporting quotes. |
| 71 | var parts []string |
| 72 | |
| 73 | // State machine: either: |
| 74 | // !quote && !mustEnd or |
| 75 | // quote && !mustEnd or |
| 76 | // !quote && mustEnd. |
| 77 | quoted := false |
| 78 | mustEnd := false |
| 79 | // Current part. |
| 80 | part := "" |
| 81 | for _, c := range s { |
| 82 | if c > unicode.MaxASCII { |
| 83 | return fmt.Errorf("only ASCII characters supported") |
| 84 | } |
| 85 | |
| 86 | // If we must end a part, make sure it actually ends here. |
| 87 | if mustEnd { |
| 88 | if c != '.' { |
| 89 | return fmt.Errorf("quotes can only end at path boundaries") |
| 90 | } |
| 91 | mustEnd = false |
| 92 | parts = append(parts, part) |
| 93 | part = "" |
| 94 | continue |
| 95 | } |
| 96 | |
| 97 | // Perform quoting logic. Only one a full part may be quoted. |
| 98 | if c == '"' { |
| 99 | if !quoted { |
| 100 | if len(part) > 0 { |
| 101 | return fmt.Errorf("quotes can only start at path boundaries") |
| 102 | } |
| 103 | quoted = true |
| 104 | continue |
| 105 | } else { |
| 106 | quoted = false |
| 107 | mustEnd = true |
| 108 | } |
| 109 | continue |
| 110 | } |
| 111 | |
| 112 | // Perform dot/period logic - different if we're in a quoted fragment |
| 113 | // or not. |
| 114 | if !quoted { |
| 115 | // Not in quoted part: finish part. |
| 116 | if c == '.' { |
| 117 | parts = append(parts, part) |
| 118 | part = "" |
| 119 | continue |
| 120 | } |
| 121 | } else { |
| 122 | // In quoted part: consume dot into part. |
| 123 | if c == '.' { |
| 124 | part += string(c) |
| 125 | continue |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // Consume characters, making sure we only handle what's supported in a |
| 130 | // nix attrset accessor. |
| 131 | switch { |
| 132 | // Letters and underscores can be anywhere in the partq. |
| 133 | case c >= 'a' && c <= 'z': |
| 134 | case c >= 'A' && c <= 'Z': |
| 135 | case c == '_': |
| 136 | |
| 137 | // Digits/hyphens cannot start a part. |
| 138 | case c >= '0' && c <= '9', c == '-': |
| 139 | if len(part) == 0 { |
| 140 | return fmt.Errorf("part cannot start with a %q", string(c)) |
| 141 | } |
| 142 | default: |
| 143 | return fmt.Errorf("unexpected char: %q", string(c)) |
| 144 | } |
| 145 | part += string(c) |
| 146 | |
| 147 | } |
| 148 | if quoted { |
| 149 | return fmt.Errorf("unterminated quote") |
| 150 | } |
| 151 | parts = append(parts, part) |
| 152 | |
| 153 | // Make sure no parts are empty, ie. there are no double dots. |
| 154 | for _, part := range parts { |
| 155 | if len(part) == 0 { |
| 156 | return fmt.Errorf("empty attrpath part") |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | return nil |
| 161 | } |