blob: 22b50cf4fde715ec2efe365fd04811b328107c6a [file] [log] [blame]
Serge Bazanskia03b60b2023-04-01 14:47:44 +00001package workspace
2
3import (
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.
19func 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 Bazanski54183ba2023-09-01 19:12:14 +020051 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 Bazanskia03b60b2023-04-01 14:47:44 +000055 }
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.
65func 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}