This repository has no description
1package nix
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/url"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strings"
17 "sync"
18 "syscall"
19 "time"
20
21 "tangled.org/core/api/tangled"
22 "tangled.org/core/spindle/config"
23 "tangled.org/core/spindle/engine"
24 "tangled.org/core/spindle/models"
25 "tangled.org/core/spindle/secrets"
26)
27
28// eval json is buffered whole before parsing, so it needs a hard ceiling,
29// a hostile flake must not oom the executor through stdout
30const maxEvalOutputBytes = 32 << 20
31
32// paths inside the bwrap namespace, populated by symlink in baseBwrapArgs
33const (
34 containerNix = "/usr/bin/nix"
35 containerBash = "/usr/bin/bash"
36 containerPrlimit = "/usr/bin/prlimit"
37)
38
39type Engine struct {
40 cfg *config.Config
41 slotter *engine.SemaphoreSlotter
42 nixBinPath string
43 bashBinPath string
44 gitBinPath string
45 prlimitBinPath string
46 bwrapBinPath string
47 workspaces sync.Map // keyed by full models.WorkflowId
48}
49
50// per-workflow host state, workspaceDir is bound at /workdir and homeDir at
51// /home inside the sandbox
52type addlFields struct {
53 workspaceDir string
54 homeDir string
55}
56
57type Step struct {
58 name string
59 command string
60 drvPath string
61 kind models.StepKind
62 isDiscovery bool
63}
64
65func (s Step) Name() string { return s.name }
66func (s Step) Command() string { return s.command }
67func (s Step) Kind() models.StepKind { return s.kind }
68
69// targetKind names the shape of a flake output without encoding it in an attr
70// path string
71type targetKind int
72
73const (
74 targetSystemOutput targetKind = iota // packages/checks/devShells.<system>.<name>
75 targetDirect // formatter/defaultPackage/devShell.<system>
76 targetHomeActivation // homeConfigurations.<name>.activationPackage
77 targetToplevel // nixos|darwinConfigurations.<name>.config.system.build.toplevel
78)
79
80// the installable and --apply expression resolving a candidate's drvPath.
81// the installable is a parent attrset built from trusted constants and the
82// validated currentSystem, only the apply lambda sees the output name, as a
83// nixStringLit literal looked up with builtins.getAttr
84func (c outputCandidate) drvTarget() (installable, apply string, err error) {
85 switch c.kind {
86 case targetSystemOutput:
87 installable, err = parentInstallable(c.category, c.system)
88 apply = fmt.Sprintf(systemOutputDrvApply, nixStringLit(c.name))
89 case targetDirect:
90 installable, err = parentInstallable(c.category, c.system)
91 apply = directDrvApply
92 case targetHomeActivation:
93 installable = ".#homeConfigurations"
94 apply = fmt.Sprintf(homeActivationDrvApply, nixStringLit(c.name))
95 case targetToplevel:
96 installable = ".#" + c.category
97 apply = fmt.Sprintf(toplevelDrvApply, nixStringLit(c.name))
98 default:
99 return "", "", fmt.Errorf("unknown candidate kind %d for %s", c.kind, c.display())
100 }
101 if err != nil {
102 return "", "", err
103 }
104 return installable, apply, nil
105}
106
107// too many outputs is a resource attack, refuse outright
108func checkOutputLimit(count, max int) error {
109 if count > max {
110 return fmt.Errorf("flake exposes %d candidate outputs, over the limit of %d", count, max)
111 }
112 return nil
113}
114
115// a discovered buildable, kept structured end to end: category/system/name go
116// to nix as separate string literals, never joined into an attr path
117type outputCandidate struct {
118 kind targetKind
119 category string
120 system string
121 name string
122}
123
124// display is for humans only, nothing parses this back
125func (c outputCandidate) display() string {
126 switch c.kind {
127 case targetSystemOutput:
128 return fmt.Sprintf(".#%s.%s.%s", c.category, c.system, c.name)
129 case targetDirect:
130 return fmt.Sprintf(".#%s.%s", c.category, c.system)
131 case targetHomeActivation:
132 return fmt.Sprintf(".#homeConfigurations.%s.activationPackage", c.name)
133 case targetToplevel:
134 return fmt.Sprintf(".#%s.%s.config.system.build.toplevel", c.category, c.name)
135 default:
136 return ".#<unknown>"
137 }
138}
139
140func New(cfg *config.Config) (*Engine, error) {
141 // every one of these is required inside the sandbox, fail fast at startup
142 // rather than mid-pipeline
143 bwrapPath, err := executablePath("bwrap")
144 if err != nil {
145 return nil, err
146 }
147 nixPath, err := executablePath("nix")
148 if err != nil {
149 return nil, err
150 }
151 bashPath, err := executablePath("bash")
152 if err != nil {
153 return nil, err
154 }
155 gitPath, err := executablePath("git")
156 if err != nil {
157 return nil, err
158 }
159 prlimitPath, err := executablePath("prlimit")
160 if err != nil {
161 return nil, err
162 }
163
164 return &Engine{
165 cfg: cfg,
166 slotter: engine.NewSemaphoreSlotter(cfg.NixPipelines.MaxConcurrentWorkflows),
167 nixBinPath: nixPath,
168 bashBinPath: bashPath,
169 gitBinPath: gitPath,
170 prlimitBinPath: prlimitPath,
171 bwrapBinPath: bwrapPath,
172 }, nil
173}
174
175// resolves to the real binary so the sandbox symlinks survive any wrapper
176// symlinks on the host PATH
177func executablePath(name string) (string, error) {
178 path, err := exec.LookPath(name)
179 if err != nil {
180 return "", fmt.Errorf("%s executable not found: %w", name, err)
181 }
182 if resolved, err := filepath.EvalSymlinks(path); err == nil {
183 path = resolved
184 }
185 return path, nil
186}
187
188func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
189 // the nix engine owns the step list, so the only valid manifest keys are
190 // the generic workflow ones (engine/when/clone), anything else, including
191 // user steps, is a structural error
192 if err := engine.DescribeManifestError(twf.Raw, struct{}{}); err != nil {
193 return nil, err
194 }
195
196 wf := &models.Workflow{Name: twf.Name, Data: addlFields{}}
197 if tpl.TriggerMetadata != nil {
198 if cloneStep := models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev); cloneStep.Command() != "" {
199 wf.Steps = append(wf.Steps, cloneStep)
200 }
201 }
202 wf.Steps = append(wf.Steps, Step{
203 name: "Evaluate flake outputs",
204 command: "nix flake metadata --json .; nix eval --apply <discovery> --json",
205 kind: models.StepKindSystem,
206 isDiscovery: true,
207 })
208 return wf, nil
209}
210
211func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) {
212 return e.slotter.AcquireWorkflowSlot(ctx, wid, wf)
213}
214
215func (e *Engine) SetupWorkflow(_ context.Context, wid models.WorkflowId, wf *models.Workflow, _ models.WorkflowLogger) error {
216 workDirBase := e.cfg.NixPipelines.WorkDirBase
217 if workDirBase == "" {
218 workDirBase = os.TempDir()
219 }
220
221 workspaceDir, err := os.MkdirTemp(workDirBase, "spindle-nix-workspace-"+wid.String()+"-*")
222 if err != nil {
223 return fmt.Errorf("creating host workspace directory: %w", err)
224 }
225 homeDir, err := os.MkdirTemp(workDirBase, "spindle-nix-home-"+wid.String()+"-*")
226 if err != nil {
227 os.RemoveAll(workspaceDir)
228 return fmt.Errorf("creating host home directory: %w", err)
229 }
230 homeTmpDir := filepath.Join(homeDir, "tmp")
231 if err := os.MkdirAll(homeTmpDir, 0o755); err != nil {
232 os.RemoveAll(workspaceDir)
233 os.RemoveAll(homeDir)
234 return fmt.Errorf("creating host home tmp directory: %w", err)
235 }
236
237 // the sandbox runs as the configured unprivileged user, so hand it
238 // ownership of its writable dirs when we have the power to
239 if os.Geteuid() == 0 {
240 uid := e.cfg.NixPipelines.SandboxUid
241 gid := e.cfg.NixPipelines.SandboxGid
242 for _, dir := range []string{workspaceDir, homeDir, homeTmpDir} {
243 if err := os.Chown(dir, uid, gid); err != nil {
244 os.RemoveAll(workspaceDir)
245 os.RemoveAll(homeDir)
246 return fmt.Errorf("chowning %s to sandbox uid/gid: %w", dir, err)
247 }
248 }
249 }
250
251 addl := addlFields{workspaceDir: workspaceDir, homeDir: homeDir}
252 wf.Data = addl
253 e.workspaces.Store(wid, addl)
254 return nil
255}
256
257func (e *Engine) WorkflowTimeout() time.Duration {
258 if e.cfg.NixPipelines.WorkflowTimeout > 0 {
259 return e.cfg.NixPipelines.WorkflowTimeout
260 }
261 return 5 * time.Minute
262}
263
264func (e *Engine) DestroyWorkflow(_ context.Context, wid models.WorkflowId) error {
265 val, ok := e.workspaces.LoadAndDelete(wid)
266 if !ok {
267 // nothing stored: setup never ran or destroy already happened
268 return nil
269 }
270 addl := val.(addlFields)
271 return errors.Join(os.RemoveAll(addl.workspaceDir), os.RemoveAll(addl.homeDir))
272}
273
274// the entire environment the sandboxed process sees, nothing leaks in from
275// the executor
276func (e *Engine) buildEnv() []string {
277 return []string{
278 "HOME=/home",
279 "TMPDIR=/home/tmp",
280 "NIX_REMOTE=daemon",
281 "PATH=/usr/bin:/bin",
282 }
283}
284
285func (e *Engine) baseBwrapArgs(addl addlFields) []string {
286 args := []string{
287 "--die-with-parent",
288 "--new-session",
289 "--unshare-all",
290 "--share-net", // builds fetch from the network, the nix daemon gates what matters
291 "--ro-bind", "/nix/store", "/nix/store",
292 }
293
294 // evaluation and builds both talk to the host nix daemon
295 if _, err := os.Stat("/nix/var/nix/daemon-socket"); err == nil {
296 args = append(args, "--ro-bind", "/nix/var/nix/daemon-socket", "/nix/var/nix/daemon-socket")
297 }
298
299 args = append(args,
300 "--proc", "/proc",
301 "--dev", "/dev",
302 "--tmpfs", "/tmp",
303 "--bind", addl.workspaceDir, "/workdir",
304 "--bind", addl.homeDir, "/home",
305 "--dir", "/etc",
306 )
307
308 // /etc stays synthetic: only the resolved files networking/TLS actually
309 // need, bound one at a time. no blanket /etc, no /run
310 for _, f := range []string{
311 "/etc/resolv.conf",
312 "/etc/nsswitch.conf",
313 "/etc/hosts",
314 "/etc/ssl/certs/ca-certificates.crt",
315 "/etc/ssl/certs/ca-bundle.crt",
316 } {
317 resolved, err := filepath.EvalSymlinks(f)
318 if err != nil {
319 continue
320 }
321 if _, err := os.Stat(resolved); err != nil {
322 continue
323 }
324 if dir := filepath.Dir(f); dir != "/etc" && dir != "/" {
325 args = append(args, "--dir", dir)
326 }
327 args = append(args, "--ro-bind", resolved, f)
328 }
329
330 args = append(args,
331 "--dir", "/usr",
332 "--dir", "/usr/bin",
333 "--dir", "/bin",
334 "--symlink", e.nixBinPath, containerNix,
335 "--symlink", e.bashBinPath, containerBash,
336 "--symlink", e.bashBinPath, "/bin/sh",
337 "--symlink", e.gitBinPath, "/usr/bin/git",
338 "--symlink", e.prlimitBinPath, containerPrlimit,
339 "--chdir", "/workdir",
340 "--",
341 )
342
343 return args
344}
345
346// shared stdout+stderr budget, whichever stream blows the limit truncates the
347// output and kills the child
348type maxLogWriter struct {
349 mu sync.Mutex
350 written int64
351 limit int64
352 cancel context.CancelFunc
353 exceeded bool
354}
355
356type limitedStreamWriter struct {
357 parent *maxLogWriter
358 out io.Writer
359}
360
361func (w *limitedStreamWriter) Write(p []byte) (int, error) {
362 return w.parent.write(w.out, p)
363}
364
365func (mw *maxLogWriter) write(w io.Writer, p []byte) (int, error) {
366 mw.mu.Lock()
367 if mw.exceeded {
368 mw.mu.Unlock()
369 return 0, fmt.Errorf("log output exceeded the %d byte limit", mw.limit)
370 }
371 if mw.limit > 0 {
372 if remaining := mw.limit - mw.written; int64(len(p)) > remaining {
373 mw.exceeded = true
374 if mw.cancel != nil {
375 mw.cancel()
376 }
377 mw.written = mw.limit
378 var writeErr error
379 if remaining > 0 && w != nil {
380 _, writeErr = w.Write(p[:remaining])
381 }
382 mw.mu.Unlock()
383 if writeErr != nil {
384 return int(remaining), fmt.Errorf("log output exceeded the %d byte limit: %w", mw.limit, writeErr)
385 }
386 return int(remaining), fmt.Errorf("log output exceeded the %d byte limit", mw.limit)
387 }
388 }
389 mw.written += int64(len(p))
390 mw.mu.Unlock()
391 if w != nil {
392 return w.Write(p)
393 }
394 return len(p), nil
395}
396
397func (e *Engine) maxLogBytes() int64 {
398 if e.cfg == nil {
399 return 0
400 }
401 return e.cfg.NixPipelines.MaxLogBytes
402}
403
404func (e *Engine) maxEvalMemoryBytes() int64 {
405 if e.cfg != nil && e.cfg.NixPipelines.MaxEvalMemoryBytes > 0 {
406 return e.cfg.NixPipelines.MaxEvalMemoryBytes
407 }
408 return 8 << 30
409}
410
411func (e *Engine) maxOutputs() int {
412 if e.cfg != nil && e.cfg.NixPipelines.MaxOutputs > 0 {
413 return e.cfg.NixPipelines.MaxOutputs
414 }
415 return 100
416}
417
418// newSandboxCmd assembles bwrap with the sandbox argv, the returned command
419// runs as the configured unprivileged uid/gid when we're root
420func (e *Engine) newSandboxCmd(ctx context.Context, addl addlFields, executable string, args []string) *exec.Cmd {
421 bwrapArgs := append(e.baseBwrapArgs(addl), executable)
422 bwrapArgs = append(bwrapArgs, args...)
423 cmd := exec.CommandContext(ctx, e.bwrapBinPath, bwrapArgs...)
424 cmd.Env = e.buildEnv()
425 if os.Geteuid() == 0 && e.cfg != nil {
426 cmd.SysProcAttr = &syscall.SysProcAttr{
427 Credential: &syscall.Credential{
428 Uid: uint32(e.cfg.NixPipelines.SandboxUid),
429 Gid: uint32(e.cfg.NixPipelines.SandboxGid),
430 },
431 }
432 }
433 return cmd
434}
435
436// runSandbox streams a step's output to the workflow log under the configured
437// log cap
438func (e *Engine) runSandbox(ctx context.Context, addl addlFields, executable string, args []string, stdout, stderr io.Writer) error {
439 subCtx, cancel := context.WithCancel(ctx)
440 defer cancel()
441
442 logMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel}
443 cmd := e.newSandboxCmd(subCtx, addl, executable, args)
444 cmd.Stdout = &limitedStreamWriter{parent: logMgr, out: stdout}
445 cmd.Stderr = &limitedStreamWriter{parent: logMgr, out: stderr}
446
447 err := cmd.Run()
448 if logMgr.exceeded {
449 return fmt.Errorf("step log output exceeded the %d byte limit", logMgr.limit)
450 }
451 return err
452}
453
454// captureSandbox buffers stdout up to maxOut before anyone parses it, teeing
455// stderr to the workflow log. overflowing either budget kills the child
456func (e *Engine) captureSandbox(ctx context.Context, addl addlFields, executable string, args []string, maxOut int64, stderrLog io.Writer) ([]byte, error) {
457 subCtx, cancel := context.WithCancel(ctx)
458 defer cancel()
459
460 buf := new(bytes.Buffer)
461 outMgr := &maxLogWriter{limit: maxOut, cancel: cancel}
462 errMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel}
463
464 cmd := e.newSandboxCmd(subCtx, addl, executable, args)
465 cmd.Stdout = &limitedStreamWriter{parent: outMgr, out: buf}
466 cmd.Stderr = &limitedStreamWriter{parent: errMgr, out: stderrLog}
467
468 err := cmd.Run()
469 if outMgr.exceeded {
470 return nil, fmt.Errorf("evaluation output exceeded the %d byte limit", maxOut)
471 }
472 if errMgr.exceeded {
473 return nil, fmt.Errorf("evaluation stderr exceeded the %d byte limit", errMgr.limit)
474 }
475 if err != nil {
476 return nil, err
477 }
478 return buf.Bytes(), nil
479}
480
481// every nix evaluation runs under prlimit --as so a hostile flake cannot OOM
482// the executor while evaluating. the sandbox HOME is synthetic, so the
483// flakes/nix-command features must be requested on the command line instead
484// of relying on a nix.conf, builds keep the host daemon's own config
485func (e *Engine) evalArgv(nixArgs []string) (string, []string) {
486 argv := append([]string{
487 fmt.Sprintf("--as=%d", e.maxEvalMemoryBytes()),
488 containerNix,
489 "--extra-experimental-features", "nix-command flakes",
490 }, nixArgs...)
491 return containerPrlimit, argv
492}
493
494func (e *Engine) captureEval(ctx context.Context, addl addlFields, nixArgs []string, stderrLog io.Writer) ([]byte, error) {
495 executable, argv := e.evalArgv(nixArgs)
496 return e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, stderrLog)
497}
498
499// nixStringLit quotes s as a nix string literal. discovery names reach nix
500// only through this: the constant apply templates take parameters as escaped
501// literals and look them up with builtins.getAttr, never joined attr paths
502func nixStringLit(s string) string {
503 r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `${`, `\${`)
504 return `"` + r.Replace(s) + `"`
505}
506
507// constant --apply templates against parent installables, parameterized only
508// through nixStringLit. every eval targets a whole category attrset, never a
509// full output path
510const (
511 // names inside a category (home/nixos/darwinConfigurations) or per-system
512 // category attrset (packages/checks/devShells.<system>)
513 attrNamesApply = `attrs: builtins.attrNames attrs`
514
515 // presence probe for a direct output (formatter/defaultPackage/devShell)
516 presentApply = `x: true`
517
518 systemOutputDrvApply = `attrs:
519let
520 output = builtins.getAttr %s attrs;
521in
522if builtins.isAttrs output && output ? drvPath
523then output.drvPath
524else throw "flake output is not a derivation"`
525
526 directDrvApply = `output:
527if builtins.isAttrs output && output ? drvPath
528then output.drvPath
529else throw "flake output is not a derivation"`
530
531 homeActivationDrvApply = `attrs:
532let
533 output = (builtins.getAttr %s attrs).activationPackage;
534in
535if builtins.isAttrs output && output ? drvPath
536then output.drvPath
537else throw "home configuration activationPackage is not a derivation"`
538
539 toplevelDrvApply = `attrs:
540let
541 output = (builtins.getAttr %s attrs).config.system.build.toplevel;
542in
543if builtins.isAttrs output && output ? drvPath
544then output.drvPath
545else throw "system configuration toplevel is not a derivation"`
546)
547
548// currentSystem comes from nix itself, but it still lands inside an
549// installable argv string, so pin it to the charset real system strings use
550// before trusting it
551var systemPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
552
553// parentInstallable builds the installable for a whole category
554// (home/nixos/darwinConfigurations) or a per-system category attrset
555// (packages/checks/devShells/formatter/defaultPackage/devShell)
556func parentInstallable(category, system string) (string, error) {
557 if system == "" {
558 return ".#" + category, nil
559 }
560 if !systemPattern.MatchString(system) {
561 return "", fmt.Errorf("refusing to build an installable from unsafe system string %q", system)
562 }
563 return ".#" + category + "." + system, nil
564}
565
566// nix answers a probe of a category or system attr the flake doesn't define
567// with "does not provide attribute" on stderr, like garnix's
568// isDoesNotProvideAttributeError, probes treat that as "absent" rather than
569// a workflow failure. older nix phrases it as "attribute ... missing"
570func isMissingAttrError(stderr string) bool {
571 return strings.Contains(stderr, "does not provide attribute") ||
572 (strings.Contains(stderr, "error: attribute") && strings.Contains(stderr, "missing"))
573}
574
575type flakeLockNode struct {
576 Original map[string]any `json:"original"`
577 Locked map[string]any `json:"locked"`
578}
579
580// authorizeFlakeInputs decides which fetched sources a flake may pull from
581// before we evaluate it any further. anything not explicitly permitted here
582// is rejected
583func authorizeFlakeInputs(metadataBytes []byte, workspaceDir string) error {
584 var meta struct {
585 Locks struct {
586 Root string `json:"root"`
587 Nodes map[string]flakeLockNode `json:"nodes"`
588 } `json:"locks"`
589 }
590 if err := json.Unmarshal(metadataBytes, &meta); err != nil {
591 return fmt.Errorf("parsing flake metadata JSON: %w", err)
592 }
593
594 root := meta.Locks.Root
595 if root == "" {
596 root = "root"
597 }
598
599 for name, node := range meta.Locks.Nodes {
600 if name == root {
601 continue
602 }
603 if err := authorizeFlakeInput(name, node, workspaceDir); err != nil {
604 return err
605 }
606 }
607 return nil
608}
609
610func authorizeFlakeInput(name string, node flakeLockNode, workspaceDir string) error {
611 // what the user wrote is authoritative, only indirect (registry) inputs
612 // get their fetch source from the lock
613 target := node.Original
614 if origType, _ := node.Original["type"].(string); origType == "indirect" {
615 target = node.Locked
616 }
617 if target == nil {
618 // a follows-only node fetches nothing
619 return nil
620 }
621
622 typ, _ := target["type"].(string)
623 switch typ {
624 case "github", "gitlab", "sourcehut", "tarball":
625 return nil
626 case "path":
627 return authorizePathInput(name, target, workspaceDir)
628 case "file":
629 return authorizeURLInput(name, typ, target, "http", "https")
630 case "git", "hg":
631 return authorizeURLInput(name, typ, target, "http", "https", "ssh")
632 default:
633 return fmt.Errorf("flake input %q uses unauthorized type %q", name, typ)
634 }
635}
636
637// path inputs must stay relative and land inside the checked-out workspace;
638// anything absolute or escaping is a sandbox breakout attempt
639func authorizePathInput(name string, target map[string]any, workspaceDir string) error {
640 p, _ := target["path"].(string)
641 if p == "" {
642 return fmt.Errorf("flake input %q is a path input without a path", name)
643 }
644 if filepath.IsAbs(p) {
645 return fmt.Errorf("flake input %q uses absolute path %q", name, p)
646 }
647
648 canonicalWorkspace, err := filepath.EvalSymlinks(workspaceDir)
649 if err != nil {
650 return fmt.Errorf("resolving workspace dir: %w", err)
651 }
652 joined := filepath.Join(canonicalWorkspace, filepath.Clean(p))
653 if joined != canonicalWorkspace &&
654 !strings.HasPrefix(joined, canonicalWorkspace+string(filepath.Separator)) {
655 return fmt.Errorf("flake input %q path %q escapes the workspace", name, p)
656 }
657 // a lexically-inside path can still escape through a symlink, so re-check
658 // the canonical target when it exists, nonexistent paths are created by
659 // the fetch inside the sandbox and stay where the lexical check put them
660 if canonical, err := filepath.EvalSymlinks(joined); err == nil {
661 if canonical != canonicalWorkspace &&
662 !strings.HasPrefix(canonical, canonicalWorkspace+string(filepath.Separator)) {
663 return fmt.Errorf("flake input %q path %q escapes the workspace through a symlink", name, p)
664 }
665 }
666 return nil
667}
668
669func authorizeURLInput(name, typ string, target map[string]any, schemes ...string) error {
670 raw, _ := target["url"].(string)
671 u, err := url.Parse(raw)
672 if err != nil {
673 return fmt.Errorf("flake input %q has unparseable url %q: %w", name, raw, err)
674 }
675 for _, scheme := range schemes {
676 if u.Scheme == scheme {
677 return nil
678 }
679 }
680 return fmt.Errorf("flake input %q (%s) url %q must use one of: %s", name, typ, raw, strings.Join(schemes, ", "))
681}
682
683// discover evaluates the checked-out flake and appends one build step per
684// buildable output to the workflow
685func (e *Engine) discover(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, wfLogger models.WorkflowLogger) error {
686 val, ok := e.workspaces.Load(wid)
687 if !ok {
688 return errors.New("no workspace for workflow; SetupWorkflow must run first")
689 }
690 addl := val.(addlFields)
691
692 stdout := wfLogger.DataWriter(idx, "stdout")
693 stderr := wfLogger.DataWriter(idx, "stderr")
694
695 sysBytes, err := e.captureEval(ctx, addl,
696 []string{"eval", "--impure", "--raw", "--expr", "builtins.currentSystem"}, stderr)
697 if err != nil {
698 return fmt.Errorf("determining currentSystem: %w", err)
699 }
700 system := strings.TrimSpace(string(sysBytes))
701 if system == "" {
702 return errors.New("nix reported an empty builtins.currentSystem")
703 }
704 if !systemPattern.MatchString(system) {
705 return fmt.Errorf("nix reported an unsafe currentSystem %q", system)
706 }
707
708 metaBytes, err := e.captureEval(ctx, addl, []string{"flake", "metadata", "--json", "."}, stderr)
709 if err != nil {
710 return fmt.Errorf("reading flake metadata: %w", err)
711 }
712 if err := authorizeFlakeInputs(metaBytes, addl.workspaceDir); err != nil {
713 return fmt.Errorf("authorizing flake inputs: %w", err)
714 }
715
716 var candidates []outputCandidate
717
718 for _, category := range []string{"packages", "checks", "devShells"} {
719 names, err := e.evalNames(ctx, addl, category, system)
720 if err != nil {
721 return fmt.Errorf("listing %s for %s: %w", category, system, err)
722 }
723 for _, name := range names {
724 candidates = append(candidates, outputCandidate{
725 kind: targetSystemOutput, category: category, system: system, name: name,
726 })
727 }
728 }
729
730 for _, category := range []string{"formatter", "defaultPackage", "devShell"} {
731 present, err := e.evalPresent(ctx, addl, category, system)
732 if err != nil {
733 return fmt.Errorf("checking %s.%s: %w", category, system, err)
734 }
735 if present {
736 candidates = append(candidates, outputCandidate{
737 kind: targetDirect, category: category, system: system,
738 })
739 }
740 }
741
742 homeNames, err := e.evalNames(ctx, addl, "homeConfigurations", "")
743 if err != nil {
744 return fmt.Errorf("listing homeConfigurations: %w", err)
745 }
746 for _, name := range homeNames {
747 candidates = append(candidates, outputCandidate{
748 kind: targetHomeActivation, category: "homeConfigurations", name: name,
749 })
750 }
751
752 for _, category := range []string{"nixosConfigurations", "darwinConfigurations"} {
753 names, err := e.evalNames(ctx, addl, category, "")
754 if err != nil {
755 return fmt.Errorf("listing %s: %w", category, err)
756 }
757 for _, name := range names {
758 candidates = append(candidates, outputCandidate{
759 kind: targetToplevel, category: category, name: name,
760 })
761 }
762 }
763
764 if err := checkOutputLimit(len(candidates), e.maxOutputs()); err != nil {
765 return err
766 }
767
768 for _, cand := range candidates {
769 drvPath, err := e.resolveDrvPath(ctx, addl, cand, stderr)
770 if err != nil {
771 return err
772 }
773 step := Step{
774 name: "Build " + cand.display(),
775 command: fmt.Sprintf("nix build --no-link --print-build-logs %s^*", drvPath),
776 drvPath: drvPath,
777 kind: models.StepKindUser,
778 }
779 wf.Steps = append(wf.Steps, step)
780 _, _ = fmt.Fprintln(stdout, step.Name())
781 }
782
783 return nil
784}
785
786// captureEvalProbe captures stderr instead of teeing it to the workflow log:
787// presence/name probes hit attributes the flake may not define, and nix's
788// "does not provide attribute" spew would read as a failure to users
789func (e *Engine) captureEvalProbe(ctx context.Context, addl addlFields, nixArgs []string) (stdout, stderr []byte, err error) {
790 errBuf := new(bytes.Buffer)
791 executable, argv := e.evalArgv(nixArgs)
792 out, runErr := e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, errBuf)
793 return out, errBuf.Bytes(), runErr
794}
795
796// evalNames lists the outputs inside a category or per-system category
797// parent attrset, a flake that doesn't define the category or system yields
798// an empty list, not an error
799func (e *Engine) evalNames(ctx context.Context, addl addlFields, category, system string) ([]string, error) {
800 installable, err := parentInstallable(category, system)
801 if err != nil {
802 return nil, err
803 }
804 out, probeErr, err := e.captureEvalProbe(ctx, addl,
805 []string{"eval", installable, "--apply", attrNamesApply, "--json"})
806 if err != nil {
807 if isMissingAttrError(string(probeErr)) {
808 return nil, nil
809 }
810 return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr)))
811 }
812 var names []string
813 if err := json.Unmarshal(out, &names); err != nil {
814 return nil, fmt.Errorf("parsing output names JSON: %w", err)
815 }
816 sort.Strings(names)
817 return names, nil
818}
819
820// evalPresent probes a direct output (formatter/defaultPackage/devShell);
821// a flake that doesn't define it yields false, not an error
822func (e *Engine) evalPresent(ctx context.Context, addl addlFields, category, system string) (bool, error) {
823 installable, err := parentInstallable(category, system)
824 if err != nil {
825 return false, err
826 }
827 _, probeErr, err := e.captureEvalProbe(ctx, addl,
828 []string{"eval", installable, "--apply", presentApply, "--json"})
829 if err != nil {
830 if isMissingAttrError(string(probeErr)) {
831 return false, nil
832 }
833 return false, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr)))
834 }
835 return true, nil
836}
837
838// resolveDrvPath pins a candidate to a concrete store derivation, anything
839// that isn't a derivation fails the workflow rather than being skipped
840func (e *Engine) resolveDrvPath(ctx context.Context, addl addlFields, cand outputCandidate, stderr io.Writer) (string, error) {
841 installable, apply, err := cand.drvTarget()
842 if err != nil {
843 return "", err
844 }
845
846 out, err := e.captureEval(ctx, addl,
847 []string{"eval", installable, "--apply", apply, "--raw"}, stderr)
848 if err != nil {
849 return "", fmt.Errorf("resolving %s: %w", cand.display(), err)
850 }
851 drvPath := strings.TrimSpace(string(out))
852 if !isValidDrvPath(drvPath) {
853 return "", fmt.Errorf("%s resolved to invalid derivation path %q", cand.display(), drvPath)
854 }
855 return drvPath, nil
856}
857
858// the resolved path becomes a build installable, so pin its shape before
859// trusting it
860func isValidDrvPath(p string) bool {
861 return strings.HasPrefix(p, "/nix/store/") &&
862 strings.HasSuffix(p, ".drv") &&
863 !strings.ContainsAny(p, " \t\n")
864}
865
866func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, _ []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
867 if idx < 0 || idx >= len(wf.Steps) {
868 return fmt.Errorf("step index %d out of range (%d steps)", idx, len(wf.Steps))
869 }
870
871 val, ok := e.workspaces.Load(wid)
872 if !ok {
873 return errors.New("no workspace for workflow; SetupWorkflow must run first")
874 }
875 addl := val.(addlFields)
876
877 var err error
878 switch s := wf.Steps[idx].(type) {
879 case models.CloneStep:
880 err = e.runSandbox(ctx, addl, containerBash,
881 []string{"-euo", "pipefail", "-c", s.Command()},
882 wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr"))
883 case Step:
884 switch {
885 case s.isDiscovery:
886 err = e.discover(ctx, wid, wf, idx, wfLogger)
887 case s.drvPath != "":
888 err = e.runSandbox(ctx, addl, containerNix,
889 []string{"--extra-experimental-features", "nix-command flakes", "build", "--no-link", "--print-build-logs", s.drvPath + "^*"},
890 wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr"))
891 default:
892 err = fmt.Errorf("nix engine step %q has nothing to run", s.Name())
893 }
894 default:
895 err = fmt.Errorf("unknown step type %T", wf.Steps[idx])
896 }
897
898 if err != nil {
899 // a cancellation must survive untouched, only a deadline is a timeout
900 if ctxErr := ctx.Err(); ctxErr != nil {
901 if errors.Is(ctxErr, context.DeadlineExceeded) {
902 return fmt.Errorf("%w: %v", engine.ErrTimedOut, ctxErr)
903 }
904 return ctxErr
905 }
906 return err
907 }
908 return nil
909}