Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/cascade/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@ func TestGraphCommand_BlandThemeStylesNodes(t *testing.T) {
// color, and grayscale node fills. These tokens are absent from the branded
// cascade default, so their presence proves the bland theme reached the
// emitter rather than the default palette.
for _, want := range []string{`"theme": "neutral"`, "#8c959f", "#f6f8fa"} {
for _, want := range []string{`"theme":"neutral"`, "#8c959f", "#f6f8fa"} {
if !contains(stdout, want) {
t.Errorf("expected bland token %q in output, got:\n%s", want, stdout)
}
Expand Down Expand Up @@ -983,7 +983,7 @@ func TestGraphCommand_CustomThemeFileStylesNodes(t *testing.T) {
if payload.Theme != "midnight" {
t.Errorf("expected theme name midnight, got %q", payload.Theme)
}
for _, want := range []string{`"theme": "dark"`, "#abcdef", "fill:#123456"} {
for _, want := range []string{`"theme":"dark"`, "#abcdef", "fill:#123456"} {
if !contains(payload.Diagram, want) {
t.Errorf("expected custom theme token %q in diagram, got:\n%s", want, payload.Diagram)
}
Expand Down
10 changes: 6 additions & 4 deletions internal/graph/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,12 @@ func TestRun_CrossRepoGranularity_EmitsLanesAndEdges(t *testing.T) {

got := out.String()
require.Contains(t, got, "flowchart TD")
// A lane per repository.
// A lane per repository. Slugged lane ids carry a short content-hash
// suffix so distinct repos that fold to the same alphanumeric text cannot
// merge into one lane.
require.Contains(t, got, `subgraph primary["primary"]`)
require.Contains(t, got, `subgraph repo_org_cdk_infra["org/cdk-infra"]`)
require.Contains(t, got, `subgraph repo_org_platform["org/platform"]`)
require.Contains(t, got, `subgraph repo_org_cdk_infra_d8ebfd17["org/cdk-infra"]`)
require.Contains(t, got, `subgraph repo_org_platform_e0d10933["org/platform"]`)
// Cross-repo edges in both directions: primary coordinates the dependent and
// the local pipeline notifies its upstream primary.
require.Contains(t, got, "==>|cdk|")
Expand Down Expand Up @@ -249,7 +251,7 @@ func TestRun_BlandTheme_StylesOutput(t *testing.T) {
got := out.String()
require.Contains(t, got, "flowchart TD")
// The bland theme sets a neutral Mermaid base and its own line color.
require.Contains(t, got, `"theme": "neutral"`)
require.Contains(t, got, `"theme":"neutral"`)
require.Contains(t, got, "classDef node_validate")
}

Expand Down
18 changes: 15 additions & 3 deletions internal/visualize/crossrepo.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package visualize

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"

Expand Down Expand Up @@ -94,18 +96,28 @@ func BuildCrossRepoViewModel(cfg *config.TrunkConfig) (ViewModel, error) {

// repoSlug folds a repository reference (for example "org/cdk-infra") into a
// Mermaid-safe identifier fragment by replacing every rune that is not a letter
// or digit with an underscore. The human-facing repo string is kept on the node
// or group label, so only the identity token is slugged.
// or digit with an underscore. Folding is lossy ("org/api-v2" and "org/api.v2"
// fold identically), so whenever any rune was folded a short content-hash
// suffix keeps the slug injective; without it two distinct repos would merge
// into one lane and one would silently disappear from the diagram. The
// human-facing repo string is kept on the node or group label, so only the
// identity token is slugged.
func repoSlug(repo string) string {
var b strings.Builder
b.Grow(len(repo))
folded := false
for _, r := range repo {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteByte('_')
folded = true
}
}
return b.String()
if !folded {
return b.String()
}
sum := sha256.Sum256([]byte(repo))
return b.String() + "_" + hex.EncodeToString(sum[:4])
}
8 changes: 5 additions & 3 deletions internal/visualize/crossrepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,11 @@ func TestMermaidEmitter_CrossRepoGolden(t *testing.T) {
for _, want := range []string{
"flowchart TD",
`subgraph primary["primary"]`,
`subgraph repo_org_cdk_infra["org/cdk-infra"]`,
`subgraph repo_org_web_edge["org/web-edge"]`,
`subgraph repo_org_platform["org/platform"]`,
// Slugged lane ids carry a short content-hash suffix so distinct repos
// that fold to the same alphanumeric text cannot merge into one lane.
`subgraph repo_org_cdk_infra_d8ebfd17["org/cdk-infra"]`,
`subgraph repo_org_web_edge_2d1cec96["org/web-edge"]`,
`subgraph repo_org_platform_e0d10933["org/platform"]`,
"==>|cdk|",
"==>|edge|",
"-. notify .->",
Expand Down
164 changes: 164 additions & 0 deletions internal/visualize/escaping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package visualize

import (
"encoding/json"
"strings"
"testing"

"github.com/stablekernel/cascade/internal/config"
)

// TestMermaidLabels_FoldNewlines proves a newline in a manifest-derived display
// name cannot split a node, state, or subgraph declaration across lines: a raw
// newline inside any label would terminate the Mermaid statement mid-declaration
// and the remainder would parse as diagram syntax.
func TestMermaidLabels_FoldNewlines(t *testing.T) {
t.Run("flowchart node label", func(t *testing.T) {
vm := ViewModel{
Kind: DiagramFlowchart,
Nodes: []Node{{ID: "build-app", Label: "line one\nline two", Kind: NodeBuild}},
}
out, err := NewMermaidEmitter().Emit(vm, Theme{})
if err != nil {
t.Fatalf("Emit: %v", err)
}
if strings.Contains(out, "line one\nline two") {
t.Errorf("node label newline must be folded, got:\n%s", out)
}
if !strings.Contains(out, "line one line two") {
t.Errorf("folded label text missing, got:\n%s", out)
}
})

t.Run("subgraph label", func(t *testing.T) {
vm := ViewModel{
Kind: DiagramFlowchart,
Nodes: []Node{{ID: "build-app", Label: "app", Kind: NodeBuild}},
Groups: []Group{
{ID: "lane", Label: "repo\nend\nevil --> injected", NodeIDs: []string{"build-app"}},
},
}
out, err := NewMermaidEmitter().Emit(vm, Theme{})
if err != nil {
t.Fatalf("Emit: %v", err)
}
if strings.Contains(out, "repo\nend") {
t.Errorf("subgraph label newline must be folded, got:\n%s", out)
}
})

t.Run("state label", func(t *testing.T) {
vm := ViewModel{
Kind: DiagramState,
Nodes: []Node{{ID: "dev", Label: "dev\nevil --> injected", Kind: NodeEnv}},
}
out, err := NewMermaidEmitter().Emit(vm, Theme{})
if err != nil {
t.Fatalf("Emit: %v", err)
}
if strings.Contains(out, "dev\nevil") {
t.Errorf("state label newline must be folded, got:\n%s", out)
}
})
}

// TestRepoSlug_DistinctReposStayDistinct proves two repos that fold to the same
// alphanumeric slug (org/api-v2 vs org/api.v2) still produce distinct lane and
// node identities, so neither lane is silently dropped or merged.
func TestRepoSlug_DistinctReposStayDistinct(t *testing.T) {
cfg := &config.TrunkConfig{
TrunkBranch: "main",
Environments: config.EnvNames("dev", "prod"),
Builds: []config.BuildConfig{
{Name: "api", Workflow: ".github/workflows/build-api.yaml"},
},
External: []config.ExternalRepoConfig{
{Repo: "org/api-v2", Deploys: []config.ExternalDeployConfig{
{Name: "svc", Workflow: ".github/workflows/svc.yaml"},
}},
{Repo: "org/api.v2", Deploys: []config.ExternalDeployConfig{
{Name: "svc", Workflow: ".github/workflows/svc.yaml"},
}},
},
}

vm, err := BuildCrossRepoViewModel(cfg)
if err != nil {
t.Fatalf("BuildCrossRepoViewModel: %v", err)
}

groupIDs := make(map[string]bool)
for _, g := range vm.Groups {
if groupIDs[g.ID] {
t.Fatalf("duplicate group id %q: distinct repos must not collide", g.ID)
}
groupIDs[g.ID] = true
}

nodeIDs := make(map[string]bool)
for _, n := range vm.Nodes {
if nodeIDs[n.ID] {
t.Fatalf("duplicate node id %q: distinct repos must not collide", n.ID)
}
nodeIDs[n.ID] = true
}
}

// TestRepoSlug_PlainSlugStaysReadable proves a repo already made of safe runes
// keeps a readable slug with no hash suffix, so common diagrams stay clean.
func TestRepoSlug_PlainSlugStaysReadable(t *testing.T) {
if got := repoSlug("orgrepo"); got != "orgrepo" {
t.Errorf("repoSlug(orgrepo) = %q, want orgrepo", got)
}
}

// TestThemeInit_ValuesAreJSONEncoded proves the init directive is real JSON:
// a theme value carrying a quote or backslash must be encoded, not spliced,
// or the directive breaks (and a crafted value could inject directive keys).
func TestThemeInit_ValuesAreJSONEncoded(t *testing.T) {
vm := ViewModel{
Kind: DiagramFlowchart,
Nodes: []Node{{ID: "build-app", Label: "app", Kind: NodeBuild}},
}
theme := Theme{Base: `ba"se`, LineColor: "#57606a\\"}

out, err := NewMermaidEmitter().Emit(vm, theme)
if err != nil {
t.Fatalf("Emit: %v", err)
}

start := strings.Index(out, "%%{init: ")
if start == -1 {
t.Fatalf("init directive missing:\n%s", out)
}
rest := out[start+len("%%{init: "):]
end := strings.Index(rest, "}%%")
if end == -1 {
t.Fatalf("init directive not closed:\n%s", out)
}
payload := rest[:end]

var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
t.Fatalf("init payload is not valid JSON: %v\npayload: %s", err, payload)
}
if decoded["theme"] != `ba"se` {
t.Errorf("theme value did not round-trip, got %v", decoded["theme"])
}
}

// TestClassDefBody_SanitizesUserThemeValues proves a user-theme color value
// cannot smuggle extra classDef attributes or statements: the comma, semicolon,
// and newline that would act as separators are stripped from the value.
func TestClassDefBody_SanitizesUserThemeValues(t *testing.T) {
body := classDefBody(NodeStyle{
Fill: "red,stroke:#f00",
Stroke: "blue;classDef evil fill:#000",
Text: "white\nlinkStyle default stroke:#000",
})
for _, forbidden := range []string{",stroke:#f00", ";", "\n", "\r"} {
if strings.Contains(body, forbidden) {
t.Errorf("classDef body must not contain %q, got %q", forbidden, body)
}
}
}
47 changes: 39 additions & 8 deletions internal/visualize/mermaid.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package visualize

import (
"encoding/json"
"fmt"
"strings"
)
Expand Down Expand Up @@ -198,7 +199,9 @@ func emitState(b *strings.Builder, vm ViewModel, theme Theme) (string, error) {
// writeThemeInit emits the Mermaid init directive carrying the theme's base and
// edge color. It writes nothing when the theme sets neither, so an unstyled
// theme produces no directive. The directive must follow any frontmatter block
// and precede the diagram keyword.
// and precede the diagram keyword. The payload is built with encoding/json,
// never string splicing: Go's %q emits Go escape syntax, not JSON, so a theme
// value with a quote or non-ASCII rune would produce an invalid directive.
func writeThemeInit(b *strings.Builder, theme Theme) {
if theme.Base == "" && theme.LineColor == "" {
return
Expand All @@ -207,11 +210,21 @@ func writeThemeInit(b *strings.Builder, theme Theme) {
if base == "" {
base = "base"
}
fmt.Fprintf(b, "%%%%{init: {%q: %q", "theme", base)

type initThemeVariables struct {
LineColor string `json:"lineColor"`
}
payload := struct {
Theme string `json:"theme"`
ThemeVariables *initThemeVariables `json:"themeVariables,omitempty"`
}{Theme: base}
if theme.LineColor != "" {
fmt.Fprintf(b, ", %q: {%q: %q}", "themeVariables", "lineColor", theme.LineColor)
payload.ThemeVariables = &initThemeVariables{LineColor: theme.LineColor}
}
b.WriteString("}}%%\n")

// Marshaling a struct of strings cannot fail.
data, _ := json.Marshal(payload)
fmt.Fprintf(b, "%%%%{init: %s}%%%%\n", data)
}

// writeThemeClasses emits the theme's per-kind classDef lines followed by the
Expand Down Expand Up @@ -276,9 +289,16 @@ func mermaidID(id string) string {

// mermaidStateLabel renders a state's display name as a quoted Mermaid string
// for the `state "name" as id` rename form, escaping any embedded quote so the
// alias declaration cannot be broken by punctuation in the name.
// alias declaration cannot be broken by punctuation in the name. Newlines are
// folded to spaces: a raw newline splits the declaration mid-statement and the
// remainder parses as diagram syntax.
func mermaidStateLabel(label string) string {
return `"` + strings.ReplaceAll(label, `"`, "&quot;") + `"`
r := strings.NewReplacer(
`"`, "&quot;",
"\n", " ",
"\r", " ",
)
return `"` + r.Replace(label) + `"`
}

// mermaidTransitionLabel sanitizes a transition caption. A colon would otherwise
Expand All @@ -295,9 +315,16 @@ func mermaidTransitionLabel(label string) string {

// mermaidSubgraphLabel renders a lane caption as a quoted Mermaid string for the
// `subgraph id["label"]` form, escaping any embedded quote so a repo name with
// punctuation cannot break the subgraph header.
// punctuation cannot break the subgraph header. Newlines are folded to spaces:
// a raw newline splits the header mid-statement and the remainder parses as
// diagram syntax.
func mermaidSubgraphLabel(label string) string {
return `"` + strings.ReplaceAll(label, `"`, "&quot;") + `"`
r := strings.NewReplacer(
`"`, "&quot;",
"\n", " ",
"\r", " ",
)
return `"` + r.Replace(label) + `"`
}

// mermaidEdgeLabel sanitizes a flowchart edge caption. A pipe would close the
Expand All @@ -318,13 +345,17 @@ func mermaidEdgeLabel(label string) string {
// mermaidLabel escapes a display label for use inside a Mermaid node shape.
// Brackets would otherwise close the shape early and quotes would confuse the
// parser, so both are replaced with HTML entities Mermaid renders verbatim.
// Newlines are folded to spaces: a raw newline terminates the node statement
// mid-declaration and the remainder parses as diagram syntax.
func mermaidLabel(label string) string {
r := strings.NewReplacer(
"[", "&#91;",
"]", "&#93;",
"(", "&#40;",
")", "&#41;",
`"`, "&quot;",
"\n", " ",
"\r", " ",
)
return r.Replace(label)
}
Loading