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
6 changes: 6 additions & 0 deletions ci/release/changelogs/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
- Limit recursive glob matching and generated-field work so small diagrams
fail with a clear error instead of consuming excessive CPU or memory.
[#2925](https://github.com/d2lang/d2/pull/2925)
- Limit wildcard edge connections and selectors to bounded expansion work,
returning a clear error before excessive fanout consumes resources.
[#2926](https://github.com/d2lang/d2/pull/2926)
- decoding and assets:
- Cap decompressed URL-encoded D2 input at 16 MiB. [#2902](https://github.com/d2lang/d2/pull/2902)
- Bound image references, locators, fetched and decoded bytes, cached data, and
Expand All @@ -59,6 +62,9 @@
- Limit grids to 10,000 rows or columns and 1,000,000 total cells, returning
a clear error for oversized dimensions instead of crashing or exhausting
memory. [#2924](https://github.com/d2lang/d2/pull/2924)
- Limit Dagre and ELK inputs to 1,024 objects and 1,024 edges, and reject
overly interconnected graphs before entering non-cancellable layout.
[#2926](https://github.com/d2lang/d2/pull/2926)
- links and paints:
- Reject dangerous ordinary link schemes after decoding common obfuscation. [#2896](https://github.com/d2lang/d2/pull/2896)
- Require gradient stop positions to be finite numbers or percentages. [#2905](https://github.com/d2lang/d2/pull/2905)
Expand Down
9 changes: 9 additions & 0 deletions d2compiler/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ type CompileOptions struct {
// materialization. Zero uses d2ir.DefaultMaxGlobExpansion. Explicit source
// fields are not counted as materialization work.
MaxGlobExpansion int64
// MaxEdgeExpansion bounds distinct edge-segment and endpoint combinations
// considered by edge globs. Zero uses d2ir.DefaultMaxEdgeExpansion. Explicit
// edges do not consume this budget.
MaxEdgeExpansion int64
// MaxEdgeExpansionWork bounds all endpoint-pair examinations performed by
// edge globs, including lazy replays. Zero uses the secure compiler default.
MaxEdgeExpansionWork int64
// FS is the file system used for resolving imports in the D2 text. Nil
// disables imports. Callers that accept untrusted input should prefer a
// filesystem constrained to the intended import root; lib/localfile provides
Expand All @@ -62,6 +69,8 @@ func Compile(p string, r io.Reader, opts *CompileOptions) (*d2graph.Graph, *d2ta
UTF16Pos: opts.UTF16Pos,
MaxVariableExpansion: opts.MaxVariableExpansion,
MaxGlobExpansion: opts.MaxGlobExpansion,
MaxEdgeExpansion: opts.MaxEdgeExpansion,
MaxEdgeExpansionWork: opts.MaxEdgeExpansionWork,
FS: opts.FS,
})
if err != nil {
Expand Down
63 changes: 63 additions & 0 deletions d2compiler/edge_expansion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package d2compiler_test

import (
"strings"
"testing"

"github.com/d2lang/d2/d2compiler"
)

func TestCompileEdgeExpansionLimit(t *testing.T) {
t.Parallel()

_, _, err := d2compiler.Compile(
"edge-expansion.d2",
strings.NewReader("a\nb\nc\n* -> *\n"),
&d2compiler.CompileOptions{MaxEdgeExpansion: 8},
)
if err == nil || !strings.Contains(err.Error(), "edge glob expansion exceeds limit of 8 endpoint pairs") {
t.Fatalf("Compile error = %v, want edge expansion limit", err)
}
}

func TestCompileEdgeSelectorExpansionLimit(t *testing.T) {
t.Parallel()

_, _, err := d2compiler.Compile(
"edge-selector-expansion.d2",
strings.NewReader("a\nb\nc\n(* -> *)[*].style.opacity: 0\n"),
&d2compiler.CompileOptions{MaxEdgeExpansion: 8},
)
if err == nil || !strings.Contains(err.Error(), "edge glob expansion exceeds limit of 8 endpoint pairs") {
t.Fatalf("Compile error = %v, want edge expansion limit", err)
}
}

func TestCompileEdgeExpansionWorkLimit(t *testing.T) {
t.Parallel()

_, _, err := d2compiler.Compile(
"edge-expansion-work.d2",
strings.NewReader("(* -> *)[*].style.opacity: 0\na\nb\nc\n"),
&d2compiler.CompileOptions{
MaxEdgeExpansion: 9,
MaxEdgeExpansionWork: 9,
},
)
if err == nil || !strings.Contains(err.Error(), "work limit of 9 endpoint-pair examinations") {
t.Fatalf("Compile error = %v, want edge expansion work limit", err)
}
}

func TestCompileEdgeExpansionDoesNotChargeExplicitEdges(t *testing.T) {
t.Parallel()

_, _, err := d2compiler.Compile(
"explicit-edges.d2",
strings.NewReader("a -> b\na -> b\na -> b\n"),
&d2compiler.CompileOptions{MaxEdgeExpansion: 1},
)
if err != nil {
t.Fatalf("Compile explicit edges: %v", err)
}
}
36 changes: 28 additions & 8 deletions d2ir/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ type globContext struct {
}

type compiler struct {
err *d2parser.ParseError
ctx context.Context
contextErr error
expansionErr error
globExpansionErr error
halted bool
variableExpansion *variableExpansionBudget
globExpansion *globExpansionBudget
err *d2parser.ParseError
ctx context.Context
contextErr error
expansionErr error
globExpansionErr error
halted bool
variableExpansion *variableExpansionBudget
globExpansion *globExpansionBudget
edgeExpansion *edgeExpansionBudget
edgeExpansionWork *edgeExpansionWorkBudget
edgeExpansionPairs map[edgeExpansionPair]struct{}

fs fs.FS
imports []string
Expand Down Expand Up @@ -87,6 +90,13 @@ type CompileOptions struct {
// materialization. Zero uses DefaultMaxGlobExpansion. Explicit source fields
// are not counted as materialization work.
MaxGlobExpansion int64
// MaxEdgeExpansion bounds distinct edge-segment and endpoint combinations
// considered by edge globs. Zero uses DefaultMaxEdgeExpansion. Explicit edges
// do not consume this budget.
MaxEdgeExpansion int64
// MaxEdgeExpansionWork bounds all endpoint-pair examinations performed by
// edge globs, including lazy replays. Zero uses DefaultMaxEdgeExpansionWork.
MaxEdgeExpansionWork int64
// FS resolves imports. Nil disables imports. The lib/localfile package
// provides rooted and explicit unrestricted host-filesystem policies.
FS fs.FS
Expand Down Expand Up @@ -115,12 +125,22 @@ func Compile(ast *d2ast.Map, opts *CompileOptions) (*Map, []string, error) {
if err != nil {
return nil, nil, err
}
edgeExpansion, err := newEdgeExpansionBudget(opts.MaxEdgeExpansion)
if err != nil {
return nil, nil, err
}
edgeExpansionWork, err := newEdgeExpansionWorkBudget(opts.MaxEdgeExpansionWork)
if err != nil {
return nil, nil, err
}
c := &compiler{
err: &d2parser.ParseError{},
ctx: ctx,
fs: opts.FS,
variableExpansion: variableExpansion,
globExpansion: globExpansion,
edgeExpansion: edgeExpansion,
edgeExpansionWork: edgeExpansionWork,

seenImports: make(map[string]struct{}),
parsedImports: make(map[string]*d2ast.Map),
Expand Down
27 changes: 21 additions & 6 deletions d2ir/d2ir.go
Original file line number Diff line number Diff line change
Expand Up @@ -1563,7 +1563,7 @@ func (m *Map) getEdgesMode(eid *EdgeID, refctx *RefContext, c *compiler, indexed
gctx = c.ensureGlobContext(refctx)
}
var ea []*Edge
m.getEdges(eid, refctx, gctx, indexed, &ea)
m.getEdges(eid, refctx, gctx, c, indexed, &ea)
return ea
}

Expand All @@ -1590,7 +1590,10 @@ func (m *Map) getEdgesIndexed(eid *EdgeID) []*Edge {
return edges
}

func (m *Map) getEdges(eid *EdgeID, refctx *RefContext, gctx *globContext, indexed bool, ea *[]*Edge) error {
func (m *Map) getEdges(eid *EdgeID, refctx *RefContext, gctx *globContext, c *compiler, indexed bool, ea *[]*Edge) error {
if c != nil && c.stopped() {
return nil
}
eid, m, common, err := eid.resolve(m)
if err != nil {
return err
Expand All @@ -1608,11 +1611,14 @@ func (m *Map) getEdges(eid *EdgeID, refctx *RefContext, gctx *globContext, index
}
}
}
fa, err := m.ensureFieldMode(commonKP, nil, false, nil, indexed)
fa, err := m.ensureFieldMode(commonKP, nil, false, c, indexed)
if err != nil {
return nil
}
for _, f := range fa {
if c != nil && c.stopped() {
return nil
}
if _, ok := f.Composite.(*Array); ok {
return d2parser.Errorf(refctx.Edge.Src, "cannot index into array")
}
Expand All @@ -1621,25 +1627,31 @@ func (m *Map) getEdges(eid *EdgeID, refctx *RefContext, gctx *globContext, index
parent: f,
}
}
err = f.Map().getEdges(eid, refctx, gctx, indexed, ea)
err = f.Map().getEdges(eid, refctx, gctx, c, indexed, ea)
if err != nil {
return err
}
}
return nil
}

srcFA, err := refctx.ScopeMap.ensureFieldMode(refctx.Edge.Src, nil, false, nil, indexed)
srcFA, err := refctx.ScopeMap.ensureFieldMode(refctx.Edge.Src, nil, false, c, indexed)
if err != nil {
return err
}
dstFA, err := refctx.ScopeMap.ensureFieldMode(refctx.Edge.Dst, nil, false, nil, indexed)
dstFA, err := refctx.ScopeMap.ensureFieldMode(refctx.Edge.Dst, nil, false, c, indexed)
if err != nil {
return err
}

for _, src := range srcFA {
for _, dst := range dstFA {
if c != nil && c.stopped() {
return nil
}
if c != nil && (refctx.Edge.Src.HasGlob() || refctx.Edge.Dst.HasGlob()) && !c.reserveEdgeExpansion(refctx.Edge, gctx, src, dst, true) {
return nil
}
eid2 := eid.Copy()
eid2.SrcPath = RelIDA(m, src)
eid2.DstPath = RelIDA(m, dst)
Expand Down Expand Up @@ -1782,6 +1794,9 @@ func (m *Map) createEdge(eid *EdgeID, refctx *RefContext, gctx *globContext, c *
if c != nil && c.stopped() {
return nil
}
if c != nil && (refctx.Edge.Src.HasGlob() || refctx.Edge.Dst.HasGlob()) && !c.reserveEdgeExpansion(refctx.Edge, gctx, src, dst, false) {
return nil
}
if src == dst && (refctx.Edge.Src.HasGlob() || refctx.Edge.Dst.HasGlob()) {
// Globs do not make self edges.
continue
Expand Down
127 changes: 127 additions & 0 deletions d2ir/edge_expansion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package d2ir

import (
"fmt"

"github.com/d2lang/d2/d2ast"
)

// DefaultMaxEdgeExpansion is the maximum number of distinct edge-segment and
// endpoint combinations that edge globs may consider during one compilation.
// Explicit edges do not consume this budget because their work is proportional
// to the input size. The limit is calibrated so a sparse star at the boundary
// remains bounded in the built-in Dagre and ELK layouts, while larger wildcard
// fanout stops before layout.
const DefaultMaxEdgeExpansion int64 = 1_024

// DefaultMaxEdgeExpansionWork is the maximum number of endpoint-pair
// examinations edge globs may perform, including repeated lazy replays.
const DefaultMaxEdgeExpansionWork int64 = 65_536

type edgeExpansionPair struct {
glob *globContext
segment *d2ast.Edge
src *Field
dst *Field
selector bool
}

type edgeExpansionBudget struct {
limit int64
used int64
}

type edgeExpansionLimitError struct {
limit int64
}

type edgeExpansionWorkBudget struct {
limit int64
used int64
}

type edgeExpansionWorkLimitError struct {
limit int64
}

func (e *edgeExpansionWorkLimitError) Error() string {
return fmt.Sprintf("edge glob expansion exceeds work limit of %d endpoint-pair examinations", e.limit)
}

func (e *edgeExpansionLimitError) Error() string {
return fmt.Sprintf("edge glob expansion exceeds limit of %d endpoint pairs", e.limit)
}

func newEdgeExpansionBudget(limit int64) (*edgeExpansionBudget, error) {
if limit < 0 {
return nil, fmt.Errorf("MaxEdgeExpansion must not be negative")
}
if limit == 0 {
limit = DefaultMaxEdgeExpansion
}
return &edgeExpansionBudget{limit: limit}, nil
}

func newEdgeExpansionWorkBudget(limit int64) (*edgeExpansionWorkBudget, error) {
if limit < 0 {
return nil, fmt.Errorf("MaxEdgeExpansionWork must not be negative")
}
if limit == 0 {
limit = DefaultMaxEdgeExpansionWork
}
return &edgeExpansionWorkBudget{limit: limit}, nil
}

func (b *edgeExpansionBudget) reserve() error {
if b == nil || b.used >= b.limit {
limit := int64(0)
if b != nil {
limit = b.limit
}
return &edgeExpansionLimitError{limit: limit}
}
b.used++
return nil
}

func (b *edgeExpansionWorkBudget) reserve() error {
if b == nil || b.used >= b.limit {
limit := int64(0)
if b != nil {
limit = b.limit
}
return &edgeExpansionWorkLimitError{limit: limit}
}
b.used++
return nil
}

func (c *compiler) reserveEdgeExpansion(segment *d2ast.Edge, glob *globContext, src, dst *Field, selector bool) bool {
if c.stopped() {
return false
}
if err := c.edgeExpansionWork.reserve(); err != nil {
return c.rejectEdgeExpansion(segment, err)
}
pair := edgeExpansionPair{glob: glob, segment: segment, src: src, dst: dst, selector: selector}
if _, ok := c.edgeExpansionPairs[pair]; ok {
return true
}
if err := c.edgeExpansion.reserve(); err != nil {
return c.rejectEdgeExpansion(segment, err)
}
if c.edgeExpansionPairs == nil {
c.edgeExpansionPairs = make(map[edgeExpansionPair]struct{})
}
c.edgeExpansionPairs[pair] = struct{}{}
return true
}

func (c *compiler) rejectEdgeExpansion(n d2ast.Node, err error) bool {
c.expansionErr = err
if n != nil {
c.errorf(n, "%v", err)
}
c.halted = true
return false
}
Loading
Loading