package birddeploy import ( "context" "os" "path/filepath" "strings" "testing" "evobgp/internal/store" ) func TestWriteGenerationPinsCompleteIncludeTree(t *testing.T) { active := t.TempDir() if err := os.Mkdir(filepath.Join(active, "bird.d"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(active, "bird.d", "operator.conf"), []byte("# operator"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(active, "bird.d", "evobgp_removed.conf"), []byte("# removed"), 0o644); err != nil { t.Fatal(err) } gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } fragments := map[string]string{"bird.conf": "include \"bird.d/*.conf\";\n", "bird.d/evobgp_new.conf": "# new"} if err := writeGeneration(active, gen, fragments); err != nil { t.Fatal(err) } main, err := os.ReadFile(filepath.Join(gen, "bird.conf")) if err != nil { t.Fatal(err) } if !strings.Contains(string(main), filepath.ToSlash(filepath.Join(gen, "bird.d", "*.conf"))) { t.Fatalf("include is not pinned to generation: %s", main) } for _, name := range []string{"operator.conf", "evobgp_new.conf"} { if _, err := os.Stat(filepath.Join(gen, "bird.d", name)); err != nil { t.Fatalf("%s missing: %v", name, err) } } if _, err := os.Stat(filepath.Join(gen, "bird.d", "evobgp_removed.conf")); !os.IsNotExist(err) { t.Fatalf("removed fragment retained: %v", err) } } func TestWriteGenerationRejectsUnsafePaths(t *testing.T) { for _, bad := range []string{"../escape.conf", "bird.d/../../escape.conf", "other.conf"} { t.Run(bad, func(t *testing.T) { active := t.TempDir() gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } fragments := map[string]string{"bird.conf": "router id 192.0.2.1;\n", bad: "bad"} if err := writeGeneration(active, gen, fragments); err == nil { t.Fatal("unsafe path accepted") } }) } } func TestWriteGenerationRejectsExternalAndNestedIncludes(t *testing.T) { for _, main := range []string{ `include "/outside/bird.d/peer.conf";` + "\n", `include "other/peer.conf";` + "\n", `include "bird.d/peer.conf"; include "other.conf";` + "\n", `router id 192.0.2.1; include "/outside/peer.conf";` + "\n", "/*\ninclude \"bird.d/decoy.conf\";\n*/\nrouter id 192.0.2.1; include \"/outside/live.conf\";\n", } { active := t.TempDir() gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } if err := writeGeneration(active, gen, map[string]string{"bird.conf": main}); err == nil { t.Fatalf("include accepted: %s", main) } } active := t.TempDir() if err := os.Mkdir(filepath.Join(active, "bird.d"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(active, "bird.d", "operator.conf"), []byte(`define X=1; include "/outside/foo.conf";`), 0o644); err != nil { t.Fatal(err) } gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } if err := writeGeneration(active, gen, map[string]string{"bird.conf": "router id 192.0.2.1;\n"}); err == nil { t.Fatal("nested operator include accepted") } } func TestCountIncludeTokensIgnoresCommentsAndStrings(t *testing.T) { content := "# include \"ignored.conf\";\n" + "/* include \"ignored.conf\"; */\n" + "define note = \"include in string\";\n" + "include \"bird.d/real.conf\";\n" if got := countIncludeTokens(content); got != 1 { t.Fatalf("want one include, got %d", got) } } func TestIncludeAfterBackslashStringCannotHideNestedInclude(t *testing.T) { content := `define note = "\";` + "\n" + `include "/outside/hidden.conf";` + "\n" if got := countIncludeTokens(content); got != 1 { t.Fatalf("live include hidden after quoted backslash: %d", got) } active := t.TempDir() gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } fragments := map[string]string{"bird.conf": "router id 192.0.2.1;\n", "bird.d/evobgp_test.conf": content} if err := writeGeneration(active, gen, fragments); err == nil { t.Fatal("nested external include accepted after quoted backslash") } } func TestWriteGenerationRewritesAbsoluteActiveInclude(t *testing.T) { active := t.TempDir() gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } inc := filepath.ToSlash(filepath.Join(active, "bird.d", "peer.conf")) main := "include \"" + inc + "\";\n" if err := writeGeneration(active, gen, map[string]string{"bird.conf": main, "bird.d/peer.conf": "# peer"}); err != nil { t.Fatal(err) } b, err := os.ReadFile(filepath.Join(gen, "bird.conf")) if err != nil { t.Fatal(err) } if !strings.Contains(string(b), filepath.ToSlash(filepath.Join(gen, "bird.d", "peer.conf"))) { t.Fatalf("absolute include not rewritten: %s", b) } } func TestWriteGenerationRejectsSymlinkBirdDRoot(t *testing.T) { active := t.TempDir() outside := t.TempDir() if err := os.WriteFile(filepath.Join(outside, "operator.conf"), []byte("# operator"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(active, "bird.d")); err != nil { t.Skipf("symlinks unavailable: %v", err) } old := []byte("router id 192.0.2.2;\n") if err := os.WriteFile(filepath.Join(active, "bird.conf"), old, 0o644); err != nil { t.Fatal(err) } gen := filepath.Join(active, ".evobgp-generation-test") if err := os.Mkdir(gen, 0o755); err != nil { t.Fatal(err) } if err := writeGeneration(active, gen, map[string]string{"bird.conf": "router id 192.0.2.1;\n"}); err == nil { t.Fatal("bird.d symlink accepted") } current, err := os.ReadFile(filepath.Join(active, "bird.conf")) if err != nil { t.Fatal(err) } if string(current) != string(old) { t.Fatalf("active config changed: %s", current) } if _, err := os.ReadFile(filepath.Join(outside, "operator.conf")); err != nil { t.Fatalf("operator snippet changed: %v", err) } } func TestApplyRevisionRejectsSymlinkMain(t *testing.T) { active := t.TempDir() target := filepath.Join(t.TempDir(), "operator-bird.conf") old := []byte("router id 192.0.2.2;\n") if err := os.WriteFile(target, old, 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(target, filepath.Join(active, "bird.conf")); err != nil { t.Skipf("symlinks unavailable: %v", err) } err := ApplyRevision(context.Background(), nil, &store.Revision{PreviewFragments: map[string]string{"bird.conf": "router id 192.0.2.1;\n"}}, Config{ActiveDir: active}) if err == nil || !strings.Contains(err.Error(), "regular file") { t.Fatalf("want explicit symlink rejection, got %v", err) } info, err := os.Lstat(filepath.Join(active, "bird.conf")) if err != nil { t.Fatal(err) } if info.Mode()&os.ModeSymlink == 0 { t.Fatal("active symlink replaced") } current, err := os.ReadFile(target) if err != nil { t.Fatal(err) } if string(current) != string(old) { t.Fatalf("operator target changed: %s", current) } }