This repository has no description
1package nix
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 "tangled.org/core/api/tangled"
15 "tangled.org/core/spindle/config"
16 "tangled.org/core/spindle/models"
17 "tangled.org/core/workflow"
18)
19
20func TestInitWorkflow(t *testing.T) {
21 e := &Engine{cfg: &config.Config{}}
22 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: "{}"}, tangled.Pipeline{})
23 require.NoError(t, err)
24 require.Len(t, wf.Steps, 1)
25 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
26 assert.Equal(t, models.StepKindSystem, wf.Steps[0].Kind())
27}
28
29func TestInitWorkflowCompiledProductionWorkflow(t *testing.T) {
30 e := &Engine{cfg: &config.Config{}}
31 raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n"
32 compiled := (&workflow.Compiler{}).Compile([]workflow.Workflow{{
33 Name: ".tangled/workflows/ci.yml",
34 Engine: "nix",
35 Raw: raw,
36 }})
37 require.Len(t, compiled.Workflows, 1)
38 require.Equal(t, raw, compiled.Workflows[0].Raw)
39
40 wf, err := e.InitWorkflow(*compiled.Workflows[0], tangled.Pipeline{})
41 require.NoError(t, err)
42 require.Len(t, wf.Steps, 1)
43 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
44}
45
46func TestInitWorkflowRejectsCustomSteps(t *testing.T) {
47 e := &Engine{cfg: &config.Config{}}
48 _, err := e.InitWorkflow(tangled.Pipeline_Workflow{
49 Name: "flake",
50 Raw: "steps:\n - name: nope\n command: echo nope\n",
51 }, tangled.Pipeline{})
52 require.Error(t, err)
53 assert.Contains(t, err.Error(), "unknown field")
54}
55
56// a generic workflow document (engine/when/clone, no engine-specific keys)
57// must initialize, only structural keys like `steps` are rejected
58func TestInitWorkflowAcceptsGenericKeys(t *testing.T) {
59 e := &Engine{cfg: &config.Config{}}
60 raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n depth: 1\n"
61 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: raw}, tangled.Pipeline{})
62 require.NoError(t, err)
63 require.Len(t, wf.Steps, 1)
64 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
65
66 _, err = e.InitWorkflow(tangled.Pipeline_Workflow{
67 Name: "flake",
68 Raw: raw + "steps:\n - name: nope\n command: echo nope\n",
69 }, tangled.Pipeline{})
70 require.Error(t, err)
71 assert.Contains(t, err.Error(), "unknown field")
72}
73
74// every eval/metadata invocation needs the flakes features flags because the
75// sandbox HOME has no nix.conf
76func TestEvalArgv(t *testing.T) {
77 e := &Engine{cfg: &config.Config{}}
78 exe, argv := e.evalArgv([]string{"flake", "metadata", "--json", "."})
79 assert.Equal(t, containerPrlimit, exe)
80 require.GreaterOrEqual(t, len(argv), 6)
81 assert.True(t, strings.HasPrefix(argv[0], "--as="))
82 assert.Equal(t, containerNix, argv[1])
83 assert.Equal(t, "--extra-experimental-features", argv[2])
84 assert.Equal(t, "nix-command flakes", argv[3])
85 assert.Equal(t, []string{"flake", "metadata", "--json", "."}, argv[4:])
86}
87
88func TestNixStringLit(t *testing.T) {
89 cases := map[string]string{
90 "plain": `"plain"`,
91 "dots.and%percent": `"dots.and%percent"`,
92 `quote"injection`: `"quote\"injection"`,
93 `interp${pwn}`: `"interp\${pwn}"`,
94 `back\slash`: `"back\\slash"`,
95 `"; throw "escaped`: `"\"; throw \"escaped"`,
96 "newline\ninside": "\"newline\ninside\"", // literal newlines are legal in nix strings
97 }
98 for in, want := range cases {
99 assert.Equal(t, want, nixStringLit(in), "input %q", in)
100 }
101}
102
103// metacharacter attr names stay structured: the installable is only the
104// trusted category.system parent, the apply lambda sees just the escaped
105// name, display is for humans
106func TestOutputCandidateMetachars(t *testing.T) {
107 cand := outputCandidate{
108 kind: targetSystemOutput,
109 category: "checks",
110 system: "x86_64-linux",
111 name: `unit.100%"cov${throw}`,
112 }
113 installable, apply, err := cand.drvTarget()
114 require.NoError(t, err)
115 assert.Equal(t, ".#checks.x86_64-linux", installable)
116 assert.Contains(t, apply, nixStringLit(cand.name))
117 assert.NotContains(t, apply, cand.name)
118 assert.NotContains(t, installable, cand.name)
119 assert.Equal(t, `.#checks.x86_64-linux.unit.100%"cov${throw}`, cand.display())
120
121 _, _, err = outputCandidate{kind: targetKind(99), name: "x"}.drvTarget()
122 require.Error(t, err)
123}
124
125// only validated system strings may reach an installable, anything else is
126// refused before it becomes argv
127func TestParentInstallable(t *testing.T) {
128 installable, err := parentInstallable("packages", "x86_64-linux")
129 require.NoError(t, err)
130 assert.Equal(t, ".#packages.x86_64-linux", installable)
131
132 installable, err = parentInstallable("nixosConfigurations", "")
133 require.NoError(t, err)
134 assert.Equal(t, ".#nixosConfigurations", installable)
135
136 for _, system := range []string{
137 `x86_64-linux"; throw "`,
138 "x86_64-linux ${throw 1}",
139 "x86_64-linux --option",
140 "x86_64-linux/x",
141 "x86_64 linux",
142 } {
143 _, err := parentInstallable("packages", system)
144 require.Error(t, err, "system %q", system)
145 assert.Contains(t, err.Error(), "unsafe system")
146 }
147}
148
149// a flake that doesn't define a probed category is "absent", not broken;
150// other evaluation failures must not be swallowed
151func TestIsMissingAttrError(t *testing.T) {
152 // verbatim nix 2.x output for `nix eval .#checks.x86_64-linux` on a flake
153 // without checks
154 assert.True(t, isMissingAttrError(
155 "error: flake 'path:/workdir' does not provide attribute 'packages.x86_64-linux.checks.x86_64-linux', 'legacyPackages.x86_64-linux.checks.x86_64-linux' or 'checks.x86_64-linux'"))
156 // older nix phrasing
157 assert.True(t, isMissingAttrError("error: attribute 'homeConfigurations' missing"))
158
159 assert.False(t, isMissingAttrError("error: syntax error, unexpected end of file"))
160 assert.False(t, isMissingAttrError("error: flake output is not a derivation"))
161 assert.False(t, isMissingAttrError(""))
162}
163
164func TestCheckOutputLimit(t *testing.T) {
165 require.NoError(t, checkOutputLimit(0, 100))
166 require.NoError(t, checkOutputLimit(100, 100))
167 err := checkOutputLimit(101, 100)
168 require.Error(t, err)
169 assert.Contains(t, err.Error(), "101")
170 assert.Contains(t, err.Error(), "100")
171}
172
173func TestMaxOutputsDefault(t *testing.T) {
174 assert.Equal(t, 100, (&Engine{cfg: &config.Config{}}).maxOutputs())
175 assert.Equal(t, 3, (&Engine{cfg: &config.Config{NixPipelines: config.NixPipelines{MaxOutputs: 3}}}).maxOutputs())
176}
177
178func TestMaxLogWriterOverflow(t *testing.T) {
179 var buf bytes.Buffer
180 canceled := false
181 mw := &maxLogWriter{limit: 10, cancel: func() { canceled = true }}
182 w := &limitedStreamWriter{parent: mw, out: &buf}
183 n, err := w.Write([]byte("0123456789"))
184 require.NoError(t, err)
185 require.Equal(t, 10, n)
186 assert.False(t, mw.exceeded)
187
188 n, err = w.Write([]byte("overflow"))
189 require.Error(t, err)
190 assert.Equal(t, 0, n)
191 assert.Contains(t, err.Error(), "exceeded the 10 byte limit")
192 assert.True(t, mw.exceeded)
193 assert.True(t, canceled)
194 assert.Equal(t, "0123456789", buf.String())
195
196 _, err = w.Write([]byte("more"))
197 require.Error(t, err)
198 assert.Equal(t, "0123456789", buf.String())
199}
200
201func TestMaxLogWriterTruncatesPartialWrite(t *testing.T) {
202 var buf bytes.Buffer
203 mw := &maxLogWriter{limit: 5}
204 w := &limitedStreamWriter{parent: mw, out: &buf}
205 n, err := w.Write([]byte("0123456789"))
206 require.Error(t, err)
207 assert.Equal(t, 5, n)
208 assert.Equal(t, "01234", buf.String())
209 assert.True(t, mw.exceeded)
210}
211
212func TestAuthorizePathInput(t *testing.T) {
213 workspace := t.TempDir()
214 outside := t.TempDir()
215
216 require.NoError(t, os.MkdirAll(filepath.Join(workspace, "sub", "dir"), 0o755))
217 require.NoError(t, authorizePathInput("a", map[string]any{"path": "sub/dir"}, workspace))
218 require.NoError(t, authorizePathInput("b", map[string]any{"path": "not/there/yet"}, workspace))
219 require.NoError(t, authorizePathInput("c", map[string]any{"path": "."}, workspace))
220
221 require.Error(t, authorizePathInput("d", map[string]any{"path": "/etc/passwd"}, workspace))
222 require.Error(t, authorizePathInput("e", map[string]any{"path": "../outside"}, workspace))
223 require.Error(t, authorizePathInput("f", map[string]any{"path": "sub/../../outside"}, workspace))
224
225 require.Error(t, authorizePathInput("g", map[string]any{}, workspace))
226
227 require.NoError(t, os.Symlink(filepath.Join(workspace, "sub"), filepath.Join(workspace, "inner-link")))
228 require.NoError(t, authorizePathInput("h", map[string]any{"path": "inner-link/dir"}, workspace))
229
230 // a lexically-inside path can still escape through a symlink
231 require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "escape")))
232 err := authorizePathInput("i", map[string]any{"path": "escape"}, workspace)
233 require.Error(t, err)
234 assert.Contains(t, err.Error(), "symlink")
235
236 require.NoError(t, os.MkdirAll(filepath.Join(workspace, "deep"), 0o755))
237 require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "deep", "escape")))
238 require.Error(t, authorizePathInput("j", map[string]any{"path": "deep/escape"}, workspace))
239}
240
241func TestInitWorkflowWithClone(t *testing.T) {
242 e := &Engine{cfg: &config.Config{Server: config.Server{Dev: true}}}
243 repoName := "my-repo"
244 repoDid := "did:plc:repo"
245 tpl := tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{
246 Kind: string(workflow.TriggerKindPush),
247 Push: &tangled.Pipeline_PushTriggerData{
248 Ref: "refs/heads/main",
249 NewSha: "1234567890abcdef1234567890abcdef12345678",
250 },
251 Repo: &tangled.Pipeline_TriggerRepo{
252 Knot: "example.com", Did: "did:plc:owner", Repo: &repoName, RepoDid: &repoDid,
253 },
254 }}
255 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tpl)
256 require.NoError(t, err)
257 require.Len(t, wf.Steps, 2)
258 assert.IsType(t, models.CloneStep{}, wf.Steps[0])
259 assert.Equal(t, "Evaluate flake outputs", wf.Steps[1].Name())
260}
261
262func TestBaseBwrapArgs(t *testing.T) {
263 e := &Engine{
264 nixBinPath: "/nix/store/nix/bin/nix",
265 bashBinPath: "/nix/store/bash/bin/bash",
266 gitBinPath: "/nix/store/git/bin/git",
267 prlimitBinPath: "/nix/store/prlimit/bin/prlimit",
268 }
269 args := e.baseBwrapArgs(addlFields{
270 workspaceDir: "/tmp/host/workspace",
271 homeDir: "/tmp/host/home",
272 })
273 argString := strings.Join(args, " ")
274 for _, expected := range []string{
275 "--die-with-parent", "--new-session", "--unshare-all --share-net",
276 "--ro-bind /nix/store /nix/store",
277 "--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket",
278 "--proc /proc", "--dev /dev", "--tmpfs /tmp",
279 "--bind /tmp/host/workspace /workdir", "--bind /tmp/host/home /home",
280 "--symlink /nix/store/nix/bin/nix /usr/bin/nix",
281 "--symlink /nix/store/bash/bin/bash /usr/bin/bash",
282 "--symlink /nix/store/bash/bin/bash /bin/sh",
283 "--symlink /nix/store/git/bin/git /usr/bin/git",
284 "--symlink /nix/store/prlimit/bin/prlimit /usr/bin/prlimit",
285 "--chdir /workdir",
286 } {
287 assert.Contains(t, argString, expected)
288 }
289 assert.NotContains(t, argString, "nixpkgs#")
290 assert.NotContains(t, argString, " shell ")
291}
292
293type mockWorkflowLogger struct {
294 stdout strings.Builder
295 stderr strings.Builder
296}
297
298func (m *mockWorkflowLogger) Close() error { return nil }
299func (m *mockWorkflowLogger) DataWriter(_ int, stream string) io.Writer {
300 if stream == "stderr" {
301 return &m.stderr
302 }
303 return &m.stdout
304}
305func (m *mockWorkflowLogger) ControlWriter(_ int, _ models.Step, _ models.StepStatus) io.Writer {
306 return io.Discard
307}
308
309func TestIntegrationRealFlakeBuilds(t *testing.T) {
310 if os.Getenv("RUN_NIX_INTEGRATION_TEST") != "true" {
311 t.Skip("set RUN_NIX_INTEGRATION_TEST=true to run")
312 }
313
314 cfg := &config.Config{NixPipelines: config.NixPipelines{WorkDirBase: t.TempDir()}}
315 e, err := New(cfg)
316 require.NoError(t, err)
317 wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tangled.Pipeline{})
318 require.NoError(t, err)
319 wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"}, Name: "flake"}
320 logger := &mockWorkflowLogger{}
321 require.NoError(t, e.SetupWorkflow(context.Background(), wid, wf, logger))
322 defer e.DestroyWorkflow(context.Background(), wid)
323
324 addl := wf.Data.(addlFields)
325 flake := `{
326 inputs.nixpkgs.url = "nixpkgs";
327 outputs = { nixpkgs, ... }:
328 let
329 system = "x86_64-linux";
330 pkgs = nixpkgs.legacyPackages.${system};
331 in {
332 packages.${system}.hello = pkgs.runCommand "spindle-package" {} "echo package > $out";
333 checks.${system}."unit.100%cov" = pkgs.runCommand "spindle-check" {} "echo check > $out";
334 formatter.${system} = pkgs.hello;
335 devShells.${system}.ci = pkgs.mkShell {};
336 };
337}
338`
339 require.NoError(t, os.WriteFile(filepath.Join(addl.workspaceDir, "flake.nix"), []byte(flake), 0o600))
340
341 // the loop bound is evaluated per iteration, so build steps appended by
342 // the discovery step run in the same pass
343 for idx := 0; idx < len(wf.Steps); idx++ {
344 require.NoError(t, e.RunStep(context.Background(), wid, wf, idx, nil, logger), logger.stderr.String())
345 }
346
347 require.GreaterOrEqual(t, len(wf.Steps), 5, "discovery + packages/checks/formatter/devShells builds")
348 assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
349 var names []string
350 for _, step := range wf.Steps[1:] {
351 s, ok := step.(Step)
352 require.True(t, ok, "generated step %q is a nix engine Step", step.Name())
353 require.NotEmpty(t, s.drvPath, s.Name())
354 assert.Contains(t, s.Command(), "^*", s.Name())
355 names = append(names, s.Name())
356 }
357 for _, fragment := range []string{"packages", "checks", "formatter", "devShells", "unit.100%cov"} {
358 assert.Condition(t, func() bool { return containsName(names, fragment) }, fragment)
359 }
360
361 require.NoError(t, e.DestroyWorkflow(context.Background(), wid))
362 assert.NoDirExists(t, addl.workspaceDir)
363 assert.NoDirExists(t, addl.homeDir)
364 assert.NoError(t, e.DestroyWorkflow(context.Background(), wid))
365}
366
367func containsName(names []string, fragment string) bool {
368 for _, name := range names {
369 if strings.Contains(name, fragment) {
370 return true
371 }
372 }
373 return false
374}