package nix import ( "bytes" "context" "io" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "tangled.org/core/api/tangled" "tangled.org/core/spindle/config" "tangled.org/core/spindle/models" "tangled.org/core/workflow" ) func TestInitWorkflow(t *testing.T) { e := &Engine{cfg: &config.Config{}} wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: "{}"}, tangled.Pipeline{}) require.NoError(t, err) require.Len(t, wf.Steps, 1) assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) assert.Equal(t, models.StepKindSystem, wf.Steps[0].Kind()) } func TestInitWorkflowCompiledProductionWorkflow(t *testing.T) { e := &Engine{cfg: &config.Config{}} raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n" compiled := (&workflow.Compiler{}).Compile([]workflow.Workflow{{ Name: ".tangled/workflows/ci.yml", Engine: "nix", Raw: raw, }}) require.Len(t, compiled.Workflows, 1) require.Equal(t, raw, compiled.Workflows[0].Raw) wf, err := e.InitWorkflow(*compiled.Workflows[0], tangled.Pipeline{}) require.NoError(t, err) require.Len(t, wf.Steps, 1) assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) } func TestInitWorkflowRejectsCustomSteps(t *testing.T) { e := &Engine{cfg: &config.Config{}} _, err := e.InitWorkflow(tangled.Pipeline_Workflow{ Name: "flake", Raw: "steps:\n - name: nope\n command: echo nope\n", }, tangled.Pipeline{}) require.Error(t, err) assert.Contains(t, err.Error(), "unknown field") } // a generic workflow document (engine/when/clone, no engine-specific keys) // must initialize, only structural keys like `steps` are rejected func TestInitWorkflowAcceptsGenericKeys(t *testing.T) { e := &Engine{cfg: &config.Config{}} raw := "engine: nix\nwhen:\n - event: push\n branch: [main]\nclone:\n skip: false\n depth: 1\n" wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: raw}, tangled.Pipeline{}) require.NoError(t, err) require.Len(t, wf.Steps, 1) assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) _, err = e.InitWorkflow(tangled.Pipeline_Workflow{ Name: "flake", Raw: raw + "steps:\n - name: nope\n command: echo nope\n", }, tangled.Pipeline{}) require.Error(t, err) assert.Contains(t, err.Error(), "unknown field") } // every eval/metadata invocation needs the flakes features flags because the // sandbox HOME has no nix.conf func TestEvalArgv(t *testing.T) { e := &Engine{cfg: &config.Config{}} exe, argv := e.evalArgv([]string{"flake", "metadata", "--json", "."}) assert.Equal(t, containerPrlimit, exe) require.GreaterOrEqual(t, len(argv), 6) assert.True(t, strings.HasPrefix(argv[0], "--as=")) assert.Equal(t, containerNix, argv[1]) assert.Equal(t, "--extra-experimental-features", argv[2]) assert.Equal(t, "nix-command flakes", argv[3]) assert.Equal(t, []string{"flake", "metadata", "--json", "."}, argv[4:]) } func TestNixStringLit(t *testing.T) { cases := map[string]string{ "plain": `"plain"`, "dots.and%percent": `"dots.and%percent"`, `quote"injection`: `"quote\"injection"`, `interp${pwn}`: `"interp\${pwn}"`, `back\slash`: `"back\\slash"`, `"; throw "escaped`: `"\"; throw \"escaped"`, "newline\ninside": "\"newline\ninside\"", // literal newlines are legal in nix strings } for in, want := range cases { assert.Equal(t, want, nixStringLit(in), "input %q", in) } } // metacharacter attr names stay structured: the installable is only the // trusted category.system parent, the apply lambda sees just the escaped // name, display is for humans func TestOutputCandidateMetachars(t *testing.T) { cand := outputCandidate{ kind: targetSystemOutput, category: "checks", system: "x86_64-linux", name: `unit.100%"cov${throw}`, } installable, apply, err := cand.drvTarget() require.NoError(t, err) assert.Equal(t, ".#checks.x86_64-linux", installable) assert.Contains(t, apply, nixStringLit(cand.name)) assert.NotContains(t, apply, cand.name) assert.NotContains(t, installable, cand.name) assert.Equal(t, `.#checks.x86_64-linux.unit.100%"cov${throw}`, cand.display()) _, _, err = outputCandidate{kind: targetKind(99), name: "x"}.drvTarget() require.Error(t, err) } // only validated system strings may reach an installable, anything else is // refused before it becomes argv func TestParentInstallable(t *testing.T) { installable, err := parentInstallable("packages", "x86_64-linux") require.NoError(t, err) assert.Equal(t, ".#packages.x86_64-linux", installable) installable, err = parentInstallable("nixosConfigurations", "") require.NoError(t, err) assert.Equal(t, ".#nixosConfigurations", installable) for _, system := range []string{ `x86_64-linux"; throw "`, "x86_64-linux ${throw 1}", "x86_64-linux --option", "x86_64-linux/x", "x86_64 linux", } { _, err := parentInstallable("packages", system) require.Error(t, err, "system %q", system) assert.Contains(t, err.Error(), "unsafe system") } } // a flake that doesn't define a probed category is "absent", not broken; // other evaluation failures must not be swallowed func TestIsMissingAttrError(t *testing.T) { // verbatim nix 2.x output for `nix eval .#checks.x86_64-linux` on a flake // without checks assert.True(t, isMissingAttrError( "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'")) // older nix phrasing assert.True(t, isMissingAttrError("error: attribute 'homeConfigurations' missing")) assert.False(t, isMissingAttrError("error: syntax error, unexpected end of file")) assert.False(t, isMissingAttrError("error: flake output is not a derivation")) assert.False(t, isMissingAttrError("")) } func TestCheckOutputLimit(t *testing.T) { require.NoError(t, checkOutputLimit(0, 100)) require.NoError(t, checkOutputLimit(100, 100)) err := checkOutputLimit(101, 100) require.Error(t, err) assert.Contains(t, err.Error(), "101") assert.Contains(t, err.Error(), "100") } func TestMaxOutputsDefault(t *testing.T) { assert.Equal(t, 100, (&Engine{cfg: &config.Config{}}).maxOutputs()) assert.Equal(t, 3, (&Engine{cfg: &config.Config{NixPipelines: config.NixPipelines{MaxOutputs: 3}}}).maxOutputs()) } func TestMaxLogWriterOverflow(t *testing.T) { var buf bytes.Buffer canceled := false mw := &maxLogWriter{limit: 10, cancel: func() { canceled = true }} w := &limitedStreamWriter{parent: mw, out: &buf} n, err := w.Write([]byte("0123456789")) require.NoError(t, err) require.Equal(t, 10, n) assert.False(t, mw.exceeded) n, err = w.Write([]byte("overflow")) require.Error(t, err) assert.Equal(t, 0, n) assert.Contains(t, err.Error(), "exceeded the 10 byte limit") assert.True(t, mw.exceeded) assert.True(t, canceled) assert.Equal(t, "0123456789", buf.String()) _, err = w.Write([]byte("more")) require.Error(t, err) assert.Equal(t, "0123456789", buf.String()) } func TestMaxLogWriterTruncatesPartialWrite(t *testing.T) { var buf bytes.Buffer mw := &maxLogWriter{limit: 5} w := &limitedStreamWriter{parent: mw, out: &buf} n, err := w.Write([]byte("0123456789")) require.Error(t, err) assert.Equal(t, 5, n) assert.Equal(t, "01234", buf.String()) assert.True(t, mw.exceeded) } func TestAuthorizePathInput(t *testing.T) { workspace := t.TempDir() outside := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(workspace, "sub", "dir"), 0o755)) require.NoError(t, authorizePathInput("a", map[string]any{"path": "sub/dir"}, workspace)) require.NoError(t, authorizePathInput("b", map[string]any{"path": "not/there/yet"}, workspace)) require.NoError(t, authorizePathInput("c", map[string]any{"path": "."}, workspace)) require.Error(t, authorizePathInput("d", map[string]any{"path": "/etc/passwd"}, workspace)) require.Error(t, authorizePathInput("e", map[string]any{"path": "../outside"}, workspace)) require.Error(t, authorizePathInput("f", map[string]any{"path": "sub/../../outside"}, workspace)) require.Error(t, authorizePathInput("g", map[string]any{}, workspace)) require.NoError(t, os.Symlink(filepath.Join(workspace, "sub"), filepath.Join(workspace, "inner-link"))) require.NoError(t, authorizePathInput("h", map[string]any{"path": "inner-link/dir"}, workspace)) // a lexically-inside path can still escape through a symlink require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "escape"))) err := authorizePathInput("i", map[string]any{"path": "escape"}, workspace) require.Error(t, err) assert.Contains(t, err.Error(), "symlink") require.NoError(t, os.MkdirAll(filepath.Join(workspace, "deep"), 0o755)) require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "deep", "escape"))) require.Error(t, authorizePathInput("j", map[string]any{"path": "deep/escape"}, workspace)) } func TestInitWorkflowWithClone(t *testing.T) { e := &Engine{cfg: &config.Config{Server: config.Server{Dev: true}}} repoName := "my-repo" repoDid := "did:plc:repo" tpl := tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{ Kind: string(workflow.TriggerKindPush), Push: &tangled.Pipeline_PushTriggerData{ Ref: "refs/heads/main", NewSha: "1234567890abcdef1234567890abcdef12345678", }, Repo: &tangled.Pipeline_TriggerRepo{ Knot: "example.com", Did: "did:plc:owner", Repo: &repoName, RepoDid: &repoDid, }, }} wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tpl) require.NoError(t, err) require.Len(t, wf.Steps, 2) assert.IsType(t, models.CloneStep{}, wf.Steps[0]) assert.Equal(t, "Evaluate flake outputs", wf.Steps[1].Name()) } func TestBaseBwrapArgs(t *testing.T) { e := &Engine{ nixBinPath: "/nix/store/nix/bin/nix", bashBinPath: "/nix/store/bash/bin/bash", gitBinPath: "/nix/store/git/bin/git", prlimitBinPath: "/nix/store/prlimit/bin/prlimit", } args := e.baseBwrapArgs(addlFields{ workspaceDir: "/tmp/host/workspace", homeDir: "/tmp/host/home", }) argString := strings.Join(args, " ") for _, expected := range []string{ "--die-with-parent", "--new-session", "--unshare-all --share-net", "--ro-bind /nix/store /nix/store", "--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket", "--proc /proc", "--dev /dev", "--tmpfs /tmp", "--bind /tmp/host/workspace /workdir", "--bind /tmp/host/home /home", "--symlink /nix/store/nix/bin/nix /usr/bin/nix", "--symlink /nix/store/bash/bin/bash /usr/bin/bash", "--symlink /nix/store/bash/bin/bash /bin/sh", "--symlink /nix/store/git/bin/git /usr/bin/git", "--symlink /nix/store/prlimit/bin/prlimit /usr/bin/prlimit", "--chdir /workdir", } { assert.Contains(t, argString, expected) } assert.NotContains(t, argString, "nixpkgs#") assert.NotContains(t, argString, " shell ") } type mockWorkflowLogger struct { stdout strings.Builder stderr strings.Builder } func (m *mockWorkflowLogger) Close() error { return nil } func (m *mockWorkflowLogger) DataWriter(_ int, stream string) io.Writer { if stream == "stderr" { return &m.stderr } return &m.stdout } func (m *mockWorkflowLogger) ControlWriter(_ int, _ models.Step, _ models.StepStatus) io.Writer { return io.Discard } func TestIntegrationRealFlakeBuilds(t *testing.T) { if os.Getenv("RUN_NIX_INTEGRATION_TEST") != "true" { t.Skip("set RUN_NIX_INTEGRATION_TEST=true to run") } cfg := &config.Config{NixPipelines: config.NixPipelines{WorkDirBase: t.TempDir()}} e, err := New(cfg) require.NoError(t, err) wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tangled.Pipeline{}) require.NoError(t, err) wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"}, Name: "flake"} logger := &mockWorkflowLogger{} require.NoError(t, e.SetupWorkflow(context.Background(), wid, wf, logger)) defer e.DestroyWorkflow(context.Background(), wid) addl := wf.Data.(addlFields) flake := `{ inputs.nixpkgs.url = "nixpkgs"; outputs = { nixpkgs, ... }: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; in { packages.${system}.hello = pkgs.runCommand "spindle-package" {} "echo package > $out"; checks.${system}."unit.100%cov" = pkgs.runCommand "spindle-check" {} "echo check > $out"; formatter.${system} = pkgs.hello; devShells.${system}.ci = pkgs.mkShell {}; }; } ` require.NoError(t, os.WriteFile(filepath.Join(addl.workspaceDir, "flake.nix"), []byte(flake), 0o600)) // the loop bound is evaluated per iteration, so build steps appended by // the discovery step run in the same pass for idx := 0; idx < len(wf.Steps); idx++ { require.NoError(t, e.RunStep(context.Background(), wid, wf, idx, nil, logger), logger.stderr.String()) } require.GreaterOrEqual(t, len(wf.Steps), 5, "discovery + packages/checks/formatter/devShells builds") assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name()) var names []string for _, step := range wf.Steps[1:] { s, ok := step.(Step) require.True(t, ok, "generated step %q is a nix engine Step", step.Name()) require.NotEmpty(t, s.drvPath, s.Name()) assert.Contains(t, s.Command(), "^*", s.Name()) names = append(names, s.Name()) } for _, fragment := range []string{"packages", "checks", "formatter", "devShells", "unit.100%cov"} { assert.Condition(t, func() bool { return containsName(names, fragment) }, fragment) } require.NoError(t, e.DestroyWorkflow(context.Background(), wid)) assert.NoDirExists(t, addl.workspaceDir) assert.NoDirExists(t, addl.homeDir) assert.NoError(t, e.DestroyWorkflow(context.Background(), wid)) } func containsName(names []string, fragment string) bool { for _, name := range names { if strings.Contains(name, fragment) { return true } } return false }